From ff6f1bb81f617d0f5ee9abae8717b258de186ac3 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 21:03:59 +0700 Subject: [PATCH] Plan for advanced components 7 components: Form, Calendar, DatePicker, CommandPalette, DataTable, VirtualList, Wizard + createVirtualizer primitive. --- .../2026-03-29-phase2-advanced-components.md | 1588 +++++++++++++++++ 1 file changed, 1588 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-29-phase2-advanced-components.md diff --git a/docs/superpowers/plans/2026-03-29-phase2-advanced-components.md b/docs/superpowers/plans/2026-03-29-phase2-advanced-components.md new file mode 100644 index 0000000..2b0e7ab --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-phase2-advanced-components.md @@ -0,0 +1,1588 @@ +# 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 { + 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; + /** The scrollable container element. */ + getScrollElement: Accessor; + /** 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; + /** Total scrollable size in pixels. */ + totalSize: Accessor; + /** Imperatively scroll to an item by index. */ + scrollToIndex: (index: number, options?: { align?: "start" | "center" | "end" }) => void; + /** Current scroll offset. */ + scrollOffset: Accessor; + /** 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(); + + 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 = () => { + const count = options.count(); + let total = 0; + for (let i = 0; i < count; i++) { + total += getSize(i); + } + return total; + }; + + const virtualItems: Accessor = () => { + 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, "count" | "estimateSize">, Omit, "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(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 ( +
+
+ + {(item) => ( +
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)} +
+ )} +
+
+
+ ); +} +``` + +- [ ] **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(() => ( + + {(item) =>
Item {item.index}
} +
+ )); + 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 { + onValueChange?: (date: string) => void; + onMonthChange?: (month: number, year: number) => void; + children: JSX.Element; +} + +export interface CalendarHeaderProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface CalendarHeadingProps extends JSX.HTMLAttributes {} +export interface CalendarNavProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface CalendarPrevProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface CalendarNextProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface CalendarGridProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface CalendarGridHeadProps extends JSX.HTMLAttributes {} +export interface CalendarGridBodyProps extends JSX.HTMLAttributes {} +export interface CalendarCellProps extends JSX.HTMLAttributes { + 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; + selectedDate: Accessor; + displayMonth: Accessor; + displayYear: Accessor; + locale: Accessor; + weekStartsOn: Accessor; + minDate: Accessor; + maxDate: Accessor; + disabled: Accessor; + 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 `
` 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

{heading()}

; +``` + +**calendar-nav.tsx:** Container for prev/next buttons. Contains CalendarPrev and CalendarNext sub-components that call `ctx.goToPrevMonth()` / `ctx.goToNextMonth()`. + +**calendar-grid.tsx:** ``. + +**calendar-grid-head.tsx:** Renders weekday headers using `ctx.getWeekDayNames()` as `` with `` 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(() => ( + + + + + + + + + + + + + + )); + expect(screen.getByText("March 2026")).toBeTruthy(); + }); + + it("calls onValueChange when date selected", () => { + let selected = ""; + render(() => ( + { selected = d; }}> + + + + + )); + 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 { + onValueChange?: (date: string) => void; + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export interface DatePickerInputProps extends Omit, "value"> {} +export interface DatePickerTriggerProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface DatePickerContentProps extends JSX.HTMLAttributes { 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 `` 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(() => ( + + + Open + +
Calendar goes here
+
+
+ )); + const input = screen.getByTestId("input"); + expect(input).toBeTruthy(); + }); + + it("shows formatted date when value set", () => { + render(() => ( + + +
Calendar
+
+ )); + 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 { + onValueChange?: (value: string) => void; + onSearchChange?: (search: string) => void; + onSelect?: (value: string) => void; + children: JSX.Element; +} + +export interface CommandPaletteInputProps extends Omit, "value" | "onInput"> { placeholder?: string; } +export interface CommandPaletteListProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface CommandPaletteItemProps extends JSX.HTMLAttributes { + value: string; + keywords?: string[]; + disabled?: boolean; + onSelect?: () => void; + children?: JSX.Element; +} +export interface CommandPaletteGroupProps extends JSX.HTMLAttributes { heading?: string; children: JSX.Element; } +export interface CommandPaletteEmptyProps extends JSX.HTMLAttributes { children?: JSX.Element; } +export interface CommandPaletteSeparatorProps extends JSX.HTMLAttributes {} + +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:** `` 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. ``. + +**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(() => ( + + + + Copy + Paste + + + )); + expect(screen.getByPlaceholderText("Search commands...")).toBeTruthy(); + expect(screen.getByText("Copy")).toBeTruthy(); + expect(screen.getByText("Paste")).toBeTruthy(); + }); + + it("renders groups with headings", () => { + render(() => ( + + + + + Save + + + + )); + expect(screen.getByText("Actions")).toBeTruthy(); + expect(screen.getByText("Save")).toBeTruthy(); + }); + + it("calls onSelect when item activated", () => { + let selected = ""; + render(() => ( + { selected = v; }}> + + + Run + + + )); + 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, Omit, "onSubmit"> { + schema?: z.ZodType; + onSubmit?: (values: Record, event: SubmitEvent) => void; + onValidationError?: (errors: Record) => 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 { + children: JSX.Element; +} + +export interface FormLabelProps extends JSX.LabelHTMLAttributes { 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 { children?: JSX.Element; } +export interface FormErrorMessageProps extends JSX.HTMLAttributes { children?: JSX.Element | ((errors: string[]) => JSX.Element); } +export interface FormSubmitProps extends JSX.ButtonHTMLAttributes { 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>; + getFieldErrors: (name: string) => string[]; + setFieldValue: (name: string, value: unknown) => void; + getFieldValue: (name: string) => unknown; + validateField: (name: string) => void; + validateAll: () => boolean; + disabled: Accessor; + 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 = (e) => { + e.preventDefault(); + if (local.schema) { + const result = local.schema.safeParse(formValues); + if (!result.success) { + const fieldErrors: Record = {}; + 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:** `
` for each day. + +**calendar-grid-body.tsx:** Renders weeks as `
` element with context provider. + +- [ ] **Step 4: Create sub-components** + +**data-table-header.tsx:** `` with auto-generated header row from columns if no children. + +**data-table-header-cell.tsx:** `` that renders rows. If render prop `children` is provided, maps over `pageData`. Otherwise auto-renders cells from column definitions. + +**data-table-row.tsx:** `` with optional selection checkbox. `data-selected`, `aria-selected`. + +**data-table-cell.tsx:** `
` for a column. Shows sort indicator. Calls `toggleSort` on click for sortable columns. `aria-sort`. + +**data-table-body.tsx:** `
` 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` |