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
computePositionfrom@floating-ui/domreactively when anchor, floating, placement, or middleware change. - Sets up
autoUpdate(scroll/resize listener) whenopen()is true. Cleans up whenopen()becomes false or on disposal. - Returns reactive
x,y,placement, and a conveniencestyleaccessor. - 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
getLabelis 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-itemattribute. - 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— controlleddefaultOpen?: boolean— uncontrolledonOpenChange?: (open: boolean) => voidopenDelay?: number— ms before showing, default 700closeDelay?: number— ms before hiding, default 300
ARIA
- Content:
role="tooltip", uniqueid - 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)
- Content has
role="tooltip"when open - Trigger has
aria-describedbypointing to content - Content not rendered when closed
- Opens on trigger focus
- Closes on Escape
- Controlled mode works
- Content positions via floating (has
stylewith 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,onOpenChangemodal?: 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)
- Content has
role="dialog"when open - Trigger has correct ARIA attributes
- Click trigger opens
- Escape closes
- Close button closes
aria-labelledbylinks to title- Controlled mode
- 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,onOpenChangeopenDelay?: number— default 700closeDelay?: 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)
- Content not rendered when closed
- Opens with defaultOpen
- Closes on Escape
- Content has
data-state - Controlled mode
- 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 selectionvalues,defaultValues,onValuesChange— multiple selectionselectionMode?: "single" | "multiple"— default "single"items: string[]— ordered list of active (non-disabled) item valuesorientation?: "vertical" | "horizontal"— default "vertical"loop?: boolean— default truegetLabel?: (value: string) => string— for typeaheaddisabled?: 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-labelledbypointing to GroupLabel
Behavior
- Uses
createListNavigationin selection mode - Container holds DOM focus. Items get virtual focus via
aria-activedescendant. data-highlightedon currently highlighted item for CSS stylingdata-state="active"|"inactive"on items reflecting selection
Tests (8)
- Root has
role="listbox" - Items have
role="option" - ArrowDown highlights next item
- Enter selects highlighted item
aria-selectedreflects selection- Home/End navigate to first/last
- Multiple selection mode works
- 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,onValueChangeopen,defaultOpen,onOpenChangeitems: string[]— active item valuesdisabled?: booleanrequired?: booleanname?: string— renders hidden input for form submissiongetLabel?: (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
nameis provided, renders a hidden<input>with the selected value for form integration.
Tests (9)
- Trigger has
role="combobox" - Content has
role="listbox"when open - Click trigger opens
- ArrowDown on trigger opens and highlights first
- Enter selects and closes
- Escape closes
aria-selectedon selected item- Controlled mode
- Hidden input rendered when
nameprovided
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 iteminputValue,onInputChange— text in the input (consumer controls filtering)open,defaultOpen,onOpenChangeitems: string[]— filtered active item values (consumer filters, we navigate)disabled?: booleanrequired?: booleanname?: stringallowCustomValue?: boolean— if true, Enter on non-matching input fires onValueChange with the textgetLabel?: (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 newitems. We highlight first match. - ArrowDown on input opens if closed. If open, navigates.
- Enter: if item highlighted, selects it and closes. If
allowCustomValueand 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 === 0and content is open.
Tests (9)
- Input has
role="combobox" - Content has
role="listbox"when open - Typing opens content
- ArrowDown highlights first item
- Enter selects highlighted item
- Escape closes
- Empty message shown when no items
- Controlled value works
allowCustomValueworks
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'sonSelectcallsevent.preventDefault(). - CheckboxItem: toggles
checkedstate 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)
- Content has
role="menu"when open - Items have
role="menuitem" - Click trigger opens
- Enter activates item
- Escape closes
- ArrowDown navigates
- CheckboxItem toggles
- RadioItem selects
- Submenu opens on ArrowRight
- 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).
createFloatingreceives a virtual element at{ x: event.clientX, y: event.clientY }. - Long-press on touch devices opens the menu (via
onPointerDownwith 700ms timeout, canceled byonPointerMove/onPointerUp).
ARIA
Same as DropdownMenu. Trigger area does not get aria-haspopup (context menus are discoverable by convention, not announced).
Tests (7)
- Right-click opens menu
- Content has
role="menu" - Items have
role="menuitem" - Enter activates item
- Escape closes
- ArrowDown navigates
- 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 trueorientation?: "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
createRovingFocusfor 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)
- Root has
role="menubar" - Click trigger opens menu
- ArrowRight on trigger moves to next trigger
- ArrowRight in menu opens next menu
- ArrowLeft in menu opens previous menu
- Escape closes menu
- Hover trigger switches open menu
- Items have
role="menuitem"
Implementation Phases
Plan 3: Primitives + Floating Components
Build order:
createFloatingprimitivecreateListNavigationprimitive (with typeahead)createRovingFocusprimitive (optional)- Tooltip
- Popover
- HoverCard
Estimated: ~40 tests
Plan 4: Collection Components
Build order:
- Listbox
- Select
- Combobox
- DropdownMenu
- ContextMenu
- 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
- splitProps — never destructure SolidJS props
- JSDoc on every exported function/interface
- createUniqueId() for all generated IDs
- aria-* as explicit strings —
"true"/"false", never booleans - hidden={expr || undefined} — never
hidden="false" - Compound component pattern —
Object.assign(Root, { Part }) - Dual context — Internal (for parts) + Public (for consumers) on multi-part components
- Error messages —
[PettyUI] Component.Part used outside <Component>. Fix: ... - Consistent anatomy — every floating/overlay component follows: Root, Trigger, Content, Arrow/Close pattern
- Value-based identity — list items identified by string values, not indices