PettyUI/docs/superpowers/plans/2026-03-29-phase2-advanced-components.md
2026-03-31 21:42:28 +07:00

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` |