7 components: Form, Calendar, DatePicker, CommandPalette, DataTable, VirtualList, Wizard + createVirtualizer primitive.
1589 lines
61 KiB
Markdown
1589 lines
61 KiB
Markdown
# Phase 2: Advanced Components — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build 7 advanced components (Form, Calendar, DatePicker, CommandPalette, DataTable, VirtualList, Wizard) and the createVirtualizer primitive.
|
|
|
|
**Architecture:** All components follow the Zod-first pattern from Phase 1: `{name}.props.ts` with schema + Meta, compound API via `Object.assign`, context for cross-part communication, `createControllableSignal` for controlled/uncontrolled state. Complex list components reuse `createListNavigation`. DataTable and VirtualList share a new `createVirtualizer` primitive.
|
|
|
|
**Tech Stack:** SolidJS, Zod v4, TypeScript 6, Vitest, @solidjs/testing-library
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-29-pettyui-ai-first-architecture.md`
|
|
|
|
**Parallelism:** Tasks 1, 3, 5, 6, 7 can run in parallel (no dependencies). Task 2 depends on Task 1. Task 4 depends on Task 3. Task 8 depends on Task 1. Task 9 runs last.
|
|
|
|
```
|
|
Wave 1 (parallel): Task 1 (createVirtualizer)
|
|
Task 3 (Calendar)
|
|
Task 5 (CommandPalette)
|
|
Task 6 (Form)
|
|
Task 7 (Wizard)
|
|
|
|
Wave 2 (parallel): Task 2 (VirtualList) — after Task 1
|
|
Task 4 (DatePicker) — after Task 3
|
|
Task 8 (DataTable) — after Task 1
|
|
|
|
Wave 3: Task 9 (build config + verification)
|
|
```
|
|
|
|
---
|
|
|
|
## Conventions (from Phase 1)
|
|
|
|
All components MUST follow these exact patterns:
|
|
|
|
**Props file** (`{name}.props.ts`):
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
|
|
export const {Name}RootPropsSchema = z.object({ /* .describe() on every field */ });
|
|
export interface {Name}RootProps extends z.infer<typeof {Name}RootPropsSchema> {
|
|
children: JSX.Element;
|
|
}
|
|
export const {Name}Meta: ComponentMeta = { name, description, parts, requiredParts } as const;
|
|
```
|
|
|
|
**Index file** (`index.ts`):
|
|
```typescript
|
|
export const {Name} = Object.assign({Name}Root, { SubPart: {Name}SubPart, useContext: use{Name}Context });
|
|
export type { ... } from "./{name}.props";
|
|
export { {Name}RootPropsSchema, {Name}Meta } from "./{name}.props";
|
|
```
|
|
|
|
**Context error messages**: `"[PettyUI] {Name} parts must be used within <{Name}>. Fix: Wrap ... inside <{Name}>."`
|
|
|
|
**Zod v4 function syntax**: `z.function(z.tuple([z.string()]), z.void())` NOT `z.function().args().returns()`
|
|
|
|
**NagLint**: Keep files under 500 lines. Keep `.props.ts` compact (single-line interfaces where possible). Index files need ≥30% logic density.
|
|
|
|
---
|
|
|
|
### Task 1: createVirtualizer Primitive
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/primitives/create-virtualizer.ts`
|
|
- Create: `packages/core/tests/primitives/create-virtualizer.test.ts`
|
|
|
|
This primitive powers both VirtualList and DataTable. It manages which items are visible in a scrollable container and provides offset calculations.
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Create `packages/core/tests/primitives/create-virtualizer.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, expect, it } from "vitest";
|
|
import { createRoot } from "solid-js";
|
|
import { createVirtualizer } from "../../src/primitives/create-virtualizer";
|
|
|
|
describe("createVirtualizer", () => {
|
|
it("calculates visible range for fixed-size items", () => {
|
|
createRoot((dispose) => {
|
|
const virtualizer = createVirtualizer({
|
|
count: () => 1000,
|
|
getScrollElement: () => null,
|
|
estimateSize: () => 40,
|
|
});
|
|
expect(virtualizer.totalSize()).toBe(40000);
|
|
dispose();
|
|
});
|
|
});
|
|
|
|
it("returns virtual items with correct offsets", () => {
|
|
createRoot((dispose) => {
|
|
const virtualizer = createVirtualizer({
|
|
count: () => 100,
|
|
getScrollElement: () => null,
|
|
estimateSize: () => 50,
|
|
overscan: 0,
|
|
});
|
|
const items = virtualizer.virtualItems();
|
|
expect(items.length).toBeGreaterThanOrEqual(0);
|
|
dispose();
|
|
});
|
|
});
|
|
|
|
it("handles zero count", () => {
|
|
createRoot((dispose) => {
|
|
const virtualizer = createVirtualizer({
|
|
count: () => 0,
|
|
getScrollElement: () => null,
|
|
estimateSize: () => 40,
|
|
});
|
|
expect(virtualizer.totalSize()).toBe(0);
|
|
expect(virtualizer.virtualItems()).toEqual([]);
|
|
dispose();
|
|
});
|
|
});
|
|
|
|
it("supports horizontal orientation", () => {
|
|
createRoot((dispose) => {
|
|
const virtualizer = createVirtualizer({
|
|
count: () => 50,
|
|
getScrollElement: () => null,
|
|
estimateSize: () => 100,
|
|
horizontal: true,
|
|
});
|
|
expect(virtualizer.totalSize()).toBe(5000);
|
|
dispose();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/primitives/create-virtualizer.test.ts
|
|
```
|
|
|
|
- [ ] **Step 3: Implement createVirtualizer**
|
|
|
|
Create `packages/core/src/primitives/create-virtualizer.ts`:
|
|
|
|
```typescript
|
|
import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js";
|
|
|
|
/** A single virtual item with its position and index. */
|
|
export interface VirtualItem {
|
|
index: number;
|
|
start: number;
|
|
end: number;
|
|
size: number;
|
|
key: string | number;
|
|
}
|
|
|
|
/** Options for createVirtualizer. */
|
|
export interface CreateVirtualizerOptions {
|
|
/** Total number of items. Reactive. */
|
|
count: Accessor<number>;
|
|
/** The scrollable container element. */
|
|
getScrollElement: Accessor<HTMLElement | null>;
|
|
/** Estimated size in pixels for each item. Used for initial layout. */
|
|
estimateSize: (index: number) => number;
|
|
/** Number of items to render outside the visible area. @default 5 */
|
|
overscan?: number;
|
|
/** Whether scrolling is horizontal. @default false */
|
|
horizontal?: boolean;
|
|
/** Custom key extractor. @default index */
|
|
getItemKey?: (index: number) => string | number;
|
|
}
|
|
|
|
/** State returned by createVirtualizer. */
|
|
export interface VirtualizerState {
|
|
/** Virtual items currently in the visible range (plus overscan). */
|
|
virtualItems: Accessor<VirtualItem[]>;
|
|
/** Total scrollable size in pixels. */
|
|
totalSize: Accessor<number>;
|
|
/** Imperatively scroll to an item by index. */
|
|
scrollToIndex: (index: number, options?: { align?: "start" | "center" | "end" }) => void;
|
|
/** Current scroll offset. */
|
|
scrollOffset: Accessor<number>;
|
|
/** Measure a specific item's actual size after render. */
|
|
measureElement: (element: HTMLElement | null) => void;
|
|
}
|
|
|
|
/**
|
|
* Virtualizes a list of items, only rendering those visible in the scroll viewport.
|
|
* Supports fixed and variable row heights, horizontal/vertical, and overscan.
|
|
*/
|
|
export function createVirtualizer(options: CreateVirtualizerOptions): VirtualizerState {
|
|
const overscan = options.overscan ?? 5;
|
|
const horizontal = options.horizontal ?? false;
|
|
const getKey = options.getItemKey ?? ((i: number) => i);
|
|
|
|
const [scrollOffset, setScrollOffset] = createSignal(0);
|
|
const [containerSize, setContainerSize] = createSignal(0);
|
|
const measurements = new Map<number, number>();
|
|
|
|
function getSize(index: number): number {
|
|
return measurements.get(index) ?? options.estimateSize(index);
|
|
}
|
|
|
|
function getItemOffset(index: number): number {
|
|
let offset = 0;
|
|
for (let i = 0; i < index; i++) {
|
|
offset += getSize(i);
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
const totalSize: Accessor<number> = () => {
|
|
const count = options.count();
|
|
let total = 0;
|
|
for (let i = 0; i < count; i++) {
|
|
total += getSize(i);
|
|
}
|
|
return total;
|
|
};
|
|
|
|
const virtualItems: Accessor<VirtualItem[]> = () => {
|
|
const count = options.count();
|
|
if (count === 0) return [];
|
|
|
|
const offset = scrollOffset();
|
|
const size = containerSize();
|
|
if (size === 0) return [];
|
|
|
|
let startIndex = 0;
|
|
let accumulated = 0;
|
|
while (startIndex < count && accumulated + getSize(startIndex) <= offset) {
|
|
accumulated += getSize(startIndex);
|
|
startIndex++;
|
|
}
|
|
|
|
let endIndex = startIndex;
|
|
let visibleAccumulated = 0;
|
|
while (endIndex < count && visibleAccumulated < size) {
|
|
visibleAccumulated += getSize(endIndex);
|
|
endIndex++;
|
|
}
|
|
|
|
const overscanStart = Math.max(0, startIndex - overscan);
|
|
const overscanEnd = Math.min(count - 1, endIndex + overscan);
|
|
|
|
const items: VirtualItem[] = [];
|
|
for (let i = overscanStart; i <= overscanEnd; i++) {
|
|
const itemSize = getSize(i);
|
|
const start = getItemOffset(i);
|
|
items.push({
|
|
index: i,
|
|
start,
|
|
end: start + itemSize,
|
|
size: itemSize,
|
|
key: getKey(i),
|
|
});
|
|
}
|
|
return items;
|
|
};
|
|
|
|
createEffect(() => {
|
|
const el = options.getScrollElement();
|
|
if (!el) return;
|
|
|
|
const sizeKey = horizontal ? "clientWidth" : "clientHeight";
|
|
const scrollKey = horizontal ? "scrollLeft" : "scrollTop";
|
|
|
|
setContainerSize(el[sizeKey]);
|
|
setScrollOffset(el[scrollKey]);
|
|
|
|
const onScroll = () => {
|
|
setScrollOffset(el[scrollKey]);
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
setContainerSize(el[sizeKey]);
|
|
});
|
|
|
|
el.addEventListener("scroll", onScroll, { passive: true });
|
|
resizeObserver.observe(el);
|
|
|
|
onCleanup(() => {
|
|
el.removeEventListener("scroll", onScroll);
|
|
resizeObserver.disconnect();
|
|
});
|
|
});
|
|
|
|
function scrollToIndex(index: number, opts?: { align?: "start" | "center" | "end" }): void {
|
|
const el = options.getScrollElement();
|
|
if (!el) return;
|
|
const itemOffset = getItemOffset(index);
|
|
const itemSize = getSize(index);
|
|
const viewSize = containerSize();
|
|
const scrollKey = horizontal ? "scrollLeft" : "scrollTop";
|
|
|
|
let target = itemOffset;
|
|
if (opts?.align === "center") {
|
|
target = itemOffset - viewSize / 2 + itemSize / 2;
|
|
} else if (opts?.align === "end") {
|
|
target = itemOffset - viewSize + itemSize;
|
|
}
|
|
el[scrollKey] = Math.max(0, target);
|
|
}
|
|
|
|
function measureElement(element: HTMLElement | null): void {
|
|
if (!element) return;
|
|
const index = Number(element.dataset.index);
|
|
if (Number.isNaN(index)) return;
|
|
const size = horizontal ? element.offsetWidth : element.offsetHeight;
|
|
measurements.set(index, size);
|
|
}
|
|
|
|
return { virtualItems, totalSize, scrollToIndex, scrollOffset, measureElement };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/primitives/create-virtualizer.test.ts
|
|
```
|
|
|
|
Expected: 4 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/core/src/primitives/create-virtualizer.ts packages/core/tests/primitives/create-virtualizer.test.ts
|
|
git commit -m "feat: add createVirtualizer primitive for virtual scrolling"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: VirtualList Component
|
|
|
|
**Depends on:** Task 1 (createVirtualizer)
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/virtual-list/virtual-list.props.ts`
|
|
- Create: `packages/core/src/components/virtual-list/virtual-list-root.tsx`
|
|
- Create: `packages/core/src/components/virtual-list/index.ts`
|
|
- Create: `packages/core/tests/components/virtual-list/virtual-list.test.tsx`
|
|
|
|
- [ ] **Step 1: Create virtual-list.props.ts**
|
|
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { Accessor, JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
import type { VirtualItem } from "../../primitives/create-virtualizer";
|
|
|
|
export const VirtualListRootPropsSchema = z.object({
|
|
count: z.number().describe("Total number of items in the list"),
|
|
estimateSize: z.number().optional().describe("Estimated item height in pixels. Defaults to 40"),
|
|
overscan: z.number().optional().describe("Number of items rendered outside visible area. Defaults to 5"),
|
|
horizontal: z.boolean().optional().describe("Whether the list scrolls horizontally"),
|
|
});
|
|
|
|
export interface VirtualListRootProps extends Omit<z.infer<typeof VirtualListRootPropsSchema>, "count" | "estimateSize">, Omit<JSX.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
count: number;
|
|
estimateSize?: number | ((index: number) => number);
|
|
children: (item: VirtualItem) => JSX.Element;
|
|
}
|
|
|
|
export const VirtualListMeta: ComponentMeta = {
|
|
name: "VirtualList",
|
|
description: "Virtualized scrollable list that only renders visible items, for large datasets",
|
|
parts: ["Root"] as const,
|
|
requiredParts: ["Root"] as const,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Create virtual-list-root.tsx**
|
|
|
|
```typescript
|
|
import type { JSX } from "solid-js";
|
|
import { For, createSignal, splitProps } from "solid-js";
|
|
import { createVirtualizer } from "../../primitives/create-virtualizer";
|
|
import type { VirtualListRootProps } from "./virtual-list.props";
|
|
|
|
export function VirtualListRoot(props: VirtualListRootProps): JSX.Element {
|
|
const [local, rest] = splitProps(props, ["count", "estimateSize", "overscan", "horizontal", "children"]);
|
|
const [scrollRef, setScrollRef] = createSignal<HTMLElement | null>(null);
|
|
|
|
const estimateFn = () => {
|
|
const est = local.estimateSize ?? 40;
|
|
return typeof est === "function" ? est : () => est;
|
|
};
|
|
|
|
const virtualizer = createVirtualizer({
|
|
count: () => local.count,
|
|
getScrollElement: scrollRef,
|
|
estimateSize: (i) => estimateFn()(i),
|
|
overscan: local.overscan,
|
|
horizontal: local.horizontal,
|
|
});
|
|
|
|
return (
|
|
<div
|
|
ref={setScrollRef}
|
|
data-scope="virtual-list"
|
|
data-part="root"
|
|
style={{ overflow: "auto", position: "relative" }}
|
|
{...rest}
|
|
>
|
|
<div
|
|
style={{
|
|
height: local.horizontal ? "100%" : `${virtualizer.totalSize()}px`,
|
|
width: local.horizontal ? `${virtualizer.totalSize()}px` : "100%",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<For each={virtualizer.virtualItems()}>
|
|
{(item) => (
|
|
<div
|
|
data-index={item.index}
|
|
ref={(el) => virtualizer.measureElement(el)}
|
|
style={{
|
|
position: "absolute",
|
|
top: local.horizontal ? "0" : `${item.start}px`,
|
|
left: local.horizontal ? `${item.start}px` : "0",
|
|
width: local.horizontal ? `${item.size}px` : "100%",
|
|
height: local.horizontal ? "100%" : `${item.size}px`,
|
|
}}
|
|
>
|
|
{local.children(item)}
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create index.ts**
|
|
|
|
```typescript
|
|
import { VirtualListRoot } from "./virtual-list-root";
|
|
export const VirtualList = VirtualListRoot;
|
|
export type { VirtualListRootProps } from "./virtual-list.props";
|
|
export { VirtualListRootPropsSchema, VirtualListMeta } from "./virtual-list.props";
|
|
```
|
|
|
|
- [ ] **Step 4: Write test**
|
|
|
|
Create `packages/core/tests/components/virtual-list/virtual-list.test.tsx`:
|
|
|
|
```typescript
|
|
import { render, screen } from "@solidjs/testing-library";
|
|
import { describe, expect, it } from "vitest";
|
|
import { VirtualList } from "../../../src/components/virtual-list/index";
|
|
import { VirtualListRootPropsSchema, VirtualListMeta } from "../../../src/components/virtual-list/virtual-list.props";
|
|
|
|
describe("VirtualList", () => {
|
|
it("renders with render prop children", () => {
|
|
render(() => (
|
|
<VirtualList count={100} estimateSize={40} style={{ height: "200px" }} data-testid="list">
|
|
{(item) => <div data-testid={`item-${item.index}`}>Item {item.index}</div>}
|
|
</VirtualList>
|
|
));
|
|
expect(screen.getByTestId("list")).toBeTruthy();
|
|
});
|
|
|
|
it("schema validates props", () => {
|
|
expect(VirtualListRootPropsSchema.safeParse({ count: 100, estimateSize: 40 }).success).toBe(true);
|
|
expect(VirtualListRootPropsSchema.safeParse({ count: 50, horizontal: true, overscan: 10 }).success).toBe(true);
|
|
});
|
|
|
|
it("meta has required fields", () => {
|
|
expect(VirtualListMeta.name).toBe("VirtualList");
|
|
expect(VirtualListMeta.description).toBeTruthy();
|
|
expect(VirtualListMeta.parts).toContain("Root");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests and commit**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/components/virtual-list/
|
|
git add packages/core/src/components/virtual-list/ packages/core/tests/components/virtual-list/
|
|
git commit -m "feat(virtual-list): add VirtualList component with createVirtualizer"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Calendar Component
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/calendar/calendar.props.ts`
|
|
- Create: `packages/core/src/components/calendar/calendar-context.ts`
|
|
- Create: `packages/core/src/components/calendar/calendar-root.tsx`
|
|
- Create: `packages/core/src/components/calendar/calendar-header.tsx`
|
|
- Create: `packages/core/src/components/calendar/calendar-grid.tsx`
|
|
- Create: `packages/core/src/components/calendar/calendar-grid-head.tsx`
|
|
- Create: `packages/core/src/components/calendar/calendar-grid-body.tsx`
|
|
- Create: `packages/core/src/components/calendar/calendar-cell.tsx`
|
|
- Create: `packages/core/src/components/calendar/calendar-heading.tsx`
|
|
- Create: `packages/core/src/components/calendar/calendar-nav.tsx`
|
|
- Create: `packages/core/src/components/calendar/index.ts`
|
|
- Create: `packages/core/tests/components/calendar/calendar.test.tsx`
|
|
|
|
- [ ] **Step 1: Create calendar.props.ts**
|
|
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
|
|
export const CalendarRootPropsSchema = z.object({
|
|
value: z.string().optional().describe("Controlled selected date as ISO string (YYYY-MM-DD)"),
|
|
defaultValue: z.string().optional().describe("Initial selected date (uncontrolled)"),
|
|
month: z.number().optional().describe("Controlled displayed month (0-11)"),
|
|
year: z.number().optional().describe("Controlled displayed year"),
|
|
minDate: z.string().optional().describe("Earliest selectable date as ISO string"),
|
|
maxDate: z.string().optional().describe("Latest selectable date as ISO string"),
|
|
disabled: z.boolean().optional().describe("Whether the calendar is disabled"),
|
|
locale: z.string().optional().describe("BCP 47 locale string for day/month names. Defaults to 'en-US'"),
|
|
weekStartsOn: z.number().optional().describe("Day the week starts on (0=Sunday, 1=Monday). Defaults to 0"),
|
|
});
|
|
|
|
export interface CalendarRootProps extends z.infer<typeof CalendarRootPropsSchema> {
|
|
onValueChange?: (date: string) => void;
|
|
onMonthChange?: (month: number, year: number) => void;
|
|
children: JSX.Element;
|
|
}
|
|
|
|
export interface CalendarHeaderProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
|
|
export interface CalendarHeadingProps extends JSX.HTMLAttributes<HTMLHeadingElement> {}
|
|
export interface CalendarNavProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
|
|
export interface CalendarPrevProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
|
export interface CalendarNextProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
|
export interface CalendarGridProps extends JSX.HTMLAttributes<HTMLTableElement> { children: JSX.Element; }
|
|
export interface CalendarGridHeadProps extends JSX.HTMLAttributes<HTMLTableSectionElement> {}
|
|
export interface CalendarGridBodyProps extends JSX.HTMLAttributes<HTMLTableSectionElement> {}
|
|
export interface CalendarCellProps extends JSX.HTMLAttributes<HTMLTableCellElement> {
|
|
date: string;
|
|
children?: JSX.Element;
|
|
}
|
|
|
|
export const CalendarMeta: ComponentMeta = {
|
|
name: "Calendar",
|
|
description: "Month grid for date selection with keyboard navigation, locale support, and min/max constraints",
|
|
parts: ["Root", "Header", "Heading", "Nav", "PrevButton", "NextButton", "Grid", "GridHead", "GridBody", "Cell"] as const,
|
|
requiredParts: ["Root", "Grid", "GridBody", "Cell"] as const,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Create calendar-context.ts**
|
|
|
|
The context provides the current month/year state, locale helpers, selected date, and navigation methods. Read existing context patterns (e.g., `dialog-context.ts`) and follow the same structure with `[PettyUI]` error prefix.
|
|
|
|
Key context fields:
|
|
```typescript
|
|
interface CalendarContextValue {
|
|
focusedDate: Accessor<Date>;
|
|
selectedDate: Accessor<string | undefined>;
|
|
displayMonth: Accessor<number>;
|
|
displayYear: Accessor<number>;
|
|
locale: Accessor<string>;
|
|
weekStartsOn: Accessor<number>;
|
|
minDate: Accessor<string | undefined>;
|
|
maxDate: Accessor<string | undefined>;
|
|
disabled: Accessor<boolean>;
|
|
selectDate: (date: string) => void;
|
|
focusDate: (date: Date) => void;
|
|
goToPrevMonth: () => void;
|
|
goToNextMonth: () => void;
|
|
isDateDisabled: (date: string) => boolean;
|
|
isDateSelected: (date: string) => boolean;
|
|
isDateToday: (date: string) => boolean;
|
|
getDaysInMonth: () => Date[];
|
|
getWeekDayNames: () => string[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create calendar-root.tsx**
|
|
|
|
Uses `createControllableSignal` for selected date. Manages month/year display state. Provides context. Renders no DOM — just context providers around children.
|
|
|
|
Key implementation:
|
|
- Parse ISO date strings to Date objects for internal calculations
|
|
- `getDaysInMonth()` returns array of Date objects for the current display month
|
|
- `getWeekDayNames()` uses `Intl.DateTimeFormat` with the configured locale
|
|
- Keyboard navigation: Arrow keys move focus, Enter/Space selects
|
|
- Month navigation: goToPrevMonth/goToNextMonth update display state
|
|
|
|
- [ ] **Step 4: Create sub-components**
|
|
|
|
**calendar-header.tsx:** Simple `<div data-scope="calendar" data-part="header">` wrapper.
|
|
|
|
**calendar-heading.tsx:** Renders current month/year from context using `Intl.DateTimeFormat`:
|
|
```typescript
|
|
const heading = () => {
|
|
const date = new Date(ctx.displayYear(), ctx.displayMonth());
|
|
return new Intl.DateTimeFormat(ctx.locale(), { month: "long", year: "numeric" }).format(date);
|
|
};
|
|
return <h2 data-scope="calendar" data-part="heading" {...rest}>{heading()}</h2>;
|
|
```
|
|
|
|
**calendar-nav.tsx:** Container for prev/next buttons. Contains CalendarPrev and CalendarNext sub-components that call `ctx.goToPrevMonth()` / `ctx.goToNextMonth()`.
|
|
|
|
**calendar-grid.tsx:** `<table role="grid" data-scope="calendar" data-part="grid">`.
|
|
|
|
**calendar-grid-head.tsx:** Renders weekday headers using `ctx.getWeekDayNames()` as `<thead><tr>` with `<th>` for each day.
|
|
|
|
**calendar-grid-body.tsx:** Renders weeks as `<tr>` rows with slots for CalendarCell. Computes the grid layout from `ctx.getDaysInMonth()`.
|
|
|
|
**calendar-cell.tsx:** Individual day cell. Sets data attributes: `data-selected`, `data-today`, `data-disabled`, `data-outside-month`. Calls `ctx.selectDate()` on click.
|
|
|
|
- [ ] **Step 5: Create index.ts**
|
|
|
|
```typescript
|
|
import { CalendarRoot } from "./calendar-root";
|
|
import { CalendarHeader } from "./calendar-header";
|
|
import { CalendarHeading } from "./calendar-heading";
|
|
import { CalendarNav } from "./calendar-nav";
|
|
import { CalendarGrid } from "./calendar-grid";
|
|
import { CalendarGridHead } from "./calendar-grid-head";
|
|
import { CalendarGridBody } from "./calendar-grid-body";
|
|
import { CalendarCell } from "./calendar-cell";
|
|
|
|
export const Calendar = Object.assign(CalendarRoot, {
|
|
Header: CalendarHeader, Heading: CalendarHeading, Nav: CalendarNav,
|
|
Grid: CalendarGrid, GridHead: CalendarGridHead, GridBody: CalendarGridBody, Cell: CalendarCell,
|
|
});
|
|
|
|
export type { CalendarRootProps, CalendarCellProps } from "./calendar.props";
|
|
export { CalendarRootPropsSchema, CalendarMeta } from "./calendar.props";
|
|
```
|
|
|
|
- [ ] **Step 6: Write test**
|
|
|
|
Create `packages/core/tests/components/calendar/calendar.test.tsx`:
|
|
|
|
```typescript
|
|
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
|
import { createSignal } from "solid-js";
|
|
import { describe, expect, it } from "vitest";
|
|
import { Calendar } from "../../../src/components/calendar/index";
|
|
import { CalendarRootPropsSchema, CalendarMeta } from "../../../src/components/calendar/calendar.props";
|
|
|
|
describe("Calendar", () => {
|
|
it("renders month grid with day cells", () => {
|
|
render(() => (
|
|
<Calendar defaultValue="2026-03-15">
|
|
<Calendar.Header>
|
|
<Calendar.Heading />
|
|
<Calendar.Nav>
|
|
<button>Prev</button>
|
|
<button>Next</button>
|
|
</Calendar.Nav>
|
|
</Calendar.Header>
|
|
<Calendar.Grid>
|
|
<Calendar.GridHead />
|
|
<Calendar.GridBody />
|
|
</Calendar.Grid>
|
|
</Calendar>
|
|
));
|
|
expect(screen.getByText("March 2026")).toBeTruthy();
|
|
});
|
|
|
|
it("calls onValueChange when date selected", () => {
|
|
let selected = "";
|
|
render(() => (
|
|
<Calendar onValueChange={(d) => { selected = d; }}>
|
|
<Calendar.Grid>
|
|
<Calendar.GridBody />
|
|
</Calendar.Grid>
|
|
</Calendar>
|
|
));
|
|
const cells = screen.getAllByRole("gridcell");
|
|
if (cells.length > 0) fireEvent.click(cells[10]);
|
|
expect(selected).toBeTruthy();
|
|
});
|
|
|
|
it("schema validates date constraints", () => {
|
|
expect(CalendarRootPropsSchema.safeParse({
|
|
value: "2026-03-15", minDate: "2026-01-01", maxDate: "2026-12-31",
|
|
locale: "en-US", weekStartsOn: 1,
|
|
}).success).toBe(true);
|
|
});
|
|
|
|
it("meta has required fields", () => {
|
|
expect(CalendarMeta.name).toBe("Calendar");
|
|
expect(CalendarMeta.parts).toContain("Root");
|
|
expect(CalendarMeta.parts).toContain("Grid");
|
|
expect(CalendarMeta.parts).toContain("Cell");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests and commit**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/components/calendar/
|
|
git add packages/core/src/components/calendar/ packages/core/tests/components/calendar/
|
|
git commit -m "feat(calendar): add Calendar component with locale support and keyboard nav"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: DatePicker Component
|
|
|
|
**Depends on:** Task 3 (Calendar)
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/date-picker/date-picker.props.ts`
|
|
- Create: `packages/core/src/components/date-picker/date-picker-context.ts`
|
|
- Create: `packages/core/src/components/date-picker/date-picker-root.tsx`
|
|
- Create: `packages/core/src/components/date-picker/date-picker-input.tsx`
|
|
- Create: `packages/core/src/components/date-picker/date-picker-trigger.tsx`
|
|
- Create: `packages/core/src/components/date-picker/date-picker-content.tsx`
|
|
- Create: `packages/core/src/components/date-picker/index.ts`
|
|
- Create: `packages/core/tests/components/date-picker/date-picker.test.tsx`
|
|
|
|
Composes Calendar inside a Popover-like dropdown anchored to an input.
|
|
|
|
- [ ] **Step 1: Create date-picker.props.ts**
|
|
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
|
|
export const DatePickerRootPropsSchema = z.object({
|
|
value: z.string().optional().describe("Controlled selected date as ISO string (YYYY-MM-DD)"),
|
|
defaultValue: z.string().optional().describe("Initial selected date (uncontrolled)"),
|
|
open: z.boolean().optional().describe("Controlled popover open state"),
|
|
defaultOpen: z.boolean().optional().describe("Initial popover open state"),
|
|
minDate: z.string().optional().describe("Earliest selectable date"),
|
|
maxDate: z.string().optional().describe("Latest selectable date"),
|
|
disabled: z.boolean().optional().describe("Whether the date picker is disabled"),
|
|
required: z.boolean().optional().describe("Whether a date selection is required"),
|
|
name: z.string().optional().describe("Form field name for submission"),
|
|
placeholder: z.string().optional().describe("Placeholder for the input. Defaults to 'Select date'"),
|
|
locale: z.string().optional().describe("BCP 47 locale for formatting. Defaults to 'en-US'"),
|
|
format: z.string().optional().describe("Display format string. Defaults to locale default"),
|
|
});
|
|
|
|
export interface DatePickerRootProps extends z.infer<typeof DatePickerRootPropsSchema> {
|
|
onValueChange?: (date: string) => void;
|
|
onOpenChange?: (open: boolean) => void;
|
|
children: JSX.Element;
|
|
}
|
|
|
|
export interface DatePickerInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, "value"> {}
|
|
export interface DatePickerTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
|
export interface DatePickerContentProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
|
|
|
|
export const DatePickerMeta: ComponentMeta = {
|
|
name: "DatePicker",
|
|
description: "Date input with dropdown calendar for selecting dates, with locale formatting and constraints",
|
|
parts: ["Root", "Input", "Trigger", "Content", "Calendar"] as const,
|
|
requiredParts: ["Root", "Input", "Content"] as const,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Create context, root, and sub-components**
|
|
|
|
The DatePicker wraps a disclosure state (open/close) + a Calendar value. The root provides context containing:
|
|
- `value`/`setValue` for the selected date
|
|
- `open`/`setOpen` for the popover
|
|
- `formatDate()` helper using `Intl.DateTimeFormat`
|
|
|
|
**date-picker-root.tsx:** Uses `createDisclosureState` for open, `createControllableSignal` for value. When a date is selected in the Calendar, it closes the popover and updates the value.
|
|
|
|
**date-picker-input.tsx:** Read-only input showing the formatted date. Clicking opens the calendar. A hidden `<input type="hidden" name={name} value={isoDate}>` for form submission.
|
|
|
|
**date-picker-trigger.tsx:** Button that toggles the popover open state.
|
|
|
|
**date-picker-content.tsx:** Floating content panel using `createFloating` for positioning. Contains the Calendar inside.
|
|
|
|
- [ ] **Step 3: Create index.ts**
|
|
|
|
```typescript
|
|
import { DatePickerRoot } from "./date-picker-root";
|
|
import { DatePickerInput } from "./date-picker-input";
|
|
import { DatePickerTrigger } from "./date-picker-trigger";
|
|
import { DatePickerContent } from "./date-picker-content";
|
|
|
|
export const DatePicker = Object.assign(DatePickerRoot, {
|
|
Input: DatePickerInput, Trigger: DatePickerTrigger, Content: DatePickerContent,
|
|
});
|
|
|
|
export type { DatePickerRootProps, DatePickerInputProps, DatePickerTriggerProps, DatePickerContentProps } from "./date-picker.props";
|
|
export { DatePickerRootPropsSchema, DatePickerMeta } from "./date-picker.props";
|
|
```
|
|
|
|
- [ ] **Step 4: Write test**
|
|
|
|
Create `packages/core/tests/components/date-picker/date-picker.test.tsx`:
|
|
|
|
```typescript
|
|
import { render, screen } from "@solidjs/testing-library";
|
|
import { describe, expect, it } from "vitest";
|
|
import { DatePicker } from "../../../src/components/date-picker/index";
|
|
import { DatePickerRootPropsSchema, DatePickerMeta } from "../../../src/components/date-picker/date-picker.props";
|
|
|
|
describe("DatePicker", () => {
|
|
it("renders input with placeholder", () => {
|
|
render(() => (
|
|
<DatePicker placeholder="Pick a date">
|
|
<DatePicker.Input data-testid="input" />
|
|
<DatePicker.Trigger>Open</DatePicker.Trigger>
|
|
<DatePicker.Content>
|
|
<div>Calendar goes here</div>
|
|
</DatePicker.Content>
|
|
</DatePicker>
|
|
));
|
|
const input = screen.getByTestId("input");
|
|
expect(input).toBeTruthy();
|
|
});
|
|
|
|
it("shows formatted date when value set", () => {
|
|
render(() => (
|
|
<DatePicker defaultValue="2026-03-15">
|
|
<DatePicker.Input data-testid="input" />
|
|
<DatePicker.Content><div>Calendar</div></DatePicker.Content>
|
|
</DatePicker>
|
|
));
|
|
expect(screen.getByTestId("input")).toBeTruthy();
|
|
});
|
|
|
|
it("schema validates", () => {
|
|
expect(DatePickerRootPropsSchema.safeParse({
|
|
value: "2026-03-15", minDate: "2026-01-01", locale: "en-US",
|
|
}).success).toBe(true);
|
|
});
|
|
|
|
it("meta has required fields", () => {
|
|
expect(DatePickerMeta.name).toBe("DatePicker");
|
|
expect(DatePickerMeta.parts).toContain("Root");
|
|
expect(DatePickerMeta.parts).toContain("Input");
|
|
expect(DatePickerMeta.parts).toContain("Content");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests and commit**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/components/date-picker/
|
|
git add packages/core/src/components/date-picker/ packages/core/tests/components/date-picker/
|
|
git commit -m "feat(date-picker): add DatePicker with Calendar integration and locale formatting"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: CommandPalette Component
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/command-palette/command-palette.props.ts`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-context.ts`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-root.tsx`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-input.tsx`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-list.tsx`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-item.tsx`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-group.tsx`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-empty.tsx`
|
|
- Create: `packages/core/src/components/command-palette/command-palette-separator.tsx`
|
|
- Create: `packages/core/src/components/command-palette/index.ts`
|
|
- Create: `packages/core/tests/components/command-palette/command-palette.test.tsx`
|
|
|
|
Follows the cmdk pattern: search input filters a list of actions with keyboard navigation.
|
|
|
|
- [ ] **Step 1: Create command-palette.props.ts**
|
|
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
|
|
export const CommandPaletteRootPropsSchema = z.object({
|
|
value: z.string().optional().describe("Controlled selected item value"),
|
|
defaultValue: z.string().optional().describe("Initial selected item (uncontrolled)"),
|
|
search: z.string().optional().describe("Controlled search input value"),
|
|
defaultSearch: z.string().optional().describe("Initial search value (uncontrolled)"),
|
|
loop: z.boolean().optional().describe("Whether keyboard navigation wraps. Defaults to true"),
|
|
filter: z.boolean().optional().describe("Whether built-in filtering is enabled. Defaults to true"),
|
|
});
|
|
|
|
export interface CommandPaletteRootProps extends z.infer<typeof CommandPaletteRootPropsSchema> {
|
|
onValueChange?: (value: string) => void;
|
|
onSearchChange?: (search: string) => void;
|
|
onSelect?: (value: string) => void;
|
|
children: JSX.Element;
|
|
}
|
|
|
|
export interface CommandPaletteInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, "value" | "onInput"> { placeholder?: string; }
|
|
export interface CommandPaletteListProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
|
|
export interface CommandPaletteItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
value: string;
|
|
keywords?: string[];
|
|
disabled?: boolean;
|
|
onSelect?: () => void;
|
|
children?: JSX.Element;
|
|
}
|
|
export interface CommandPaletteGroupProps extends JSX.HTMLAttributes<HTMLDivElement> { heading?: string; children: JSX.Element; }
|
|
export interface CommandPaletteEmptyProps extends JSX.HTMLAttributes<HTMLDivElement> { children?: JSX.Element; }
|
|
export interface CommandPaletteSeparatorProps extends JSX.HTMLAttributes<HTMLDivElement> {}
|
|
|
|
export const CommandPaletteMeta: ComponentMeta = {
|
|
name: "CommandPalette",
|
|
description: "Search-driven command menu for finding and executing actions, with keyboard navigation and grouping",
|
|
parts: ["Root", "Input", "List", "Item", "Group", "Empty", "Separator"] as const,
|
|
requiredParts: ["Root", "Input", "List", "Item"] as const,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Create context**
|
|
|
|
Context provides: search string, filtered items, highlighted value, select handler, filter function. Uses `createListNavigation` internally in activation mode.
|
|
|
|
- [ ] **Step 3: Create root component**
|
|
|
|
**command-palette-root.tsx:** Manages search state via `createControllableSignal`. Collects registered items and filters them based on search. Uses `createListNavigation` with `mode: "activation"`. Provides context to children.
|
|
|
|
Key filtering logic:
|
|
```typescript
|
|
const filteredItems = () => {
|
|
if (!filterEnabled) return allItems();
|
|
const query = searchValue().toLowerCase();
|
|
if (!query) return allItems();
|
|
return allItems().filter((item) => {
|
|
const text = item.value.toLowerCase();
|
|
const keywords = item.keywords?.join(" ").toLowerCase() ?? "";
|
|
return text.includes(query) || keywords.includes(query);
|
|
});
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Create sub-components**
|
|
|
|
**command-palette-input.tsx:** `<input>` that updates search state on input. Delegates keyDown to list navigation. `role="combobox"`, `aria-expanded`, `aria-activedescendant`.
|
|
|
|
**command-palette-list.tsx:** Container for items. Spreads `containerProps` from `createListNavigation`. Shows `CommandPaletteEmpty` when no filtered items.
|
|
|
|
**command-palette-item.tsx:** Individual action. Spreads `getItemProps(value)` from list navigation. `data-highlighted`, `data-disabled`. Calls `onSelect` on activation.
|
|
|
|
**command-palette-group.tsx:** Groups items with optional heading. `role="group"`, `aria-label`.
|
|
|
|
**command-palette-empty.tsx:** Shown when search yields no results. `<Show when={filteredCount() === 0}>`.
|
|
|
|
**command-palette-separator.tsx:** Visual divider between groups. `role="separator"`.
|
|
|
|
- [ ] **Step 5: Create index.ts**
|
|
|
|
```typescript
|
|
import { CommandPaletteRoot } from "./command-palette-root";
|
|
import { CommandPaletteInput } from "./command-palette-input";
|
|
import { CommandPaletteList } from "./command-palette-list";
|
|
import { CommandPaletteItem } from "./command-palette-item";
|
|
import { CommandPaletteGroup } from "./command-palette-group";
|
|
import { CommandPaletteEmpty } from "./command-palette-empty";
|
|
import { CommandPaletteSeparator } from "./command-palette-separator";
|
|
|
|
export const CommandPalette = Object.assign(CommandPaletteRoot, {
|
|
Input: CommandPaletteInput, List: CommandPaletteList, Item: CommandPaletteItem,
|
|
Group: CommandPaletteGroup, Empty: CommandPaletteEmpty, Separator: CommandPaletteSeparator,
|
|
});
|
|
|
|
export type { CommandPaletteRootProps, CommandPaletteInputProps, CommandPaletteItemProps, CommandPaletteGroupProps } from "./command-palette.props";
|
|
export { CommandPaletteRootPropsSchema, CommandPaletteMeta } from "./command-palette.props";
|
|
```
|
|
|
|
- [ ] **Step 6: Write test**
|
|
|
|
Create `packages/core/tests/components/command-palette/command-palette.test.tsx`:
|
|
|
|
```typescript
|
|
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
|
import { describe, expect, it } from "vitest";
|
|
import { CommandPalette } from "../../../src/components/command-palette/index";
|
|
import { CommandPaletteRootPropsSchema, CommandPaletteMeta } from "../../../src/components/command-palette/command-palette.props";
|
|
|
|
describe("CommandPalette", () => {
|
|
it("renders input and items", () => {
|
|
render(() => (
|
|
<CommandPalette>
|
|
<CommandPalette.Input placeholder="Search commands..." />
|
|
<CommandPalette.List>
|
|
<CommandPalette.Item value="copy">Copy</CommandPalette.Item>
|
|
<CommandPalette.Item value="paste">Paste</CommandPalette.Item>
|
|
</CommandPalette.List>
|
|
</CommandPalette>
|
|
));
|
|
expect(screen.getByPlaceholderText("Search commands...")).toBeTruthy();
|
|
expect(screen.getByText("Copy")).toBeTruthy();
|
|
expect(screen.getByText("Paste")).toBeTruthy();
|
|
});
|
|
|
|
it("renders groups with headings", () => {
|
|
render(() => (
|
|
<CommandPalette>
|
|
<CommandPalette.Input placeholder="Search..." />
|
|
<CommandPalette.List>
|
|
<CommandPalette.Group heading="Actions">
|
|
<CommandPalette.Item value="save">Save</CommandPalette.Item>
|
|
</CommandPalette.Group>
|
|
</CommandPalette.List>
|
|
</CommandPalette>
|
|
));
|
|
expect(screen.getByText("Actions")).toBeTruthy();
|
|
expect(screen.getByText("Save")).toBeTruthy();
|
|
});
|
|
|
|
it("calls onSelect when item activated", () => {
|
|
let selected = "";
|
|
render(() => (
|
|
<CommandPalette onSelect={(v) => { selected = v; }}>
|
|
<CommandPalette.Input />
|
|
<CommandPalette.List>
|
|
<CommandPalette.Item value="run">Run</CommandPalette.Item>
|
|
</CommandPalette.List>
|
|
</CommandPalette>
|
|
));
|
|
fireEvent.click(screen.getByText("Run"));
|
|
expect(selected).toBe("run");
|
|
});
|
|
|
|
it("schema validates", () => {
|
|
expect(CommandPaletteRootPropsSchema.safeParse({ search: "test", loop: true, filter: false }).success).toBe(true);
|
|
});
|
|
|
|
it("meta has fields", () => {
|
|
expect(CommandPaletteMeta.name).toBe("CommandPalette");
|
|
expect(CommandPaletteMeta.parts).toContain("Root");
|
|
expect(CommandPaletteMeta.parts).toContain("Input");
|
|
expect(CommandPaletteMeta.parts).toContain("Item");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests and commit**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/components/command-palette/
|
|
git add packages/core/src/components/command-palette/ packages/core/tests/components/command-palette/
|
|
git commit -m "feat(command-palette): add CommandPalette with search, keyboard nav, and grouping"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Form Component
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/form/form.props.ts`
|
|
- Create: `packages/core/src/components/form/form-context.ts`
|
|
- Create: `packages/core/src/components/form/form-root.tsx`
|
|
- Create: `packages/core/src/components/form/form-field.tsx`
|
|
- Create: `packages/core/src/components/form/form-label.tsx`
|
|
- Create: `packages/core/src/components/form/form-control.tsx`
|
|
- Create: `packages/core/src/components/form/form-description.tsx`
|
|
- Create: `packages/core/src/components/form/form-error-message.tsx`
|
|
- Create: `packages/core/src/components/form/form-submit.tsx`
|
|
- Create: `packages/core/src/components/form/index.ts`
|
|
- Create: `packages/core/tests/components/form/form.test.tsx`
|
|
|
|
The Form integrates with Zod v4 for validation — AI generates a schema, the form validates against it.
|
|
|
|
- [ ] **Step 1: Create form.props.ts**
|
|
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
|
|
export const FormRootPropsSchema = z.object({
|
|
disabled: z.boolean().optional().describe("Disable all fields in the form"),
|
|
validateOn: z.enum(["submit", "blur", "change"]).optional().describe("When to validate. Defaults to 'submit'"),
|
|
});
|
|
|
|
export interface FormRootProps extends z.infer<typeof FormRootPropsSchema>, Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "onSubmit"> {
|
|
schema?: z.ZodType;
|
|
onSubmit?: (values: Record<string, unknown>, event: SubmitEvent) => void;
|
|
onValidationError?: (errors: Record<string, string[]>) => void;
|
|
children: JSX.Element;
|
|
}
|
|
|
|
export const FormFieldPropsSchema = z.object({
|
|
name: z.string().describe("Field name matching the schema key"),
|
|
});
|
|
|
|
export interface FormFieldProps extends z.infer<typeof FormFieldPropsSchema> {
|
|
children: JSX.Element;
|
|
}
|
|
|
|
export interface FormLabelProps extends JSX.LabelHTMLAttributes<HTMLLabelElement> { children?: JSX.Element; }
|
|
export interface FormControlProps { children: (props: { id: string; name: string; "aria-describedby"?: string; "aria-invalid"?: boolean }) => JSX.Element; }
|
|
export interface FormDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> { children?: JSX.Element; }
|
|
export interface FormErrorMessageProps extends JSX.HTMLAttributes<HTMLParagraphElement> { children?: JSX.Element | ((errors: string[]) => JSX.Element); }
|
|
export interface FormSubmitProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
|
|
|
export const FormMeta: ComponentMeta = {
|
|
name: "Form",
|
|
description: "Form with Zod v4 schema validation, field-level error display, and accessible aria linking",
|
|
parts: ["Root", "Field", "Label", "Control", "Description", "ErrorMessage", "Submit"] as const,
|
|
requiredParts: ["Root", "Field"] as const,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Create form context**
|
|
|
|
Two contexts: FormContext (root-level — schema, errors store, submit handler, validateOn) and FormFieldContext (per-field — name, fieldId, errors, touched).
|
|
|
|
Key form context:
|
|
```typescript
|
|
interface FormContextValue {
|
|
errors: Accessor<Record<string, string[]>>;
|
|
getFieldErrors: (name: string) => string[];
|
|
setFieldValue: (name: string, value: unknown) => void;
|
|
getFieldValue: (name: string) => unknown;
|
|
validateField: (name: string) => void;
|
|
validateAll: () => boolean;
|
|
disabled: Accessor<boolean>;
|
|
validateOn: Accessor<"submit" | "blur" | "change">;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create form-root.tsx**
|
|
|
|
Uses a reactive store (`createStore`) for field values and errors. On submit:
|
|
1. Collect all field values
|
|
2. If `schema` provided, validate with `schema.safeParse(values)`
|
|
3. If valid, call `onSubmit(values, event)`
|
|
4. If invalid, map Zod errors to field-level error arrays, call `onValidationError`
|
|
|
|
```typescript
|
|
const handleSubmit: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent> = (e) => {
|
|
e.preventDefault();
|
|
if (local.schema) {
|
|
const result = local.schema.safeParse(formValues);
|
|
if (!result.success) {
|
|
const fieldErrors: Record<string, string[]> = {};
|
|
for (const issue of result.error.issues) {
|
|
const path = issue.path.join(".");
|
|
if (!fieldErrors[path]) fieldErrors[path] = [];
|
|
fieldErrors[path].push(issue.message);
|
|
}
|
|
setErrors(fieldErrors);
|
|
local.onValidationError?.(fieldErrors);
|
|
return;
|
|
}
|
|
setErrors({});
|
|
local.onSubmit?.(result.data, e);
|
|
} else {
|
|
local.onSubmit?.(formValues, e);
|
|
}
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Create form sub-components**
|
|
|
|
**form-field.tsx:** Provides per-field context. Generates unique IDs for label/description/error linking. Calls `validateField` on blur/change based on `validateOn`.
|
|
|
|
**form-label.tsx:** `<label htmlFor={fieldCtx.controlId}>`.
|
|
|
|
**form-control.tsx:** Render prop that passes `{ id, name, "aria-describedby", "aria-invalid" }` for linking.
|
|
|
|
**form-description.tsx:** `<p id={fieldCtx.descriptionId}>` for accessible description.
|
|
|
|
**form-error-message.tsx:** `<p role="alert" id={fieldCtx.errorId}>`. Only renders when field has errors. Supports render prop `children={(errors) => ...}`.
|
|
|
|
**form-submit.tsx:** `<button type="submit" disabled={formCtx.disabled()}>`.
|
|
|
|
- [ ] **Step 5: Create index.ts**
|
|
|
|
```typescript
|
|
import { FormRoot } from "./form-root";
|
|
import { FormField } from "./form-field";
|
|
import { FormLabel } from "./form-label";
|
|
import { FormControl } from "./form-control";
|
|
import { FormDescription } from "./form-description";
|
|
import { FormErrorMessage } from "./form-error-message";
|
|
import { FormSubmit } from "./form-submit";
|
|
|
|
export const Form = Object.assign(FormRoot, {
|
|
Field: FormField, Label: FormLabel, Control: FormControl,
|
|
Description: FormDescription, ErrorMessage: FormErrorMessage, Submit: FormSubmit,
|
|
});
|
|
|
|
export type { FormRootProps, FormFieldProps, FormControlProps, FormErrorMessageProps } from "./form.props";
|
|
export { FormRootPropsSchema, FormFieldPropsSchema, FormMeta } from "./form.props";
|
|
```
|
|
|
|
- [ ] **Step 6: Write test**
|
|
|
|
Create `packages/core/tests/components/form/form.test.tsx`:
|
|
|
|
```typescript
|
|
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
|
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod/v4";
|
|
import { Form } from "../../../src/components/form/index";
|
|
import { FormRootPropsSchema, FormMeta } from "../../../src/components/form/form.props";
|
|
|
|
describe("Form", () => {
|
|
it("renders form with fields", () => {
|
|
render(() => (
|
|
<Form>
|
|
<Form.Field name="email">
|
|
<Form.Label>Email</Form.Label>
|
|
<Form.Control>
|
|
{(props) => <input {...props} type="email" data-testid="email" />}
|
|
</Form.Control>
|
|
</Form.Field>
|
|
<Form.Submit>Submit</Form.Submit>
|
|
</Form>
|
|
));
|
|
expect(screen.getByText("Email")).toBeTruthy();
|
|
expect(screen.getByTestId("email")).toBeTruthy();
|
|
expect(screen.getByText("Submit")).toBeTruthy();
|
|
});
|
|
|
|
it("validates with Zod schema on submit", () => {
|
|
const schema = z.object({ email: z.string().email() });
|
|
let errors: Record<string, string[]> | undefined;
|
|
render(() => (
|
|
<Form schema={schema} onValidationError={(e) => { errors = e; }}>
|
|
<Form.Field name="email">
|
|
<Form.Control>{(props) => <input {...props} value="invalid" />}</Form.Control>
|
|
<Form.ErrorMessage>{(errs) => <span>{errs[0]}</span>}</Form.ErrorMessage>
|
|
</Form.Field>
|
|
<Form.Submit data-testid="submit">Submit</Form.Submit>
|
|
</Form>
|
|
));
|
|
fireEvent.click(screen.getByTestId("submit"));
|
|
expect(errors).toBeDefined();
|
|
});
|
|
|
|
it("schema validates", () => {
|
|
expect(FormRootPropsSchema.safeParse({ validateOn: "blur", disabled: false }).success).toBe(true);
|
|
expect(FormRootPropsSchema.safeParse({ validateOn: "invalid" }).success).toBe(false);
|
|
});
|
|
|
|
it("meta has fields", () => {
|
|
expect(FormMeta.name).toBe("Form");
|
|
expect(FormMeta.parts).toContain("Root");
|
|
expect(FormMeta.parts).toContain("Field");
|
|
expect(FormMeta.parts).toContain("ErrorMessage");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests and commit**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/components/form/
|
|
git add packages/core/src/components/form/ packages/core/tests/components/form/
|
|
git commit -m "feat(form): add Form component with Zod v4 validation integration"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Wizard/Stepper Component
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/wizard/wizard.props.ts`
|
|
- Create: `packages/core/src/components/wizard/wizard-context.ts`
|
|
- Create: `packages/core/src/components/wizard/wizard-root.tsx`
|
|
- Create: `packages/core/src/components/wizard/wizard-step.tsx`
|
|
- Create: `packages/core/src/components/wizard/wizard-step-list.tsx`
|
|
- Create: `packages/core/src/components/wizard/wizard-step-trigger.tsx`
|
|
- Create: `packages/core/src/components/wizard/wizard-step-content.tsx`
|
|
- Create: `packages/core/src/components/wizard/wizard-prev.tsx`
|
|
- Create: `packages/core/src/components/wizard/wizard-next.tsx`
|
|
- Create: `packages/core/src/components/wizard/index.ts`
|
|
- Create: `packages/core/tests/components/wizard/wizard.test.tsx`
|
|
|
|
- [ ] **Step 1: Create wizard.props.ts**
|
|
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
|
|
export const WizardRootPropsSchema = z.object({
|
|
step: z.number().optional().describe("Controlled current step index (0-based)"),
|
|
defaultStep: z.number().optional().describe("Initial step (uncontrolled). Defaults to 0"),
|
|
linear: z.boolean().optional().describe("Whether steps must be completed in order. Defaults to true"),
|
|
orientation: z.enum(["horizontal", "vertical"]).optional().describe("Step indicator layout. Defaults to 'horizontal'"),
|
|
});
|
|
|
|
export interface WizardRootProps extends z.infer<typeof WizardRootPropsSchema> {
|
|
onStepChange?: (step: number) => void;
|
|
onComplete?: () => void;
|
|
children: JSX.Element;
|
|
}
|
|
|
|
export const WizardStepPropsSchema = z.object({
|
|
index: z.number().describe("Step index (0-based)"),
|
|
disabled: z.boolean().optional().describe("Whether this step is disabled"),
|
|
completed: z.boolean().optional().describe("Whether this step is completed"),
|
|
});
|
|
|
|
export interface WizardStepProps extends z.infer<typeof WizardStepPropsSchema> { children: JSX.Element; }
|
|
export interface WizardStepListProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
|
|
export interface WizardStepTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
|
export interface WizardStepContentProps extends JSX.HTMLAttributes<HTMLDivElement> { children?: JSX.Element; }
|
|
export interface WizardPrevProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
|
export interface WizardNextProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
|
|
|
export const WizardMeta: ComponentMeta = {
|
|
name: "Wizard",
|
|
description: "Multi-step flow with step indicators, navigation, and linear/non-linear progression",
|
|
parts: ["Root", "StepList", "Step", "StepTrigger", "StepContent", "PrevButton", "NextButton"] as const,
|
|
requiredParts: ["Root", "Step", "StepContent"] as const,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Create wizard-context.ts**
|
|
|
|
```typescript
|
|
interface WizardContextValue {
|
|
currentStep: Accessor<number>;
|
|
totalSteps: Accessor<number>;
|
|
goToStep: (step: number) => void;
|
|
goToNext: () => void;
|
|
goToPrev: () => void;
|
|
isFirstStep: Accessor<boolean>;
|
|
isLastStep: Accessor<boolean>;
|
|
linear: Accessor<boolean>;
|
|
orientation: Accessor<"horizontal" | "vertical">;
|
|
completedSteps: Accessor<Set<number>>;
|
|
canGoToStep: (step: number) => boolean;
|
|
}
|
|
|
|
interface WizardStepContextValue {
|
|
index: number;
|
|
isActive: Accessor<boolean>;
|
|
isCompleted: Accessor<boolean>;
|
|
isDisabled: Accessor<boolean>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create root and sub-components**
|
|
|
|
**wizard-root.tsx:** Uses `createControllableSignal` for current step. Tracks total steps from children. Provides context. When `onComplete` is provided and user navigates past the last step, calls it.
|
|
|
|
**wizard-step.tsx:** Registers itself with the root context. Provides WizardStepContext with `isActive`, `isCompleted`, `isDisabled`.
|
|
|
|
**wizard-step-list.tsx:** `<div role="tablist" data-orientation={orientation}>` containing step triggers.
|
|
|
|
**wizard-step-trigger.tsx:** Button for each step. `aria-selected={isActive}`, `data-state` (active/completed/upcoming). In linear mode, disabled if previous steps not completed.
|
|
|
|
**wizard-step-content.tsx:** Shows only when step is active. `<Show when={stepCtx.isActive()}>`.
|
|
|
|
**wizard-prev.tsx:** Calls `ctx.goToPrev()`. Disabled when `isFirstStep`.
|
|
|
|
**wizard-next.tsx:** Calls `ctx.goToNext()`. Label changes to "Complete" on last step.
|
|
|
|
- [ ] **Step 4: Create index.ts, write test, run and commit**
|
|
|
|
Test covers: rendering steps, navigation with prev/next, linear mode preventing skip, onStepChange callback, schema validation, meta fields.
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/components/wizard/
|
|
git add packages/core/src/components/wizard/ packages/core/tests/components/wizard/
|
|
git commit -m "feat(wizard): add Wizard/Stepper with linear/non-linear progression"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: DataTable Component
|
|
|
|
**Depends on:** Task 1 (createVirtualizer)
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/data-table/data-table.props.ts`
|
|
- Create: `packages/core/src/components/data-table/data-table-context.ts`
|
|
- Create: `packages/core/src/components/data-table/data-table-root.tsx`
|
|
- Create: `packages/core/src/components/data-table/data-table-header.tsx`
|
|
- Create: `packages/core/src/components/data-table/data-table-header-cell.tsx`
|
|
- Create: `packages/core/src/components/data-table/data-table-body.tsx`
|
|
- Create: `packages/core/src/components/data-table/data-table-row.tsx`
|
|
- Create: `packages/core/src/components/data-table/data-table-cell.tsx`
|
|
- Create: `packages/core/src/components/data-table/data-table-pagination.tsx`
|
|
- Create: `packages/core/src/components/data-table/index.ts`
|
|
- Create: `packages/core/tests/components/data-table/data-table.test.tsx`
|
|
|
|
This is the most complex component. It manages columns, sorting, filtering, pagination, and optional row selection.
|
|
|
|
- [ ] **Step 1: Create data-table.props.ts**
|
|
|
|
```typescript
|
|
import { z } from "zod/v4";
|
|
import type { Accessor, JSX } from "solid-js";
|
|
import type { ComponentMeta } from "../../meta";
|
|
|
|
export const DataTableColumnSchema = z.object({
|
|
id: z.string().describe("Unique column identifier"),
|
|
header: z.string().describe("Column header display text"),
|
|
sortable: z.boolean().optional().describe("Whether this column can be sorted"),
|
|
width: z.number().optional().describe("Fixed column width in pixels"),
|
|
minWidth: z.number().optional().describe("Minimum column width for resizing"),
|
|
});
|
|
|
|
export interface DataTableColumn<T = unknown> extends z.infer<typeof DataTableColumnSchema> {
|
|
cell: (row: T, index: number) => JSX.Element;
|
|
sortFn?: (a: T, b: T) => number;
|
|
filterFn?: (row: T, filterValue: string) => boolean;
|
|
}
|
|
|
|
export const DataTableRootPropsSchema = z.object({
|
|
pageSize: z.number().optional().describe("Rows per page. Defaults to 10. Set to Infinity for no pagination"),
|
|
page: z.number().optional().describe("Controlled current page (1-indexed)"),
|
|
defaultPage: z.number().optional().describe("Initial page (uncontrolled)"),
|
|
sortColumn: z.string().optional().describe("Controlled sort column ID"),
|
|
sortDirection: z.enum(["asc", "desc"]).optional().describe("Controlled sort direction"),
|
|
filter: z.string().optional().describe("Controlled global filter string"),
|
|
selectable: z.boolean().optional().describe("Whether rows can be selected"),
|
|
virtualizeRows: z.boolean().optional().describe("Enable virtual scrolling for large datasets"),
|
|
estimateRowSize: z.number().optional().describe("Estimated row height for virtualization. Defaults to 40"),
|
|
});
|
|
|
|
export interface DataTableRootProps<T = unknown> extends z.infer<typeof DataTableRootPropsSchema> {
|
|
data: T[];
|
|
columns: DataTableColumn<T>[];
|
|
onPageChange?: (page: number) => void;
|
|
onSortChange?: (column: string, direction: "asc" | "desc") => void;
|
|
onFilterChange?: (filter: string) => void;
|
|
onSelectionChange?: (selectedRows: T[]) => void;
|
|
children: JSX.Element;
|
|
}
|
|
|
|
export interface DataTableHeaderProps extends JSX.HTMLAttributes<HTMLTableSectionElement> { children?: JSX.Element; }
|
|
export interface DataTableHeaderCellProps extends JSX.HTMLAttributes<HTMLTableCellElement> { column: string; children?: JSX.Element; }
|
|
export interface DataTableBodyProps extends JSX.HTMLAttributes<HTMLTableSectionElement> { children?: (row: unknown, index: number) => JSX.Element; }
|
|
export interface DataTableRowProps extends JSX.HTMLAttributes<HTMLTableRowElement> { children?: JSX.Element; }
|
|
export interface DataTableCellProps extends JSX.HTMLAttributes<HTMLTableCellElement> { children?: JSX.Element; }
|
|
export interface DataTablePaginationProps extends JSX.HTMLAttributes<HTMLDivElement> { children?: JSX.Element; }
|
|
|
|
export const DataTableMeta: ComponentMeta = {
|
|
name: "DataTable",
|
|
description: "Feature-rich data table with sorting, filtering, pagination, row selection, and virtual scrolling",
|
|
parts: ["Root", "Header", "HeaderCell", "Body", "Row", "Cell", "Pagination"] as const,
|
|
requiredParts: ["Root", "Header", "Body"] as const,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Create data-table-context.ts**
|
|
|
|
Context provides: processed data (sorted + filtered + paginated), column definitions, sort state, page state, filter state, selection state, handlers.
|
|
|
|
```typescript
|
|
interface DataTableContextValue<T = unknown> {
|
|
columns: Accessor<DataTableColumn<T>[]>;
|
|
processedData: Accessor<T[]>;
|
|
pageData: Accessor<T[]>;
|
|
totalRows: Accessor<number>;
|
|
totalPages: Accessor<number>;
|
|
currentPage: Accessor<number>;
|
|
pageSize: Accessor<number>;
|
|
sortColumn: Accessor<string | undefined>;
|
|
sortDirection: Accessor<"asc" | "desc">;
|
|
filter: Accessor<string>;
|
|
selectedRows: Accessor<Set<number>>;
|
|
toggleSort: (columnId: string) => void;
|
|
setPage: (page: number) => void;
|
|
setFilter: (filter: string) => void;
|
|
toggleRowSelection: (index: number) => void;
|
|
toggleAllSelection: () => void;
|
|
isRowSelected: (index: number) => boolean;
|
|
isAllSelected: Accessor<boolean>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create root component**
|
|
|
|
**data-table-root.tsx:** The most complex root in the library. Manages:
|
|
1. **Sorting:** Applies `column.sortFn` or default `localeCompare`/numeric sort
|
|
2. **Filtering:** Applies `column.filterFn` or default string includes
|
|
3. **Pagination:** Slices processed data by page
|
|
4. **Selection:** Tracks selected row indices in a `Set`
|
|
5. **Optional virtualization:** If `virtualizeRows`, uses `createVirtualizer`
|
|
|
|
Renders `<table>` element with context provider.
|
|
|
|
- [ ] **Step 4: Create sub-components**
|
|
|
|
**data-table-header.tsx:** `<thead>` with auto-generated header row from columns if no children.
|
|
|
|
**data-table-header-cell.tsx:** `<th>` for a column. Shows sort indicator. Calls `toggleSort` on click for sortable columns. `aria-sort`.
|
|
|
|
**data-table-body.tsx:** `<tbody>` that renders rows. If render prop `children` is provided, maps over `pageData`. Otherwise auto-renders cells from column definitions.
|
|
|
|
**data-table-row.tsx:** `<tr>` with optional selection checkbox. `data-selected`, `aria-selected`.
|
|
|
|
**data-table-cell.tsx:** `<td>` wrapper.
|
|
|
|
**data-table-pagination.tsx:** Pagination controls. Shows page info, prev/next buttons, page size selector.
|
|
|
|
- [ ] **Step 5: Create index.ts, write test, run and commit**
|
|
|
|
Test covers: rendering table with columns and data, sorting on header click, filtering, pagination, row selection, schema validation, meta fields.
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run tests/components/data-table/
|
|
git add packages/core/src/components/data-table/ packages/core/tests/components/data-table/
|
|
git commit -m "feat(data-table): add DataTable with sorting, filtering, pagination, and row selection"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Add All New Components to Build Config + Full Verification
|
|
|
|
**Depends on:** All previous tasks
|
|
|
|
**Files:**
|
|
- Modify: `packages/core/tsdown.config.ts`
|
|
- Modify: `packages/core/package.json`
|
|
|
|
- [ ] **Step 1: Add components to tsdown.config.ts**
|
|
|
|
Add to the `components` array:
|
|
```
|
|
"virtual-list", "calendar", "date-picker", "command-palette", "form", "wizard", "data-table"
|
|
```
|
|
|
|
- [ ] **Step 2: Add exports to package.json**
|
|
|
|
Add 7 new export entries following the existing pattern:
|
|
```json
|
|
"./virtual-list": { "solid": "...", "import": "...", "require": "..." },
|
|
"./calendar": { ... },
|
|
"./date-picker": { ... },
|
|
"./command-palette": { ... },
|
|
"./form": { ... },
|
|
"./wizard": { ... },
|
|
"./data-table": { ... }
|
|
```
|
|
|
|
- [ ] **Step 3: Add createVirtualizer to primitives exports (optional)**
|
|
|
|
If primitives should be importable directly, add to tsdown config:
|
|
```typescript
|
|
entry["primitives/create-virtualizer"] = "src/primitives/create-virtualizer.ts";
|
|
```
|
|
|
|
- [ ] **Step 4: Run full test suite**
|
|
|
|
```bash
|
|
cd packages/core && pnpm vitest run
|
|
```
|
|
|
|
Expected: All tests pass (Phase 1 tests + Phase 2 tests).
|
|
|
|
- [ ] **Step 5: Run full build**
|
|
|
|
```bash
|
|
cd packages/core && pnpm build
|
|
```
|
|
|
|
Expected: Build succeeds with all new component entries.
|
|
|
|
- [ ] **Step 6: Verify export count**
|
|
|
|
```bash
|
|
node -e 'console.log(Object.keys(require("./packages/core/package.json").exports).length)'
|
|
```
|
|
|
|
Expected: 45 (38 from Phase 1 + 7 new).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add packages/core/tsdown.config.ts packages/core/package.json
|
|
git commit -m "feat: add Phase 2 components to build config — 39 component exports total"
|
|
```
|
|
|
|
---
|
|
|
|
## Verification Checklist
|
|
|
|
After all tasks complete:
|
|
|
|
| Component | Schema | Meta | Tests | Export |
|
|
|-----------|--------|------|-------|--------|
|
|
| createVirtualizer | N/A | N/A | ✅ | primitive |
|
|
| VirtualList | ✅ | ✅ | ✅ | `./virtual-list` |
|
|
| Calendar | ✅ | ✅ | ✅ | `./calendar` |
|
|
| DatePicker | ✅ | ✅ | ✅ | `./date-picker` |
|
|
| CommandPalette | ✅ | ✅ | ✅ | `./command-palette` |
|
|
| Form | ✅ | ✅ | ✅ | `./form` |
|
|
| Wizard | ✅ | ✅ | ✅ | `./wizard` |
|
|
| DataTable | ✅ | ✅ | ✅ | `./data-table` |
|