PettyUI/docs/superpowers/specs/2026-03-29-spec-a-primitives-floating-collections.md
2026-03-29 10:06:48 +07:00

26 KiB

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

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<HTMLElement | null>;
  /** The floating element to position. */
  floating: Accessor<HTMLElement | null>;
  /** Desired placement. @default "bottom" */
  placement?: Accessor<Placement>;
  /** Floating UI middleware (flip, shift, offset, arrow, etc.). */
  middleware?: Accessor<Middleware[]>;
  /** CSS positioning strategy. @default "absolute" */
  strategy?: Accessor<Strategy>;
  /** Only compute position when true. @default () => true */
  open?: Accessor<boolean>;
}

interface FloatingState {
  /** Computed x position in px. */
  x: Accessor<number>;
  /** Computed y position in px. */
  y: Accessor<number>;
  /** Actual placement after middleware (may differ from requested). */
  placement: Accessor<Placement>;
  /** Ready-to-spread CSS: { position, top, left }. */
  style: Accessor<JSX.CSSProperties>;
}

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

import type { Accessor } from "solid-js";

interface CreateListNavigationOptions {
  /** Ordered list of valid item values. Reactive — can change (e.g., filtering). */
  items: Accessor<string[]>;
  /** "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<string | undefined>;
  /** 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<string[]>;
  /** 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<string | undefined>;
  /** Currently selected value (selection mode, single). */
  selectedValue: Accessor<string | undefined>;
  /** Currently selected values (selection mode, multiple). */
  selectedValues: Accessor<string[]>;

  /** 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

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<string | undefined>) => 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 <input> 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 patternObject.assign(Root, { Part })
  7. Dual context — Internal (for parts) + Public (for consumers) on multi-part components
  8. Error messages[PettyUI] Component.Part used outside <Component>. 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