diff --git a/internal/design/ui/menu/architecture.md b/internal/design/ui/menu/architecture.md new file mode 100644 index 000000000..e87dbfa54 --- /dev/null +++ b/internal/design/ui/menu/architecture.md @@ -0,0 +1,423 @@ +# Architecture + +Core classes, DOM interaction layer, and file structure for the Menu component. + +## Core Layer + +**Location:** `packages/core/src/core/ui/menu/` + +### MenuCore + +Follows the `PopoverCore` pattern — a framework-agnostic class that computes state and ARIA attributes from props and input. + +```ts +interface MenuProps { + mode?: 'flyout' | 'panel'; + side?: PopoverSide; + align?: PopoverAlign; + open?: boolean; + defaultOpen?: boolean; + closeOnEscape?: boolean; + closeOnOutsideClick?: boolean; + /** Initial panel ID for panel mode. Defaults to first Panel's id. */ + defaultPanel?: string; +} + +interface MenuInput extends TransitionState { + /** Index of the currently highlighted item (-1 = none). */ + highlightedIndex: number; + /** Total number of interactive items. */ + itemCount: number; +} + +interface MenuState extends TransitionFlags { + open: boolean; + status: TransitionStatus; + side: PopoverSide; + align: PopoverAlign; + mode: 'flyout' | 'panel'; + highlightedIndex: number; +} +``` + +**Methods:** + +- `setProps(props: MenuProps)` — merge with defaults +- `setInput(input: MenuInput)` — accept reactive state +- `getState(): MenuState` — compute derived state +- `getTriggerAttrs(state, contentId?)` — returns `{ 'aria-expanded', 'aria-haspopup': 'menu', 'aria-controls' }` +- `getContentAttrs(state)` — returns `{ role: 'menu', tabIndex: -1, popover: 'manual' }` +- `getItemAttrs(state, index, options?)` — returns `{ role, tabIndex, 'aria-checked', 'aria-disabled', id }` + +**Namespace:** `MenuCore.Props`, `MenuCore.State`, `MenuCore.Input` + +### PanelNavigationState + +Panel stack management, separate from the menu open/close transition: + +```ts +interface PanelNavigationState { + /** Stack of panel IDs. Last entry = visible panel. */ + stack: string[]; + /** Direction of the last navigation. */ + direction: 'forward' | 'back' | null; + /** Panel ID that is transitioning out (for animation). Null when idle. */ + exitingPanelId: string | null; + /** Whether a panel transition is in progress. */ + transitioning: boolean; +} +``` + +### Constants + +Following the slider's `*-data-attrs.ts` and `*-css-vars.ts` pattern: + +**`menu-data-attrs.ts`** — Content-level data attributes: + +```ts +export const MenuDataAttrs = { + /** Present when the menu is open. */ + open: 'data-open', + /** Popover positioning side. */ + side: 'data-side', + /** Popover positioning alignment. */ + align: 'data-align', + /** Menu interaction mode. */ + mode: 'data-mode', + /** Present during open transition. */ + startingStyle: 'data-starting-style', + /** Present during close transition. */ + endingStyle: 'data-ending-style', +} as const; +``` + +**`menu-item-data-attrs.ts`** — Item-level data attributes: + +```ts +export const MenuItemDataAttrs = { + /** Present when item has keyboard/pointer focus. */ + highlighted: 'data-highlighted', + /** Present when radio/checkbox item is selected. */ + checked: 'data-checked', + /** Present when item is disabled. */ + disabled: 'data-disabled', +} as const; +``` + +**`menu-panel-data-attrs.ts`** — Panel-level data attributes: + +```ts +export const MenuPanelDataAttrs = { + /** Present on the currently visible panel. */ + active: 'data-active', + /** Present on the panel being animated out. */ + exiting: 'data-exiting', + /** Direction of current panel transition. */ + direction: 'data-direction', +} as const; +``` + +**`menu-css-vars.ts`** — CSS custom properties for panel transitions: + +```ts +export const MenuCSSVars = { + /** Measured width of the incoming panel. */ + panelWidth: '--media-menu-panel-width', + /** Measured height of the incoming panel. */ + panelHeight: '--media-menu-panel-height', +} as const; +``` + +--- + +## DOM Layer + +**Location:** `packages/core/src/dom/ui/menu/` + +### `createMenu()` + +Main DOM interaction factory. Composes `createPopover()` internally for open/close behavior, then layers menu-specific keyboard navigation and focus management on top. + +```ts +interface MenuOptions { + transition: TransitionApi; + mode: () => 'flyout' | 'panel'; + onOpenChange: (open: boolean, details: PopoverChangeDetails) => void; + onOpenChangeComplete?: (open: boolean) => void; + closeOnEscape: () => boolean; + closeOnOutsideClick: () => boolean; + onHighlightChange?: (index: number) => void; + onItemActivate?: (index: number, id?: string) => void; + onPanelChange?: (panel: PanelNavigationState) => void; +} + +interface MenuApi { + input: State; + panelState: State; + triggerProps: MenuTriggerProps; + contentProps: MenuContentProps; + readonly triggerElement: HTMLElement | null; + setTriggerElement: (el: HTMLElement | null) => void; + setContentElement: (el: HTMLElement | null) => void; + open: (reason?: PopoverOpenChangeReason) => void; + close: (reason?: PopoverOpenChangeReason) => void; + registerItem: (el: HTMLElement, options?: { disabled?: boolean }) => () => void; + highlight: (index: number) => void; + pushPanel: (panelId: string) => void; + popPanel: () => void; + destroy: () => void; +} +``` + +### Keyboard Navigation + +`contentProps.onKeyDown` handles all keyboard interaction: + +| Key | Action | +| --- | ------ | +| `ArrowDown` | Highlight next item (wraps to first) | +| `ArrowUp` | Highlight previous item (wraps to last) | +| `Home` | Highlight first item | +| `End` | Highlight last item | +| `Enter` / `Space` | Activate highlighted item | +| `Escape` | Close menu (fly-out) or pop panel / close menu (panel) | +| `ArrowRight` | Open submenu (fly-out) or push panel (panel mode) | +| `ArrowLeft` | Close submenu (fly-out) or pop panel (panel mode) | +| Printable character | Type-ahead jump | + +### Roving Tabindex + +Maintains an ordered collection of registered item elements: + +- Only the highlighted item has `tabindex="0"` +- All other items have `tabindex="-1"` +- Content element has `tabindex="-1"` for programmatic focus +- Arrow keys move highlight and focus between items + +### Item Collection + +Items self-register via `registerItem(el)` which returns a cleanup function (subscribe pattern). The collection is kept sorted by `compareDocumentPosition` to maintain DOM order. When items are added or removed, the internal list recomputes. + +This approach: +- Works across Shadow DOM boundaries +- Doesn't couple to ARIA role strings +- Follows the existing subscribe/cleanup pattern + +### Type-ahead Search + +Internal to `createMenu()`: +- Accumulates typed printable characters into a buffer +- Resets buffer after 500ms of inactivity +- Searches item `textContent` for a match starting from the item after the current highlight +- Wraps around to the beginning if no match found after current position + +### Focus Management + +| Event | Action | +| ----- | ------ | +| Menu opens | Focus Content, then highlight first (or previously selected) item | +| Menu closes | Return focus to Trigger | +| Submenu opens | Focus first item in submenu content | +| Submenu closes | Return focus to parent SubMenuTrigger | +| Panel push | After transition completes, focus first item in new panel | +| Panel pop | After transition completes, return focus to PanelTrigger that navigated forward | + +### `createCarouselTransition()` + +Separate from the popover open/close transition. Coordinates panel swap animations. + +```ts +interface CarouselTransitionOptions { + getContentElement: () => HTMLElement | null; + onTransitionComplete?: () => void; +} + +interface CarouselTransitionApi { + state: State; + navigate: (panelId: string) => void; + back: () => void; + reset: () => void; + destroy: () => void; +} +``` + +**Transition lifecycle:** + +1. `navigate('quality')` called +2. **Measure outgoing panel** — capture `offsetWidth`, `offsetHeight` +3. **Patch state** — `{ stack: [..., 'quality'], direction: 'forward', exitingPanelId: previous, transitioning: true }` +4. **First RAF** — incoming panel is in DOM. Measure its dimensions. Set `--media-menu-panel-width`, `--media-menu-panel-height` on Content. +5. **Second RAF** — browser has painted "from" state. CSS transitions take over: container resizes, panels slide via `transform: translateX(...)`. +6. **Wait for animations** — `getAnimations()` settles on Content element (same pattern as `createTransition()`) +7. **Cleanup** — `{ exitingPanelId: null, transitioning: false }`. Only active panel remains in DOM. + +**Rapid navigation:** If user navigates while a transition is in progress, cancel the current transition (skip to end state), start new transition from current visual state. + +**Menu close:** `reset()` clears stack to `[defaultPanel]` immediately. No panel animation — the popover close animation handles the visual exit. When the menu re-opens, it starts at the root panel. + +**Container size animation:** The Content element uses CSS `transition` on `width` and `height`, reading from `--media-menu-panel-width` and `--media-menu-panel-height`. JS measures the incoming panel dimensions and sets the CSS custom properties. CSS handles the smooth interpolation. + +--- + +## Platform Layer File Structure + +### React (`packages/react/src/ui/menu/`) + +```text +context.tsx — MenuContext, RadioGroupContext +index.parts.ts — Namespace re-exports +index.ts — export { Menu } +menu-root.tsx — Menu.Root +menu-trigger.tsx — Menu.Trigger +menu-content.tsx — Menu.Content +menu-item.tsx — Menu.Item +menu-label.tsx — Menu.Label +menu-separator.tsx — Menu.Separator +menu-group.tsx — Menu.Group +menu-radio-group.tsx — Menu.RadioGroup +menu-radio-item.tsx — Menu.RadioItem +menu-checkbox-item.tsx — Menu.CheckboxItem +menu-item-indicator.tsx — Menu.ItemIndicator +menu-sub.tsx — Menu.SubMenu +menu-sub-trigger.tsx — Menu.SubMenuTrigger +menu-sub-content.tsx — Menu.SubMenuContent +menu-panel.tsx — Menu.Panel +menu-panel-trigger.tsx — Menu.PanelTrigger +menu-panel-back.tsx — Menu.PanelBack +menu-panel-title.tsx — Menu.PanelTitle +``` + +### HTML (`packages/html/src/ui/menu/`) + +```text +menu-element.ts — +menu-item-element.ts — +menu-label-element.ts — +menu-separator-element.ts — +menu-group-element.ts — +menu-radio-group-element.ts — +menu-radio-item-element.ts — +menu-checkbox-item-element.ts — +menu-panel-element.ts — +menu-panel-trigger-element.ts — +menu-panel-back-element.ts — +``` + +### HTML Registration + +Registration files in `src/define/ui/` exported as `@videojs/html/ui/menu`. Importing registers all menu elements: + +```ts +// @videojs/html/ui/menu +// Registers: media-menu, media-menu-item, media-menu-label, +// media-menu-separator, media-menu-group, media-menu-radio-group, +// media-menu-radio-item, media-menu-checkbox-item, +// media-menu-panel, media-menu-panel-trigger, media-menu-panel-back +``` + +### React Component Patterns + +**`Menu.Root`** follows `PopoverRoot`: +- `useState(() => new MenuCore())` and `useState(() => createMenu(...))` +- `useSnapshot(menu.input)` for open/close re-renders +- `useSnapshot(menu.panelState)` for panel mode +- `useLatestRef` for callback props to avoid stale closures +- `useDestroy(menu)` for cleanup + +**`Menu.Content`** follows `PopoverPopup`: +- Uses Popover positioning (CSS Anchor Positioning with JS fallback) +- Adds `contentProps` (`onKeyDown` for arrow nav, type-ahead) +- In panel mode: wraps children in overflow container with measured dimensions + +**`Menu.Item`** registers with `menu.registerItem()` on mount: +- `useEffect` calls `registerItem(el)`, returns cleanup +- `data-highlighted` when index matches `highlightedIndex` +- `onPointerEnter` highlights, `onPointerLeave` clears +- `onClick` activates + +**`Menu.RadioGroup`** provides its own context: +- Controlled: `value` + `onValueChange` +- Uncontrolled: `defaultValue` + internal state + +**`Menu.Panel`** (panel mode): +- Renders children only when `active` or `exiting` (for animation) +- Applies panel data attributes +- Measures own dimensions, reports to parent + +**`Menu.PanelTrigger`** extends `Menu.Item`: +- `onClick` calls `menu.pushPanel(target)` where `target` is a prop + +### Context Shape + +```ts +interface MenuContextValue { + core: MenuCore; + menu: MenuApi; + state: MenuCore.State; + stateAttrMap: StateAttrMap; + contentId: string; + mode: 'flyout' | 'panel'; +} + +interface MenuRadioGroupContextValue { + value: string; + onValueChange: (value: string) => void; +} +``` + +`Menu.SubMenu` creates a nested `MenuContextValue` with its own `createMenu()` instance, linked to the parent for hover-to-open and arrow-key triggering. + +--- + +## Domain Menu Roots (Future Work) + +Similar to how `TimeSlider` extends `Slider`, domain menus compose `Menu.*` parts with media store connections. These are outlined here for direction but are not in scope for the initial `Menu` implementation. + +### SettingsMenu + +Top-level settings menu using panel mode. Connects to the media store to auto-discover available settings. + +- `SettingsMenu.Root` — Composes `Menu.Root` with `mode="panel"`. Reads available features from the store. +- Namespace re-exports all generic `Menu.*` parts. +- React: `import { SettingsMenu } from '@videojs/react'` +- HTML: `` + +### QualityMenu + +Quality/resolution selection. Connects to `selectQuality` from the store. + +- `QualityMenu.Root` — Provides quality levels as `RadioItem` children. Handles `onValueChange` → `quality.setLevel()`. +- Can be used standalone (fly-out) or as a panel within `SettingsMenu`. +- React: `import { QualityMenu } from '@videojs/react'` +- HTML: `` + +### PlaybackRateMenu + +Playback speed selection. Connects to `selectPlaybackRate`. + +- `PlaybackRateMenu.Root` — Provides rate options. Handles `onValueChange` → `playbackRate.set()`. +- React: `import { PlaybackRateMenu } from '@videojs/react'` +- HTML: `` + +### CaptionsMenu + +Caption/subtitle track selection. Connects to `selectTextTracks`. + +- `CaptionsMenu.Root` — Lists available text tracks plus "Off" option. Handles selection → `textTracks.setActive()`. +- React: `import { CaptionsMenu } from '@videojs/react'` +- HTML: `` + +### Domain Pattern + +Domain menus only customize Root. All other parts are generic `Menu.*` re-exported under the domain namespace — same pattern as `TimeSlider.Track` = `Slider.Track`. + +```ts +// settings-menu/index.parts.ts +export { Root } from './settings-menu-root'; +// Re-export all generic parts +export { + Trigger, Content, Item, Label, Separator, Group, + RadioGroup, RadioItem, CheckboxItem, ItemIndicator, + Panel, PanelTrigger, PanelBack, PanelTitle, +} from '../menu/index.parts'; +``` diff --git a/internal/design/ui/menu/decisions.md b/internal/design/ui/menu/decisions.md new file mode 100644 index 000000000..79103fdc3 --- /dev/null +++ b/internal/design/ui/menu/decisions.md @@ -0,0 +1,265 @@ +# Design Decisions + +Rationale behind menu component choices. + +## Component Structure + +### Single Menu with Mode Prop + +**Decision:** A single `Menu.Root` with `mode: 'flyout' | 'panel'` rather than separate `DropdownMenu` and `PanelMenu` components. + +**Alternatives:** + +- Separate `DropdownMenu.*` and `PanelMenu.*` namespaces — clearer mode separation, but duplicates shared parts (Item, RadioGroup, Separator, etc.) and doubles the API surface. +- Separate packages — maximum tree-shaking, but unnecessary complexity for parts that share 80% of their logic. + +**Rationale:** Both modes share Trigger, Content, Item, Label, Separator, Group, RadioGroup, RadioItem, CheckboxItem, and ItemIndicator. Only SubMenu parts (fly-out) and Panel parts (carousel) are mode-specific. A single `Menu.*` namespace keeps the API smaller and composition intuitive. Parts that only apply to one mode are simply unused in the other — no error, no overhead. This also leaves room for a future hybrid mode. + +### No Default Children + +**Decision:** Menu does not render default children. Users compose everything explicitly. + +**Alternatives:** + +- Bake in a default item layout (icon + label + indicator) — easier to start, but breaks when users need different structures. +- Provide a `Default` export alongside parts — extra API surface without clear benefit. + +**Rationale:** Same reasoning as slider — compound components should not assume structure. Different media players need different menu layouts (icons, descriptions, badges, shortcuts). Forcing explicit composition avoids "how do I remove the default indicator" problems. + +### Compound Namespace Pattern + +**Decision:** Use `Menu.*` namespace exports, matching the `Slider.*` and `TimeSlider.*` pattern. + +```tsx +import { Menu } from '@videojs/react'; + + + Options + + Copy Link + + +``` + +**Alternatives:** + +- Flat exports (`MenuRoot`, `MenuTrigger`, `MenuItem`) — verbose imports, no visual grouping. +- Nested namespaces (`Menu.Flyout.SubTrigger`) — too deep, awkward to use. + +**Rationale:** Namespaces make composition readable and imports clean. One import gives access to all parts. Matches the established project convention. + +## Popover Integration + +### Compose Popover Internally + +**Decision:** `createMenu()` creates a `createPopover()` internally for open/close, positioning, and dismiss behavior. The popover is an implementation detail, not exposed in the menu API. + +**Alternatives:** + +- Extend `PopoverCore` — tight coupling, menu has different ARIA roles and keyboard behavior. +- Require users to wrap Menu in a Popover — leaky abstraction, requires understanding both APIs. +- Duplicate popover logic in menu — maintenance burden. + +**Rationale:** Popover handles what it's good at: dismiss layers, Escape handling, outside-click detection, CSS Anchor Positioning with JS fallback, hover intent. Menu adds what's unique to menus: `role="menu"`, roving tabindex, arrow key navigation, type-ahead, panel stack. Composition keeps both focused. + +## Keyboard & Focus + +### Roving Tabindex + +**Decision:** Roving tabindex (`tabindex="0"` on the highlighted item, `tabindex="-1"` on all others) rather than `aria-activedescendant`. + +**Alternatives:** + +- `aria-activedescendant` on Content — Content keeps focus, `aria-activedescendant` points to the visually highlighted item. Simpler focus management, but inconsistent screen reader support across browsers and platforms. + +**Rationale:** Roving tabindex is recommended by the [WAI-ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/). Items receive real DOM focus, so `:focus-visible` works naturally for keyboard-only styling. Both Radix and Base UI use this approach. The tradeoff is more DOM mutations on highlight change (updating two elements' `tabindex`), but this is negligible. + +### Item Self-Registration + +**Decision:** Items call `registerItem(el)` on mount and return a cleanup function. The collection is maintained in DOM order via `compareDocumentPosition`. + +**Alternatives:** + +- Query the DOM for `[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]` — simpler, but couples to ARIA role strings, doesn't work across Shadow DOM boundaries, and requires re-querying on every change. +- Track items by React key or index — fragile with conditional rendering and doesn't work in HTML. + +**Rationale:** Self-registration follows the existing subscribe/cleanup pattern used throughout the codebase. It works across Shadow DOM boundaries, doesn't couple to attribute strings, and items are naturally sorted by DOM position. Registration/deregistration is O(n log n) at worst (re-sort on change), but n is typically small (5-20 items). + +### Type-ahead with 500ms Debounce + +**Decision:** Typed printable characters accumulate into a buffer. Search starts from the item after the current highlight. Buffer resets after 500ms of inactivity. + +**Alternatives:** + +- Single-character search (reset immediately) — faster for single-letter jumps, but can't search multi-word items like "Playback Rate". +- Longer debounce (1000ms) — more forgiving, but feels sluggish for single-letter searches. + +**Rationale:** 500ms is the standard debounce window used by Radix, Base UI, and native OS menus. Multi-character accumulation allows searching "1080" to jump to "1080p" rather than cycling through items starting with "1". Matches the [WAI-ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) recommendation. + +## Selection + +### RadioGroup Context for Selection State + +**Decision:** `RadioGroup` provides its own `RadioGroupContext`. `RadioItem` reads checked state from this context. RadioGroup can be controlled or uncontrolled independently of the menu. + +**Alternatives:** + +- Menu-level selection state — simpler, but couples selection to the menu. Can't have two independent radio groups in the same menu. +- Item-level `checked` prop — no group concept, users manage exclusivity manually. + +**Rationale:** Decouples selection logic from menu navigation. Each RadioGroup manages its own value independently. Same pattern as Radix's `DropdownMenu.RadioGroup`. Controlled and uncontrolled modes both work naturally. + +### Auto-back on RadioItem Selection in Panel Mode + +**Decision:** When a `RadioItem` is selected in panel mode, automatically navigate back to the parent panel. + +**Alternatives:** + +- Stay on current panel after selection — user must press back manually. More control but more clicks for the most common workflow. +- Configurable via prop (`closeOnSelect`) — maximum flexibility but adds API surface for a behavior that should have a strong default. + +**Rationale:** YouTube and Plyr both auto-navigate back after selecting a quality or speed option. This is the expected behavior for settings menus — you open, pick an option, and you're back at the main panel. The transition animation provides visual feedback that the selection was made. Users who need to stay on the panel can use regular `Item` instead of `RadioItem`. + +## Panel Transitions + +### Panel Stack as State + +**Decision:** Panel navigation is modeled as a stack of `{ panelId, triggerId }` entries stored in `State`. Push adds to the stack, pop removes. + +**Alternatives:** + +- Flat `activePanel` string — can't support deep nesting (quality → advanced quality → codec). +- Router-style path matching — over-engineered for a menu component. + +**Rationale:** A stack naturally supports arbitrary nesting depth and provides the data needed for focus restoration (which PanelTrigger to return focus to on pop). Both React and HTML layers can subscribe to state changes via `createState()`. The stack is small (typically 2-3 entries), so no performance concern. + +### CSS-Driven Panel Animations + +**Decision:** Panel transitions are driven by CSS custom properties (`--media-menu-panel-width`, `--media-menu-panel-height`) and data attributes (`data-active`, `data-exiting`, `data-direction`). No imperative DOM animation. + +**Alternatives:** + +- Web Animations API (`el.animate()`) — more control over timing, but requires imperative cleanup, doesn't compose with user CSS, and is harder to override. +- FLIP technique — overkill for a simple slide + resize. + +**Rationale:** Follows the existing project convention — slider uses CSS vars for positioning, popover uses data attrs for transition states. CSS transforms are GPU-composited for 60fps performance. Users can override timing, easing, and even the animation itself via CSS. `getAnimations()` handles completion detection (same pattern as `createTransition()`). + +### Container Size Animation via JS Measurement + CSS Transition + +**Decision:** Measure panel dimensions with JS (`offsetWidth`, `offsetHeight`), set CSS custom properties, let CSS `transition` animate the container. + +**Alternatives:** + +- CSS `auto` height transition — not possible, CSS can't transition to/from `auto`. +- `ResizeObserver` only — detects size changes but doesn't provide "from" dimensions for smooth transitions. +- Fixed panel sizes — users define sizes via CSS. Simpler, but doesn't adapt to dynamic content. + +**Rationale:** JS measurement is necessary because CSS can't transition to `auto`. The double-RAF pattern (matching `createTransition()`) ensures the browser paints the "from" state before the transition starts. CSS handles the actual interpolation for GPU compositing. The menu sets `overflow: hidden` during transitions to prevent content flash. + +### Both Panels in DOM During Transition + +**Decision:** During a panel transition, both the outgoing and incoming panels are rendered in the DOM. After the transition completes, only the active panel remains. + +**Alternatives:** + +- Keep all panels in DOM, toggle visibility — simpler rendering, but wastes DOM weight and may cause issues with duplicate IDs or focusable elements. +- Use `display: contents` on inactive panels — still in DOM for measurement but doesn't affect layout. Browser support concerns. + +**Rationale:** CSS transitions require both panels to be present for the slide animation. Removing the outgoing panel after animation completes reduces DOM weight and avoids focus traps (inactive panels should not contain focusable elements). The `data-exiting` attribute lets CSS target the outgoing panel for its exit animation. + +### Panel Mode Resets on Menu Close + +**Decision:** When the menu closes, the panel stack resets to the root panel immediately. No panel animation plays — the popover's own close animation handles the visual exit. When the menu re-opens, it starts at the root panel. + +**Alternatives:** + +- Preserve panel state across close/open — user returns to whatever panel they were on. Useful for quick toggles, but unexpected for most settings workflows. +- Configurable via prop — adds API surface for an uncommon need. + +**Rationale:** Users expect to see the root settings panel when reopening. YouTube and Plyr both reset. Preserving state would require managing stale panel content (what if a quality level disappears while the menu is closed?). The strong default covers 95% of use cases. + +### Rapid Navigation Cancels In-Progress Transition + +**Decision:** If the user navigates to a new panel while a transition is still in progress, the current transition is cancelled (skip to end state) and a new transition starts immediately. + +**Alternatives:** + +- Queue navigations — transitions play in sequence. Feels sluggish for fast users. +- Block navigation during transition — prevents rapid clicking but feels unresponsive. + +**Rationale:** Immediate cancellation feels responsive. The user's intent is to reach the target panel, not to watch every intermediate animation. Cancellation reuses the same pattern as `createTransition().cancel()` — patch `status: 'idle'` to skip to end, then start fresh. + +## Fly-out Submenus + +### SubMenu as Nested Menu Context + +**Decision:** `SubMenu` creates its own `createMenu()` instance linked to the parent menu. Each submenu manages its own focus, highlight, and open/close state independently. + +**Alternatives:** + +- Single flat item list with indentation — simpler navigation, but can't support independent keyboard scopes or positioning. +- SubMenu shares parent's item collection — items from all levels are in one list. Arrow keys navigate everything. Confusing when submenus are visually separated. + +**Rationale:** Nested contexts allow each submenu to have its own keyboard scope (arrow keys only navigate within that submenu), its own `role="menu"` for assistive technology, and independent positioning. The parent tracks submenu open state so `ArrowLeft` can close the submenu and return focus. Same architecture as Radix and Base UI. + +### Hover-to-Open Submenus on Pointer Devices + +**Decision:** Fly-out submenus open on hover (for pointer devices with `(hover: hover)` media query) + `ArrowRight` (keyboard). Click also works as a fallback. + +**Alternatives:** + +- Click-only — simpler, but doesn't match desktop user expectations for dropdown menus. +- Hover without media query check — would cause issues on touch devices where hover events fire on tap. + +**Rationale:** Desktop users expect submenus to open on hover — this is standard behavior for Radix, Base UI, and native OS menus. The `(hover: hover)` media query check ensures touch devices use click-only. Hover uses the composed popover's `openOnHover` with a configurable delay (default 300ms from popover) to prevent accidental opens during pointer transit. + +## Styling + +### Data Attributes Inherited by Children + +**Decision:** Content-level data attributes (`data-open`, `data-side`, `data-align`, `data-mode`, `data-starting-style`, `data-ending-style`) are applied to Content **and all children**, following the slider pattern. + +**Alternatives:** + +- Content-only — children use ancestor selectors. More verbose CSS. +- Selective inheritance — inconsistent, requires memorizing rules. + +**Rationale:** Same reasoning as slider — enables `data-[open]:` Tailwind selectors directly on children, avoids ancestor selector verbosity, consistent with the established project convention. Performance cost of updating attributes on a few extra elements is negligible. + +### `data-starting-style` / `data-ending-style` for Open/Close Transitions + +**Decision:** Reuse the same transition data attributes as Popover (`data-starting-style`, `data-ending-style`) rather than inventing menu-specific names. + +**Alternatives:** + +- Menu-specific names (`data-menu-opening`, `data-menu-closing`) — clearer provenance, but fragments the convention. + +**Rationale:** Menus use the same `createTransition()` lifecycle as popovers. CSS authors targeting transitions should use the same attributes regardless of whether the element is a popover or a menu. Consistent naming across all transitioning components. + +### No Part Identification Attributes + +**Decision:** No `data-part` attributes. In HTML, tag names identify parts (``). In React, users apply their own classes. + +**Rationale:** Same as slider — HTML custom elements are self-identifying by tag name. React users compose their own elements and provide `className`. Adding `data-part` creates a parallel identification system redundant with both approaches. + +## Accessibility + +### ARIA Roles + +**Decision:** Content uses `role="menu"`, items use `role="menuitem"`, radio items use `role="menuitemradio"`, checkbox items use `role="menuitemcheckbox"`. Trigger uses `aria-haspopup="menu"`. + +**Rationale:** Directly follows the [WAI-ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) and [Menu Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/). Using the correct roles ensures screen readers announce items, selection state, and menu structure correctly. + +## RTL Support + +### CSS Handles Direction Flip + +**Decision:** Panel slide direction is handled in CSS via `[dir="rtl"]` selectors. No JavaScript changes needed for RTL. + +`ArrowRight` always pushes a panel (opens submenu), `ArrowLeft` always pops (closes submenu) — these are logical directions independent of text direction. The visual slide direction (which way panels physically move on screen) is a CSS concern. + +**Alternatives:** + +- Swap `ArrowRight`/`ArrowLeft` behavior in RTL — matches native OS behavior for some menus, but the WAI-ARIA Menu Pattern doesn't mandate this for vertical menus. Would add complexity to the keyboard handler. + +**Rationale:** CSS `[dir="rtl"]` selectors can flip `translateX` directions for panel animations. Keeping JS direction-agnostic simplifies the keyboard handler and matches the approach used by the slider component for RTL. diff --git a/internal/design/ui/menu/index.md b/internal/design/ui/menu/index.md new file mode 100644 index 000000000..60f2cb4ce --- /dev/null +++ b/internal/design/ui/menu/index.md @@ -0,0 +1,347 @@ +--- +status: draft +date: 2026-03-23 +--- + +# Menu + +Compound, headless menu components for media controls — settings, context actions, and option selection. + +## Contents + +| Document | Purpose | +| ---------------------------------- | -------------------------------------------------- | +| [index.md](index.md) | Overview, anatomy, quick start | +| [architecture.md](architecture.md) | Core classes, DOM interaction, file structure | +| [parts.md](parts.md) | All compound parts — props, state, data attributes | +| [decisions.md](decisions.md) | Design decisions and rationale | + +## Problem + +Media players need menus for three core interactions: + +1. **Settings** — quality, playback speed, captions, audio tracks +2. **Context actions** — copy link, report, stats for nerds +3. **Option selection** — single-choice (radio) and multi-choice (checkbox) within groups + +These menus come in two distinct interaction patterns: + +- **Fly-out menus** — standard dropdown/context menus where submenus open to the side (Base UI Menu, Radix Dropdown Menu) +- **Panel menus** — carousel-style settings menus where clicking an item navigates to a sub-panel with animated transitions (YouTube, Plyr) + +Requirements: + +- Compound and composable — users assemble parts, omit what they don't need +- Headless — no baked-in styles, CSS custom properties for animation values +- Accessible — `role="menu"`, full keyboard support, roving tabindex, type-ahead +- Two interaction modes with shared primitives (items, radio groups, separators) +- Panel mode with smooth transitions: slide + container resize +- Treeshakeable — parts only imported when used +- Cross-platform — same core logic drives React components and HTML custom elements + +## Anatomy + +### React — Fly-out + +```tsx +import { Menu } from '@videojs/react'; + + + Options + + copyLink()}>Copy Link + + + Quality + Auto + 1080p + 720p + + + Speed + + + 0.5x + Normal + 2x + + + + + +``` + +### React — Panel + +```tsx +import { Menu } from '@videojs/react'; + + + Settings + + + Quality + Speed + + + + Quality + + Auto + 1080p + 720p + + + + + Speed + + 0.5x + Normal + 2x + + + + +``` + +### HTML — Fly-out + +```ts +import '@videojs/html/ui/menu'; +``` + +```html + + + Copy Link + + + Quality + Auto + 1080p + 720p + + +``` + +### HTML — Panel + +```ts +import '@videojs/html/ui/menu'; +``` + +```html + + + + Quality + Speed + + + + + Auto + 1080p + + + + + + 0.5x + Normal + 2x + + + +``` + +## Layers + +Three layers, each independently useful: + +| Layer | Package | Purpose | +| ----- | ------- | ------- | +| Core | `@videojs/core` | State computation, ARIA attrs, panel stack navigation. No DOM. | +| DOM | `@videojs/core/dom` | Keyboard navigation, type-ahead, panel transitions, focus management. | +| UI | `@videojs/react`, `@videojs/html` | Compound components and custom elements. HTML elements dispatch custom DOM events. | + +See [architecture.md](architecture.md) for internals. + +## CSS Custom Properties + +Panel mode exposes measured dimensions as CSS custom properties on the Content element. Users style container transitions using these — no inline styles are applied. + +| Property | Example | Description | +| -------- | ------- | ----------- | +| `--media-menu-panel-width` | `240px` | Measured width of the incoming panel | +| `--media-menu-panel-height` | `320px` | Measured height of the incoming panel | + +```css +/* Animate container size between panels */ +media-menu[mode="panel"] { + width: var(--media-menu-panel-width); + height: var(--media-menu-panel-height); + overflow: hidden; + transition: + width 200ms ease, + height 200ms ease; +} +``` + +## Data Attributes + +### Content (inherited by all children) + +State is exposed through data attributes for CSS targeting. Applied to the Content element **and all children**. + +| Attribute | Values | When | +| --------- | ------ | ---- | +| `data-open` | present/absent | Menu is open | +| `data-side` | `top` / `bottom` / `left` / `right` | Popover positioning side | +| `data-align` | `start` / `center` / `end` | Popover positioning alignment | +| `data-mode` | `flyout` / `panel` | Menu interaction mode | +| `data-starting-style` | present/absent | Open transition in progress | +| `data-ending-style` | present/absent | Close transition in progress | + +```css +/* Show menu content only when open */ +media-menu:not([data-open]) { + display: none; +} + +/* Fade-in animation */ +media-menu[data-starting-style] { + opacity: 0; + transform: scale(0.95); +} +``` + +### Items + +| Attribute | Values | When | +| --------- | ------ | ---- | +| `data-highlighted` | present/absent | Item has keyboard/pointer focus | +| `data-checked` | present/absent | Radio/checkbox item is selected | +| `data-disabled` | present/absent | Item is disabled | + +```css +/* Highlight style */ +media-menu-item[data-highlighted] { + background: rgba(255, 255, 255, 0.1); +} + +/* Checked indicator */ +media-menu-radio-item[data-checked]::before { + content: '✓'; +} +``` + +### Panels (panel mode) + +| Attribute | Values | When | +| --------- | ------ | ---- | +| `data-active` | present/absent | Panel is the currently visible panel | +| `data-exiting` | present/absent | Panel is animating out | +| `data-direction` | `forward` / `back` | Direction of the current panel transition | + +```css +/* Panel slide transitions */ +media-menu-panel { + transition: transform 200ms ease; +} + +/* Forward: incoming slides in from right */ +media-menu-panel[data-active][data-direction="forward"] { + animation: slide-in-right 200ms ease; +} +media-menu-panel[data-exiting][data-direction="forward"] { + animation: slide-out-left 200ms ease; +} + +/* Back: incoming slides in from left */ +media-menu-panel[data-active][data-direction="back"] { + animation: slide-in-left 200ms ease; +} +media-menu-panel[data-exiting][data-direction="back"] { + animation: slide-out-right 200ms ease; +} + +@keyframes slide-in-right { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} +@keyframes slide-out-left { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} +@keyframes slide-in-left { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} +@keyframes slide-out-right { + from { transform: translateX(0); } + to { transform: translateX(100%); } +} +``` + +## Keyboard + +Keyboard events are handled by the **Content** element. Focus management uses **roving tabindex** — only the highlighted item has `tabindex="0"`, all others have `tabindex="-1"`. + +| Key | Fly-out | Panel | +| --- | ------- | ----- | +| `ArrowDown` | Next item (wraps) | Next item (wraps) | +| `ArrowUp` | Previous item (wraps) | Previous item (wraps) | +| `ArrowRight` | Open submenu | Push panel (if panel trigger) | +| `ArrowLeft` | Close submenu | Pop panel (go back) | +| `Home` | First item | First item in current panel | +| `End` | Last item | Last item in current panel | +| `Enter` / `Space` | Activate item | Activate item | +| `Escape` | Close menu or submenu | Close menu or pop panel | +| `a-z, 0-9` | Type-ahead search | Type-ahead in current panel | + +Type-ahead: characters accumulate and match item text content. Buffer resets after 500ms of inactivity. Search starts from the item after the current highlight. + +## Accessibility + +The menu follows the [WAI-ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/) and [Menu Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/). + +```html + + +``` + +**Focus management:** + +- On open: focus moves to Content, then to first (or previously selected) item +- On close: focus returns to Trigger +- Submenu open: focus moves to first item in submenu +- Submenu close (`ArrowLeft`): focus returns to parent submenu trigger +- Panel push: focus moves to first item in new panel (after transition) +- Panel pop: focus returns to the PanelTrigger that navigated forward + +**Screen reader announcements:** + +- `aria-checked` changes on radio/checkbox items are announced natively +- Panel title changes use `aria-live="polite"` on a visually hidden region within Content + +## Related Docs + +- [architecture.md](architecture.md) — Core classes, file structure, data flow +- [parts.md](parts.md) — Full API for every compound part +- [decisions.md](decisions.md) — Design rationale +- [Popover design](../popover/) — Underlying positioning and dismiss behavior +- [Slider design](../slider/) — Related compound component pattern diff --git a/internal/design/ui/menu/parts.md b/internal/design/ui/menu/parts.md new file mode 100644 index 000000000..3ab0fcb47 --- /dev/null +++ b/internal/design/ui/menu/parts.md @@ -0,0 +1,793 @@ +# Parts + +Full API for every compound part across shared, fly-out, and panel menu modes. + +## Shared Parts + +These parts are used in both fly-out and panel modes. In React, they're accessed via `Menu.*`. In HTML, they're `` elements. + +--- + +### Root + +Context provider. Owns menu state, creates `MenuCore` + `createMenu()`, provides context to children. Does not render a DOM element in React — Content is the rendered container. In HTML, `` serves as both Root and Content. + +#### React + +```tsx +import { Menu } from '@videojs/react'; + + {}} +> + Options + + {/* children */} + + +``` + +#### Props + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `mode` | `'flyout' \| 'panel'` | `'flyout'` | Menu interaction mode. | +| `side` | `PopoverSide` | `'bottom'` | Which side of the trigger the popup appears on. | +| `align` | `PopoverAlign` | `'start'` | Alignment of the popup along the trigger's edge. | +| `open` | `boolean` | — | Controlled open state. | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). | +| `closeOnEscape` | `boolean` | `true` | Close the menu when Escape is pressed. | +| `closeOnOutsideClick` | `boolean` | `true` | Close the menu when clicking outside. | +| `defaultPanel` | `string` | First Panel's `id` | Initial panel for panel mode. | + +#### Callbacks + +| Callback | Signature | Description | +| -------- | --------- | ----------- | +| `onOpenChange` | `(open: boolean) => void` | Fired when the menu opens or closes. | + +#### Renders + +React: No DOM element (provider only). +HTML: `` serves as the root and content container. + +--- + +### Trigger + +Button that opens and closes the menu. Clicking toggles the menu. Carries ARIA attributes that link to Content. + +#### React + +```tsx +Settings +``` + +#### Props + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `render` | `RenderProp` | — | Custom render element. | + +#### ARIA (automatic) + +| Attribute | Value | +| --------- | ----- | +| `aria-haspopup` | `"menu"` | +| `aria-expanded` | `"true"` when menu is open, `"false"` when closed. | +| `aria-controls` | ID of the Content element. | + +#### Renders + +React: `