From 292db3a4228ad22b9f7e95d5a54981b45173da32 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 10:06:48 +0700 Subject: [PATCH] Architecture spec --- ...-spec-a-primitives-floating-collections.md | 689 ++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-29-spec-a-primitives-floating-collections.md diff --git a/docs/superpowers/specs/2026-03-29-spec-a-primitives-floating-collections.md b/docs/superpowers/specs/2026-03-29-spec-a-primitives-floating-collections.md new file mode 100644 index 0000000..5cd9331 --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-spec-a-primitives-floating-collections.md @@ -0,0 +1,689 @@ +# Spec A: Core Primitives, Floating Components, and Collection Components + +## Goal + +Add 3 new primitives and 9 new components to PettyUI, covering positioned overlays (Tooltip, Popover, HoverCard) and list-based interactions (Listbox, Select, Combobox, DropdownMenu, ContextMenu, Menubar). After this spec, PettyUI matches Kobalte's coverage for all interactive compound components. + +## Architecture + +Three new primitives power all 9 components. Components are built in dependency order — each one builds on the previous. The primitives use value-based identity (not index-based) so they work correctly with filtered/reordered lists. + +**Dependency chain:** +``` +createFloating ─────────────────────────────────────────────┐ +createListNavigation ───────────────────────────────────┐ │ +createRovingFocus (optional refactor) ──────────────┐ │ │ + │ │ │ +Tooltip ────────────────────────────────────────────│───│───┤ +Popover ────────────────────────────────────────────│───│───┤ +HoverCard ──────────────────────────────────────────│───│───┤ +Listbox ────────────────────────────────────────────│───┤ │ +Select ─────────────────────────────────────────────│───┤───┤ +Combobox ───────────────────────────────────────────│───┤───┤ +DropdownMenu ───────────────────────────────────────│───┤───┤ +ContextMenu ────────────────────────────────────────│───┤───┤ +Menubar ────────────────────────────────────────────┤───┤───┤ +``` + +## Tech Stack + +- SolidJS 1.9.x +- `@floating-ui/dom` (already in dependencies) +- TypeScript with `exactOptionalPropertyTypes: true` +- Vitest + `@solidjs/testing-library` +- Biome for linting + +--- + +## Primitive 1: `createFloating` + +**File:** `packages/core/src/primitives/create-floating.ts` + +Thin reactive wrapper around `@floating-ui/dom`. Computes position of a floating element relative to an anchor element. + +### Interface + +```ts +import type { Middleware, Placement, Strategy } from "@floating-ui/dom"; +import type { Accessor } from "solid-js"; +import type { JSX } from "solid-js"; + +interface CreateFloatingOptions { + /** Reference/anchor element. */ + anchor: Accessor; + /** The floating element to position. */ + floating: Accessor; + /** Desired placement. @default "bottom" */ + placement?: Accessor; + /** Floating UI middleware (flip, shift, offset, arrow, etc.). */ + middleware?: Accessor; + /** CSS positioning strategy. @default "absolute" */ + strategy?: Accessor; + /** Only compute position when true. @default () => true */ + open?: Accessor; +} + +interface FloatingState { + /** Computed x position in px. */ + x: Accessor; + /** Computed y position in px. */ + y: Accessor; + /** Actual placement after middleware (may differ from requested). */ + placement: Accessor; + /** Ready-to-spread CSS: { position, top, left }. */ + style: Accessor; +} +``` + +### Behavior + +- Calls `computePosition` from `@floating-ui/dom` reactively when anchor, floating, placement, or middleware change. +- Sets up `autoUpdate` (scroll/resize listener) when `open()` is true. Cleans up when `open()` becomes false or on disposal. +- Returns reactive `x`, `y`, `placement`, and a convenience `style` accessor. +- Does not render any DOM — purely computational. + +--- + +## Primitive 2: `createListNavigation` + +**File:** `packages/core/src/primitives/create-list-navigation.ts` + +Value-based keyboard navigation, selection/activation, and typeahead for list-like components. Uses `aria-activedescendant` (virtual focus) so the container/input retains DOM focus while items are visually highlighted. + +### Interface + +```ts +import type { Accessor } from "solid-js"; + +interface CreateListNavigationOptions { + /** Ordered list of valid item values. Reactive — can change (e.g., filtering). */ + items: Accessor; + /** "selection" for Listbox/Select/Combobox. "activation" for Menu. */ + mode: "selection" | "activation"; + /** @default "vertical" */ + orientation?: "vertical" | "horizontal"; + /** Wrap at list boundaries. @default true */ + loop?: boolean; + + // --- Selection mode props --- + /** Controlled selected value. */ + value?: Accessor; + /** Initial uncontrolled value. */ + defaultValue?: string; + /** Called when selection changes. */ + onValueChange?: ((value: string) => void) | undefined; + /** Allow multiple selection. @default false */ + multiple?: boolean; + /** For multiple mode: controlled values. */ + values?: Accessor; + /** For multiple mode: initial uncontrolled values. */ + defaultValues?: string[]; + /** For multiple mode: called when selection changes. */ + onValuesChange?: ((values: string[]) => void) | undefined; + + // --- Activation mode props --- + /** Called when an item is activated (Enter/click in activation mode). */ + onActivate?: (value: string) => void; + + // --- Typeahead --- + /** Enable typeahead. @default true */ + typeahead?: boolean; + /** Return the display label for a value. Used for typeahead matching. */ + getLabel?: (value: string) => string; + + // --- ID generation --- + /** Base ID for generating item IDs. Uses createUniqueId() if not provided. */ + baseId?: string; +} + +interface ListNavigationState { + /** Currently highlighted item value (virtual focus). */ + highlightedValue: Accessor; + /** Currently selected value (selection mode, single). */ + selectedValue: Accessor; + /** Currently selected values (selection mode, multiple). */ + selectedValues: Accessor; + + /** Props to spread on the list container (div/ul). */ + containerProps: { + role: string; // "listbox" or "menu" + "aria-orientation": string; + "aria-activedescendant": string | undefined; + onKeyDown: (e: KeyboardEvent) => void; + onPointerLeave: () => void; + }; + + /** Get props for a specific item by value. */ + getItemProps: (value: string) => { + id: string; + role: string; // "option" or "menuitem" + "aria-selected"?: string; // selection mode only + "aria-disabled"?: string; + "data-highlighted": "" | undefined; + "data-state"?: string; + onPointerEnter: () => void; + onPointerMove: () => void; + onClick: () => void; + }; + + /** Imperatively set highlighted value. */ + highlight: (value: string | undefined) => void; + /** Highlight the first item. */ + highlightFirst: () => void; + /** Highlight the last item. */ + highlightLast: () => void; + /** Clear highlight. */ + clearHighlight: () => void; +} +``` + +### Keyboard behavior + +| Key | Action | +|-----|--------| +| ArrowDown (vertical) / ArrowRight (horizontal) | Highlight next item | +| ArrowUp (vertical) / ArrowLeft (horizontal) | Highlight previous item | +| Home | Highlight first item | +| End | Highlight last item | +| Enter / Space | Select (selection mode) or activate (activation mode) | +| Escape | Clear highlight (consumers handle close) | +| Printable characters | Typeahead — jump to first item whose label starts with typed characters | + +### Typeahead behavior + +- Accumulates keystrokes within a 500ms window. +- Matches prefix of `getLabel(value)` (case-insensitive). +- After timeout, buffer resets. +- If `getLabel` is not provided, uses the value string itself. + +### Disabled items + +Items are not in the `items` accessor when disabled — the consumer filters them out. This keeps the primitive simple. The consumer renders disabled items with `aria-disabled` but excludes their values from the `items()` list. + +--- + +## Primitive 3: `createRovingFocus` + +**File:** `packages/core/src/primitives/create-roving-focus.ts` + +Extracted from existing Tabs/RadioGroup/Accordion keyboard navigation. Manages `tabIndex` roving — one item has `tabIndex={0}`, rest have `tabIndex={-1}`. Arrow keys move real DOM focus. + +### Interface + +```ts +interface CreateRovingFocusOptions { + /** @default "horizontal" */ + orientation?: "horizontal" | "vertical" | "both"; + /** Wrap at boundaries. @default true */ + loop?: boolean; +} + +interface RovingFocusState { + /** Spread on the container element. */ + containerProps: { + onKeyDown: (e: KeyboardEvent) => void; + }; + /** Get tabIndex for an item. Pass the item's value and current active value. */ + getTabIndex: (value: string, currentValue: Accessor) => number; +} +``` + +### Behavior + +- Arrow keys find focusable items within the container via `querySelectorAll("[data-roving-item]:not([disabled])")`. +- Items must have `data-roving-item` attribute. +- Supports Home/End. +- Orientation filters which arrow keys are active. +- This is a refactor/extraction — existing components (Tabs, RadioGroup, Accordion) can optionally migrate to it. Not a blocker for new components. + +--- + +## Component 1: Tooltip + +**Directory:** `packages/core/src/components/tooltip/` + +**Files:** `tooltip-context.ts`, `tooltip-root.tsx`, `tooltip-trigger.tsx`, `tooltip-content.tsx`, `tooltip-arrow.tsx`, `index.ts` + +### Anatomy +``` +Tooltip (root — manages open state with delay timers) + Tooltip.Trigger (the anchor element — hover/focus opens) + Tooltip.Content (positioned floating panel) + Tooltip.Arrow (optional decorative arrow) +``` + +### Props (Root) +- `open?: boolean` — controlled +- `defaultOpen?: boolean` — uncontrolled +- `onOpenChange?: (open: boolean) => void` +- `openDelay?: number` — ms before showing, default 700 +- `closeDelay?: number` — ms before hiding, default 300 + +### ARIA +- Content: `role="tooltip"`, unique `id` +- Trigger: `aria-describedby={contentId}` when open + +### Behavior +- Open on: pointer enter trigger (after openDelay), focus trigger (after openDelay) +- Close on: pointer leave trigger (after closeDelay), blur trigger, Escape key, scroll +- Instant open if another tooltip was recently closed (within 300ms) — "tooltip group" behavior. Implemented via a module-level timestamp of last close. +- No focus trap. No scroll lock. No outside click dismiss. + +### Tests (7) +1. Content has `role="tooltip"` when open +2. Trigger has `aria-describedby` pointing to content +3. Content not rendered when closed +4. Opens on trigger focus +5. Closes on Escape +6. Controlled mode works +7. Content positions via floating (has `style` with position) + +--- + +## Component 2: Popover + +**Directory:** `packages/core/src/components/popover/` + +**Files:** `popover-context.ts`, `popover-root.tsx`, `popover-trigger.tsx`, `popover-content.tsx`, `popover-arrow.tsx`, `popover-close.tsx`, `popover-portal.tsx`, `index.ts` + +### Anatomy +``` +Popover (root — disclosure state) + Popover.Trigger (click toggles) + Popover.Portal (optional) + Popover.Content (floating panel with focus trap) + Popover.Arrow (optional) + Popover.Close (button inside content) +``` + +### Props (Root) +- `open`, `defaultOpen`, `onOpenChange` +- `modal?: boolean` — default false. When true: focus trap + scroll lock + outside dismiss. + +### ARIA +- Content: `role="dialog"`, `aria-labelledby`, `aria-describedby` +- Trigger: `aria-haspopup="dialog"`, `aria-expanded="true"/"false"`, `aria-controls` + +### Behavior +- Open on: trigger click +- Close on: Escape, outside click (via createDismiss), Close button click +- When `modal`: focus trap active, scroll lock active +- When non-modal: no focus trap, no scroll lock, Tab moves out of popover and closes it + +### Tests (8) +1. Content has `role="dialog"` when open +2. Trigger has correct ARIA attributes +3. Click trigger opens +4. Escape closes +5. Close button closes +6. `aria-labelledby` links to title +7. Controlled mode +8. Content is positioned (has style) + +--- + +## Component 3: HoverCard + +**Directory:** `packages/core/src/components/hover-card/` + +**Files:** `hover-card-context.ts`, `hover-card-root.tsx`, `hover-card-trigger.tsx`, `hover-card-content.tsx`, `hover-card-arrow.tsx`, `index.ts` + +### Anatomy +``` +HoverCard (root — manages open state with delays) + HoverCard.Trigger (hover opens) + HoverCard.Content (floating panel) + HoverCard.Arrow (optional) +``` + +### Props (Root) +- `open`, `defaultOpen`, `onOpenChange` +- `openDelay?: number` — default 700 +- `closeDelay?: number` — default 300 + +### ARIA +- No specific role on content (supplementary, not announced) +- Trigger: `data-state="open"|"closed"` + +### Behavior +- Open on: pointer enter trigger (after delay) +- Close on: pointer leave trigger AND content (after delay). If pointer moves from trigger to content, stays open. +- Safe area: pointer can move diagonally from trigger to content without closing (grace area / safe triangle). +- Escape closes immediately. +- No focus trap. No scroll lock. + +### Tests (6) +1. Content not rendered when closed +2. Opens with defaultOpen +3. Closes on Escape +4. Content has `data-state` +5. Controlled mode +6. Content is positioned + +--- + +## Component 4: Listbox + +**Directory:** `packages/core/src/components/listbox/` + +**Files:** `listbox-context.ts`, `listbox-root.tsx`, `listbox-item.tsx`, `listbox-group.tsx`, `listbox-group-label.tsx`, `index.ts` + +### Anatomy +``` +Listbox (root — createListNavigation in selection mode) + Listbox.Group (optional) + Listbox.GroupLabel + Listbox.Item (value, disabled, textValue) +``` + +### Props (Root) +- `value`, `defaultValue`, `onValueChange` — single selection +- `values`, `defaultValues`, `onValuesChange` — multiple selection +- `selectionMode?: "single" | "multiple"` — default "single" +- `items: string[]` — ordered list of active (non-disabled) item values +- `orientation?: "vertical" | "horizontal"` — default "vertical" +- `loop?: boolean` — default true +- `getLabel?: (value: string) => string` — for typeahead +- `disabled?: boolean` + +### ARIA +- Root: `role="listbox"`, `aria-orientation`, `aria-activedescendant`, `aria-multiselectable` (when multiple) +- Item: `role="option"`, `aria-selected="true"|"false"`, `aria-disabled` +- Group: `role="group"`, `aria-labelledby` pointing to GroupLabel + +### Behavior +- Uses `createListNavigation` in selection mode +- Container holds DOM focus. Items get virtual focus via `aria-activedescendant`. +- `data-highlighted` on currently highlighted item for CSS styling +- `data-state="active"|"inactive"` on items reflecting selection + +### Tests (8) +1. Root has `role="listbox"` +2. Items have `role="option"` +3. ArrowDown highlights next item +4. Enter selects highlighted item +5. `aria-selected` reflects selection +6. Home/End navigate to first/last +7. Multiple selection mode works +8. Disabled items are skipped + +--- + +## Component 5: Select + +**Directory:** `packages/core/src/components/select/` + +**Files:** `select-context.ts`, `select-root.tsx`, `select-trigger.tsx`, `select-value.tsx`, `select-content.tsx`, `select-item.tsx`, `select-group.tsx`, `select-group-label.tsx`, `index.ts` + +### Anatomy +``` +Select (root — disclosure + list navigation) + Select.Trigger (button) + Select.Value (displays selected text) + Select.Content (floating listbox) + Select.Group + Select.GroupLabel + Select.Item (value, disabled, textValue) +``` + +### Props (Root) +- `value`, `defaultValue`, `onValueChange` +- `open`, `defaultOpen`, `onOpenChange` +- `items: string[]` — active item values +- `disabled?: boolean` +- `required?: boolean` +- `name?: string` — renders hidden input for form submission +- `getLabel?: (value: string) => string` + +### ARIA +- Trigger: `role="combobox"`, `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls` +- Content: `role="listbox"`, `aria-activedescendant` +- Item: `role="option"`, `aria-selected` + +### Behavior +- Closed: Trigger click or ArrowDown/Up/Enter/Space opens. Typeahead on trigger navigates without opening. +- Open: Arrow keys navigate. Enter/Space selects and closes. Escape closes. Tab closes and moves focus. +- Content positioned via `createFloating` (default placement "bottom-start", with flip + shift + offset(8)) +- When `name` is provided, renders a hidden `` with the selected value for form integration. + +### Tests (9) +1. Trigger has `role="combobox"` +2. Content has `role="listbox"` when open +3. Click trigger opens +4. ArrowDown on trigger opens and highlights first +5. Enter selects and closes +6. Escape closes +7. `aria-selected` on selected item +8. Controlled mode +9. Hidden input rendered when `name` provided + +--- + +## Component 6: Combobox + +**Directory:** `packages/core/src/components/combobox/` + +**Files:** `combobox-context.ts`, `combobox-root.tsx`, `combobox-input.tsx`, `combobox-trigger.tsx`, `combobox-content.tsx`, `combobox-item.tsx`, `combobox-group.tsx`, `combobox-group-label.tsx`, `combobox-empty.tsx`, `index.ts` + +### Anatomy +``` +Combobox (root) + Combobox.Input (text input — the anchor) + Combobox.Trigger (optional toggle button) + Combobox.Content (floating listbox) + Combobox.Group + Combobox.GroupLabel + Combobox.Item (value, disabled, textValue) + Combobox.Empty (shown when items is empty) +``` + +### Props (Root) +- `value`, `defaultValue`, `onValueChange` — selected item +- `inputValue`, `onInputChange` — text in the input (consumer controls filtering) +- `open`, `defaultOpen`, `onOpenChange` +- `items: string[]` — filtered active item values (consumer filters, we navigate) +- `disabled?: boolean` +- `required?: boolean` +- `name?: string` +- `allowCustomValue?: boolean` — if true, Enter on non-matching input fires onValueChange with the text +- `getLabel?: (value: string) => string` + +### ARIA +- Input: `role="combobox"`, `aria-expanded`, `aria-activedescendant`, `aria-autocomplete="list"`, `aria-controls` +- Content: `role="listbox"` +- Item: `role="option"`, `aria-selected` + +### Behavior +- Typing in input: consumer receives `onInputChange`, updates their filter, passes new `items`. We highlight first match. +- ArrowDown on input opens if closed. If open, navigates. +- Enter: if item highlighted, selects it and closes. If `allowCustomValue` and no highlight, fires onValueChange with input text. +- Escape: if open, closes. If closed, clears input. +- Content positioned via `createFloating`. +- Empty component shown when `items().length === 0` and content is open. + +### Tests (9) +1. Input has `role="combobox"` +2. Content has `role="listbox"` when open +3. Typing opens content +4. ArrowDown highlights first item +5. Enter selects highlighted item +6. Escape closes +7. Empty message shown when no items +8. Controlled value works +9. `allowCustomValue` works + +--- + +## Component 7: DropdownMenu + +**Directory:** `packages/core/src/components/dropdown-menu/` + +**Files:** `dropdown-menu-context.ts`, `dropdown-menu-root.tsx`, `dropdown-menu-trigger.tsx`, `dropdown-menu-content.tsx`, `dropdown-menu-item.tsx`, `dropdown-menu-group.tsx`, `dropdown-menu-group-label.tsx`, `dropdown-menu-separator.tsx`, `dropdown-menu-checkbox-item.tsx`, `dropdown-menu-radio-group.tsx`, `dropdown-menu-radio-item.tsx`, `dropdown-menu-sub.tsx`, `dropdown-menu-sub-trigger.tsx`, `dropdown-menu-sub-content.tsx`, `index.ts` + +### Anatomy +``` +DropdownMenu (root — disclosure + list navigation in activation mode) + DropdownMenu.Trigger + DropdownMenu.Content (floating menu) + DropdownMenu.Item (value, onSelect) + DropdownMenu.Group + DropdownMenu.GroupLabel + DropdownMenu.Separator + DropdownMenu.CheckboxItem (checked, onCheckedChange) + DropdownMenu.RadioGroup (value, onValueChange) + DropdownMenu.RadioItem (value) + DropdownMenu.Sub (nested submenu) + DropdownMenu.SubTrigger + DropdownMenu.SubContent +``` + +### Props (Root) +- `open`, `defaultOpen`, `onOpenChange` + +### ARIA +- Content: `role="menu"`, `aria-activedescendant` +- Item: `role="menuitem"` +- CheckboxItem: `role="menuitemcheckbox"`, `aria-checked` +- RadioItem: `role="menuitemradio"`, `aria-checked` +- Separator: `role="separator"` +- Trigger: `aria-haspopup="menu"`, `aria-expanded` + +### Behavior +- Trigger click opens. ArrowDown on trigger opens and highlights first. ArrowUp opens and highlights last. +- Items: Enter/Space activates (fires `onSelect`). Menu closes after activation unless the item's `onSelect` calls `event.preventDefault()`. +- CheckboxItem: toggles `checked` state on activation. +- RadioGroup/RadioItem: selects the radio value on activation. +- Submenus: ArrowRight on SubTrigger opens submenu. ArrowLeft in submenu closes it and returns to parent. Pointer enter on SubTrigger opens after ~100ms delay. +- Typeahead navigates within current menu level. +- Escape closes current level (submenu first, then root). + +### Tests (10) +1. Content has `role="menu"` when open +2. Items have `role="menuitem"` +3. Click trigger opens +4. Enter activates item +5. Escape closes +6. ArrowDown navigates +7. CheckboxItem toggles +8. RadioItem selects +9. Submenu opens on ArrowRight +10. Submenu closes on ArrowLeft + +--- + +## Component 8: ContextMenu + +**Directory:** `packages/core/src/components/context-menu/` + +**Files:** Same structure as DropdownMenu but with `context-menu-` prefix. Internally reuses the same `createListNavigation` activation mode. + +### Anatomy +Same as DropdownMenu. Only difference is the trigger mechanism. + +### Trigger behavior +- `onContextMenu` (right-click) on the trigger area opens the menu. +- Menu positions at pointer coordinates (not relative to an anchor element). `createFloating` receives a virtual element at `{ x: event.clientX, y: event.clientY }`. +- Long-press on touch devices opens the menu (via `onPointerDown` with 700ms timeout, canceled by `onPointerMove`/`onPointerUp`). + +### ARIA +Same as DropdownMenu. Trigger area does not get `aria-haspopup` (context menus are discoverable by convention, not announced). + +### Tests (7) +1. Right-click opens menu +2. Content has `role="menu"` +3. Items have `role="menuitem"` +4. Enter activates item +5. Escape closes +6. ArrowDown navigates +7. Menu positions at click coordinates + +--- + +## Component 9: Menubar + +**Directory:** `packages/core/src/components/menubar/` + +**Files:** `menubar-context.ts`, `menubar-root.tsx`, `menubar-menu.tsx`, `menubar-trigger.tsx`, `menubar-content.tsx`, `menubar-item.tsx`, `menubar-group.tsx`, `menubar-group-label.tsx`, `menubar-separator.tsx`, `menubar-checkbox-item.tsx`, `menubar-radio-group.tsx`, `menubar-radio-item.tsx`, `index.ts` + +### Anatomy +``` +Menubar (root — horizontal bar, roving focus between triggers) + Menubar.Menu (wraps one dropdown — its own disclosure state) + Menubar.Trigger (opens this menu) + Menubar.Content (floating menu) + Menubar.Item / .Group / .GroupLabel / .Separator + Menubar.CheckboxItem / .RadioGroup / .RadioItem +``` + +### Props (Root) +- `loop?: boolean` — wrap trigger navigation, default true +- `orientation?: "horizontal"` — always horizontal (for now) + +### ARIA +- Root: `role="menubar"`, `aria-orientation="horizontal"` +- Trigger: `role="menuitem"`, `aria-haspopup="menu"`, `aria-expanded` +- Content, items: same as DropdownMenu + +### Behavior +- Triggers use `createRovingFocus` for horizontal arrow key navigation. +- Click trigger opens its menu. When a menu is open, hovering another trigger opens that menu instead (instant switch). +- ArrowRight on the last item of a menu closes it and opens the next menu. ArrowLeft does the reverse. +- Escape closes the current menu and returns focus to its trigger. +- When no menu is open, ArrowRight/Left moves between triggers without opening. + +### Tests (8) +1. Root has `role="menubar"` +2. Click trigger opens menu +3. ArrowRight on trigger moves to next trigger +4. ArrowRight in menu opens next menu +5. ArrowLeft in menu opens previous menu +6. Escape closes menu +7. Hover trigger switches open menu +8. Items have `role="menuitem"` + +--- + +## Implementation Phases + +### Plan 3: Primitives + Floating Components +Build order: +1. `createFloating` primitive +2. `createListNavigation` primitive (with typeahead) +3. `createRovingFocus` primitive (optional) +4. Tooltip +5. Popover +6. HoverCard + +Estimated: ~40 tests + +### Plan 4: Collection Components +Build order: +1. Listbox +2. Select +3. Combobox +4. DropdownMenu +5. ContextMenu +6. Menubar + +Estimated: ~51 tests + +### Package exports +Each new component gets a sub-path export in `package.json`: +`./tooltip`, `./popover`, `./hover-card`, `./listbox`, `./select`, `./combobox`, `./dropdown-menu`, `./context-menu`, `./menubar` + +Each new primitive is exported from the existing `./primitives` sub-path (or individual sub-paths if preferred). + +--- + +## Patterns enforced across all components + +1. **splitProps** — never destructure SolidJS props +2. **JSDoc** on every exported function/interface +3. **createUniqueId()** for all generated IDs +4. **aria-\* as explicit strings** — `"true"/"false"`, never booleans +5. **hidden={expr || undefined}** — never `hidden="false"` +6. **Compound component pattern** — `Object.assign(Root, { Part })` +7. **Dual context** — Internal (for parts) + Public (for consumers) on multi-part components +8. **Error messages** — `[PettyUI] Component.Part used outside . Fix: ...` +9. **Consistent anatomy** — every floating/overlay component follows: Root, Trigger, Content, Arrow/Close pattern +10. **Value-based identity** — list items identified by string values, not indices