690 lines
26 KiB
Markdown
690 lines
26 KiB
Markdown
# 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<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
|
|
|
|
```ts
|
|
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
|
|
|
|
```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<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 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 <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
|