7 components: Form, Calendar, DatePicker, CommandPalette, DataTable, VirtualList, Wizard + createVirtualizer primitive.
61 KiB
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):
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):
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:
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
cd packages/core && pnpm vitest run tests/primitives/create-virtualizer.test.ts
- Step 3: Implement createVirtualizer
Create packages/core/src/primitives/create-virtualizer.ts:
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
cd packages/core && pnpm vitest run tests/primitives/create-virtualizer.test.ts
Expected: 4 tests pass.
- Step 5: Commit
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
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
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
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:
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
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
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:
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()usesIntl.DateTimeFormatwith 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:
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
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:
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
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
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/setValuefor the selected dateopen/setOpenfor the popoverformatDate()helper usingIntl.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
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:
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
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
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:
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
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:
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
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
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:
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:
- Collect all field values
- If
schemaprovided, validate withschema.safeParse(values) - If valid, call
onSubmit(values, event) - If invalid, map Zod errors to field-level error arrays, call
onValidationError
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
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:
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
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
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
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.
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
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.
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:
- Sorting: Applies
column.sortFnor defaultlocaleCompare/numeric sort - Filtering: Applies
column.filterFnor default string includes - Pagination: Slices processed data by page
- Selection: Tracks selected row indices in a
Set - Optional virtualization: If
virtualizeRows, usescreateVirtualizer
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.
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:
"./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:
entry["primitives/create-virtualizer"] = "src/primitives/create-virtualizer.ts";
- Step 4: Run full test suite
cd packages/core && pnpm vitest run
Expected: All tests pass (Phase 1 tests + Phase 2 tests).
- Step 5: Run full build
cd packages/core && pnpm build
Expected: Build succeeds with all new component entries.
- Step 6: Verify export count
node -e 'console.log(Object.keys(require("./packages/core/package.json").exports).length)'
Expected: 45 (38 from Phase 1 + 7 new).
- Step 7: Commit
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 |