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

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() uses Intl.DateTimeFormat with the configured locale

  • Keyboard navigation: Arrow keys move focus, Enter/Space selects

  • Month navigation: goToPrevMonth/goToNextMonth update display state

  • Step 4: Create sub-components

calendar-header.tsx: Simple <div data-scope="calendar" data-part="header"> wrapper.

calendar-heading.tsx: Renders current month/year from context using Intl.DateTimeFormat:

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/setValue for the selected date
  • open/setOpen for the popover
  • formatDate() helper using Intl.DateTimeFormat

date-picker-root.tsx: Uses createDisclosureState for open, createControllableSignal for value. When a date is selected in the Calendar, it closes the popover and updates the value.

date-picker-input.tsx: Read-only input showing the formatted date. Clicking opens the calendar. A hidden <input type="hidden" name={name} value={isoDate}> for form submission.

date-picker-trigger.tsx: Button that toggles the popover open state.

date-picker-content.tsx: Floating content panel using createFloating for positioning. Contains the Calendar inside.

  • Step 3: Create index.ts
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:

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

  1. Sorting: Applies column.sortFn or default localeCompare/numeric sort
  2. Filtering: Applies column.filterFn or default string includes
  3. Pagination: Slices processed data by page
  4. Selection: Tracks selected row indices in a Set
  5. Optional virtualization: If virtualizeRows, uses createVirtualizer

Renders <table> element with context provider.

  • Step 4: Create sub-components

data-table-header.tsx: <thead> with auto-generated header row from columns if no children.

data-table-header-cell.tsx: <th> for a column. Shows sort indicator. Calls toggleSort on click for sortable columns. aria-sort.

data-table-body.tsx: <tbody> that renders rows. If render prop children is provided, maps over pageData. Otherwise auto-renders cells from column definitions.

data-table-row.tsx: <tr> with optional selection checkbox. data-selected, aria-selected.

data-table-cell.tsx: <td> wrapper.

data-table-pagination.tsx: Pagination controls. Shows page info, prev/next buttons, page size selector.

  • Step 5: Create index.ts, write test, run and commit

Test covers: rendering table with columns and data, sorting on header click, filtering, pagination, row selection, schema validation, meta fields.

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