Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .claude/plans/menus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Menus Implementation

Design doc: `internal/design/ui/menus.md`
Branch: `feat/menu-ui-component` (PR #1078)

## Overview

Three PRs along the dependency chain:

```
PR 1 (Core + DOM) → PR 2 (UI flat) → PR 3 (Submenus)
```

---

## PR 1 — Core + DOM layer

**Status:** DONE — `feat/menu-core-dom` (PR #1503)

### Files

**New:**
- `packages/core/src/core/ui/menu/menu-core.ts`
- `packages/core/src/core/ui/menu/menu-data-attrs.ts`
- `packages/core/src/core/ui/menu/menu-item-data-attrs.ts`
- `packages/core/src/core/ui/menu/menu-css-vars.ts`
- `packages/core/src/core/ui/menu/tests/menu-core.test.ts`
- `packages/core/src/dom/ui/menu/create-menu.ts`
- `packages/core/src/dom/ui/menu/tests/create-menu.test.ts`
- `packages/core/src/dom/ui/menu/tests/create-menu-helpers.ts`

**Modified:**
- `packages/core/src/core/index.ts` — add menu exports
- `packages/core/src/dom/index.ts` — add menu exports
- `packages/core/src/core/ui/transition.ts` — extract `TransitionDataAttrs`
- `packages/core/src/core/ui/popover/popover-data-attrs.ts` — spread `TransitionDataAttrs`
- `packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts` — spread `TransitionDataAttrs`
- `packages/core/src/core/ui/alert-dialog/alert-dialog-data-attrs.ts` — spread `TransitionDataAttrs`

### Key decisions
- `MenuCore` follows `PopoverCore` pattern: `setProps` + `setInput(TransitionState)` + `getState()`
- `isSubmenu` prop on `MenuCore` — suppresses `popover="manual"` in `getContentAttrs` and disables positioning props for nested menus
- `MenuItemDataAttrs` is not constrained by `StateAttrMap` since items have their own state, not `MenuState`
- `data-direction` belongs in DOM layer alongside `NavigationState`, not in core constants
- `createMenu()` composes `createPopover()` internally; items stored as ordered array (registration order matches DOM order for standard React list rendering)
- `destroy()` cancels the open RAF and typeahead timer before delegating to `popover.destroy()`
- Open RAF guards against `status === 'ending'` to prevent highlight firing during a rapid open/close

---

## PR 2 — UI layer: flat menu (React + HTML)

**Status:** PENDING — branch off `feat/menu-core-dom`

**Status:** PENDING

### React files (`packages/react/src/ui/menu/`)
- `context.tsx`, `index.parts.ts`, `index.ts`
- `menu-root.tsx`, `menu-trigger.tsx`, `menu-content.tsx`
- `menu-item.tsx`, `menu-label.tsx`, `menu-separator.tsx`, `menu-group.tsx`
- `menu-radio-group.tsx`, `menu-radio-item.tsx`, `menu-checkbox-item.tsx`, `menu-item-indicator.tsx`

### HTML files (`packages/html/src/ui/menu/`)
- `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-item-indicator-element.ts`

**Modified:**
- `packages/react/src/ui/index.ts` — add Menu export
- `packages/html/src/define/ui/menu.ts` — registration barrel
- `packages/html/src/ui/index.ts` — add menu exports

### Scope
- Fully functional flat single-level menu with items, radio groups, checkboxes, labels, separators
- No nested Root / Back / submenu navigation — that comes in PR 4

---

## PR 3 — Submenu navigation

**Status:** PENDING

### Files

**New DOM:**
- `packages/core/src/dom/ui/menu/create-sub-menu-transition.ts`

**New React:**
- `packages/react/src/ui/menu/menu-back.tsx`

**New HTML:**
- `packages/html/src/ui/menu/menu-back-element.ts`

**Modified:**
- `packages/core/src/dom/ui/menu/create-menu.ts` — add `push`/`pop` to `MenuApi`, `NavigationState`, wire transition
- `packages/react/src/ui/menu/menu-root.tsx` — nested Root detects parent context → submenu mode
- `packages/react/src/ui/menu/menu-content.tsx` — `data-submenu`, `data-direction`, slide transition wiring
- `packages/html/src/ui/menu/menu-element.ts` — nested `<media-menu>` + `commandfor` support
- `packages/html/src/ui/menu/menu-item-element.ts` — `commandfor` attribute handling
- `packages/core/src/dom/index.ts` — add transition export
- `packages/react/src/ui/index.ts` — add Back to Menu exports
- `packages/html/src/define/ui/menu.ts` — register `<media-menu-back>`

**Status:** PENDING — branch off `feat/menu-react-html`

### Scope
- `NavigationState`: stack of `{ menuId, triggerId }`, direction, exitingMenuId, transitioning
- `createSubMenuTransition()`: double-RAF lifecycle, `--media-menu-width/height` measurement, `getAnimations()` settle
- Nested `Menu.Root` detection via parent `MenuContext` → `isSubmenu: true` prop, Trigger registers as parent item
- `Menu.Back` / `<media-menu-back>`: pops stack, focus restoration to trigger
- Auto-back on `RadioItem` selection in submenu
- RTL: direction-agnostic JS, CSS handles `translateX` flip via `[dir="rtl"]`
4 changes: 4 additions & 0 deletions packages/core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export * from './ui/fullscreen-button/fullscreen-button-core';
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
export * from './ui/live-button/live-button-core';
export * from './ui/live-button/live-button-data-attrs';
export * from './ui/menu/menu-core';
export * from './ui/menu/menu-css-vars';
export * from './ui/menu/menu-data-attrs';
export * from './ui/menu/menu-item-data-attrs';
export * from './ui/mute-button/mute-button-core';
export * from './ui/mute-button/mute-button-data-attrs';
export * from './ui/pip-button/pip-button-core';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { TransitionDataAttrs } from '../transition';
import type { StateAttrMap } from '../types';
import type { AlertDialogState } from './alert-dialog-core';

export const AlertDialogDataAttrs = {
/** Present when the dialog is open. */
open: 'data-open',
/** Present when the open transition is in progress. */
transitionStarting: 'data-starting-style',
/** Present when the close transition is in progress. */
transitionEnding: 'data-ending-style',
...TransitionDataAttrs,
} as const satisfies StateAttrMap<AlertDialogState>;
104 changes: 104 additions & 0 deletions packages/core/src/core/ui/menu/menu-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { defaults } from '@videojs/utils/object';
import type { NonNullableObject } from '@videojs/utils/types';
import type { PopoverAlign, PopoverSide } from '../popover/popover-core';
import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition';
import { getTransitionFlags } from '../transition';

export type { PopoverAlign, PopoverSide };

export interface MenuProps {
/** Which side of the trigger the menu appears on. Root menus only. */
side?: PopoverSide | undefined;
/** Alignment along the trigger's edge. Root menus only. */
align?: PopoverAlign | undefined;
/** Controlled open state. */
open?: boolean | undefined;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean | undefined;
/** Close the menu when Escape is pressed at root level. */
closeOnEscape?: boolean | undefined;
/** Close the menu when clicking outside. Root menus only. */
closeOnOutsideClick?: boolean | undefined;
/** True when this menu instance is nested inside a parent menu's content. */
isSubmenu?: boolean | undefined;
}

/** Raw transition state provided by `createTransition`. */
export interface MenuInput extends TransitionState {}

export interface MenuState extends TransitionFlags {
open: boolean;
status: TransitionStatus;
side: PopoverSide;
align: PopoverAlign;
/** Whether this menu is nested inside another menu's content. */
isSubmenu: boolean;
}

/** Base menu logic: ARIA attributes and open/close state computation. */
export class MenuCore {
static readonly defaultProps: NonNullableObject<MenuProps> = {
side: 'bottom',
align: 'start',
open: false,
defaultOpen: false,
closeOnEscape: true,
closeOnOutsideClick: true,
isSubmenu: false,
};

#props = { ...MenuCore.defaultProps };
#input: MenuInput | null = null;

get props(): Readonly<NonNullableObject<MenuProps>> {
return this.#props;
}

constructor(props?: MenuProps) {
if (props) this.setProps(props);
}

setProps(props: MenuProps): void {
this.#props = defaults(props, MenuCore.defaultProps);
}

setInput(input: MenuInput): void {
this.#input = input;
}

getState(): MenuState {
const input = this.#input!;
return {
open: input.active,
status: input.status,
side: this.#props.side,
align: this.#props.align,
isSubmenu: this.#props.isSubmenu,
...getTransitionFlags(input.status),
};
}

getTriggerAttrs(state: MenuState, contentId?: string) {
return {
'aria-haspopup': 'menu' as const,
'aria-expanded': state.open ? 'true' : 'false',
'aria-controls': contentId,
};
}

getContentAttrs(state: MenuState) {
return {
role: 'menu' as const,
tabIndex: -1,
// Root menus use the Popover API for dismiss and focus handling.
// Submenus render inline inside the parent viewport — no popover.
...(!state.isSubmenu && { popover: 'manual' as const }),
};
}
}

export namespace MenuCore {
export type Props = MenuProps;
export type State = MenuState;
export type Input = MenuInput;
}
11 changes: 11 additions & 0 deletions packages/core/src/core/ui/menu/menu-css-vars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** CSS custom property names for menu layout and submenu transitions. */
export const MenuCSSVars = {
/** Width of the incoming submenu view (px). Set on root Content before each transition. */
width: '--media-menu-width',
/** Height of the incoming submenu view (px). Set on root Content before each transition. */
height: '--media-menu-height',
/** Viewport-constrained max width for the menu (px). Set from popover positioning. */
availableWidth: '--media-menu-available-width',
/** Viewport-constrained max height for the menu (px). Set from popover positioning. */
availableHeight: '--media-menu-available-height',
} as const;
16 changes: 16 additions & 0 deletions packages/core/src/core/ui/menu/menu-data-attrs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { TransitionDataAttrs } from '../transition';
import type { StateAttrMap } from '../types';
import type { MenuState } from './menu-core';

/** Data attributes set on the menu Content element and inherited by all children. */
export const MenuDataAttrs = {
/** Present when the menu is open. */
open: 'data-open',
/** Popover positioning side. Absent on submenus. */
side: 'data-side',
/** Popover positioning alignment. Absent on submenus. */
align: 'data-align',
/** Present on Content when this menu is nested inside a parent menu. */
isSubmenu: 'data-submenu',
...TransitionDataAttrs,
} as const satisfies StateAttrMap<MenuState>;
11 changes: 11 additions & 0 deletions packages/core/src/core/ui/menu/menu-item-data-attrs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** Data attributes set on all navigable menu item elements. */
export const MenuItemDataAttrs = {
/**
* Present on all navigable item types: Item, RadioItem, CheckboxItem, and
* the Trigger when acting as a submenu trigger inside a parent menu.
* Use `[data-item]` as a shared selector to target all item types at once.
*/
item: 'data-item',
/** Present when the item has keyboard or pointer focus (via roving tabindex). */
highlighted: 'data-highlighted',
} as const;
Loading
Loading