From e3c1b280325493909ef1da193855832b8c51fb48 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Wed, 6 May 2026 18:15:49 +1000 Subject: [PATCH] feat(core): menu core layer and DOM keyboard navigation (#1503) Co-authored-by: Claude Sonnet 4.6 --- .claude/plans/menus.md | 116 +++ apps/sandbox/templates/html-menu/index.html | 15 + apps/sandbox/templates/html-menu/main.ts | 256 ++++++ apps/sandbox/templates/index.html | 14 + apps/sandbox/templates/react-menu/index.html | 14 + apps/sandbox/templates/react-menu/main.tsx | 312 +++++++ internal/design/ui/menus.md | 120 ++- packages/core/src/core/index.ts | 4 + .../alert-dialog/alert-dialog-data-attrs.ts | 6 +- packages/core/src/core/ui/menu/menu-core.ts | 106 +++ .../core/src/core/ui/menu/menu-css-vars.ts | 11 + .../core/src/core/ui/menu/menu-data-attrs.ts | 16 + .../src/core/ui/menu/menu-item-data-attrs.ts | 11 + .../src/core/ui/menu/tests/menu-core.test.ts | 200 +++++ .../src/core/ui/popover/popover-data-attrs.ts | 6 +- .../src/core/ui/tooltip/tooltip-data-attrs.ts | 6 +- packages/core/src/core/ui/transition.ts | 25 + packages/core/src/dom/index.ts | 3 + packages/core/src/dom/ui/dismiss-layer.ts | 1 + packages/core/src/dom/ui/event.ts | 1 + .../ui/menu/create-menu-view-transition.ts | 218 +++++ packages/core/src/dom/ui/menu/create-menu.ts | 381 +++++++++ .../dom/ui/menu/menu-viewport-transition.ts | 313 +++++++ .../dom/ui/menu/tests/create-menu-helpers.ts | 31 + .../tests/create-menu-view-transition.test.ts | 232 +++++ .../src/dom/ui/menu/tests/create-menu.test.ts | 800 ++++++++++++++++++ .../tests/menu-viewport-transition.test.ts | 282 ++++++ .../src/dom/ui/tests/dismiss-layer.test.ts | 13 + packages/core/src/dom/utils/index.ts | 1 + packages/core/src/dom/utils/layout.ts | 3 + packages/html/src/define/ui/compounds.ts | 26 + packages/html/src/define/ui/menu.ts | 30 + packages/html/src/index.ts | 17 + packages/html/src/ui/menu/context.ts | 23 + .../html/src/ui/menu/menu-back-element.ts | 63 ++ .../src/ui/menu/menu-checkbox-item-element.ts | 78 ++ packages/html/src/ui/menu/menu-element.ts | 321 +++++++ .../html/src/ui/menu/menu-group-element.ts | 30 + .../html/src/ui/menu/menu-item-element.ts | 106 +++ .../ui/menu/menu-item-indicator-element.ts | 27 + .../html/src/ui/menu/menu-label-element.ts | 11 + .../src/ui/menu/menu-radio-group-element.ts | 41 + .../src/ui/menu/menu-radio-item-element.ts | 83 ++ .../src/ui/menu/menu-separator-element.ts | 21 + .../html/src/ui/menu/menu-view-element.ts | 14 + .../src/ui/menu/tests/menu-element.test.ts | 385 +++++++++ packages/react/package.json | 3 +- packages/react/src/index.ts | 1 + packages/react/src/types.d.ts | 10 + packages/react/src/ui/menu/context.tsx | 78 ++ packages/react/src/ui/menu/index.parts.ts | 19 + packages/react/src/ui/menu/index.ts | 2 + packages/react/src/ui/menu/menu-back.tsx | 53 ++ .../react/src/ui/menu/menu-checkbox-item.tsx | 72 ++ packages/react/src/ui/menu/menu-content.tsx | 323 +++++++ packages/react/src/ui/menu/menu-group.tsx | 37 + .../react/src/ui/menu/menu-item-indicator.tsx | 41 + packages/react/src/ui/menu/menu-item.tsx | 73 ++ packages/react/src/ui/menu/menu-label.tsx | 34 + .../react/src/ui/menu/menu-radio-group.tsx | 45 + .../react/src/ui/menu/menu-radio-item.tsx | 76 ++ packages/react/src/ui/menu/menu-root.tsx | 135 +++ packages/react/src/ui/menu/menu-separator.tsx | 34 + packages/react/src/ui/menu/menu-trigger.tsx | 131 +++ packages/react/src/ui/menu/menu-view.tsx | 34 + .../react/src/ui/menu/tests/menu.test.tsx | 441 ++++++++++ 66 files changed, 6379 insertions(+), 56 deletions(-) create mode 100644 .claude/plans/menus.md create mode 100644 apps/sandbox/templates/html-menu/index.html create mode 100644 apps/sandbox/templates/html-menu/main.ts create mode 100644 apps/sandbox/templates/index.html create mode 100644 apps/sandbox/templates/react-menu/index.html create mode 100644 apps/sandbox/templates/react-menu/main.tsx create mode 100644 packages/core/src/core/ui/menu/menu-core.ts create mode 100644 packages/core/src/core/ui/menu/menu-css-vars.ts create mode 100644 packages/core/src/core/ui/menu/menu-data-attrs.ts create mode 100644 packages/core/src/core/ui/menu/menu-item-data-attrs.ts create mode 100644 packages/core/src/core/ui/menu/tests/menu-core.test.ts create mode 100644 packages/core/src/dom/ui/menu/create-menu-view-transition.ts create mode 100644 packages/core/src/dom/ui/menu/create-menu.ts create mode 100644 packages/core/src/dom/ui/menu/menu-viewport-transition.ts create mode 100644 packages/core/src/dom/ui/menu/tests/create-menu-helpers.ts create mode 100644 packages/core/src/dom/ui/menu/tests/create-menu-view-transition.test.ts create mode 100644 packages/core/src/dom/ui/menu/tests/create-menu.test.ts create mode 100644 packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts create mode 100644 packages/core/src/dom/utils/layout.ts create mode 100644 packages/html/src/define/ui/menu.ts create mode 100644 packages/html/src/ui/menu/context.ts create mode 100644 packages/html/src/ui/menu/menu-back-element.ts create mode 100644 packages/html/src/ui/menu/menu-checkbox-item-element.ts create mode 100644 packages/html/src/ui/menu/menu-element.ts create mode 100644 packages/html/src/ui/menu/menu-group-element.ts create mode 100644 packages/html/src/ui/menu/menu-item-element.ts create mode 100644 packages/html/src/ui/menu/menu-item-indicator-element.ts create mode 100644 packages/html/src/ui/menu/menu-label-element.ts create mode 100644 packages/html/src/ui/menu/menu-radio-group-element.ts create mode 100644 packages/html/src/ui/menu/menu-radio-item-element.ts create mode 100644 packages/html/src/ui/menu/menu-separator-element.ts create mode 100644 packages/html/src/ui/menu/menu-view-element.ts create mode 100644 packages/html/src/ui/menu/tests/menu-element.test.ts create mode 100644 packages/react/src/ui/menu/context.tsx create mode 100644 packages/react/src/ui/menu/index.parts.ts create mode 100644 packages/react/src/ui/menu/index.ts create mode 100644 packages/react/src/ui/menu/menu-back.tsx create mode 100644 packages/react/src/ui/menu/menu-checkbox-item.tsx create mode 100644 packages/react/src/ui/menu/menu-content.tsx create mode 100644 packages/react/src/ui/menu/menu-group.tsx create mode 100644 packages/react/src/ui/menu/menu-item-indicator.tsx create mode 100644 packages/react/src/ui/menu/menu-item.tsx create mode 100644 packages/react/src/ui/menu/menu-label.tsx create mode 100644 packages/react/src/ui/menu/menu-radio-group.tsx create mode 100644 packages/react/src/ui/menu/menu-radio-item.tsx create mode 100644 packages/react/src/ui/menu/menu-root.tsx create mode 100644 packages/react/src/ui/menu/menu-separator.tsx create mode 100644 packages/react/src/ui/menu/menu-trigger.tsx create mode 100644 packages/react/src/ui/menu/menu-view.tsx create mode 100644 packages/react/src/ui/menu/tests/menu.test.tsx diff --git a/.claude/plans/menus.md b/.claude/plans/menus.md new file mode 100644 index 00000000..bac68128 --- /dev/null +++ b/.claude/plans/menus.md @@ -0,0 +1,116 @@ +# 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:** OPEN — `feat/menu-react-html` (PR #1504) + +### 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:** OPEN — `feat/menu-sub` (PR pending) + +### Files + +**New DOM:** +- `packages/core/src/dom/ui/menu/create-menu-view-transition.ts` +- `packages/core/src/dom/ui/menu/menu-viewport-transition.ts` + +**New React:** +- `packages/react/src/ui/menu/menu-back.tsx` +- `packages/react/src/ui/menu/menu-view.tsx` + +**New HTML:** +- `packages/html/src/ui/menu/menu-back-element.ts` +- `packages/html/src/ui/menu/menu-view-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/react/src/ui/menu/index.parts.ts` — export `Menu.Back` and `Menu.View` +- `packages/html/src/ui/menu/menu-element.ts` — nested `` + `commandfor` support +- `packages/html/src/ui/menu/menu-item-element.ts` — `commandfor` attribute handling +- `packages/core/src/dom/index.ts` — add submenu and viewport transition exports +- `packages/html/src/define/ui/menu.ts` — register `` and `` + +**Status:** OPEN — branched off `feat/menu-react-html` + +### Scope +- `NavigationState`: stack of `{ menuId, triggerId }`, direction, exitingMenuId, transitioning +- `createMenuViewTransition()`: generic menu view double-RAF lifecycle, data attribute hooks, `getAnimations()` settle +- `menu-viewport-transition.ts`: shared root/child view measurement and `--media-menu-width/height` sizing +- `Menu.View` / ``: optional root view wrapper for in-place view navigation; receives `data-menu-root-view` while root `Content` / `` acts as the shared viewport +- Traditional flyout submenus are out of scope for this PR and should not require `Menu.View` / `` when added later +- Nested `Menu.Root` detection via parent `MenuContext` → `isSubmenu: true` prop, Trigger registers as parent item +- `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"]` diff --git a/apps/sandbox/templates/html-menu/index.html b/apps/sandbox/templates/html-menu/index.html new file mode 100644 index 00000000..1a591e66 --- /dev/null +++ b/apps/sandbox/templates/html-menu/index.html @@ -0,0 +1,15 @@ + + + + + + Sandbox — HTML Menu + + + + + +
+ + + diff --git a/apps/sandbox/templates/html-menu/main.ts b/apps/sandbox/templates/html-menu/main.ts new file mode 100644 index 00000000..f4778a41 --- /dev/null +++ b/apps/sandbox/templates/html-menu/main.ts @@ -0,0 +1,256 @@ +// HTML Menu sandbox +// http://localhost:5173/html-menu/ +import '@app/styles.css'; + +import '@videojs/html/ui/menu'; + +// ── Class constants ─────────────────────────────────────────────────────────── + +const menuContentSurfaceClass = + 'bg-white border-none ring-1 ring-black/10 shadow-sm rounded-md p-1 min-w-[10rem] overflow-hidden outline-none'; + +const menuNavSurfaceClass = + 'bg-white border-none ring-1 ring-black/10 shadow-sm rounded-md min-w-[10rem] overflow-hidden outline-none'; + +const menuContentPlacementClass = [ + 'data-side=bottom:origin-top data-side=top:origin-bottom', + 'data-side=left:origin-right data-side=right:origin-left', + 'data-starting-style:opacity-0 data-starting-style:scale-95 data-starting-style:-translate-y-1 data-starting-style:blur-sm', + 'data-ending-style:opacity-0 data-ending-style:scale-95 data-ending-style:-translate-y-1 data-ending-style:blur-sm', +].join(' '); + +const menuContentClass = [ + menuContentSurfaceClass, + 'transition-[opacity,scale,translate,filter] duration-150', + menuContentPlacementClass, +].join(' '); + +const menuLabelClass = 'block px-2 pt-1.5 pb-0.5 text-xs font-semibold text-slate-500 select-none'; + +const menuSeparatorClass = 'block h-px bg-slate-200 -mx-1 my-1'; + +const menuItemClass = [ + 'relative flex items-center gap-2 rounded-[calc(0.375rem-2px)] px-2 py-1.5', + 'text-sm text-slate-900 cursor-default select-none outline-none transition-colors', + 'data-[highlighted]:bg-slate-100', + 'aria-disabled:opacity-50 aria-disabled:pointer-events-none', +].join(' '); + +const radioItemClass = [menuItemClass, 'pl-8'].join(' '); +const checkboxItemClass = [menuItemClass, 'pl-8'].join(' '); + +// Submenu trigger — same as a regular item but with space-between layout +const subMenuTriggerClass = [menuItemClass, 'justify-between'].join(' '); + +const subMenuContentClass = [ + 'absolute inset-0 z-10 bg-white rounded-[inherit] p-1 outline-none overflow-hidden translate-x-0', + 'transition-transform duration-300 ease-in-out will-change-transform', + '[&[data-starting-style][data-direction=forward]]:translate-x-full', + '[&[data-ending-style][data-direction=forward]]:-translate-x-full', + '[&[data-starting-style][data-direction=back]]:-translate-x-full', + '[&[data-ending-style][data-direction=back]]:translate-x-full', +].join(' '); + +// Root and submenu views share the same viewport so they can slide over each other. +const rootViewClass = [ + 'absolute inset-0 p-1 translate-x-0', + 'transition-transform duration-300 ease-in-out will-change-transform', + 'data-[menu-view-state=inactive]:-translate-x-full', +].join(' '); + +const backButtonClass = [ + 'flex items-center gap-1.5 w-full rounded-[calc(0.375rem-2px)] px-2 py-1.5 mb-0.5', + 'text-sm font-medium text-slate-500 cursor-default select-none outline-none transition-colors', + 'hover:bg-slate-100 hover:text-slate-900', +].join(' '); + +const menuNavPopupClass = [ + 'group relative', + menuNavSurfaceClass, + 'w-(--media-menu-width) h-(--media-menu-height)', + 'transition-[opacity,scale,translate,filter,width,height] duration-300 ease-in-out', + menuContentPlacementClass, +].join(' '); + +// ── Render ──────────────────────────────────────────────────────────────────── + +const root = document.getElementById('root')!; + +root.innerHTML = ` +
+ +
+

HTML Menu

+

Custom elements — <media-menu> and friends

+
+ +
+ + +
+ Radio group + + + Resolution + + Auto + 1080p + 720p + 480p + 360p (unavailable) + + +

Selected: auto

+
+ + +
+ Mixed items + + + Playback + Loop + Autoplay + + Copy link + Report issue + +

Loop: off

+
+ + +
+ Submenu + + + + + + + + + Quality + + auto + + + + + + + Speed + + Normal + + + + + + Copy link + + + + + + + Quality + + + Auto + 1080p + 720p + 480p + + + + + + + Speed + + + 0.5x + 0.75x + Normal + 1.25x + 1.5x + 2x + + + + +

+ Quality: auto +  ·  + Speed: Normal +

+
+ +
+ +
+`; + +// ── Event listeners ─────────────────────────────────────────────────────────── + +document.getElementById('quality-group')!.addEventListener('value-change', (e) => { + const { value } = (e as CustomEvent).detail; + (e.target as HTMLElement).setAttribute('value', value); + document.getElementById('quality-output')!.textContent = value; +}); + +document.getElementById('loop-item')!.addEventListener('checked-change', (e) => { + const { checked } = (e as CustomEvent).detail; + (e.target as HTMLElement).toggleAttribute('checked', checked); + document.getElementById('loop-output')!.textContent = checked ? 'on' : 'off'; +}); + +document.getElementById('autoplay-item')!.addEventListener('checked-change', (e) => { + const checked = (e as CustomEvent).detail.checked; + (e.target as HTMLElement).toggleAttribute('checked', checked); +}); + +document.getElementById('copy-item')!.addEventListener('select', () => { + console.log('copy link'); +}); + +document.getElementById('report-item')!.addEventListener('select', () => { + console.log('report issue'); +}); + +document.getElementById('nav-quality-group')!.addEventListener('value-change', (event) => { + const { value } = (event as CustomEvent).detail; + (event.target as HTMLElement).setAttribute('value', value); + document.getElementById('nav-quality-output')!.textContent = value; + document.getElementById('nav-quality-hint')!.textContent = value; +}); + +document.getElementById('nav-speed-group')!.addEventListener('value-change', (event) => { + const { value } = (event as CustomEvent).detail; + (event.target as HTMLElement).setAttribute('value', value); + const label = value === '1' ? 'Normal' : `${value}x`; + document.getElementById('nav-speed-output')!.textContent = label; + document.getElementById('nav-speed-hint')!.textContent = label; +}); + +document.getElementById('nav-copy-item')!.addEventListener('select', () => { + console.log('nav: copy link'); +}); diff --git a/apps/sandbox/templates/index.html b/apps/sandbox/templates/index.html new file mode 100644 index 00000000..ae7430c3 --- /dev/null +++ b/apps/sandbox/templates/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox + + + + +
+ + + diff --git a/apps/sandbox/templates/react-menu/index.html b/apps/sandbox/templates/react-menu/index.html new file mode 100644 index 00000000..d7091a9c --- /dev/null +++ b/apps/sandbox/templates/react-menu/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — React Menu + + + + +
+ + + diff --git a/apps/sandbox/templates/react-menu/main.tsx b/apps/sandbox/templates/react-menu/main.tsx new file mode 100644 index 00000000..cc345dad --- /dev/null +++ b/apps/sandbox/templates/react-menu/main.tsx @@ -0,0 +1,312 @@ +import '@app/styles.css'; +// React Menu sandbox +// http://localhost:5173/react-menu/ + +import { Menu } from '@videojs/react'; +import { useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +// ── Class constants ─────────────────────────────────────────────────────────── + +const menuContentSurfaceClass = + 'bg-white border-none ring-1 ring-black/10 shadow-sm rounded-md p-1 min-w-[10rem] overflow-hidden outline-none'; + +const menuNavSurfaceClass = + 'bg-white border-none ring-1 ring-black/10 shadow-sm rounded-md min-w-[10rem] overflow-hidden outline-none'; + +const menuContentPlacementClass = [ + 'data-side=bottom:origin-top data-side=top:origin-bottom', + 'data-side=left:origin-right data-side=right:origin-left', + 'data-starting-style:opacity-0 data-starting-style:scale-95 data-starting-style:-translate-y-1 data-starting-style:blur-sm', + 'data-ending-style:opacity-0 data-ending-style:scale-95 data-ending-style:-translate-y-1 data-ending-style:blur-sm', +].join(' '); + +const menuContentClass = [ + menuContentSurfaceClass, + 'transition-[opacity,scale,translate,filter] duration-150', + menuContentPlacementClass, +].join(' '); + +const menuLabelClass = 'block px-2 pt-1.5 pb-0.5 text-xs font-semibold text-slate-500 select-none'; + +const menuSeparatorClass = 'block h-px bg-slate-200 -mx-1 my-1'; + +const menuItemClass = [ + 'relative flex items-center gap-2 rounded-[calc(0.375rem-2px)] px-2 py-1.5', + 'text-sm text-slate-900 cursor-default select-none outline-none transition-colors', + 'data-[highlighted]:bg-slate-100', + 'aria-disabled:opacity-50 aria-disabled:pointer-events-none', +].join(' '); + +const radioItemClass = [menuItemClass, 'pl-8'].join(' '); +const checkboxItemClass = [menuItemClass, 'pl-8'].join(' '); + +// Submenu trigger — same as a regular item but with space-between layout +const subMenuTriggerClass = [menuItemClass, 'justify-between'].join(' '); + +const subMenuContentClass = [ + 'absolute inset-0 z-10 bg-white rounded-[inherit] p-1 outline-none overflow-hidden translate-x-0', + 'transition-transform duration-300 ease-in-out will-change-transform', + '[&[data-starting-style][data-direction=forward]]:translate-x-full', + '[&[data-ending-style][data-direction=forward]]:-translate-x-full', + '[&[data-starting-style][data-direction=back]]:-translate-x-full', + '[&[data-ending-style][data-direction=back]]:translate-x-full', +].join(' '); + +// Root and submenu views share the same viewport so they can slide over each other. +const rootViewClass = [ + 'absolute inset-0 p-1 translate-x-0', + 'transition-transform duration-300 ease-in-out will-change-transform', + 'data-[menu-view-state=inactive]:-translate-x-full', +].join(' '); + +const backButtonClass = [ + 'flex items-center gap-1.5 w-full rounded-[calc(0.375rem-2px)] px-2 py-1.5 mb-0.5', + 'text-sm font-medium text-slate-500 cursor-default select-none outline-none transition-colors', + 'hover:bg-slate-100 hover:text-slate-900', +].join(' '); + +const menuNavPopupClass = [ + 'group relative', + menuNavSurfaceClass, + 'w-(--media-menu-width) h-(--media-menu-height)', + 'transition-[opacity,scale,translate,filter,width,height] duration-300 ease-in-out', + menuContentPlacementClass, +].join(' '); + +// ── Indicators ──────────────────────────────────────────────────────────────── + +function RadioDot() { + return ; +} + +function Checkmark() { + return ( + + + + ); +} + +// ── Shared trigger button ───────────────────────────────────────────────────── + +function TriggerButton({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + ); +} + +// ── Demo ────────────────────────────────────────────────────────────────────── + +function ChevronRight() { + return ( + + ); +} + +function ChevronLeft() { + return ( + + ); +} + +function App() { + const [quality, setQuality] = useState('auto'); + const [speed, setSpeed] = useState('1'); + const [loop, setLoop] = useState(false); + const [autoplay, setAutoplay] = useState(false); + + return ( +
+
+

React Menu

+

+ {''} and friends from @videojs/react +

+
+ +
+ {/* Radio group */} +
+ Radio group + + Quality + + Resolution + + {['auto', '1080p', '720p', '480p'].map((value) => ( + + {quality === value && } + {value} + + ))} + + 360p (unavailable) + + + + +

+ Selected: {quality} +

+
+ + {/* Mixed items */} +
+ Mixed items + + Settings + + Playback + + {loop && } + Loop + + + {autoplay && } + Autoplay + + + console.log('copy link')} className={menuItemClass}> + Copy link + + console.log('report issue')} className={menuItemClass}> + Report issue + + + +

+ Loop: {loop ? 'on' : 'off'} +

+
+ + {/* Submenu navigation */} +
+ Submenu + + Settings + + + {/* Quality submenu */} + + + Quality + + {quality} + + + + + + + Quality + + + {['auto', '1080p', '720p', '480p'].map((v) => ( + + {quality === v && } + {v} + + ))} + + + + + {/* Speed submenu */} + + + Speed + + {speed === '1' ? 'Normal' : `${speed}x`} + + + + + + + Speed + + + {[ + { value: '0.5', label: '0.5x' }, + { value: '0.75', label: '0.75x' }, + { value: '1', label: 'Normal' }, + { value: '1.25', label: '1.25x' }, + { value: '1.5', label: '1.5x' }, + { value: '2', label: '2x' }, + ].map(({ value, label }) => ( + + {speed === value && } + {label} + + ))} + + + + + + console.log('copy link')} className={menuItemClass}> + Copy link + + + + +

+ Quality: {quality} + {' · '} + Speed: {speed === '1' ? 'Normal' : `${speed}x`} +

+
+
+
+ ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/internal/design/ui/menus.md b/internal/design/ui/menus.md index 8cee8c4e..7a64b947 100644 --- a/internal/design/ui/menus.md +++ b/internal/design/ui/menus.md @@ -32,6 +32,8 @@ Requirements: Submenus are expressed by nesting `Menu.Root` inside `Menu.Content`. There are no separate `SubMenu*` parts — the same three structural parts (`Root`, `Trigger`, `Content`) compose at every level. A nested `Root` detects its parent context and behaves as a submenu automatically. +For the in-place settings-menu pattern, the root list is wrapped in `Menu.View`. `Menu.View` is not a generic submenu requirement; it marks the root logical view inside a shared viewport so CSS can animate between the root view and nested `Menu.Content` views. Traditional flyout submenus would use positioned nested content instead and would not require `Menu.View`. + ```tsx import { Menu } from '@videojs/react'; @@ -39,32 +41,34 @@ import { Menu } from '@videojs/react'; Settings - - Quality - - - - Auto - 1080p - 720p - - - + + + Quality + + + + Auto + 1080p + 720p + + + - - Speed - - - - 0.5× - Normal - - - - + + Speed + + + + 0.5× + Normal + + + + - - Copy Link + + Copy Link + @@ -74,6 +78,8 @@ import { Menu } from '@videojs/react'; Submenus are nested `` elements. A `` with `commandfor` links to its target submenu by ID — consistent with how other floating components use the invoker API. +For the in-place settings-menu pattern, the root list is wrapped in ``. Like `Menu.View`, it is a shared-viewport view-navigation boundary, not a requirement for future flyout submenu rendering. + ```ts import '@videojs/html/ui/menu'; ``` @@ -82,7 +88,13 @@ import '@videojs/html/ui/menu'; - Quality + + Quality + Speed + + Copy Link + + @@ -92,7 +104,6 @@ import '@videojs/html/ui/menu'; - Speed @@ -102,9 +113,6 @@ import '@videojs/html/ui/menu'; - - Copy Link - ``` @@ -166,9 +174,9 @@ When acting as a submenu trigger inside a parent menu: `role="menuitem"`, roving #### Content -Popup container. At the root level, handles popover positioning and dismiss behavior. Acts as the **viewport** for navigation — only one view is visible at a time. +Popup container. At the root level, handles popover positioning and dismiss behavior. When paired with `Menu.View` / ``, it also acts as the shared **viewport** for in-place menu view navigation — only one logical view is active at a time. -When nested (submenu), does not use popover positioning. Instead it renders in-place as a slide-transition view inside the parent Content's viewport. Gets `data-submenu` to distinguish it from the root Content. +When nested in the current in-place navigation mode, it does not use popover positioning. Instead it renders in-place as a generic menu view inside the parent Content's viewport. Gets `data-submenu` to distinguish it from the root Content. Future flyout submenu support would use positioned nested content and would not require the root `Menu.View` boundary. **ARIA (automatic):** @@ -188,6 +196,7 @@ When nested (submenu), does not use popover positioning. Instead it renders in-p | `data-side` | `top` / `bottom` / `left` / `right` | Popover side (root only) | | `data-align` | `start` / `center` / `end` | Popover alignment (root only) | | `data-submenu` | present/absent | This Content belongs to a nested submenu | +| `data-menu-viewport` | present | Root Content is the viewport for menu view transitions | **CSS custom properties** (set by JS on the root Content during submenu transitions): @@ -205,6 +214,22 @@ When nested (submenu), does not use popover positioning. Instead it renders in-p --- +#### View + +Root menu view inside `Content` for in-place view navigation. This is the root panel in the shared menu viewport; CSS decides whether it slides, fades, crossfades, scales, or remains static while child views enter. + +`Menu.View` / `` is only needed when the root menu and nested `Content` views share one viewport and transition in-place. It is not part of the flat menu API, and it should not be needed for a traditional side-opening flyout submenu pattern. + +**Data attributes:** + +| Attribute | Values | When | +|-----------|--------|------| +| `data-menu-root-view` | present | Marks the root list view | +| `data-menu-view` | present | Marks this element as a menu view | +| `data-menu-view-state` | `active` / `inactive` | Root view state within the viewport | + +--- + #### Back Button that navigates back to the parent view. Placed at the top of a submenu `Content`. Not rendered (or disabled) when already at root depth. @@ -349,6 +374,7 @@ Visual indicator that renders when the parent RadioItem or CheckboxItem is check | Part | Tag | |------|-----| | Root / Content | `` | +| View | `` | | Back | `` | | Item | `` | | Label | `` | @@ -359,11 +385,11 @@ Visual indicator that renders when the parent RadioItem or CheckboxItem is check | CheckboxItem | `` | | ItemIndicator | `` | -Submenus are expressed by nesting `` and linking via `commandfor`. No separate sub-element tags are needed. +In-place submenus are expressed by nesting sibling `` elements in the parent `` and linking triggers via `commandfor`. The root list lives in `` so it can participate in the same view lifecycle as child submenu content. ## Navigation model -Content acts as a fixed-size **viewport**. Only one view is visible at a time: the root list or one submenu's content. Navigation is modelled as a **stack**: +For in-place navigation, Content acts as a fixed-size **viewport**. Only one logical view is active at a time: the root list or one submenu's content. Navigation is modelled as a **stack**: ```ts type StackEntry = { @@ -417,7 +443,7 @@ The stack supports arbitrary depth. A submenu `Content` can contain another nest ## CSS animation -Animation is driven entirely by data attributes and CSS custom properties. No inline styles are applied. +Animation is driven by data attributes and CSS custom properties. Core may set sizing CSS variables and temporary measurement styles internally; sandbox/user-authored motion styling stays in CSS. ### Data attributes @@ -436,9 +462,11 @@ Animation is driven entirely by data attributes and CSS custom properties. No in | Attribute | Values | When | |-----------|--------|------| | `data-submenu` | present | Always — identifies this as a submenu view | -| `data-open` | present/absent | This submenu is the active view | -| `data-starting-style` | present/absent | Submenu is entering | -| `data-ending-style` | present/absent | Submenu is exiting | +| `data-menu-view` | present | Marks this element as a menu view | +| `data-menu-view-state` | `active` / `inactive` | This view is entering/current or exiting/hidden | +| `data-open` | present/absent | This view is mounted for transition or active | +| `data-starting-style` | present/absent | View is entering | +| `data-ending-style` | present/absent | View is exiting | | `data-direction` | `forward` / `back` | Direction of the transition | **On items** (Item, RadioItem, CheckboxItem, and Trigger when used as a submenu trigger): @@ -582,10 +610,12 @@ The menu follows the [WAI-ARIA Menu Pattern](https://www.w3.org/WAI/ARIA/apg/pat @@ -719,7 +749,8 @@ interface MenuApi { } ``` -`createSubMenuTransition()` handles the double-RAF lifecycle for submenu navigation (same pattern as `createTransition()`). +`createMenuViewTransition()` handles the double-RAF lifecycle for menu view enter/exit hooks (same pattern as `createTransition()`). +`menu-viewport-transition.ts` handles shared menu viewport measurement, width/height variables, and root/child view state coordination. **Item collection:** Items self-register via `registerItem(el)` returning a cleanup function. Sorted by `compareDocumentPosition`. Works across Shadow DOM boundaries without coupling to ARIA role strings. @@ -738,7 +769,8 @@ menu-css-vars.ts ```text create-menu.ts -create-sub-menu-transition.ts +create-menu-view-transition.ts +menu-viewport-transition.ts ``` **React** (`packages/react/src/ui/menu/`): @@ -750,6 +782,7 @@ index.ts menu-root.tsx menu-trigger.tsx menu-content.tsx +menu-view.tsx menu-back.tsx menu-item.tsx menu-label.tsx @@ -765,6 +798,7 @@ menu-item-indicator.tsx ```text menu-element.ts +menu-view-element.ts menu-back-element.ts menu-item-element.ts menu-label-element.ts diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index e79e53d1..161e1a30 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -26,6 +26,10 @@ export * from './ui/input-feedback/volume-indicator-css-vars'; export * from './ui/input-feedback/volume-indicator-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'; diff --git a/packages/core/src/core/ui/alert-dialog/alert-dialog-data-attrs.ts b/packages/core/src/core/ui/alert-dialog/alert-dialog-data-attrs.ts index 868168a0..9968ec36 100644 --- a/packages/core/src/core/ui/alert-dialog/alert-dialog-data-attrs.ts +++ b/packages/core/src/core/ui/alert-dialog/alert-dialog-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/menu/menu-core.ts b/packages/core/src/core/ui/menu/menu-core.ts new file mode 100644 index 00000000..601cd8ef --- /dev/null +++ b/packages/core/src/core/ui/menu/menu-core.ts @@ -0,0 +1,106 @@ +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 | undefined; + align: PopoverAlign | undefined; + /** 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 = { + side: 'bottom', + align: 'start', + open: false, + defaultOpen: false, + closeOnEscape: true, + closeOnOutsideClick: true, + isSubmenu: false, + }; + + #props = { ...MenuCore.defaultProps }; + #input: MenuInput | null = null; + + get props(): Readonly> { + 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!; + const isSubmenu = this.#props.isSubmenu; + + return { + open: input.active, + status: input.status, + side: isSubmenu ? undefined : this.#props.side, + align: isSubmenu ? undefined : this.#props.align, + 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; +} diff --git a/packages/core/src/core/ui/menu/menu-css-vars.ts b/packages/core/src/core/ui/menu/menu-css-vars.ts new file mode 100644 index 00000000..3538d679 --- /dev/null +++ b/packages/core/src/core/ui/menu/menu-css-vars.ts @@ -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; diff --git a/packages/core/src/core/ui/menu/menu-data-attrs.ts b/packages/core/src/core/ui/menu/menu-data-attrs.ts new file mode 100644 index 00000000..dc65a4f3 --- /dev/null +++ b/packages/core/src/core/ui/menu/menu-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/menu/menu-item-data-attrs.ts b/packages/core/src/core/ui/menu/menu-item-data-attrs.ts new file mode 100644 index 00000000..9fa97360 --- /dev/null +++ b/packages/core/src/core/ui/menu/menu-item-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/menu/tests/menu-core.test.ts b/packages/core/src/core/ui/menu/tests/menu-core.test.ts new file mode 100644 index 00000000..6286b2bb --- /dev/null +++ b/packages/core/src/core/ui/menu/tests/menu-core.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; + +import { MenuCore, type MenuInput } from '../menu-core'; + +function createInput(overrides: Partial = {}): MenuInput { + return { + active: false, + status: 'idle', + ...overrides, + }; +} + +describe('MenuCore', () => { + describe('defaultProps', () => { + it('has expected defaults', () => { + expect(MenuCore.defaultProps).toEqual({ + side: 'bottom', + align: 'start', + open: false, + defaultOpen: false, + closeOnEscape: true, + closeOnOutsideClick: true, + isSubmenu: false, + }); + }); + }); + + describe('getState', () => { + it('returns closed state by default', () => { + const core = new MenuCore(); + core.setInput(createInput()); + const state = core.getState(); + + expect(state.open).toBe(false); + expect(state.status).toBe('idle'); + expect(state.side).toBe('bottom'); + expect(state.align).toBe('start'); + expect(state.isSubmenu).toBe(false); + expect(state.transitionStarting).toBe(false); + expect(state.transitionEnding).toBe(false); + }); + + it('returns open state when active', () => { + const core = new MenuCore(); + core.setInput(createInput({ active: true, status: 'idle' })); + const state = core.getState(); + + expect(state.open).toBe(true); + }); + + it('reflects transitionStarting during starting status', () => { + const core = new MenuCore(); + core.setInput(createInput({ active: true, status: 'starting' })); + const state = core.getState(); + + expect(state.transitionStarting).toBe(true); + expect(state.transitionEnding).toBe(false); + }); + + it('reflects transitionEnding during ending status', () => { + const core = new MenuCore(); + core.setInput(createInput({ active: false, status: 'ending' })); + const state = core.getState(); + + expect(state.transitionStarting).toBe(false); + expect(state.transitionEnding).toBe(true); + }); + + it('reflects custom side and align from props', () => { + const core = new MenuCore({ side: 'top', align: 'end' }); + core.setInput(createInput()); + const state = core.getState(); + + expect(state.side).toBe('top'); + expect(state.align).toBe('end'); + }); + + it('reflects isSubmenu from props', () => { + const core = new MenuCore({ isSubmenu: true }); + core.setInput(createInput()); + const state = core.getState(); + + expect(state.isSubmenu).toBe(true); + }); + + it('omits root positioning for submenus', () => { + const core = new MenuCore({ side: 'right', align: 'end', isSubmenu: true }); + core.setInput(createInput()); + const state = core.getState(); + + expect(state.side).toBeUndefined(); + expect(state.align).toBeUndefined(); + }); + }); + + describe('getTriggerAttrs', () => { + it('returns closed ARIA attrs', () => { + const core = new MenuCore(); + core.setInput(createInput()); + const state = core.getState(); + const attrs = core.getTriggerAttrs(state); + + expect(attrs['aria-haspopup']).toBe('menu'); + expect(attrs['aria-expanded']).toBe('false'); + expect(attrs['aria-controls']).toBeUndefined(); + }); + + it('returns open ARIA attrs when open', () => { + const core = new MenuCore(); + core.setInput(createInput({ active: true })); + const state = core.getState(); + const attrs = core.getTriggerAttrs(state); + + expect(attrs['aria-expanded']).toBe('true'); + }); + + it('sets aria-controls when contentId is provided', () => { + const core = new MenuCore(); + core.setInput(createInput()); + const state = core.getState(); + const attrs = core.getTriggerAttrs(state, 'my-menu'); + + expect(attrs['aria-controls']).toBe('my-menu'); + }); + }); + + describe('getContentAttrs', () => { + it('returns menu ARIA attrs with popover for root menu', () => { + const core = new MenuCore(); + core.setInput(createInput()); + const state = core.getState(); + const attrs = core.getContentAttrs(state); + + expect(attrs.role).toBe('menu'); + expect(attrs.tabIndex).toBe(-1); + expect(attrs.popover).toBe('manual'); + }); + + it('omits popover attr for submenus', () => { + const core = new MenuCore({ isSubmenu: true }); + core.setInput(createInput()); + const state = core.getState(); + const attrs = core.getContentAttrs(state); + + expect(attrs.role).toBe('menu'); + expect(attrs.tabIndex).toBe(-1); + expect('popover' in attrs).toBe(false); + }); + }); + + describe('setProps', () => { + it('updates props after construction', () => { + const core = new MenuCore(); + core.setProps({ side: 'top', align: 'center' }); + core.setInput(createInput()); + const state = core.getState(); + + expect(state.side).toBe('top'); + expect(state.align).toBe('center'); + }); + + it('preserves defaults for unset props', () => { + const core = new MenuCore(); + core.setProps({ side: 'left' }); + core.setInput(createInput()); + const state = core.getState(); + + expect(state.side).toBe('left'); + expect(state.align).toBe('start'); + expect(state.isSubmenu).toBe(false); + }); + }); + + describe('constructor', () => { + it('accepts initial props', () => { + const core = new MenuCore({ side: 'right', align: 'end' }); + core.setInput(createInput()); + const state = core.getState(); + + expect(state.side).toBe('right'); + expect(state.align).toBe('end'); + }); + + it('works without props', () => { + const core = new MenuCore(); + core.setInput(createInput()); + expect(() => core.getState()).not.toThrow(); + }); + }); + + describe('namespace', () => { + it('exports Props, State, Input types via namespace', () => { + // Compile-time check: ensure namespace types are accessible. + const _props: MenuCore.Props = {}; + const _input: MenuCore.Input = { active: false, status: 'idle' }; + expect(_props).toBeDefined(); + expect(_input).toBeDefined(); + }); + }); +}); diff --git a/packages/core/src/core/ui/popover/popover-data-attrs.ts b/packages/core/src/core/ui/popover/popover-data-attrs.ts index f8f9f74b..0d53f26c 100644 --- a/packages/core/src/core/ui/popover/popover-data-attrs.ts +++ b/packages/core/src/core/ui/popover/popover-data-attrs.ts @@ -1,3 +1,4 @@ +import { TransitionDataAttrs } from '../transition'; import type { StateAttrMap } from '../types'; import type { PopoverState } from './popover-core'; @@ -8,8 +9,5 @@ export const PopoverDataAttrs = { side: 'data-side', /** Indicates how the popover is aligned relative to the specified side. */ align: 'data-align', - /** 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; diff --git a/packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts b/packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts index fb7b3352..90886a06 100644 --- a/packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts +++ b/packages/core/src/core/ui/tooltip/tooltip-data-attrs.ts @@ -1,3 +1,4 @@ +import { TransitionDataAttrs } from '../transition'; import type { StateAttrMap } from '../types'; import type { TooltipState } from './tooltip-core'; @@ -8,8 +9,5 @@ export const TooltipDataAttrs = { side: 'data-side', /** Indicates how the tooltip is aligned relative to the specified side. */ align: 'data-align', - /** 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; diff --git a/packages/core/src/core/ui/transition.ts b/packages/core/src/core/ui/transition.ts index 314fa546..93a665e4 100644 --- a/packages/core/src/core/ui/transition.ts +++ b/packages/core/src/core/ui/transition.ts @@ -1,3 +1,5 @@ +import type { StateAttrMap } from './types'; + export type TransitionStatus = 'idle' | 'starting' | 'ending'; export interface TransitionState { @@ -14,9 +16,32 @@ export interface TransitionFlags { transitionEnding: boolean; } +export interface TransitionStyleAttrs { + 'data-starting-style'?: '' | undefined; + 'data-ending-style'?: '' | undefined; +} + +/** Shared data attributes for open/close transition state. Spread into component data-attrs objects. */ +export const TransitionDataAttrs = { + /** Present during the open transition. */ + transitionStarting: 'data-starting-style', + /** Present during the close transition. */ + transitionEnding: 'data-ending-style', +} as const satisfies StateAttrMap; + export function getTransitionFlags(status: TransitionStatus): TransitionFlags { return { transitionStarting: status === 'starting', transitionEnding: status === 'ending', }; } + +export function getTransitionStyleAttrs({ + transitionStarting, + transitionEnding, +}: TransitionFlags): TransitionStyleAttrs { + return { + [TransitionDataAttrs.transitionStarting]: transitionStarting ? '' : undefined, + [TransitionDataAttrs.transitionEnding]: transitionEnding ? '' : undefined, + }; +} diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index c1f480b1..375e432c 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -15,6 +15,9 @@ export * from './ui/button'; export * from './ui/dismiss-layer'; export * from './ui/event'; export * from './ui/input-action'; +export * from './ui/menu/create-menu'; +export * from './ui/menu/create-menu-view-transition'; +export * from './ui/menu/menu-viewport-transition'; export * from './ui/popover/popover'; export * from './ui/popover/popover-positioning'; export * from './ui/slider'; diff --git a/packages/core/src/dom/ui/dismiss-layer.ts b/packages/core/src/dom/ui/dismiss-layer.ts index 4f0e0f52..34aab63f 100644 --- a/packages/core/src/dom/ui/dismiss-layer.ts +++ b/packages/core/src/dom/ui/dismiss-layer.ts @@ -79,6 +79,7 @@ export function createDismissLayer(options: DismissLayerOptions): DismissLayerAp function handleKeydown(event: KeyboardEvent): void { if (event.key !== 'Escape') return; + if (event.defaultPrevented) return; if (!state.current.active) return; const shouldClose = options.closeOnEscape?.() ?? true; diff --git a/packages/core/src/dom/ui/event.ts b/packages/core/src/dom/ui/event.ts index 0b8d9c1c..4d3435d1 100644 --- a/packages/core/src/dom/ui/event.ts +++ b/packages/core/src/dom/ui/event.ts @@ -1,4 +1,5 @@ export interface UIEvent { + readonly defaultPrevented?: boolean; preventDefault(): void; stopPropagation(): void; } diff --git a/packages/core/src/dom/ui/menu/create-menu-view-transition.ts b/packages/core/src/dom/ui/menu/create-menu-view-transition.ts new file mode 100644 index 00000000..85a99763 --- /dev/null +++ b/packages/core/src/dom/ui/menu/create-menu-view-transition.ts @@ -0,0 +1,218 @@ +import { createState, type State } from '@videojs/store'; +import { getTransitionStyleAttrs, type TransitionStyleAttrs } from '../../../core/ui/transition'; +import { forceLayout } from '../../utils/layout'; + +import type { NavigationState } from './create-menu'; + +export type MenuViewTransitionPhase = 'hidden' | 'entering' | 'active' | 'exiting'; + +export type MenuViewTransitionDirection = NavigationState['direction']; + +export type MenuViewState = 'active' | 'inactive'; + +export interface MenuViewTransitionState { + phase: MenuViewTransitionPhase; + direction: MenuViewTransitionDirection; + triggerId: string | null; +} + +export interface MenuViewTransitionSyncOptions { + active: boolean; + direction: MenuViewTransitionDirection; + triggerId?: string | null; +} + +export interface MenuViewTransitionAttrs extends TransitionStyleAttrs { + 'data-menu-view': ''; + 'data-menu-view-state': MenuViewState; + 'data-direction': MenuViewTransitionDirection; + 'data-open'?: '' | undefined; + hidden: boolean; +} + +export interface MenuViewTransitionOptions { + focusFirstItem?: (element: HTMLElement) => void; + restoreFocus?: (triggerId: string | null) => void; + waitForAnimations?: (element: HTMLElement) => Promise; +} + +export interface MenuViewTransitionApi { + input: State; + setElement: (element: HTMLElement | null) => void; + sync: (options: MenuViewTransitionSyncOptions) => void; + destroy: () => void; +} + +const DEFAULT_MENU_VIEW_TRANSITION_STATE: MenuViewTransitionState = { + phase: 'hidden', + direction: 'forward', + triggerId: null, +}; + +async function waitForElementAnimations(element: HTMLElement): Promise { + const animations = element.getAnimations?.() ?? []; + + if (!animations.length) return; + + await Promise.all(animations.map((animation) => animation.finished)).catch(() => {}); +} + +function focusFirstMenuViewItem(element: HTMLElement): void { + const firstItem = element.querySelector('[data-item]'); + + firstItem?.focus({ preventScroll: true }); +} + +function getMenuViewState(phase: MenuViewTransitionPhase): MenuViewState { + return phase === 'entering' || phase === 'active' ? 'active' : 'inactive'; +} + +export function getMenuViewTransitionAttrs(state: MenuViewTransitionState): MenuViewTransitionAttrs { + return { + 'data-menu-view': '', + 'data-menu-view-state': getMenuViewState(state.phase), + 'data-direction': state.direction, + ...getTransitionStyleAttrs({ + transitionStarting: state.phase === 'entering', + transitionEnding: state.phase === 'exiting', + }), + 'data-open': state.phase !== 'hidden' ? '' : undefined, + hidden: state.phase === 'hidden', + }; +} + +export function createMenuViewTransition(options: MenuViewTransitionOptions = {}): MenuViewTransitionApi { + const input = createState(DEFAULT_MENU_VIEW_TRANSITION_STATE); + const waitForAnimations = options.waitForAnimations ?? waitForElementAnimations; + const focusFirstItem = options.focusFirstItem ?? focusFirstMenuViewItem; + + let element: HTMLElement | null = null; + let transitionId = 0; + let raf1 = 0; + let raf2 = 0; + let focusRaf = 0; + let scheduledTransitionId = 0; + let scheduledPhase: MenuViewTransitionPhase | null = null; + + function cancelFrames(): void { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + cancelAnimationFrame(focusRaf); + raf1 = 0; + raf2 = 0; + focusRaf = 0; + scheduledTransitionId = 0; + scheduledPhase = null; + } + + function scheduleCurrentPhase(): void { + const { phase } = input.current; + + if (!element || (phase !== 'entering' && phase !== 'exiting')) return; + if (scheduledTransitionId === transitionId && scheduledPhase === phase) return; + + scheduledTransitionId = transitionId; + scheduledPhase = phase; + + if (phase === 'entering') { + scheduleEnterComplete(transitionId, element); + } else { + scheduleExitComplete(transitionId, element); + } + } + + function scheduleEnterComplete(currentTransitionId: number, currentElement: HTMLElement): void { + forceLayout(currentElement); + + raf1 = requestAnimationFrame(() => { + if (currentTransitionId !== transitionId) return; + + raf2 = requestAnimationFrame(() => { + if (currentTransitionId !== transitionId) return; + + forceLayout(currentElement); + input.patch({ phase: 'active' }); + + focusRaf = requestAnimationFrame(() => { + if (currentTransitionId !== transitionId) return; + focusFirstItem(currentElement); + }); + }); + }); + } + + function scheduleExitComplete(currentTransitionId: number, currentElement: HTMLElement): void { + forceLayout(currentElement); + + raf1 = requestAnimationFrame(async () => { + await waitForAnimations(currentElement); + + if (currentTransitionId !== transitionId) return; + + const { direction, triggerId } = input.current; + + input.patch({ + phase: 'hidden', + triggerId: null, + }); + + if (direction === 'back') { + options.restoreFocus?.(triggerId); + } + }); + } + + function startEnter(direction: MenuViewTransitionDirection, triggerId: string | null): void { + transitionId++; + cancelFrames(); + + input.patch({ + phase: 'entering', + direction, + triggerId, + }); + scheduleCurrentPhase(); + } + + function startExit(direction: MenuViewTransitionDirection): void { + transitionId++; + cancelFrames(); + + input.patch({ + phase: 'exiting', + direction, + }); + scheduleCurrentPhase(); + } + + function setElement(nextElement: HTMLElement | null): void { + if (element === nextElement) return; + + element = nextElement; + scheduleCurrentPhase(); + } + + function sync({ active, direction, triggerId = null }: MenuViewTransitionSyncOptions): void { + const { phase } = input.current; + + if (active && (phase === 'hidden' || phase === 'exiting')) { + startEnter(direction, triggerId); + } else if (!active && (phase === 'active' || phase === 'entering')) { + startExit(direction); + } + } + + function destroy(): void { + transitionId++; + cancelFrames(); + element = null; + input.patch(DEFAULT_MENU_VIEW_TRANSITION_STATE); + } + + return { + input, + setElement, + sync, + destroy, + }; +} diff --git a/packages/core/src/dom/ui/menu/create-menu.ts b/packages/core/src/dom/ui/menu/create-menu.ts new file mode 100644 index 00000000..6818a928 --- /dev/null +++ b/packages/core/src/dom/ui/menu/create-menu.ts @@ -0,0 +1,381 @@ +import { createState, type State } from '@videojs/store'; +import type { MenuInput, MenuState } from '../../../core/ui/menu/menu-core'; +import { MenuItemDataAttrs } from '../../../core/ui/menu/menu-item-data-attrs'; +import type { UIFocusEvent, UIKeyboardEvent } from '../event'; +import { createPopover, type PopoverChangeDetails, type PopoverOpenChangeReason } from '../popover/popover'; +import type { PositioningOptions } from '../popover/popover-positioning'; +import type { TransitionApi } from '../transition'; + +export type MenuOpenChangeReason = PopoverOpenChangeReason; + +export type MenuChangeDetails = PopoverChangeDetails; + +export interface NavigationEntry { + /** ID of the nested menu (submenu) that was pushed. */ + menuId: string; + /** ID of the Trigger element that initiated the push, for focus restoration. */ + triggerId: string; +} + +export interface NavigationState { + /** Stack of active submenus (last = current). */ + stack: NavigationEntry[]; + /** Direction of the most recent navigation. */ + direction: 'forward' | 'back'; +} + +export interface MenuOptions { + transition: TransitionApi; + onOpenChange: (open: boolean, details: MenuChangeDetails) => void; + /** Fires after open/close animations complete. */ + onOpenChangeComplete?: (open: boolean) => void; + closeOnEscape: () => boolean; + closeOnOutsideClick: () => boolean; + /** Called when the highlighted item changes. */ + onHighlightChange?: (element: HTMLElement | null) => void; +} + +export interface MenuTriggerProps { + /** Called when the trigger is clicked. Uses the DOM `UIEvent` type to match the Popover API. */ + onClick: (event: UIEvent) => void; +} + +export interface MenuContentProps { + onKeyDown: (event: UIKeyboardEvent) => void; + onFocusOut: (event: UIFocusEvent) => void; +} + +export interface MenuHighlightOptions { + preventScroll?: boolean; +} + +export function isMenuNavigationKey(event: UIKeyboardEvent): boolean { + const { key } = event; + + return ( + key === 'ArrowDown' || + key === 'ArrowUp' || + key === 'ArrowLeft' || + key === 'ArrowRight' || + key === 'Home' || + key === 'End' || + key === 'Enter' || + key === ' ' || + key === 'Escape' || + (key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) + ); +} + +export function getRootPositionOptions(side: MenuState['side'], align: MenuState['align']): PositioningOptions | null { + if (!side || !align) return null; + + return { side, align }; +} + +export interface MenuApi { + /** Reactive transition state for platforms to subscribe to. */ + input: State; + /** Reactive navigation state for submenu stack. */ + navigationInput: State; + /** Attach to the trigger element. */ + triggerProps: MenuTriggerProps; + /** Attach to the content element. */ + contentProps: MenuContentProps; + /** The currently registered trigger element, if any. */ + readonly triggerElement: HTMLElement | null; + /** The currently registered content element, if any. */ + readonly contentElement: HTMLElement | null; + setTriggerElement: (element: HTMLElement | null) => void; + setContentElement: (element: HTMLElement | null) => void; + /** Register a navigable item. Returns a cleanup function. */ + registerItem: (element: HTMLElement) => () => void; + /** Programmatically highlight an item (or clear highlight with `null`). */ + highlight: (element: HTMLElement | null, options?: MenuHighlightOptions) => void; + /** Programmatically highlight the first registered item. */ + highlightFirstItem: (options?: MenuHighlightOptions) => void; + /** Push a submenu onto the navigation stack. */ + push: (menuId: string, triggerId: string) => void; + /** Pop the current submenu from the navigation stack. */ + pop: () => void; + open: (reason?: MenuOpenChangeReason) => void; + close: (reason?: MenuOpenChangeReason) => void; + destroy: () => void; +} + +export function completeMenuItemSelection(menu: MenuApi, parentMenu: MenuApi | null = null): void { + if (parentMenu) { + parentMenu.pop(); + } else { + menu.close(); + } +} + +export function createMenu(options: MenuOptions): MenuApi { + // Items are stored in DOM order. Framework/component lifecycle ordering is + // not always the same as visual order, especially across nested components. + const items: HTMLElement[] = []; + let highlightedItem: HTMLElement | null = null; + let triggerElement: HTMLElement | null = null; + let contentElement: HTMLElement | null = null; + let typeaheadBuffer = ''; + let typeaheadTimer: ReturnType | null = null; + let openRafId = 0; + + const navigationState = createState({ stack: [], direction: 'forward' }); + + function push(menuId: string, triggerId: string): void { + const stack = navigationState.current.stack; + const topEntry = stack[stack.length - 1]; + + if (topEntry?.menuId === menuId) return; + + navigationState.patch({ + stack: [...stack, { menuId, triggerId }], + direction: 'forward', + }); + } + + function pop(): void { + const stack = navigationState.current.stack; + + if (stack.length === 0) return; + + navigationState.patch({ + stack: stack.slice(0, -1), + direction: 'back', + }); + } + + // --- Highlight --- + + function highlight(element: HTMLElement | null, highlightOptions?: MenuHighlightOptions): void { + if (highlightedItem === element) return; + + if (highlightedItem) { + highlightedItem.tabIndex = -1; + highlightedItem.removeAttribute(MenuItemDataAttrs.highlighted); + } + + highlightedItem = element; + + if (element) { + element.tabIndex = 0; + element.setAttribute(MenuItemDataAttrs.highlighted, ''); + if (highlightOptions?.preventScroll) { + element.focus({ preventScroll: true }); + } else { + element.focus(); + } + } + + options.onHighlightChange?.(element); + } + + function clearHighlight(): void { + if (highlightedItem) { + highlightedItem.tabIndex = -1; + highlightedItem.removeAttribute(MenuItemDataAttrs.highlighted); + highlightedItem = null; + options.onHighlightChange?.(null); + } + } + + function highlightFirstItem(options?: MenuHighlightOptions): void { + highlight(items[0] ?? null, options); + } + + // --- Type-ahead --- + + function clearTypeahead(): void { + if (typeaheadTimer !== null) { + clearTimeout(typeaheadTimer); + typeaheadTimer = null; + } + typeaheadBuffer = ''; + } + + function scheduleInitialHighlight(): void { + cancelAnimationFrame(openRafId); + openRafId = requestAnimationFrame(() => { + openRafId = 0; + // Guard against close() being called before the RAF fires — active + // stays true during the closing animation, so also check status. + if (!popover.input.current.active || popover.input.current.status === 'ending' || highlightedItem) return; + highlightFirstItem(); + }); + } + + function handleTypeahead(char: string): void { + const repeatedChar = typeaheadBuffer.length === 1 && typeaheadBuffer.toLowerCase() === char.toLowerCase(); + typeaheadBuffer = repeatedChar ? char : typeaheadBuffer + char; + + if (typeaheadTimer !== null) clearTimeout(typeaheadTimer); + typeaheadTimer = setTimeout(clearTypeahead, 500); + + const currentIdx = highlightedItem ? items.indexOf(highlightedItem) : -1; + + // Search from after the current item so repeated chars cycle through matches. + const searchStart = currentIdx + 1; + const candidates = [...items.slice(searchStart), ...items.slice(0, searchStart)]; + + const needle = typeaheadBuffer.toLowerCase(); + const match = candidates.find((candidate) => { + const text = candidate.textContent?.trim().toLowerCase() ?? ''; + return text.startsWith(needle); + }); + + if (match) highlight(match); + } + + // --- Internal popover --- + + const popover = createPopover({ + transition: options.transition, + onOpenChange(open, details) { + options.onOpenChange(open, details); + + if (open) { + // Focus the first item after the popover element becomes visible. + // One RAF ensures the element has been shown via the Popover API. + scheduleInitialHighlight(); + } else { + clearHighlight(); + clearTypeahead(); + // Reset navigation stack so the menu starts at root next time it opens. + navigationState.patch({ stack: [], direction: 'forward' }); + } + }, + onOpenChangeComplete(open) { + options.onOpenChangeComplete?.(open); + // Return focus to the trigger after the close animation completes + // so screen readers hear the correct context. + if (!open) triggerElement?.focus(); + }, + closeOnEscape: options.closeOnEscape, + closeOnOutsideClick: options.closeOnOutsideClick, + }); + + // --- Content keyboard navigation --- + + const contentProps: MenuContentProps = { + onFocusOut: popover.popupProps.onFocusOut, + onKeyDown(event) { + const { key } = event; + + if (items.length === 0) return; + + switch (key) { + case 'ArrowDown': { + event.preventDefault(); + const currentIndex = highlightedItem ? items.indexOf(highlightedItem) : -1; + highlight(items[(currentIndex + 1) % items.length] ?? null); + break; + } + case 'ArrowUp': { + event.preventDefault(); + const currentIndex = highlightedItem ? items.indexOf(highlightedItem) : 0; + highlight(items[(currentIndex <= 0 ? items.length : currentIndex) - 1] ?? null); + break; + } + case 'Home': { + event.preventDefault(); + highlight(items[0] ?? null); + break; + } + case 'End': { + event.preventDefault(); + highlight(items[items.length - 1] ?? null); + break; + } + case 'Enter': + case ' ': { + event.preventDefault(); + highlightedItem?.click(); + break; + } + default: { + // Printable characters trigger type-ahead search. + if (key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) { + handleTypeahead(key); + } + } + } + }, + }; + + // --- Element setters --- + + function setTriggerElement(element: HTMLElement | null): void { + triggerElement = element; + popover.setTriggerElement(element); + } + + function setContentElement(element: HTMLElement | null): void { + contentElement = element; + popover.setPopupElement(element); + } + + // --- Item registration --- + + function compareItems(a: HTMLElement, b: HTMLElement): number { + if (a === b) return 0; + + const position = a.compareDocumentPosition(b); + + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + + return 0; + } + + function registerItem(element: HTMLElement): () => void { + element.tabIndex = -1; + element.setAttribute(MenuItemDataAttrs.item, ''); + items.push(element); + items.sort(compareItems); + + if (popover.input.current.active && popover.input.current.status !== 'ending' && !highlightedItem) { + scheduleInitialHighlight(); + } + + return () => { + const index = items.indexOf(element); + if (index !== -1) items.splice(index, 1); + if (highlightedItem === element) clearHighlight(); + }; + } + + function destroy(): void { + cancelAnimationFrame(openRafId); + openRafId = 0; + clearTypeahead(); + popover.destroy(); + } + + return { + input: popover.input as State, + navigationInput: navigationState, + // Menus open/close on trigger click — forward the popover's click handler. + // Hover and focus-based open are disabled (openOnHover not set). + triggerProps: { + onClick: popover.triggerProps.onClick, + }, + contentProps, + get triggerElement(): HTMLElement | null { + return triggerElement; + }, + get contentElement(): HTMLElement | null { + return contentElement; + }, + setTriggerElement, + setContentElement, + registerItem, + highlight, + highlightFirstItem, + push, + pop, + open: popover.open, + close: popover.close, + destroy, + }; +} diff --git a/packages/core/src/dom/ui/menu/menu-viewport-transition.ts b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts new file mode 100644 index 00000000..42aa7899 --- /dev/null +++ b/packages/core/src/dom/ui/menu/menu-viewport-transition.ts @@ -0,0 +1,313 @@ +import { TransitionDataAttrs } from '../../../core/ui/transition'; +import { forceLayout } from '../../utils/layout'; +import type { MenuViewTransitionState } from './create-menu-view-transition'; + +export interface MenuViewportTransitionOptions { + minWidth?: number; +} + +export interface MenuViewportAttrs { + 'data-menu-viewport': ''; +} + +export interface MenuRootViewAttrs { + 'data-menu-root-view': ''; + 'data-menu-view': ''; +} + +interface MenuViewSize { + width: number; + height: number; +} + +interface InlineStyleSnapshotEntry { + property: string; + value: string; + priority: string; +} + +interface PendingMenuViewTransition { + entering: HTMLElement; + fromSize: MenuViewSize; + toSize: MenuViewSize; +} + +interface MenuViewportTransitionState { + pending: PendingMenuViewTransition | null; + phaseKeys: WeakMap; +} + +const DEFAULT_MENU_VIEWPORT_MIN_WIDTH = 160; +const MENU_VIEW_ATTR = 'data-menu-view'; +const MENU_VIEW_STATE_ATTR = 'data-menu-view-state'; +const MENU_VIEW_ACTIVE_STATE = 'active'; +const MENU_VIEW_INACTIVE_STATE = 'inactive'; +const MENU_ROOT_VIEW_ATTR = 'data-menu-root-view'; +const MENU_VIEWPORT_ATTR = 'data-menu-viewport'; +const MENU_WIDTH_VAR = '--media-menu-width'; +const MENU_HEIGHT_VAR = '--media-menu-height'; +const MENU_VIEW_MEASURE_STYLE_PROPERTIES = [ + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'width', + 'height', + 'min-width', + 'max-width', +]; + +const viewportTransitionStates = new WeakMap(); + +export function getMenuViewportAttrs(): MenuViewportAttrs { + return { + 'data-menu-viewport': '', + }; +} + +export function getMenuRootViewAttrs(): MenuRootViewAttrs { + return { + 'data-menu-root-view': '', + 'data-menu-view': '', + }; +} + +function getViewportTransitionState(content: HTMLElement): MenuViewportTransitionState { + let state = viewportTransitionStates.get(content); + + if (!state) { + state = { + pending: null, + phaseKeys: new WeakMap(), + }; + viewportTransitionStates.set(content, state); + } + + return state; +} + +export function getMenuViewportElement(content: HTMLElement | null): HTMLElement | null { + if (!content) return null; + + return content.querySelector(`:scope > [${MENU_VIEWPORT_ATTR}]`) ?? content; +} + +function getViewportElement(content: HTMLElement, view?: HTMLElement | null): HTMLElement { + const viewport = getMenuViewportElement(content); + + if (viewport && viewport !== content) { + return viewport; + } + + if (view?.parentElement && content.contains(view.parentElement)) { + return view.parentElement; + } + + return content; +} + +function getRootViewElement(viewport: HTMLElement): HTMLElement | null { + return viewport.querySelector(`:scope > [${MENU_ROOT_VIEW_ATTR}]`); +} + +function getActiveMenuViewElement(viewport: HTMLElement): HTMLElement | null { + return ( + Array.from(viewport.children).find( + (child): child is HTMLElement => + child instanceof HTMLElement && + child.hasAttribute(MENU_VIEW_ATTR) && + !child.hasAttribute(MENU_ROOT_VIEW_ATTR) && + !child.hidden && + !child.hasAttribute(TransitionDataAttrs.transitionEnding) + ) ?? null + ); +} + +function resolveMinWidth(options: MenuViewportTransitionOptions | undefined): number { + return options?.minWidth ?? DEFAULT_MENU_VIEWPORT_MIN_WIDTH; +} + +function snapshotInlineStyle(element: HTMLElement): InlineStyleSnapshotEntry[] { + return MENU_VIEW_MEASURE_STYLE_PROPERTIES.map((property) => ({ + property, + value: element.style.getPropertyValue(property), + priority: element.style.getPropertyPriority(property), + })); +} + +function restoreInlineStyle(element: HTMLElement, snapshot: InlineStyleSnapshotEntry[]): void { + for (const { property, value, priority } of snapshot) { + if (value) { + element.style.setProperty(property, value, priority); + } else { + element.style.removeProperty(property); + } + } +} + +function measureMenuView(view: HTMLElement, minWidth: number): MenuViewSize { + const snapshot = snapshotInlineStyle(view); + + try { + view.style.setProperty('position', 'absolute'); + view.style.setProperty('top', '0px'); + view.style.setProperty('right', 'auto'); + view.style.setProperty('bottom', 'auto'); + view.style.setProperty('left', '0px'); + view.style.setProperty('width', 'max-content'); + view.style.setProperty('height', 'auto'); + view.style.setProperty('min-width', `${minWidth}px`); + view.style.setProperty('max-width', 'none'); + forceLayout(view); + + const rect = view.getBoundingClientRect(); + + return { + width: Math.ceil(Math.max(minWidth, rect.width, view.scrollWidth)), + height: Math.ceil(Math.max(rect.height, view.scrollHeight)), + }; + } finally { + restoreInlineStyle(view, snapshot); + forceLayout(view); + } +} + +function setViewportSize(content: HTMLElement, size: MenuViewSize): void { + content.style.setProperty(MENU_WIDTH_VAR, `${size.width}px`); + content.style.setProperty(MENU_HEIGHT_VAR, `${size.height}px`); +} + +function setMenuViewState( + view: HTMLElement, + state: typeof MENU_VIEW_ACTIVE_STATE | typeof MENU_VIEW_INACTIVE_STATE +): void { + view.setAttribute(MENU_VIEW_STATE_ATTR, state); + + if (state === MENU_VIEW_ACTIVE_STATE) { + view.setAttribute('data-open', ''); + } else { + view.removeAttribute('data-open'); + } +} + +function prepareEnteringMenuView( + content: HTMLElement, + rootView: HTMLElement, + entering: HTMLElement, + state: MenuViewportTransitionState, + options?: MenuViewportTransitionOptions +): void { + const minWidth = resolveMinWidth(options); + const fromSize = measureMenuView(rootView, minWidth); + const toSize = measureMenuView(entering, minWidth); + + state.pending = { entering, fromSize, toSize }; + setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE); + setViewportSize(content, fromSize); + forceLayout(content); +} + +function startEnteringMenuView( + content: HTMLElement, + rootView: HTMLElement, + entering: HTMLElement, + state: MenuViewportTransitionState, + options?: MenuViewportTransitionOptions +): void { + const minWidth = resolveMinWidth(options); + const current = + state.pending?.entering === entering + ? state.pending + : { + entering, + fromSize: measureMenuView(rootView, minWidth), + toSize: measureMenuView(entering, minWidth), + }; + + state.pending = null; + + setViewportSize(content, current.fromSize); + forceLayout(rootView); + setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE); + forceLayout(rootView); + setViewportSize(content, current.toSize); +} + +function startExitingMenuView( + content: HTMLElement, + rootView: HTMLElement, + exiting: HTMLElement, + transitionState: MenuViewportTransitionState, + options?: MenuViewportTransitionOptions +): void { + transitionState.pending = null; + + const minWidth = resolveMinWidth(options); + const fromSize = measureMenuView(exiting, minWidth); + const toSize = measureMenuView(rootView, minWidth); + + setViewportSize(content, fromSize); + setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE); + forceLayout(rootView); + setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE); + forceLayout(rootView); + setViewportSize(content, toSize); +} + +export function syncMenuViewRoot( + content: HTMLElement | null, + hasActiveChildView: boolean, + options?: MenuViewportTransitionOptions +): void { + if (!content || hasActiveChildView) return; + + const viewport = getViewportElement(content); + const rootView = getRootViewElement(viewport); + + if (!rootView || getActiveMenuViewElement(viewport)) return; + + const size = measureMenuView(rootView, resolveMinWidth(options)); + + setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE); + setViewportSize(content, size); +} + +export function syncMenuViewTransition( + content: HTMLElement | null, + view: HTMLElement | null, + viewState: MenuViewTransitionState, + options?: MenuViewportTransitionOptions +): void { + if (!content || !view) return; + + const viewport = getViewportElement(content, view); + const rootView = getRootViewElement(viewport); + + if (!rootView) return; + + const state = getViewportTransitionState(content); + const phaseKey = `${viewState.phase}:${viewState.direction}`; + + if (state.phaseKeys.get(view) === phaseKey) return; + + state.phaseKeys.set(view, phaseKey); + + if (viewState.phase === 'hidden') { + state.phaseKeys.delete(view); + syncMenuViewRoot(content, getActiveMenuViewElement(viewport) !== null, options); + return; + } + + if (viewState.phase === 'entering') { + prepareEnteringMenuView(content, rootView, view, state, options); + return; + } + + if (viewState.phase === 'active') { + startEnteringMenuView(content, rootView, view, state, options); + return; + } + + startExitingMenuView(content, rootView, view, state, options); +} diff --git a/packages/core/src/dom/ui/menu/tests/create-menu-helpers.ts b/packages/core/src/dom/ui/menu/tests/create-menu-helpers.ts new file mode 100644 index 00000000..7408b042 --- /dev/null +++ b/packages/core/src/dom/ui/menu/tests/create-menu-helpers.ts @@ -0,0 +1,31 @@ +import { vi } from 'vitest'; +import { createTransition } from '../../transition'; +import { createMenu, type MenuChangeDetails } from '../create-menu'; + +export function createTestMenu(overrides?: Partial[0]>) { + const onOpenChange = vi.fn<(open: boolean, details: MenuChangeDetails) => void>(); + const onHighlightChange = vi.fn<(element: HTMLElement | null) => void>(); + const transition = overrides?.transition ?? createTransition(); + + const menu = createMenu({ + transition, + onOpenChange, + closeOnEscape: () => true, + closeOnOutsideClick: () => true, + onHighlightChange, + ...overrides, + }); + + return { menu, onOpenChange, onHighlightChange, transition }; +} + +export function createItemElement(text: string): HTMLButtonElement { + const element = document.createElement('button'); + element.textContent = text; + document.body.appendChild(element); + return element; +} + +export function cleanupElement(element: HTMLElement): void { + element.remove(); +} diff --git a/packages/core/src/dom/ui/menu/tests/create-menu-view-transition.test.ts b/packages/core/src/dom/ui/menu/tests/create-menu-view-transition.test.ts new file mode 100644 index 00000000..5050d43f --- /dev/null +++ b/packages/core/src/dom/ui/menu/tests/create-menu-view-transition.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createMenuViewTransition, getMenuViewTransitionAttrs } from '../create-menu-view-transition'; + +function createElement(): HTMLElement { + const element = document.createElement('div'); + document.body.appendChild(element); + return element; +} + +function cleanupElement(element: HTMLElement): void { + element.remove(); +} + +async function nextFrame(): Promise { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +describe('createMenuViewTransition', () => { + const elements: HTMLElement[] = []; + + afterEach(() => { + for (const element of elements) cleanupElement(element); + elements.length = 0; + }); + + function addElement(): HTMLElement { + const element = createElement(); + elements.push(element); + return element; + } + + it('starts hidden', () => { + const transition = createMenuViewTransition(); + + expect(transition.input.current).toEqual({ + phase: 'hidden', + direction: 'forward', + triggerId: null, + }); + }); + + it('moves from entering to active after the starting style frame', async () => { + const element = addElement(); + const focusFirstItem = vi.fn(); + const transition = createMenuViewTransition({ focusFirstItem }); + + transition.setElement(element); + transition.sync({ active: true, direction: 'forward', triggerId: 'trigger-1' }); + + expect(transition.input.current).toMatchObject({ + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }); + + await nextFrame(); + await nextFrame(); + + expect(transition.input.current.phase).toBe('active'); + + await nextFrame(); + + expect(focusFirstItem).toHaveBeenCalledWith(element); + }); + + it('focuses the first menu view item without scrolling by default', async () => { + const element = addElement(); + const item = document.createElement('button'); + const focus = vi.fn(); + const transition = createMenuViewTransition(); + + item.setAttribute('data-item', ''); + item.focus = focus; + element.append(item); + + transition.setElement(element); + transition.sync({ active: true, direction: 'forward', triggerId: 'trigger-1' }); + + await nextFrame(); + await nextFrame(); + await nextFrame(); + + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('waits for exit animations before hiding', async () => { + const element = addElement(); + let resolveAnimation = () => {}; + const animationFinished = new Promise((resolve) => { + resolveAnimation = resolve; + }); + const waitForAnimations = vi.fn(() => animationFinished); + const transition = createMenuViewTransition({ waitForAnimations }); + + transition.setElement(element); + transition.sync({ active: true, direction: 'forward', triggerId: 'trigger-1' }); + + await nextFrame(); + await nextFrame(); + + transition.sync({ active: false, direction: 'back' }); + + expect(transition.input.current.phase).toBe('exiting'); + + await nextFrame(); + + expect(transition.input.current.phase).toBe('exiting'); + expect(waitForAnimations).toHaveBeenCalledWith(element); + + resolveAnimation(); + await Promise.resolve(); + await Promise.resolve(); + + expect(transition.input.current.phase).toBe('hidden'); + }); + + it('restores focus after a backward exit completes', async () => { + const element = addElement(); + let resolveAnimation = () => {}; + const animationFinished = new Promise((resolve) => { + resolveAnimation = resolve; + }); + const restoreFocus = vi.fn(); + const transition = createMenuViewTransition({ + restoreFocus, + waitForAnimations: () => animationFinished, + }); + + transition.setElement(element); + transition.sync({ active: true, direction: 'forward', triggerId: 'trigger-1' }); + + await nextFrame(); + await nextFrame(); + + transition.sync({ active: false, direction: 'back' }); + await nextFrame(); + + resolveAnimation(); + await Promise.resolve(); + await Promise.resolve(); + + expect(restoreFocus).toHaveBeenCalledWith('trigger-1'); + }); + + it('does not restore focus after a forward exit completes', async () => { + const element = addElement(); + let resolveAnimation = () => {}; + const animationFinished = new Promise((resolve) => { + resolveAnimation = resolve; + }); + const restoreFocus = vi.fn(); + const transition = createMenuViewTransition({ + restoreFocus, + waitForAnimations: () => animationFinished, + }); + + transition.setElement(element); + transition.sync({ active: true, direction: 'forward', triggerId: 'trigger-1' }); + + await nextFrame(); + await nextFrame(); + + transition.sync({ active: false, direction: 'forward' }); + await nextFrame(); + + resolveAnimation(); + await Promise.resolve(); + await Promise.resolve(); + + expect(restoreFocus).not.toHaveBeenCalled(); + }); + + it('ignores a stale exit completion after reopening', async () => { + const element = addElement(); + let resolveAnimation = () => {}; + const animationFinished = new Promise((resolve) => { + resolveAnimation = resolve; + }); + const transition = createMenuViewTransition({ waitForAnimations: () => animationFinished }); + + transition.setElement(element); + transition.sync({ active: true, direction: 'forward', triggerId: 'trigger-1' }); + + await nextFrame(); + await nextFrame(); + + transition.sync({ active: false, direction: 'back' }); + await nextFrame(); + transition.sync({ active: true, direction: 'forward', triggerId: 'trigger-1' }); + + resolveAnimation(); + await Promise.resolve(); + await Promise.resolve(); + + expect(transition.input.current.phase).toBe('entering'); + }); + + it('maps phase state to generic menu view transition attributes', () => { + expect( + getMenuViewTransitionAttrs({ + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }) + ).toEqual({ + 'data-menu-view': '', + 'data-menu-view-state': 'active', + 'data-direction': 'forward', + 'data-starting-style': '', + 'data-open': '', + 'data-ending-style': undefined, + hidden: false, + }); + + expect( + getMenuViewTransitionAttrs({ + phase: 'exiting', + direction: 'back', + triggerId: 'trigger-1', + }) + ).toEqual({ + 'data-menu-view': '', + 'data-menu-view-state': 'inactive', + 'data-direction': 'back', + 'data-starting-style': undefined, + 'data-open': '', + 'data-ending-style': '', + hidden: false, + }); + }); +}); diff --git a/packages/core/src/dom/ui/menu/tests/create-menu.test.ts b/packages/core/src/dom/ui/menu/tests/create-menu.test.ts new file mode 100644 index 00000000..2457cb33 --- /dev/null +++ b/packages/core/src/dom/ui/menu/tests/create-menu.test.ts @@ -0,0 +1,800 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MenuItemDataAttrs } from '../../../../core/ui/menu/menu-item-data-attrs'; +import type { UIFocusEvent, UIKeyboardEvent } from '../../event'; +import { completeMenuItemSelection, getRootPositionOptions, isMenuNavigationKey } from '../create-menu'; +import { cleanupElement, createItemElement, createTestMenu } from './create-menu-helpers'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeKeyEvent(key: string, modifiers?: Partial): UIKeyboardEvent { + return { + key, + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + target: document.body, + currentTarget: document.body, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...modifiers, + }; +} + +function makeFocusEvent(relatedTarget: EventTarget | null): UIFocusEvent { + return { + relatedTarget, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createMenu', () => { + let items: HTMLButtonElement[] = []; + + beforeEach(() => { + items = []; + }); + + afterEach(() => { + for (const item of items) cleanupElement(item); + items = []; + }); + + function addItem(text: string): HTMLButtonElement { + const element = createItemElement(text); + items.push(element); + return element; + } + + it('starts closed', () => { + const { menu } = createTestMenu(); + expect(menu.input.current).toEqual({ active: false, status: 'idle' }); + }); + + // ------------------------------------------------------------------------- + // open / close + // ------------------------------------------------------------------------- + + describe('open/close', () => { + it('opens and calls onOpenChange', () => { + const { menu, onOpenChange } = createTestMenu(); + + menu.open(); + + expect(menu.input.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'click' }); + }); + + it('transitions to starting status when opening', () => { + const { menu } = createTestMenu(); + + menu.open(); + + expect(menu.input.current).toEqual({ active: true, status: 'starting' }); + }); + + it('closes and calls onOpenChange', () => { + const { menu, onOpenChange } = createTestMenu(); + + menu.open(); + onOpenChange.mockClear(); + + menu.close(); + + expect(menu.input.current.active).toBe(true); // stays active during close animation + expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'click' }); + }); + + it('does not open when already open', () => { + const { menu, onOpenChange } = createTestMenu(); + + menu.open(); + onOpenChange.mockClear(); + + menu.open(); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('does not close when already closed', () => { + const { menu, onOpenChange } = createTestMenu(); + + menu.close(); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('highlights the first DOM item when items register after opening', () => { + vi.useFakeTimers(); + + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + + menu.open(); + menu.registerItem(b); + menu.registerItem(a); + + vi.runAllTimers(); + + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + + vi.useRealTimers(); + }); + + it('closes when focus moves outside the menu and trigger', () => { + const { menu, onOpenChange } = createTestMenu(); + const trigger = document.createElement('button'); + const content = document.createElement('div'); + const outside = document.createElement('button'); + + menu.setTriggerElement(trigger); + menu.setContentElement(content); + menu.open(); + onOpenChange.mockClear(); + + menu.contentProps.onFocusOut(makeFocusEvent(outside)); + + expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'blur' }); + }); + + it('keeps the menu open when focus moves inside the menu', () => { + const { menu, onOpenChange } = createTestMenu(); + const content = document.createElement('div'); + const child = document.createElement('button'); + + content.append(child); + menu.setContentElement(content); + menu.open(); + onOpenChange.mockClear(); + + menu.contentProps.onFocusOut(makeFocusEvent(child)); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('keeps the menu open when focus returns to the trigger', () => { + const { menu, onOpenChange } = createTestMenu(); + const trigger = document.createElement('button'); + const content = document.createElement('div'); + + menu.setTriggerElement(trigger); + menu.setContentElement(content); + menu.open(); + onOpenChange.mockClear(); + + menu.contentProps.onFocusOut(makeFocusEvent(trigger)); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); + + describe('completeMenuItemSelection', () => { + it('closes the menu when selection happens in a root menu', () => { + const { menu, onOpenChange } = createTestMenu(); + + menu.open(); + onOpenChange.mockClear(); + + completeMenuItemSelection(menu); + + expect(onOpenChange).toHaveBeenCalledWith(false, { reason: 'click' }); + }); + + it('pops the parent menu when selection happens in a submenu', () => { + const { menu } = createTestMenu(); + const { menu: parentMenu, onOpenChange: parentOpenChange } = createTestMenu(); + + parentMenu.open(); + parentOpenChange.mockClear(); + parentMenu.push('quality-menu', 'quality-trigger'); + + completeMenuItemSelection(menu, parentMenu); + + expect(parentMenu.navigationInput.current.stack).toEqual([]); + expect(parentMenu.navigationInput.current.direction).toBe('back'); + expect(parentOpenChange).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // triggerProps + // ------------------------------------------------------------------------- + + describe('triggerProps', () => { + it('opens on click when closed', () => { + const { menu, onOpenChange } = createTestMenu(); + + menu.triggerProps.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as UIEvent); + + expect(menu.input.current.active).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: 'click' })); + }); + + it('closes on click when open', () => { + const { menu, onOpenChange } = createTestMenu(); + + menu.open(); + onOpenChange.mockClear(); + + menu.triggerProps.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as UIEvent); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'click' })); + }); + }); + + // ------------------------------------------------------------------------- + // registerItem + // ------------------------------------------------------------------------- + + describe('registerItem', () => { + it('sets tabIndex to -1 on registration', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + + menu.registerItem(element); + + expect(element.tabIndex).toBe(-1); + }); + + it('sets data-item attribute on registration', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + + menu.registerItem(element); + + expect(element.hasAttribute(MenuItemDataAttrs.item)).toBe(true); + }); + + it('removes item from navigation on cleanup', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + + const cleanup = menu.registerItem(a); + menu.registerItem(b); + menu.open(); + menu.highlight(a); + + cleanup(); + + // After cleanup, a is no longer in the set — ArrowDown should wrap to b + const event = makeKeyEvent('ArrowDown'); + menu.contentProps.onKeyDown(event); + + expect(menu.input.current.active).toBe(true); // still open + }); + + it('removes highlight DOM state when highlighted item is unregistered', () => { + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + + const cleanup = menu.registerItem(element); + menu.highlight(element); + onHighlightChange.mockClear(); + + cleanup(); + + expect(element.hasAttribute(MenuItemDataAttrs.highlighted)).toBe(false); + expect(element.tabIndex).toBe(-1); + expect(onHighlightChange).toHaveBeenCalledWith(null); + }); + }); + + // ------------------------------------------------------------------------- + // highlight + // ------------------------------------------------------------------------- + + describe('highlight', () => { + it('sets data-highlighted attribute and tabIndex=0 on highlighted item', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + menu.highlight(element); + + expect(element.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + expect(element.tabIndex).toBe(0); + }); + + it('removes data-highlighted and resets tabIndex on previously highlighted item', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + + menu.highlight(a); + menu.highlight(b); + + expect(a.hasAttribute(MenuItemDataAttrs.highlighted)).toBe(false); + expect(a.tabIndex).toBe(-1); + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('calls onHighlightChange with the new element', () => { + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + menu.highlight(element); + + expect(onHighlightChange).toHaveBeenCalledWith(element); + }); + + it('calls onHighlightChange with null when cleared', () => { + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + menu.highlight(element); + onHighlightChange.mockClear(); + + menu.highlight(null); + + expect(onHighlightChange).toHaveBeenCalledWith(null); + }); + + it('is a no-op when same item is already highlighted', () => { + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + menu.highlight(element); + onHighlightChange.mockClear(); + + menu.highlight(element); + + expect(onHighlightChange).not.toHaveBeenCalled(); + }); + + it('highlights the first item in DOM order', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(b); + menu.registerItem(a); + + menu.highlightFirstItem(); + + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + expect(b.hasAttribute(MenuItemDataAttrs.highlighted)).toBe(false); + }); + + it('can highlight the first item without scrolling it into view', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + const focus = vi.spyOn(element, 'focus'); + menu.registerItem(element); + + menu.highlightFirstItem({ preventScroll: true }); + + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + }); + + // ------------------------------------------------------------------------- + // contentProps — keyboard navigation + // ------------------------------------------------------------------------- + + describe('contentProps.onKeyDown', () => { + it('ArrowDown highlights first item when nothing highlighted', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + + menu.contentProps.onKeyDown(makeKeyEvent('ArrowDown')); + + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('ArrowDown follows DOM order when items register out of order', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(b); + menu.registerItem(a); + + menu.contentProps.onKeyDown(makeKeyEvent('ArrowDown')); + + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('ArrowDown advances to next item', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + menu.highlight(a); + + menu.contentProps.onKeyDown(makeKeyEvent('ArrowDown')); + + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('ArrowDown wraps from last to first', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + menu.highlight(b); + + menu.contentProps.onKeyDown(makeKeyEvent('ArrowDown')); + + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('ArrowUp highlights last item when nothing highlighted', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + + menu.contentProps.onKeyDown(makeKeyEvent('ArrowUp')); + + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('ArrowUp moves to previous item', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + menu.highlight(b); + + menu.contentProps.onKeyDown(makeKeyEvent('ArrowUp')); + + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('ArrowUp wraps from first to last', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + menu.highlight(a); + + menu.contentProps.onKeyDown(makeKeyEvent('ArrowUp')); + + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('Home highlights first item', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + const c = addItem('Gamma'); + menu.registerItem(a); + menu.registerItem(b); + menu.registerItem(c); + menu.highlight(c); + + menu.contentProps.onKeyDown(makeKeyEvent('Home')); + + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('End highlights last item', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + const c = addItem('Gamma'); + menu.registerItem(a); + menu.registerItem(b); + menu.registerItem(c); + menu.highlight(a); + + menu.contentProps.onKeyDown(makeKeyEvent('End')); + + expect(c.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('Enter calls click on highlighted item', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + const onClick = vi.fn(); + element.addEventListener('click', onClick); + menu.registerItem(element); + menu.highlight(element); + + menu.contentProps.onKeyDown(makeKeyEvent('Enter')); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('Space calls click on highlighted item', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + const onClick = vi.fn(); + element.addEventListener('click', onClick); + menu.registerItem(element); + menu.highlight(element); + + menu.contentProps.onKeyDown(makeKeyEvent(' ')); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('ArrowDown calls preventDefault', () => { + const { menu } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + const event = makeKeyEvent('ArrowDown'); + menu.contentProps.onKeyDown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('does nothing when no items are registered', () => { + const { menu } = createTestMenu(); + + expect(() => menu.contentProps.onKeyDown(makeKeyEvent('ArrowDown'))).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // Submenu navigation + // ------------------------------------------------------------------------- + + describe('submenu navigation', () => { + it('pushes and pops submenu entries', () => { + const { menu } = createTestMenu(); + + menu.push('quality-menu', 'quality-trigger'); + menu.pop(); + + expect(menu.navigationInput.current).toEqual({ stack: [], direction: 'back' }); + }); + + it('ignores duplicate pushes for the active submenu', () => { + const { menu } = createTestMenu(); + + menu.push('quality-menu', 'quality-trigger'); + menu.push('quality-menu', 'quality-trigger'); + menu.pop(); + + expect(menu.navigationInput.current.stack).toEqual([]); + }); + + it('does not emit a navigation update when popping at root', () => { + const { menu } = createTestMenu(); + const listener = vi.fn(); + const cleanup = menu.navigationInput.subscribe(listener); + + menu.pop(); + + expect(listener).not.toHaveBeenCalled(); + cleanup(); + }); + }); + + // ------------------------------------------------------------------------- + // Type-ahead + // ------------------------------------------------------------------------- + + describe('type-ahead', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('highlights item matching typed character', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + menu.registerItem(a); + menu.registerItem(b); + + menu.contentProps.onKeyDown(makeKeyEvent('b')); + + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('cycles through matching items when the same character is pressed repeatedly', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Beta'); + const br = addItem('Bravo'); + const bu = addItem('Button'); + menu.registerItem(a); + menu.registerItem(b); + menu.registerItem(br); + menu.registerItem(bu); + + menu.contentProps.onKeyDown(makeKeyEvent('b')); + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + + menu.contentProps.onKeyDown(makeKeyEvent('b')); + expect(br.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + + menu.contentProps.onKeyDown(makeKeyEvent('b')); + expect(bu.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('accumulates characters for multi-char match', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const al = addItem('Almond'); + menu.registerItem(a); + menu.registerItem(al); + menu.highlight(a); + + menu.contentProps.onKeyDown(makeKeyEvent('a')); + menu.contentProps.onKeyDown(makeKeyEvent('l')); + menu.contentProps.onKeyDown(makeKeyEvent('m')); + + expect(al.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('clears buffer after 500ms so next key starts a fresh search', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Almond'); + menu.registerItem(a); + menu.registerItem(b); + + // First 'a': nothing highlighted → searchStart=0 → Alpha wins + menu.contentProps.onKeyDown(makeKeyEvent('a')); + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + + vi.advanceTimersByTime(600); + + // Buffer cleared. Alpha still highlighted (idx=0) → searchStart=1 → Almond wins + menu.contentProps.onKeyDown(makeKeyEvent('a')); + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('after buffer clears search resumes from item after current highlight', () => { + const { menu } = createTestMenu(); + const a = addItem('Alpha'); + const b = addItem('Almond'); + menu.registerItem(a); + menu.registerItem(b); + menu.highlight(a); + + // Pressing 'a' with Alpha highlighted (idx=0) → searchStart=1 → Almond + menu.contentProps.onKeyDown(makeKeyEvent('a')); + expect(b.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + + vi.advanceTimersByTime(600); + + // Buffer cleared. Almond highlighted (idx=1) → searchStart=2 → wraps → Alpha + menu.contentProps.onKeyDown(makeKeyEvent('a')); + expect(a.getAttribute(MenuItemDataAttrs.highlighted)).toBe(''); + }); + + it('ignores printable chars with modifier keys', () => { + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + onHighlightChange.mockClear(); + + menu.contentProps.onKeyDown(makeKeyEvent('a', { ctrlKey: true })); + + expect(onHighlightChange).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // destroy + // ------------------------------------------------------------------------- + + describe('destroy', () => { + it('can be destroyed without errors', () => { + const { menu } = createTestMenu(); + + expect(() => menu.destroy()).not.toThrow(); + }); + + it('is idempotent', () => { + const { menu } = createTestMenu(); + + menu.destroy(); + expect(() => menu.destroy()).not.toThrow(); + }); + + it('cancels the open RAF so highlight/focus do not fire after destroy', () => { + vi.useFakeTimers(); + + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + menu.open(); + menu.destroy(); + onHighlightChange.mockClear(); + + // Flush all pending timers and animation frames + vi.runAllTimers(); + + expect(onHighlightChange).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('clears a pending typeahead timer on destroy', () => { + vi.useFakeTimers(); + + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + menu.contentProps.onKeyDown(makeKeyEvent('a')); + menu.destroy(); + onHighlightChange.mockClear(); + + vi.advanceTimersByTime(600); + + expect(onHighlightChange).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); + + // ------------------------------------------------------------------------- + // open/close race conditions + // ------------------------------------------------------------------------- + + describe('open/close race conditions', () => { + it('does not highlight when close is called before the open RAF fires', () => { + vi.useFakeTimers(); + + const { menu, onHighlightChange } = createTestMenu(); + const element = addItem('Alpha'); + menu.registerItem(element); + + menu.open(); + menu.close(); + onHighlightChange.mockClear(); + + vi.runAllTimers(); + + expect(onHighlightChange).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); +}); + +describe('isMenuNavigationKey', () => { + it('matches keys owned by menu navigation and type-ahead', () => { + expect(isMenuNavigationKey(makeKeyEvent('ArrowDown'))).toBe(true); + expect(isMenuNavigationKey(makeKeyEvent('ArrowLeft'))).toBe(true); + expect(isMenuNavigationKey(makeKeyEvent('Escape'))).toBe(true); + expect(isMenuNavigationKey(makeKeyEvent('a'))).toBe(true); + }); + + it('ignores keys that should be allowed to bubble', () => { + expect(isMenuNavigationKey(makeKeyEvent('Tab'))).toBe(false); + expect(isMenuNavigationKey(makeKeyEvent('a', { metaKey: true }))).toBe(false); + }); +}); + +describe('getRootPositionOptions', () => { + it('returns positioning options when side and align are available', () => { + expect(getRootPositionOptions('bottom', 'start')).toEqual({ side: 'bottom', align: 'start' }); + }); + + it('returns null when root positioning is unavailable', () => { + expect(getRootPositionOptions(undefined, 'start')).toBeNull(); + expect(getRootPositionOptions('bottom', undefined)).toBeNull(); + }); +}); diff --git a/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts new file mode 100644 index 00000000..c1db3f16 --- /dev/null +++ b/packages/core/src/dom/ui/menu/tests/menu-viewport-transition.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + getMenuRootViewAttrs, + getMenuViewportAttrs, + getMenuViewportElement, + syncMenuViewRoot, + syncMenuViewTransition, +} from '../menu-viewport-transition'; + +function createElement(): HTMLElement { + const element = document.createElement('div'); + document.body.appendChild(element); + return element; +} + +function cleanupElement(element: HTMLElement): void { + element.remove(); +} + +function applyAttrs(element: HTMLElement, attrs: object): void { + for (const [key, value] of Object.entries(attrs)) { + element.setAttribute(key, String(value)); + } +} + +function createRect(width: number, height: number): DOMRect { + return { + x: 0, + y: 0, + width, + height, + top: 0, + right: width, + bottom: height, + left: 0, + toJSON: () => ({}), + } as DOMRect; +} + +function mockMenuViewSize( + element: HTMLElement, + { + currentWidth, + currentHeight, + naturalWidth, + naturalHeight, + }: { + currentWidth: number; + currentHeight: number; + naturalWidth: number; + naturalHeight: number; + } +): void { + function isMeasuringNaturalSize(): boolean { + return ( + element.style.getPropertyValue('width') === 'max-content' && element.style.getPropertyValue('height') === 'auto' + ); + } + + element.getBoundingClientRect = vi.fn(() => + isMeasuringNaturalSize() ? createRect(naturalWidth, naturalHeight) : createRect(currentWidth, currentHeight) + ); + + Object.defineProperty(element, 'scrollWidth', { + configurable: true, + get: () => (isMeasuringNaturalSize() ? naturalWidth : currentWidth), + }); + + Object.defineProperty(element, 'scrollHeight', { + configurable: true, + get: () => (isMeasuringNaturalSize() ? naturalHeight : currentHeight), + }); +} + +describe('menu-viewport-transition', () => { + const elements: HTMLElement[] = []; + + afterEach(() => { + for (const element of elements) cleanupElement(element); + elements.length = 0; + }); + + function addElement(): HTMLElement { + const element = createElement(); + elements.push(element); + return element; + } + + it('sets viewport size variables while an entering menu view holds its starting style', () => { + const content = addElement(); + const rootView = document.createElement('div'); + const menuView = document.createElement('div'); + + applyAttrs(rootView, getMenuRootViewAttrs()); + menuView.setAttribute('data-menu-view', ''); + content.append(rootView, menuView); + + syncMenuViewRoot(content, false); + syncMenuViewTransition(content, menuView, { + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }); + + expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px'); + expect(rootView.getAttribute('data-menu-view-state')).toBe('active'); + }); + + it('resolves an explicit menu viewport from the menu content element', () => { + const content = addElement(); + const viewport = document.createElement('div'); + + applyAttrs(viewport, getMenuViewportAttrs()); + content.append(viewport); + + expect(getMenuViewportElement(content)).toBe(viewport); + }); + + it('resolves the menu content element when it is the viewport', () => { + const content = addElement(); + + applyAttrs(content, getMenuViewportAttrs()); + + expect(getMenuViewportElement(content)).toBe(content); + }); + + it('toggles menu viewport data attributes for active and exiting phases', () => { + const content = addElement(); + const rootView = document.createElement('div'); + const menuView = document.createElement('div'); + + applyAttrs(rootView, getMenuRootViewAttrs()); + menuView.setAttribute('data-menu-view', ''); + content.append(rootView, menuView); + + syncMenuViewTransition(content, menuView, { + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }); + syncMenuViewTransition(content, menuView, { + phase: 'active', + direction: 'forward', + triggerId: 'trigger-1', + }); + + expect(rootView.getAttribute('data-menu-view-state')).toBe('inactive'); + + syncMenuViewTransition(content, menuView, { + phase: 'exiting', + direction: 'back', + triggerId: 'trigger-1', + }); + + expect(rootView.getAttribute('data-menu-view-state')).toBe('active'); + expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px'); + }); + + it('forces root view layout around the active submenu transition', () => { + const content = addElement(); + const rootView = document.createElement('div'); + const menuView = document.createElement('div'); + const rootMeasurements: boolean[] = []; + + rootView.getBoundingClientRect = vi.fn(() => { + rootMeasurements.push(rootView.getAttribute('data-menu-view-state') === 'inactive'); + return createRect(160, 100); + }); + + applyAttrs(rootView, getMenuRootViewAttrs()); + menuView.setAttribute('data-menu-view', ''); + content.append(rootView, menuView); + + syncMenuViewTransition(content, menuView, { + phase: 'entering', + direction: 'forward', + triggerId: 'trigger-1', + }); + + rootMeasurements.length = 0; + + syncMenuViewTransition(content, menuView, { + phase: 'active', + direction: 'forward', + triggerId: 'trigger-1', + }); + + expect(rootMeasurements).toEqual([false, true]); + expect(rootView.getAttribute('data-menu-view-state')).toBe('inactive'); + }); + + it('measures the root view natural size when exiting a larger submenu', () => { + const content = addElement(); + const viewport = document.createElement('div'); + const rootView = document.createElement('div'); + const menuView = document.createElement('div'); + + applyAttrs(viewport, getMenuViewportAttrs()); + applyAttrs(rootView, getMenuRootViewAttrs()); + menuView.setAttribute('data-menu-view', ''); + viewport.append(rootView, menuView); + content.append(viewport); + + mockMenuViewSize(rootView, { + currentWidth: 220, + currentHeight: 170, + naturalWidth: 160, + naturalHeight: 109, + }); + mockMenuViewSize(menuView, { + currentWidth: 220, + currentHeight: 170, + naturalWidth: 220, + naturalHeight: 170, + }); + + syncMenuViewTransition(content, menuView, { + phase: 'exiting', + direction: 'back', + triggerId: 'trigger-1', + }); + + expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px'); + expect(content.style.getPropertyValue('--media-menu-height')).toBe('109px'); + expect(rootView.style.getPropertyValue('width')).toBe(''); + expect(rootView.style.getPropertyValue('height')).toBe(''); + }); + + it('does not restore the root view when a hidden child sibling still has an active view', () => { + const content = addElement(); + const rootView = document.createElement('div'); + const hiddenView = document.createElement('div'); + const activeView = document.createElement('div'); + + applyAttrs(rootView, getMenuRootViewAttrs()); + hiddenView.setAttribute('data-menu-view', ''); + activeView.setAttribute('data-menu-view', ''); + content.append(rootView, hiddenView, activeView); + + mockMenuViewSize(rootView, { + currentWidth: 260, + currentHeight: 180, + naturalWidth: 160, + naturalHeight: 100, + }); + mockMenuViewSize(hiddenView, { + currentWidth: 260, + currentHeight: 180, + naturalWidth: 220, + naturalHeight: 140, + }); + mockMenuViewSize(activeView, { + currentWidth: 260, + currentHeight: 180, + naturalWidth: 260, + naturalHeight: 180, + }); + + syncMenuViewTransition(content, hiddenView, { + phase: 'active', + direction: 'forward', + triggerId: 'trigger-1', + }); + syncMenuViewTransition(content, activeView, { + phase: 'active', + direction: 'forward', + triggerId: 'trigger-2', + }); + + hiddenView.hidden = true; + syncMenuViewTransition(content, hiddenView, { + phase: 'hidden', + direction: 'back', + triggerId: 'trigger-1', + }); + + expect(rootView.getAttribute('data-menu-view-state')).toBe('inactive'); + expect(content.style.getPropertyValue('--media-menu-width')).toBe('260px'); + expect(content.style.getPropertyValue('--media-menu-height')).toBe('180px'); + }); +}); diff --git a/packages/core/src/dom/ui/tests/dismiss-layer.test.ts b/packages/core/src/dom/ui/tests/dismiss-layer.test.ts index afbfc3ae..4fa65002 100644 --- a/packages/core/src/dom/ui/tests/dismiss-layer.test.ts +++ b/packages/core/src/dom/ui/tests/dismiss-layer.test.ts @@ -136,6 +136,19 @@ describe('createDismissLayer', () => { expect(onEscapeDismiss).not.toHaveBeenCalled(); }); + it('does not call onEscapeDismiss when Escape was already handled', () => { + const { layer, onEscapeDismiss } = createTestLayer(); + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + + layer.open(); + flush(); + + event.preventDefault(); + document.dispatchEvent(event); + + expect(onEscapeDismiss).not.toHaveBeenCalled(); + }); + it('ignores non-Escape keys', () => { const { layer, onEscapeDismiss } = createTestLayer(); diff --git a/packages/core/src/dom/utils/index.ts b/packages/core/src/dom/utils/index.ts index 592bd446..16f44ddb 100644 --- a/packages/core/src/dom/utils/index.ts +++ b/packages/core/src/dom/utils/index.ts @@ -1,5 +1,6 @@ export type { StateAttrMap } from '../../core/ui/types'; export { applyElementProps } from './element-props'; +export { forceLayout } from './layout'; export { logMissingFeature } from './log'; export { getPercentFromPointerEvent } from './pointer'; export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs'; diff --git a/packages/core/src/dom/utils/layout.ts b/packages/core/src/dom/utils/layout.ts new file mode 100644 index 00000000..48e43a44 --- /dev/null +++ b/packages/core/src/dom/utils/layout.ts @@ -0,0 +1,3 @@ +export function forceLayout(element: HTMLElement | null): void { + element?.getBoundingClientRect(); +} diff --git a/packages/html/src/define/ui/compounds.ts b/packages/html/src/define/ui/compounds.ts index cdf3374a..79f2dada 100644 --- a/packages/html/src/define/ui/compounds.ts +++ b/packages/html/src/define/ui/compounds.ts @@ -4,6 +4,17 @@ import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-titl import { ControlsElement } from '../../ui/controls/controls-element'; import { ControlsGroupElement } from '../../ui/controls/controls-group-element'; import { ErrorDialogElement } from '../../ui/error-dialog/error-dialog-element'; +import { MenuBackElement } from '../../ui/menu/menu-back-element'; +import { MenuCheckboxItemElement } from '../../ui/menu/menu-checkbox-item-element'; +import { MenuElement } from '../../ui/menu/menu-element'; +import { MenuGroupElement } from '../../ui/menu/menu-group-element'; +import { MenuItemElement } from '../../ui/menu/menu-item-element'; +import { MenuItemIndicatorElement } from '../../ui/menu/menu-item-indicator-element'; +import { MenuLabelElement } from '../../ui/menu/menu-label-element'; +import { MenuRadioGroupElement } from '../../ui/menu/menu-radio-group-element'; +import { MenuRadioItemElement } from '../../ui/menu/menu-radio-item-element'; +import { MenuSeparatorElement } from '../../ui/menu/menu-separator-element'; +import { MenuViewElement } from '../../ui/menu/menu-view-element'; import { SeekIndicatorElement } from '../../ui/seek-indicator/seek-indicator-element'; import { SeekIndicatorValueElement } from '../../ui/seek-indicator/seek-indicator-value-element'; import { SliderBufferElement } from '../../ui/slider/slider-buffer-element'; @@ -29,6 +40,21 @@ import { safeDefine } from '../safe-define'; // ── Define functions ──────────────────────────────────────────────────── +export function defineMenu(): void { + // Root first — part elements consume its context. + safeDefine(MenuElement); + safeDefine(MenuBackElement); + safeDefine(MenuItemElement); + safeDefine(MenuLabelElement); + safeDefine(MenuSeparatorElement); + safeDefine(MenuGroupElement); + safeDefine(MenuRadioGroupElement); + safeDefine(MenuRadioItemElement); + safeDefine(MenuCheckboxItemElement); + safeDefine(MenuItemIndicatorElement); + safeDefine(MenuViewElement); +} + export function defineControls(): void { safeDefine(ControlsElement); safeDefine(ControlsGroupElement); diff --git a/packages/html/src/define/ui/menu.ts b/packages/html/src/define/ui/menu.ts new file mode 100644 index 00000000..0e795778 --- /dev/null +++ b/packages/html/src/define/ui/menu.ts @@ -0,0 +1,30 @@ +import { MenuBackElement } from '../../ui/menu/menu-back-element'; +import { MenuCheckboxItemElement } from '../../ui/menu/menu-checkbox-item-element'; +import { MenuElement } from '../../ui/menu/menu-element'; +import { MenuGroupElement } from '../../ui/menu/menu-group-element'; +import { MenuItemElement } from '../../ui/menu/menu-item-element'; +import { MenuItemIndicatorElement } from '../../ui/menu/menu-item-indicator-element'; +import { MenuLabelElement } from '../../ui/menu/menu-label-element'; +import { MenuRadioGroupElement } from '../../ui/menu/menu-radio-group-element'; +import { MenuRadioItemElement } from '../../ui/menu/menu-radio-item-element'; +import { MenuSeparatorElement } from '../../ui/menu/menu-separator-element'; +import { MenuViewElement } from '../../ui/menu/menu-view-element'; +import { defineMenu } from './compounds'; + +defineMenu(); + +declare global { + interface HTMLElementTagNameMap { + [MenuElement.tagName]: MenuElement; + [MenuBackElement.tagName]: MenuBackElement; + [MenuItemElement.tagName]: MenuItemElement; + [MenuLabelElement.tagName]: MenuLabelElement; + [MenuSeparatorElement.tagName]: MenuSeparatorElement; + [MenuGroupElement.tagName]: MenuGroupElement; + [MenuRadioGroupElement.tagName]: MenuRadioGroupElement; + [MenuRadioItemElement.tagName]: MenuRadioItemElement; + [MenuCheckboxItemElement.tagName]: MenuCheckboxItemElement; + [MenuItemIndicatorElement.tagName]: MenuItemIndicatorElement; + [MenuViewElement.tagName]: MenuViewElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 5017310a..f6771da2 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -46,6 +46,23 @@ export { MediaButtonElement } from './ui/media-button-element'; // Primitives export * from './ui/media-element'; export { MediaUIElement } from './ui/media-ui-element'; +export { + type MenuContextValue, + type MenuRadioGroupContextValue, + menuContext, + menuRadioGroupContext, +} from './ui/menu/context'; +export { MenuBackElement } from './ui/menu/menu-back-element'; +export { MenuCheckboxItemElement } from './ui/menu/menu-checkbox-item-element'; +export { MenuElement } from './ui/menu/menu-element'; +export { MenuGroupElement } from './ui/menu/menu-group-element'; +export { MenuItemElement } from './ui/menu/menu-item-element'; +export { MenuItemIndicatorElement } from './ui/menu/menu-item-indicator-element'; +export { MenuLabelElement } from './ui/menu/menu-label-element'; +export { MenuRadioGroupElement } from './ui/menu/menu-radio-group-element'; +export { MenuRadioItemElement } from './ui/menu/menu-radio-item-element'; +export { MenuSeparatorElement } from './ui/menu/menu-separator-element'; +export { MenuViewElement } from './ui/menu/menu-view-element'; export { MuteButtonElement } from './ui/mute-button/mute-button-element'; export { PiPButtonElement } from './ui/pip-button/pip-button-element'; export { PlayButtonElement } from './ui/play-button/play-button-element'; diff --git a/packages/html/src/ui/menu/context.ts b/packages/html/src/ui/menu/context.ts new file mode 100644 index 00000000..810b914a --- /dev/null +++ b/packages/html/src/ui/menu/context.ts @@ -0,0 +1,23 @@ +import type { MenuState, StateAttrMap } from '@videojs/core'; +import type { MenuApi, NavigationState } from '@videojs/core/dom'; +import { createContext } from '@videojs/element/context'; + +export interface MenuContextValue { + menu: MenuApi; + state: MenuState; + stateAttrMap: StateAttrMap; + navigation: NavigationState; + /** The parent menu's API — set by nested submenus for pop-on-select behavior. */ + parentMenu: MenuApi | null; +} + +export interface MenuRadioGroupContextValue { + value: string; + onValueChange: (value: string) => void; +} + +const MENU_CONTEXT_KEY = Symbol('@videojs/menu'); +const MENU_RADIO_GROUP_CONTEXT_KEY = Symbol('@videojs/menu-radio-group'); + +export const menuContext = createContext(MENU_CONTEXT_KEY); +export const menuRadioGroupContext = createContext(MENU_RADIO_GROUP_CONTEXT_KEY); diff --git a/packages/html/src/ui/menu/menu-back-element.ts b/packages/html/src/ui/menu/menu-back-element.ts new file mode 100644 index 00000000..b3d4a375 --- /dev/null +++ b/packages/html/src/ui/menu/menu-back-element.ts @@ -0,0 +1,63 @@ +import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuContext } from './context'; + +export class MenuBackElement extends MediaElement { + static readonly tagName = 'media-menu-back'; + + static override properties = { + label: { type: String }, + } satisfies PropertyDeclarationMap<'label'>; + + label = 'Back'; + + readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + + #disconnect: AbortController | null = null; + #bound = false; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + this.#bound = false; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + this.#bound = false; + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + const ctx = this.#ctx.value; + if (!ctx || !this.#disconnect) return; + + if (!this.#bound) { + this.#bound = true; + + applyElementProps( + this, + { + onClick: () => { + // Pop to the parent menu view. + ctx.parentMenu?.pop(); + }, + }, + { signal: this.#disconnect.signal } + ); + } + + applyElementProps(this, { + role: 'button', + 'aria-label': this.label, + }); + + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/menu/menu-checkbox-item-element.ts b/packages/html/src/ui/menu/menu-checkbox-item-element.ts new file mode 100644 index 00000000..dc56a085 --- /dev/null +++ b/packages/html/src/ui/menu/menu-checkbox-item-element.ts @@ -0,0 +1,78 @@ +import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuContext } from './context'; + +export class MenuCheckboxItemElement extends MediaElement { + static readonly tagName = 'media-menu-checkbox-item'; + + static override properties = { + checked: { type: Boolean }, + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap<'checked' | 'disabled'>; + + checked = false; + disabled = false; + + readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + + #disconnect: AbortController | null = null; + #registered = false; + #cleanupRegistration: (() => void) | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + this.#registered = false; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#cleanupRegistration?.(); + this.#cleanupRegistration = null; + this.#disconnect?.abort(); + this.#disconnect = null; + this.#registered = false; + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + const ctx = this.#ctx.value; + if (!ctx || !this.#disconnect) return; + + if (!this.#registered) { + this.#registered = true; + + this.#cleanupRegistration = ctx.menu.registerItem(this); + + applyElementProps( + this, + { + onClick: () => { + const currentCtx = this.#ctx.value; + if (!currentCtx || this.disabled) return; + + this.checked = !this.checked; + this.dispatchEvent(new CustomEvent('checked-change', { detail: { checked: this.checked }, bubbles: true })); + }, + onPointerenter: () => { + const currentCtx = this.#ctx.value; + if (!this.disabled) currentCtx?.menu.highlight(this); + }, + }, + { signal: this.#disconnect.signal } + ); + } + + applyElementProps(this, { + role: 'menuitemcheckbox', + 'aria-checked': String(this.checked), + 'aria-disabled': this.disabled ? 'true' : undefined, + }); + + applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/menu/menu-element.ts b/packages/html/src/ui/menu/menu-element.ts new file mode 100644 index 00000000..83cd16a2 --- /dev/null +++ b/packages/html/src/ui/menu/menu-element.ts @@ -0,0 +1,321 @@ +import { MenuCore, MenuDataAttrs, type MenuInput } from '@videojs/core'; +import { + applyElementProps, + applyStateDataAttrs, + createMenu, + createMenuViewTransition, + createTransition, + getAnchorNameStyle, + getAnchorPositionStyle, + getMenuViewportAttrs, + getMenuViewTransitionAttrs, + getPopupPositionRect, + getRootPositionOptions, + isMenuNavigationKey, + type MenuApi, + type MenuChangeDetails, + type MenuViewTransitionState, + type NavigationState, + resolveOffsets, + syncMenuViewRoot, + syncMenuViewTransition, + type UIFocusEvent, + type UIKeyboardEvent, +} from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer, ContextProvider } from '@videojs/element/context'; +import { SnapshotController } from '@videojs/store/html'; +import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; + +import { MediaElement } from '../media-element'; +import { PositionController } from '../position-controller'; +import { type MenuContextValue, menuContext } from './context'; + +export class MenuElement extends MediaElement { + static readonly tagName = 'media-menu'; + + static override properties = { + open: { type: Boolean }, + defaultOpen: { type: Boolean, attribute: 'default-open' }, + side: { type: String }, + align: { type: String }, + closeOnEscape: { type: Boolean, attribute: 'close-on-escape' }, + closeOnOutsideClick: { type: Boolean, attribute: 'close-on-outside-click' }, + } satisfies PropertyDeclarationMap< + 'open' | 'defaultOpen' | 'side' | 'align' | 'closeOnEscape' | 'closeOnOutsideClick' + >; + + open = MenuCore.defaultProps.open; + defaultOpen = MenuCore.defaultProps.defaultOpen; + side = MenuCore.defaultProps.side; + align = MenuCore.defaultProps.align; + closeOnEscape = MenuCore.defaultProps.closeOnEscape; + closeOnOutsideClick = MenuCore.defaultProps.closeOnOutsideClick; + + readonly #core = new MenuCore(); + readonly #provider = new ContextProvider(this, { context: menuContext }); + readonly #position = new PositionController(this); + // Consume parent menu context — present when this is a nested (submenu) element. + readonly #parentCtx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + readonly #menuViewTransition = createMenuViewTransition({ + focusFirstItem: () => { + this.#menu?.highlightFirstItem({ preventScroll: true }); + }, + restoreFocus: (triggerId) => { + const triggerElement = triggerId ? document.getElementById(triggerId) : null; + const fallbackTrigger = this.parentElement?.querySelector( + `[data-has-submenu][commandfor="${this.id}"]` + ); + + (triggerElement ?? fallbackTrigger)?.focus({ preventScroll: true }); + }, + }); + #menu: MenuApi | null = null; + #snapshot: SnapshotController | null = null; + #navSnapshot: SnapshotController | null = null; + #menuViewSnapshot: SnapshotController | null = null; + #navState: NavigationState = { stack: [], direction: 'forward' }; + + #disconnect: AbortController | null = null; + #triggerAbort: AbortController | null = null; + #currentTrigger: HTMLElement | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + this.#disconnect = new AbortController(); + + this.#menu = createMenu({ + transition: createTransition(), + onOpenChange: (nextOpen: boolean, details: MenuChangeDetails) => { + this.open = nextOpen; + this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen, ...details } })); + }, + closeOnEscape: () => this.closeOnEscape, + closeOnOutsideClick: () => this.closeOnOutsideClick, + }); + + // The element itself is the content (popup) for root menus. + // Submenu detection happens in update() once parent context is available. + this.#menu.setContentElement(this); + + applyElementProps( + this, + { onKeyDown: this.#handleContentKeyDown, onFocusOut: this.#handleContentFocusOut }, + { signal: this.#disconnect.signal } + ); + + if (this.#snapshot) { + this.#snapshot.track(this.#menu.input); + } else { + this.#snapshot = new SnapshotController(this, this.#menu.input); + } + + if (this.#navSnapshot) { + this.#navSnapshot.track(this.#menu.navigationInput); + } else { + this.#navSnapshot = new SnapshotController(this, this.#menu.navigationInput); + } + + if (this.#menuViewSnapshot) { + this.#menuViewSnapshot.track(this.#menuViewTransition.input); + } else { + this.#menuViewSnapshot = new SnapshotController(this, this.#menuViewTransition.input); + } + } + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + + if (this.defaultOpen && !this.open) { + this.#menu?.open(); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#cleanupTrigger(); + this.#menu?.destroy(); + this.#menu = null; + this.#disconnect?.abort(); + this.#disconnect = null; + this.#menuViewTransition.destroy(); + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + + const parentCtx = this.#parentCtx.value ?? null; + const isSubmenu = parentCtx !== null; + + this.#core.setProps({ ...this, isSubmenu }); + + if (this.#menu && changed.has('open') && !isSubmenu) { + const { active: interactionOpen } = this.#menu.input.current; + if (this.open !== interactionOpen) { + if (this.open) { + this.#menu.open(); + } else { + this.#menu.close(); + } + } + } + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + if (!this.#menu) return; + + const parentCtx = this.#parentCtx.value ?? null; + const isSubmenu = parentCtx !== null; + + this.#navState = this.#menu.navigationInput.current; + const input = this.#menu.input.current; + this.#core.setInput(input); + const state = this.#core.getState(); + + if (isSubmenu && parentCtx) { + this.#updateAsSubmenu(parentCtx); + } else { + this.#updateAsRoot(state); + } + + // Provide context to child parts. + // When nested, expose the parent menu's API so items can pop on select. + const parentMenu = parentCtx?.menu ?? null; + this.#provider.setValue({ + menu: this.#menu, + state, + stateAttrMap: MenuDataAttrs, + navigation: this.#navState, + parentMenu, + }); + } + + #updateAsRoot(state: ReturnType): void { + if (!this.#menu) return; + + const triggerElement = this.#position.findTrigger(); + this.#syncTrigger(triggerElement); + + applyElementProps(this, { + ...this.#core.getContentAttrs(state), + ...getMenuViewportAttrs(), + }); + applyStateDataAttrs(this, state, MenuDataAttrs); + + if (state.open) { + tryShowPopover(this); + } else { + tryHidePopover(this); + } + + if (this.#currentTrigger) { + applyElementProps(this.#currentTrigger, this.#core.getTriggerAttrs(state, this.id)); + applyStyles(this.#currentTrigger, getAnchorNameStyle(this.id)); + } + + if (!state.open) { + this.#position.cleanup(); + return; + } + + syncMenuViewRoot(this, this.#navState.stack.length > 0); + + const positionOptions = getRootPositionOptions(state.side, state.align); + if (!positionOptions) return; + + if (supportsAnchorPositioning()) { + applyStyles(this, getAnchorPositionStyle(this.id, positionOptions)); + } else { + const triggerRect = this.#currentTrigger?.getBoundingClientRect(); + const selfRect = getPopupPositionRect(this); + const boundaryRect = document.documentElement.getBoundingClientRect(); + const offsets = resolveOffsets(this); + applyStyles(this, getAnchorPositionStyle(this.id, positionOptions, triggerRect, selfRect, boundaryRect, offsets)); + } + + this.#position.sync(this.#currentTrigger); + } + + #updateAsSubmenu(parentCtx: MenuContextValue): void { + const parentNavigation = parentCtx.navigation; + const topEntry = parentNavigation.stack[parentNavigation.stack.length - 1]; + const activeSubMenuId = topEntry?.menuId ?? null; + const isActive = activeSubMenuId === this.id; + + this.#menuViewTransition.setElement(this); + this.#menuViewTransition.sync({ + active: isActive, + direction: parentNavigation.direction, + triggerId: topEntry?.triggerId ?? null, + }); + + // Apply base submenu attributes regardless of phase. + const transitionState = this.#menuViewTransition.input.current; + + applyElementProps(this, { + ...getMenuViewTransitionAttrs(transitionState), + role: 'menu', + tabIndex: -1, + 'data-submenu': '', + }); + syncMenuViewTransition(parentCtx.menu.contentElement, this, transitionState); + } + + #handleContentKeyDown = (event: UIKeyboardEvent): void => { + this.#menu?.contentProps.onKeyDown(event); + + const parentCtx = this.#parentCtx.value ?? null; + + if (!parentCtx) return; + + const stack = parentCtx.menu.navigationInput.current.stack; + const topEntry = stack[stack.length - 1]; + const ownsActiveSubmenu = topEntry?.menuId === this.id; + const isBackNavigationKey = event.key === 'ArrowLeft' || event.key === 'Escape'; + + if (isBackNavigationKey && ownsActiveSubmenu && !event.defaultPrevented) { + event.preventDefault(); + parentCtx.menu.pop(); + } + + if (isMenuNavigationKey(event) && (!isBackNavigationKey || ownsActiveSubmenu)) { + event.stopPropagation(); + } + }; + + #handleContentFocusOut = (event: UIFocusEvent): void => { + this.#menu?.contentProps.onFocusOut(event); + }; + + #syncTrigger(triggerElement: HTMLElement | null): void { + if (triggerElement === this.#currentTrigger) return; + + this.#position.cleanup(); + this.#cleanupTrigger(); + this.#currentTrigger = triggerElement; + this.#menu?.setTriggerElement(triggerElement); + + if (triggerElement && this.#menu) { + this.#triggerAbort = new AbortController(); + applyElementProps(triggerElement, this.#menu.triggerProps, { signal: this.#triggerAbort.signal }); + } + } + + #cleanupTrigger(): void { + if (this.#currentTrigger) { + applyElementProps(this.#currentTrigger, { + 'aria-expanded': undefined, + 'aria-haspopup': undefined, + 'aria-controls': undefined, + }); + this.#currentTrigger.style.removeProperty('anchor-name'); + } + + this.#triggerAbort?.abort(); + this.#triggerAbort = null; + this.#currentTrigger = null; + } +} diff --git a/packages/html/src/ui/menu/menu-group-element.ts b/packages/html/src/ui/menu/menu-group-element.ts new file mode 100644 index 00000000..d566f12f --- /dev/null +++ b/packages/html/src/ui/menu/menu-group-element.ts @@ -0,0 +1,30 @@ +import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuContext } from './context'; + +export class MenuGroupElement extends MediaElement { + static readonly tagName = 'media-menu-group'; + + static override properties = { + label: { type: String }, + } satisfies PropertyDeclarationMap<'label'>; + + label: string | undefined = undefined; + + readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + applyElementProps(this, { + role: 'group', + 'aria-label': this.label, + }); + + const ctx = this.#ctx.value; + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/menu/menu-item-element.ts b/packages/html/src/ui/menu/menu-item-element.ts new file mode 100644 index 00000000..63a33b46 --- /dev/null +++ b/packages/html/src/ui/menu/menu-item-element.ts @@ -0,0 +1,106 @@ +import { applyElementProps, applyStateDataAttrs, completeMenuItemSelection } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuContext } from './context'; + +export class MenuItemElement extends MediaElement { + static readonly tagName = 'media-menu-item'; + + static override properties = { + disabled: { type: Boolean }, + commandfor: { type: String }, + } satisfies PropertyDeclarationMap<'disabled' | 'commandfor'>; + + disabled = false; + /** ID of a nested `` to open when this item is activated. */ + commandfor: string | undefined = undefined; + + readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + + #disconnect: AbortController | null = null; + #registered = false; + #cleanupRegistration: (() => void) | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + this.#registered = false; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#cleanupRegistration?.(); + this.#cleanupRegistration = null; + this.#disconnect?.abort(); + this.#disconnect = null; + this.#registered = false; + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + const ctx = this.#ctx.value; + if (!ctx || !this.#disconnect) return; + + if (!this.#registered) { + this.#registered = true; + + this.#cleanupRegistration = ctx.menu.registerItem(this); + + applyElementProps( + this, + { + onClick: (event: MouseEvent) => { + const currentCtx = this.#ctx.value; + if (!currentCtx || this.disabled) return; + + const target = this.commandfor; + if (target) { + // Push the linked submenu — use this element's id as triggerId + // (ensure the element has an id for focus restoration). + currentCtx.menu.push(target, this.id); + } else { + this.dispatchEvent(new CustomEvent('select', { bubbles: true })); + completeMenuItemSelection(currentCtx.menu, currentCtx.parentMenu); + } + event.preventDefault(); + }, + onKeyDown: (event: KeyboardEvent) => { + const currentCtx = this.#ctx.value; + if (!currentCtx || this.disabled || event.key !== 'ArrowRight') return; + + const target = this.commandfor; + if (!target) return; + + currentCtx.menu.push(target, this.id); + event.preventDefault(); + }, + onPointerenter: () => { + const currentCtx = this.#ctx.value; + if (!this.disabled) currentCtx?.menu.highlight(this); + }, + }, + { signal: this.#disconnect.signal } + ); + } + + const hasSubmenu = Boolean(this.commandfor); + const topEntry = ctx.navigation.stack[ctx.navigation.stack.length - 1]; + const activeSubMenuId = topEntry?.menuId ?? null; + const isExpanded = hasSubmenu ? activeSubMenuId === this.commandfor : undefined; + + applyElementProps(this, { + role: 'menuitem', + 'aria-disabled': this.disabled ? 'true' : undefined, + ...(hasSubmenu && { + 'aria-haspopup': 'menu', + 'aria-expanded': isExpanded ? 'true' : 'false', + 'data-has-submenu': '', + }), + }); + + applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/menu/menu-item-indicator-element.ts b/packages/html/src/ui/menu/menu-item-indicator-element.ts new file mode 100644 index 00000000..d33339c2 --- /dev/null +++ b/packages/html/src/ui/menu/menu-item-indicator-element.ts @@ -0,0 +1,27 @@ +import { applyElementProps } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; + +import { MediaElement } from '../media-element'; + +export class MenuItemIndicatorElement extends MediaElement { + static readonly tagName = 'media-menu-item-indicator'; + + static override properties = { + checked: { type: Boolean }, + forceMount: { type: Boolean, attribute: 'force-mount' }, + } satisfies PropertyDeclarationMap<'checked' | 'forceMount'>; + + checked = false; + forceMount = false; + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + const hidden = !this.checked && !this.forceMount; + + applyElementProps(this, { + 'aria-hidden': 'true', + hidden: hidden, + }); + } +} diff --git a/packages/html/src/ui/menu/menu-label-element.ts b/packages/html/src/ui/menu/menu-label-element.ts new file mode 100644 index 00000000..7a7c6684 --- /dev/null +++ b/packages/html/src/ui/menu/menu-label-element.ts @@ -0,0 +1,11 @@ +import type { MenuState } from '@videojs/core'; +import { ContextConsumer } from '@videojs/element/context'; + +import { ContextPartElement } from '../context-part-element'; +import { menuContext } from './context'; + +export class MenuLabelElement extends ContextPartElement { + static readonly tagName = 'media-menu-label'; + + protected readonly consumer = new ContextConsumer(this, { context: menuContext, subscribe: true }); +} diff --git a/packages/html/src/ui/menu/menu-radio-group-element.ts b/packages/html/src/ui/menu/menu-radio-group-element.ts new file mode 100644 index 00000000..e880cb07 --- /dev/null +++ b/packages/html/src/ui/menu/menu-radio-group-element.ts @@ -0,0 +1,41 @@ +import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer, ContextProvider } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuContext, menuRadioGroupContext } from './context'; + +export class MenuRadioGroupElement extends MediaElement { + static readonly tagName = 'media-menu-radio-group'; + + static override properties = { + value: { type: String }, + label: { type: String }, + } satisfies PropertyDeclarationMap<'value' | 'label'>; + + value = ''; + label: string | undefined = undefined; + + readonly #menuCtx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + readonly #provider = new ContextProvider(this, { context: menuRadioGroupContext }); + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + applyElementProps(this, { + role: 'group', + 'aria-label': this.label, + }); + + const ctx = this.#menuCtx.value; + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + + this.#provider.setValue({ + value: this.value, + onValueChange: (next: string) => { + this.value = next; + this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true })); + }, + }); + } +} diff --git a/packages/html/src/ui/menu/menu-radio-item-element.ts b/packages/html/src/ui/menu/menu-radio-item-element.ts new file mode 100644 index 00000000..9f5cf4bb --- /dev/null +++ b/packages/html/src/ui/menu/menu-radio-item-element.ts @@ -0,0 +1,83 @@ +import { applyElementProps, applyStateDataAttrs, completeMenuItemSelection } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuContext, menuRadioGroupContext } from './context'; + +export class MenuRadioItemElement extends MediaElement { + static readonly tagName = 'media-menu-radio-item'; + + static override properties = { + value: { type: String }, + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap<'value' | 'disabled'>; + + value = ''; + disabled = false; + + readonly #menuCtx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + readonly #groupCtx = new ContextConsumer(this, { context: menuRadioGroupContext, subscribe: true }); + + #disconnect: AbortController | null = null; + #registered = false; + #cleanupRegistration: (() => void) | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + this.#registered = false; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#cleanupRegistration?.(); + this.#cleanupRegistration = null; + this.#disconnect?.abort(); + this.#disconnect = null; + this.#registered = false; + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + const menuCtx = this.#menuCtx.value; + const groupCtx = this.#groupCtx.value; + if (!menuCtx || !groupCtx || !this.#disconnect) return; + + if (!this.#registered) { + this.#registered = true; + + this.#cleanupRegistration = menuCtx.menu.registerItem(this); + + applyElementProps( + this, + { + onClick: () => { + const currentMenuCtx = this.#menuCtx.value; + const currentGroupCtx = this.#groupCtx.value; + if (!currentMenuCtx || !currentGroupCtx || this.disabled) return; + + currentGroupCtx.onValueChange(this.value); + completeMenuItemSelection(currentMenuCtx.menu, currentMenuCtx.parentMenu); + }, + onPointerenter: () => { + const currentMenuCtx = this.#menuCtx.value; + if (!this.disabled) currentMenuCtx?.menu.highlight(this); + }, + }, + { signal: this.#disconnect.signal } + ); + } + + const checked = groupCtx.value === this.value; + + applyElementProps(this, { + role: 'menuitemradio', + 'aria-checked': String(checked), + 'aria-disabled': this.disabled ? 'true' : undefined, + }); + + applyStateDataAttrs(this, menuCtx.state, menuCtx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/menu/menu-separator-element.ts b/packages/html/src/ui/menu/menu-separator-element.ts new file mode 100644 index 00000000..1f584815 --- /dev/null +++ b/packages/html/src/ui/menu/menu-separator-element.ts @@ -0,0 +1,21 @@ +import { applyElementProps, applyStateDataAttrs } from '@videojs/core/dom'; +import type { PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { menuContext } from './context'; + +export class MenuSeparatorElement extends MediaElement { + static readonly tagName = 'media-menu-separator'; + + readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true }); + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + + applyElementProps(this, { role: 'separator' }); + + const ctx = this.#ctx.value; + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/menu/menu-view-element.ts b/packages/html/src/ui/menu/menu-view-element.ts new file mode 100644 index 00000000..eba834da --- /dev/null +++ b/packages/html/src/ui/menu/menu-view-element.ts @@ -0,0 +1,14 @@ +import { applyElementProps, getMenuRootViewAttrs } from '@videojs/core/dom'; +import type { PropertyValues } from '@videojs/element'; + +import { MediaElement } from '../media-element'; + +export class MenuViewElement extends MediaElement { + static readonly tagName = 'media-menu-view'; + + protected override update(changed: PropertyValues): void { + super.update(changed); + + applyElementProps(this, getMenuRootViewAttrs()); + } +} diff --git a/packages/html/src/ui/menu/tests/menu-element.test.ts b/packages/html/src/ui/menu/tests/menu-element.test.ts new file mode 100644 index 00000000..2fd69ef1 --- /dev/null +++ b/packages/html/src/ui/menu/tests/menu-element.test.ts @@ -0,0 +1,385 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MenuCheckboxItemElement } from '../menu-checkbox-item-element'; +import { MenuElement } from '../menu-element'; +import { MenuItemElement } from '../menu-item-element'; +import { MenuViewElement } from '../menu-view-element'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(Base: abstract new () => Element): Element { + const tag = uniqueTag('test-el'); + customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {}); + return document.createElement(tag) as Element; +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('MenuElement', () => { + it('marks root and nested menu views with generic view attributes', async () => { + const root = createElement(MenuElement); + const rootView = createElement(MenuViewElement); + const child = createElement(MenuElement); + + root.open = true; + child.id = 'child-menu'; + + rootView.append(child); + root.append(rootView); + document.body.append(root); + + await root.updateComplete; + await rootView.updateComplete; + await child.updateComplete; + + expect(root.hasAttribute('data-menu-viewport')).toBe(true); + expect(rootView.hasAttribute('data-menu-root-view')).toBe(true); + expect(rootView.hasAttribute('data-menu-view')).toBe(true); + expect(child.hasAttribute('data-menu-view')).toBe(true); + expect(child.getAttribute('data-menu-view-state')).toBe('inactive'); + expect(child.hasAttribute('data-submenu')).toBe(true); + }); + + it('handles keyboard navigation in the active nested menu view', async () => { + const root = createElement(MenuElement); + const rootView = createElement(MenuViewElement); + const trigger = createElement(MenuItemElement); + const child = createElement(MenuElement); + const item = createElement(MenuItemElement); + + root.open = true; + trigger.id = 'child-trigger'; + trigger.commandfor = 'child-menu'; + child.id = 'child-menu'; + item.textContent = 'Auto'; + + rootView.append(trigger); + child.append(item); + root.append(rootView, child); + document.body.append(root); + + await root.updateComplete; + await rootView.updateComplete; + await trigger.updateComplete; + await child.updateComplete; + await item.updateComplete; + + trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await root.updateComplete; + await child.updateComplete; + + child.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + + expect(item.hasAttribute('data-highlighted')).toBe(true); + expect(trigger.hasAttribute('data-highlighted')).toBe(false); + }); + + it('highlights the first item when a nested menu view becomes active', async () => { + const root = createElement(MenuElement); + const rootView = createElement(MenuViewElement); + const trigger = createElement(MenuItemElement); + const child = createElement(MenuElement); + const item = createElement(MenuItemElement); + + root.open = true; + trigger.id = 'child-trigger'; + trigger.commandfor = 'child-menu'; + child.id = 'child-menu'; + item.textContent = 'Auto'; + + rootView.append(trigger); + child.append(item); + root.append(rootView, child); + document.body.append(root); + + await root.updateComplete; + await rootView.updateComplete; + await trigger.updateComplete; + await child.updateComplete; + await item.updateComplete; + + trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await root.updateComplete; + await child.updateComplete; + await waitForAssertion(() => { + expect(item.hasAttribute('data-highlighted')).toBe(true); + }); + }); + + it('returns to the parent view when selecting an item in a nested menu', async () => { + const root = createElement(MenuElement); + const rootView = createElement(MenuViewElement); + const trigger = createElement(MenuItemElement); + const child = createElement(MenuElement); + const item = createElement(MenuItemElement); + const onSelect = vi.fn(); + + root.open = true; + trigger.id = 'child-trigger'; + trigger.commandfor = 'child-menu'; + child.id = 'child-menu'; + item.textContent = 'Auto'; + + item.addEventListener('select', onSelect); + rootView.append(trigger); + child.append(item); + root.append(rootView, child); + document.body.append(root); + + await root.updateComplete; + await rootView.updateComplete; + await trigger.updateComplete; + await child.updateComplete; + await item.updateComplete; + + trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await root.updateComplete; + await child.updateComplete; + await waitForAssertion(() => { + expect(child.getAttribute('data-menu-view-state')).toBe('active'); + }); + + item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(onSelect).toHaveBeenCalledTimes(1); + + await root.updateComplete; + await child.updateComplete; + await waitForAssertion(() => { + expect(child.getAttribute('data-menu-view-state')).toBe('inactive'); + }); + }); + + it('keeps the menu open when a checkbox item is toggled', async () => { + const root = createElement(MenuElement); + const checkbox = createElement(MenuCheckboxItemElement); + const onCheckedChange = vi.fn(); + const onOpenChange = vi.fn(); + + root.open = true; + checkbox.textContent = 'Autoplay'; + + checkbox.addEventListener('checked-change', onCheckedChange); + root.addEventListener('open-change', onOpenChange); + root.append(checkbox); + document.body.append(root); + + await root.updateComplete; + await checkbox.updateComplete; + onOpenChange.mockClear(); + + checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(checkbox.checked).toBe(true); + expect(onCheckedChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ checked: true }) }) + ); + expect(root.open).toBe(true); + expect(onOpenChange).not.toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ open: false }) }) + ); + }); + + it('closes when focus moves outside the root menu', async () => { + const root = createElement(MenuElement); + const item = createElement(MenuItemElement); + const outside = document.createElement('button'); + const onOpenChange = vi.fn(); + + root.open = true; + item.textContent = 'Auto'; + + root.addEventListener('open-change', onOpenChange); + root.append(item); + document.body.append(root, outside); + + await root.updateComplete; + await item.updateComplete; + onOpenChange.mockClear(); + + root.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside })); + + await root.updateComplete; + + expect(root.open).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ open: false, reason: 'blur' }) }) + ); + }); + + it('returns to the parent view without closing the root menu when Escape is pressed in a nested menu', async () => { + const root = createElement(MenuElement); + const rootView = createElement(MenuViewElement); + const trigger = createElement(MenuItemElement); + const child = createElement(MenuElement); + const item = createElement(MenuItemElement); + const onOpenChange = vi.fn(); + + root.open = true; + trigger.id = 'child-trigger'; + trigger.commandfor = 'child-menu'; + child.id = 'child-menu'; + item.textContent = 'Auto'; + + root.addEventListener('open-change', onOpenChange); + rootView.append(trigger); + child.append(item); + root.append(rootView, child); + document.body.append(root); + + await root.updateComplete; + await rootView.updateComplete; + await trigger.updateComplete; + await child.updateComplete; + await item.updateComplete; + + onOpenChange.mockClear(); + trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await root.updateComplete; + await child.updateComplete; + await waitForAssertion(() => { + expect(child.getAttribute('data-menu-view-state')).toBe('active'); + }); + + child.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + await root.updateComplete; + await child.updateComplete; + await waitForAssertion(() => { + expect(child.getAttribute('data-menu-view-state')).toBe('inactive'); + }); + + expect(root.open).toBe(true); + expect(onOpenChange).not.toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ open: false }) }) + ); + }); + + it('allows Escape from an inactive sibling nested menu to close the root menu', async () => { + const root = createElement(MenuElement); + const rootView = createElement(MenuViewElement); + const qualityTrigger = createElement(MenuItemElement); + const speedTrigger = createElement(MenuItemElement); + const quality = createElement(MenuElement); + const speed = createElement(MenuElement); + const qualityItem = createElement(MenuItemElement); + const speedItem = createElement(MenuItemElement); + const onOpenChange = vi.fn(); + + root.open = true; + qualityTrigger.id = 'quality-trigger'; + qualityTrigger.commandfor = 'quality-menu'; + speedTrigger.id = 'speed-trigger'; + speedTrigger.commandfor = 'speed-menu'; + quality.id = 'quality-menu'; + speed.id = 'speed-menu'; + qualityItem.textContent = 'Auto'; + speedItem.textContent = 'Normal'; + + root.addEventListener('open-change', onOpenChange); + rootView.append(qualityTrigger, speedTrigger); + quality.append(qualityItem); + speed.append(speedItem); + root.append(rootView, quality, speed); + document.body.append(root); + + await root.updateComplete; + await rootView.updateComplete; + await qualityTrigger.updateComplete; + await speedTrigger.updateComplete; + await quality.updateComplete; + await speed.updateComplete; + await qualityItem.updateComplete; + await speedItem.updateComplete; + onOpenChange.mockClear(); + + qualityTrigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await root.updateComplete; + await quality.updateComplete; + await waitForAssertion(() => { + expect(quality.getAttribute('data-menu-view-state')).toBe('active'); + }); + + speedTrigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + quality.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + await root.updateComplete; + await quality.updateComplete; + await speed.updateComplete; + await waitForAssertion(() => { + expect(root.open).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ open: false, reason: 'escape' }) }) + ); + }); + }); + + it('only stops propagation for nested menu-owned keyboard events', async () => { + const root = createElement(MenuElement); + const rootView = createElement(MenuViewElement); + const trigger = createElement(MenuItemElement); + const child = createElement(MenuElement); + const item = createElement(MenuItemElement); + const onRootKeyDown = vi.fn(); + + root.open = true; + trigger.id = 'child-trigger'; + trigger.commandfor = 'child-menu'; + child.id = 'child-menu'; + item.textContent = 'Auto'; + + root.addEventListener('keydown', onRootKeyDown); + rootView.append(trigger); + child.append(item); + root.append(rootView, child); + document.body.append(root); + + await root.updateComplete; + await rootView.updateComplete; + await trigger.updateComplete; + await child.updateComplete; + await item.updateComplete; + + trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + await root.updateComplete; + await child.updateComplete; + + child.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); + expect(onRootKeyDown).not.toHaveBeenCalled(); + + child.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + expect(onRootKeyDown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/package.json b/packages/react/package.json index 6c6b873b..03fdfb23 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -103,7 +103,8 @@ "clean": "rimraf --glob dist types '*.tsbuildinfo'" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "dependencies": { "@videojs/core": "workspace:*", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1e6281e6..c0fabf07 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -46,6 +46,7 @@ export { Hotkey, type HotkeyProps, MediaHotkey, type MediaHotkeyProps } from './ export { useAriaKeyShortcuts } from './ui/hotkey/use-aria-key-shortcuts'; export { type UseHotkeyOptions, useHotkey } from './ui/hotkey/use-hotkey'; export { LiveButton, type LiveButtonProps } from './ui/live-button/live-button'; +export { Menu, type MenuContextValue, useMenuContext, useOptionalMenuContext } from './ui/menu'; export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; diff --git a/packages/react/src/types.d.ts b/packages/react/src/types.d.ts index 14b00d74..d96d16eb 100644 --- a/packages/react/src/types.d.ts +++ b/packages/react/src/types.d.ts @@ -6,3 +6,13 @@ declare global { } } } + +declare module 'react-dom' { + import type { ReactNode, ReactPortal } from 'react'; + + export function createPortal( + children: ReactNode, + container: Element | DocumentFragment, + key?: null | string + ): ReactPortal; +} diff --git a/packages/react/src/ui/menu/context.tsx b/packages/react/src/ui/menu/context.tsx new file mode 100644 index 00000000..68b90e0a --- /dev/null +++ b/packages/react/src/ui/menu/context.tsx @@ -0,0 +1,78 @@ +'use client'; + +import type { MenuCore, MenuState, StateAttrMap } from '@videojs/core'; +import type { MenuApi } from '@videojs/core/dom'; +import { createContext, useContext } from 'react'; + +export interface MenuContextValue { + core: MenuCore; + menu: MenuApi; + state: MenuState; + stateAttrMap: StateAttrMap; + contentId: string; + anchorName: string; + /** ID of the currently visible submenu, or null when at root view. */ + activeSubMenuId: string | null; + /** Triggerer ID of the active submenu entry (for focus restoration on pop). */ + activeSubMenuTriggerId: string | null; + /** Direction of the most recent navigation. */ + navigationDirection: 'forward' | 'back'; + /** Push a submenu onto the navigation stack. */ + push: (menuId: string, triggerId: string) => void; + /** Pop the current submenu from the navigation stack. */ + pop: () => void; +} + +const MenuContext = createContext(null); + +export const MenuContextProvider = MenuContext.Provider; + +export function useMenuContext(): MenuContextValue { + const ctx = useContext(MenuContext); + if (!ctx) throw new Error('Menu compound components must be used within a Menu.Root'); + return ctx; +} + +export function useOptionalMenuContext(): MenuContextValue | null { + return useContext(MenuContext); +} + +// --------------------------------------------------------------------------- +// Sub-menu identity context — provided by a nested Menu.Root so child parts +// (Trigger, Content) can read the submenu ID and access the parent menu for +// push/pop and item registration. +// --------------------------------------------------------------------------- + +export interface SubMenuContextValue { + /** Stable ID for this submenu (matches the contentId of the nested Root). */ + subMenuId: string; + /** The parent menu's context — used by Trigger to register and push. */ + parentMenu: MenuContextValue; +} + +const SubMenuContext = createContext(null); + +export const SubMenuContextProvider = SubMenuContext.Provider; + +export function useSubMenuContext(): SubMenuContextValue | null { + return useContext(SubMenuContext); +} + +// --------------------------------------------------------------------------- +// Radio group context — shared between MenuRadioGroup and MenuRadioItem +// --------------------------------------------------------------------------- + +export interface MenuRadioGroupContextValue { + value: string; + onValueChange: (value: string) => void; +} + +const MenuRadioGroupContext = createContext(null); + +export const MenuRadioGroupContextProvider = MenuRadioGroupContext.Provider; + +export function useMenuRadioGroupContext(): MenuRadioGroupContextValue { + const ctx = useContext(MenuRadioGroupContext); + if (!ctx) throw new Error('Menu.RadioItem must be used within a Menu.RadioGroup'); + return ctx; +} diff --git a/packages/react/src/ui/menu/index.parts.ts b/packages/react/src/ui/menu/index.parts.ts new file mode 100644 index 00000000..56f33bc8 --- /dev/null +++ b/packages/react/src/ui/menu/index.parts.ts @@ -0,0 +1,19 @@ +export { MenuBack as Back, type MenuBackProps as BackProps } from './menu-back'; +export { + MenuCheckboxItem as CheckboxItem, + type MenuCheckboxItemProps as CheckboxItemProps, +} from './menu-checkbox-item'; +export { MenuContent as Content, type MenuContentProps as ContentProps } from './menu-content'; +export { MenuGroup as Group, type MenuGroupProps as GroupProps } from './menu-group'; +export { MenuItem as Item, type MenuItemProps as ItemProps } from './menu-item'; +export { + MenuItemIndicator as ItemIndicator, + type MenuItemIndicatorProps as ItemIndicatorProps, +} from './menu-item-indicator'; +export { MenuLabel as Label, type MenuLabelProps as LabelProps } from './menu-label'; +export { MenuRadioGroup as RadioGroup, type MenuRadioGroupProps as RadioGroupProps } from './menu-radio-group'; +export { MenuRadioItem as RadioItem, type MenuRadioItemProps as RadioItemProps } from './menu-radio-item'; +export { MenuRoot as Root, type MenuRootProps as RootProps } from './menu-root'; +export { MenuSeparator as Separator, type MenuSeparatorProps as SeparatorProps } from './menu-separator'; +export { MenuTrigger as Trigger, type MenuTriggerProps as TriggerProps } from './menu-trigger'; +export { MenuView as View, type MenuViewProps as ViewProps } from './menu-view'; diff --git a/packages/react/src/ui/menu/index.ts b/packages/react/src/ui/menu/index.ts new file mode 100644 index 00000000..b40a7088 --- /dev/null +++ b/packages/react/src/ui/menu/index.ts @@ -0,0 +1,2 @@ +export { type MenuContextValue, useMenuContext, useOptionalMenuContext } from './context'; +export * as Menu from './index.parts'; diff --git a/packages/react/src/ui/menu/menu-back.tsx b/packages/react/src/ui/menu/menu-back.tsx new file mode 100644 index 00000000..1e730994 --- /dev/null +++ b/packages/react/src/ui/menu/menu-back.tsx @@ -0,0 +1,53 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef, useCallback } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSubMenuContext } from './context'; + +export interface MenuBackProps extends UIComponentProps<'button', MenuState> { + /** Accessible label for the back button. */ + label?: string; +} + +/** Button that navigates back to the parent menu view. Place at the top of a submenu Content. */ +export const MenuBack = forwardRef(function MenuBack( + { render, className, style, label = 'Back', onClick, ...elementProps }, + forwardedRef +) { + const subMenuCtx = useSubMenuContext(); + const parentMenu = subMenuCtx?.parentMenu ?? null; + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + parentMenu?.pop(); + }, + [onClick, parentMenu] + ); + + return renderElement( + 'button', + { render, className, style }, + { + state: parentMenu?.state ?? ({} as MenuState), + stateAttrMap: parentMenu?.stateAttrMap, + ref: forwardedRef, + props: [ + { + type: 'button' as const, + 'aria-label': label, + onClick: handleClick, + }, + elementProps, + ], + } + ); +}); + +export namespace MenuBack { + export type Props = MenuBackProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-checkbox-item.tsx b/packages/react/src/ui/menu/menu-checkbox-item.tsx new file mode 100644 index 00000000..4e7cbbb5 --- /dev/null +++ b/packages/react/src/ui/menu/menu-checkbox-item.tsx @@ -0,0 +1,72 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef, useCallback, useEffect, useRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext } from './context'; + +export interface MenuCheckboxItemProps extends UIComponentProps<'div', MenuState> { + /** Whether the item is currently checked. */ + checked: boolean; + /** Called when the checked state should change. */ + onCheckedChange: (checked: boolean) => void; + /** Whether the item is disabled. */ + disabled?: boolean; +} + +/** A checkbox-style menu item. Renders a `
` with `role="menuitemcheckbox"`. */ +export const MenuCheckboxItem = forwardRef(function MenuCheckboxItem( + { render, className, style, checked, onCheckedChange, disabled, onClick, ...elementProps }, + forwardedRef +) { + const { menu, state, stateAttrMap } = useMenuContext(); + const elementRef = useRef(null); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + return menu.registerItem(element); + }, [menu]); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (disabled) return; + onClick?.(event); + onCheckedChange(!checked); + }, + [disabled, onClick, onCheckedChange, checked] + ); + + const handlePointerEnter = useCallback(() => { + const element = elementRef.current; + if (!element || disabled) return; + menu.highlight(element); + }, [menu, disabled]); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef, elementRef], + props: [ + { + role: 'menuitemcheckbox' as const, + 'aria-checked': checked, + 'aria-disabled': disabled ? true : undefined, + onClick: handleClick, + onPointerEnter: handlePointerEnter, + }, + elementProps, + ], + } + ); +}); + +export namespace MenuCheckboxItem { + export type Props = MenuCheckboxItemProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-content.tsx b/packages/react/src/ui/menu/menu-content.tsx new file mode 100644 index 00000000..9257eba0 --- /dev/null +++ b/packages/react/src/ui/menu/menu-content.tsx @@ -0,0 +1,323 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { + createMenuViewTransition, + getAnchorPositionStyle, + getMenuViewportAttrs, + getMenuViewportElement, + getMenuViewTransitionAttrs, + getPopupPositionRect, + getRootPositionOptions, + isMenuNavigationKey, + resolveOffsets, + syncMenuViewRoot, + syncMenuViewTransition, + type UIFocusEvent, + type UIKeyboardEvent, +} from '@videojs/core/dom'; +import { useSnapshot } from '@videojs/store/react'; +import { supportsAnchorPositioning } from '@videojs/utils/dom'; +import type { CSSProperties } from 'react'; +import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import type { UIComponentProps } from '../../utils/types'; +import { useComposedRefs } from '../../utils/use-composed-refs'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext, useSubMenuContext } from './context'; + +export interface MenuContentProps extends UIComponentProps<'div', MenuState> {} + +const POPOVER_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0 }; + +function toUIKeyboardEvent(event: React.KeyboardEvent): UIKeyboardEvent { + return { + get defaultPrevented() { + return event.defaultPrevented; + }, + key: event.key, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + metaKey: event.metaKey, + target: event.target instanceof Node ? event.target : event.currentTarget, + currentTarget: event.currentTarget, + preventDefault: () => event.preventDefault(), + stopPropagation: () => event.stopPropagation(), + }; +} + +function toUIFocusEvent(event: React.FocusEvent): UIFocusEvent { + return { + get defaultPrevented() { + return event.defaultPrevented; + }, + relatedTarget: event.relatedTarget, + preventDefault: () => event.preventDefault(), + stopPropagation: () => event.stopPropagation(), + }; +} + +/** Container for menu items. Positioned relative to the trigger at root level; renders in-place as a submenu panel when nested. */ +export const MenuContent = forwardRef(function MenuContent( + { render, className, style, onKeyDown, onBlur, ...elementProps }, + forwardedRef +) { + const { core, menu, state, stateAttrMap, anchorName, contentId, activeSubMenuId } = useMenuContext(); + const subMenuCtx = useSubMenuContext(); + const isSubmenu = state.isSubmenu; + + const parentMenu = subMenuCtx?.parentMenu ?? null; + const subMenuId = subMenuCtx?.subMenuId ?? null; + + const isActive = + isSubmenu && parentMenu !== null && subMenuId !== null ? parentMenu.activeSubMenuId === subMenuId : false; + + const [menuViewTransition] = useState(() => + createMenuViewTransition({ + focusFirstItem() { + menu.highlightFirstItem({ preventScroll: true }); + }, + restoreFocus(triggerId) { + if (triggerId) { + document.getElementById(triggerId)?.focus({ preventScroll: true }); + } + }, + }) + ); + const menuViewTransitionState = useSnapshot(menuViewTransition.input); + const menuViewElementRef = useRef(null); + const parentContentElementRef = useRef(null); + + useLayoutEffect(() => { + return () => menuViewTransition.destroy(); + }, [menuViewTransition]); + + useLayoutEffect(() => { + if (!isSubmenu) return; + + menuViewTransition.sync({ + active: isActive, + direction: parentMenu?.navigationDirection ?? 'forward', + triggerId: parentMenu?.activeSubMenuTriggerId ?? null, + }); + }, [isActive, isSubmenu, parentMenu, menuViewTransition]); + + const setMenuViewElement = useCallback( + (element: HTMLDivElement | null) => { + menuViewElementRef.current = element; + menu.setContentElement(element); + menuViewTransition.setElement(element); + }, + [menu, menuViewTransition] + ); + + const handleSubMenuKeyDown = useCallback( + (event: React.KeyboardEvent) => { + (onKeyDown as React.KeyboardEventHandler | undefined)?.(event); + + const keyboardEvent = toUIKeyboardEvent(event); + menu.contentProps.onKeyDown(keyboardEvent); + const isBackNavigationKey = event.key === 'ArrowLeft' || event.key === 'Escape'; + + const ownsActiveSubmenu = + parentMenu !== null && + subMenuId !== null && + parentMenu.menu.navigationInput.current.stack[parentMenu.menu.navigationInput.current.stack.length - 1] + ?.menuId === subMenuId; + + if (isBackNavigationKey && ownsActiveSubmenu && !event.defaultPrevented) { + event.preventDefault(); + parentMenu.pop(); + } + + if (isMenuNavigationKey(keyboardEvent) && (!isBackNavigationKey || ownsActiveSubmenu)) { + event.stopPropagation(); + } + }, + [onKeyDown, parentMenu, subMenuId, menu] + ); + + const handleRootMenuKeyDown = useCallback( + (event: React.KeyboardEvent) => { + (onKeyDown as React.KeyboardEventHandler | undefined)?.(event); + menu.contentProps.onKeyDown(toUIKeyboardEvent(event)); + }, + [onKeyDown, menu] + ); + + const handleRootMenuBlur = useCallback( + (event: React.FocusEvent) => { + (onBlur as React.FocusEventHandler | undefined)?.(event); + menu.contentProps.onFocusOut(toUIFocusEvent(event)); + }, + [onBlur, menu] + ); + + // ─── Root content state (always declared — Rules of Hooks) ─────────────── + const internalRef = useRef(null); + + const contentRef = useCallback( + (element: HTMLDivElement | null) => { + if (isSubmenu) return; + menu.setContentElement(element); + if (element && supportsAnchorPositioning()) { + element.style.setProperty('position-anchor', `--${anchorName}`); + } + }, + [isSubmenu, menu, anchorName] + ); + + const rootComposedRef = useComposedRefs(forwardedRef, contentRef, internalRef); + const menuViewComposedRef = useComposedRefs(forwardedRef, setMenuViewElement); + + const positionOptions = useMemo(() => getRootPositionOptions(state.side, state.align), [state.side, state.align]); + + const anchorStyle = useMemo(() => { + if (isSubmenu || !positionOptions || !supportsAnchorPositioning()) return null; + const { positionAnchor: _, ...rest } = getAnchorPositionStyle(anchorName, positionOptions); + return rest as CSSProperties; + }, [isSubmenu, anchorName, positionOptions]); + + const [manualStyle, setManualStyle] = useState(null); + + useLayoutEffect(() => { + if (isSubmenu) return; + if (!state.open) return; + + syncMenuViewRoot(internalRef.current, activeSubMenuId !== null); + }, [isSubmenu, state.open, activeSubMenuId]); + + useLayoutEffect(() => { + if (!isSubmenu) return; + + const parentContentElement = parentMenu?.menu.contentElement ?? parentContentElementRef.current; + parentContentElementRef.current = parentContentElement; + syncMenuViewTransition(parentContentElement, menuViewElementRef.current, menuViewTransitionState); + }); + + useLayoutEffect(() => { + if (isSubmenu) return; + if (supportsAnchorPositioning()) return; + if (!positionOptions) return; + if (!state.open) { + setManualStyle(null); + return; + } + + const rootPositionOptions = positionOptions; + + function measure(): void { + const triggerElement = menu.triggerElement; + const contentElement = internalRef.current; + if (!triggerElement || !contentElement) return; + + const triggerRect = triggerElement.getBoundingClientRect(); + const contentRect = getPopupPositionRect(contentElement); + const boundaryRect = document.documentElement.getBoundingClientRect(); + const offsets = resolveOffsets(contentElement); + + setManualStyle( + getAnchorPositionStyle( + anchorName, + rootPositionOptions, + triggerRect, + contentRect, + boundaryRect, + offsets + ) as CSSProperties + ); + } + + measure(); + + const triggerElement = menu.triggerElement; + const contentElement = internalRef.current; + + let animationFrameId = 0; + function reposition(): void { + cancelAnimationFrame(animationFrameId); + animationFrameId = requestAnimationFrame(measure); + } + + reposition(); + + const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(() => reposition()) : null; + + if (triggerElement && resizeObserver) resizeObserver.observe(triggerElement); + if (contentElement && resizeObserver) resizeObserver.observe(contentElement); + + window.addEventListener('scroll', reposition, { capture: true, passive: true }); + window.addEventListener('resize', reposition); + + return () => { + cancelAnimationFrame(animationFrameId); + resizeObserver?.disconnect(); + window.removeEventListener('scroll', reposition, true); + window.removeEventListener('resize', reposition); + }; + }, [isSubmenu, state.open, anchorName, positionOptions, menu]); + + // ─── Render ─────────────────────────────────────────────────────────────── + + if (isSubmenu) { + if (menuViewTransitionState.phase === 'hidden') return null; + + const subMenuContent = renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: menuViewComposedRef, + props: [ + { + ...getMenuViewTransitionAttrs(menuViewTransitionState), + role: 'menu' as const, + tabIndex: -1, + 'data-submenu': '', + onKeyDown: handleSubMenuKeyDown, + onBlur, + }, + elementProps, + ], + } + ); + + const parentContentElement = parentMenu?.menu.contentElement ?? parentContentElementRef.current; + + const parentViewportElement = getMenuViewportElement(parentContentElement); + + return parentViewportElement ? createPortal(subMenuContent, parentViewportElement) : subMenuContent; + } + + if (!state.open) return null; + + const positioningStyle = anchorStyle ?? manualStyle ?? POPOVER_RESET; + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: rootComposedRef, + props: [ + { + id: contentId, + style: positioningStyle, + ...core.getContentAttrs(state), + ...getMenuViewportAttrs(), + }, + { onKeyDown: handleRootMenuKeyDown, onBlur: handleRootMenuBlur }, + elementProps, + ], + } + ); +}); + +export namespace MenuContent { + export type Props = MenuContentProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-group.tsx b/packages/react/src/ui/menu/menu-group.tsx new file mode 100644 index 00000000..5b51cec5 --- /dev/null +++ b/packages/react/src/ui/menu/menu-group.tsx @@ -0,0 +1,37 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext } from './context'; + +export interface MenuGroupProps extends UIComponentProps<'div', MenuState> { + /** Accessible label for the group. */ + label?: string; +} + +/** Groups related menu items. Renders a `
` with `role="group"`. */ +export const MenuGroup = forwardRef(function MenuGroup( + { render, className, style, label, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap } = useMenuContext(); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef], + props: [{ role: 'group' as const, 'aria-label': label }, elementProps], + } + ); +}); + +export namespace MenuGroup { + export type Props = MenuGroupProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-item-indicator.tsx b/packages/react/src/ui/menu/menu-item-indicator.tsx new file mode 100644 index 00000000..9ead0ca1 --- /dev/null +++ b/packages/react/src/ui/menu/menu-item-indicator.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext } from './context'; + +export interface MenuItemIndicatorProps extends UIComponentProps<'span', MenuState> { + /** Whether the indicator is currently shown. Typically bound to the parent item's checked state. */ + checked?: boolean; + /** When `true`, renders even when unchecked (useful for animating out). Defaults to `false`. */ + forceMount?: boolean; +} + +/** Visual indicator for a checked state. Only renders when `checked` is `true` (or `forceMount` is set). */ +export const MenuItemIndicator = forwardRef(function MenuItemIndicator( + { render, className, style, checked, forceMount = false, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap } = useMenuContext(); + + if (!checked && !forceMount) return null; + + return renderElement( + 'span', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef], + props: [{ 'aria-hidden': true }, elementProps], + } + ); +}); + +export namespace MenuItemIndicator { + export type Props = MenuItemIndicatorProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-item.tsx b/packages/react/src/ui/menu/menu-item.tsx new file mode 100644 index 00000000..8d8747b3 --- /dev/null +++ b/packages/react/src/ui/menu/menu-item.tsx @@ -0,0 +1,73 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { completeMenuItemSelection } from '@videojs/core/dom'; +import { forwardRef, useCallback, useEffect, useRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext, useSubMenuContext } from './context'; + +export interface MenuItemProps extends UIComponentProps<'div', MenuState> { + /** Called when the item is selected. */ + onSelect?: () => void; + /** Whether the item is disabled. */ + disabled?: boolean; +} + +/** A single action in the menu. Renders a `
` with `role="menuitem"`. */ +export const MenuItem = forwardRef(function MenuItem( + { render, className, style, onSelect, disabled, onClick, ...elementProps }, + forwardedRef +) { + const { menu, state, stateAttrMap } = useMenuContext(); + const subMenuCtx = useSubMenuContext(); + const parentMenu = subMenuCtx?.parentMenu.menu ?? null; + const elementRef = useRef(null); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + return menu.registerItem(element); + }, [menu]); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (disabled) return; + onClick?.(event); + onSelect?.(); + completeMenuItemSelection(menu, parentMenu); + }, + [disabled, onClick, onSelect, menu, parentMenu] + ); + + const handlePointerEnter = useCallback(() => { + const element = elementRef.current; + if (!element || disabled) return; + menu.highlight(element); + }, [menu, disabled]); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef, elementRef], + props: [ + { + role: 'menuitem' as const, + 'aria-disabled': disabled ? true : undefined, + onClick: handleClick, + onPointerEnter: handlePointerEnter, + }, + elementProps, + ], + } + ); +}); + +export namespace MenuItem { + export type Props = MenuItemProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-label.tsx b/packages/react/src/ui/menu/menu-label.tsx new file mode 100644 index 00000000..8599d632 --- /dev/null +++ b/packages/react/src/ui/menu/menu-label.tsx @@ -0,0 +1,34 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext } from './context'; + +export interface MenuLabelProps extends UIComponentProps<'div', MenuState> {} + +/** Non-interactive label for a group of items. Renders a `
`. */ +export const MenuLabel = forwardRef(function MenuLabel( + { render, className, style, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap } = useMenuContext(); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef], + props: [elementProps], + } + ); +}); + +export namespace MenuLabel { + export type Props = MenuLabelProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-radio-group.tsx b/packages/react/src/ui/menu/menu-radio-group.tsx new file mode 100644 index 00000000..b60b226c --- /dev/null +++ b/packages/react/src/ui/menu/menu-radio-group.tsx @@ -0,0 +1,45 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { MenuRadioGroupContextProvider, useMenuContext } from './context'; + +export interface MenuRadioGroupProps extends UIComponentProps<'div', MenuState> { + /** The currently selected value. */ + value: string; + /** Called when the user selects a radio item. */ + onValueChange: (value: string) => void; + /** Accessible label for the group. */ + label?: string; +} + +/** A group of mutually exclusive radio items. Renders a `
` with `role="group"`. */ +export const MenuRadioGroup = forwardRef(function MenuRadioGroup( + { render, className, style, value, onValueChange, label, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap } = useMenuContext(); + + return ( + + {renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef], + props: [{ role: 'group' as const, 'aria-label': label }, elementProps], + } + )} + + ); +}); + +export namespace MenuRadioGroup { + export type Props = MenuRadioGroupProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-radio-item.tsx b/packages/react/src/ui/menu/menu-radio-item.tsx new file mode 100644 index 00000000..e44b9b7e --- /dev/null +++ b/packages/react/src/ui/menu/menu-radio-item.tsx @@ -0,0 +1,76 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { completeMenuItemSelection } from '@videojs/core/dom'; +import { forwardRef, useCallback, useEffect, useRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext, useMenuRadioGroupContext, useSubMenuContext } from './context'; + +export interface MenuRadioItemProps extends UIComponentProps<'div', MenuState> { + /** The value this item represents. */ + value: string; + /** Whether the item is disabled. */ + disabled?: boolean; +} + +/** A radio-style menu item. Renders a `
` with `role="menuitemradio"`. */ +export const MenuRadioItem = forwardRef(function MenuRadioItem( + { render, className, style, value, disabled, onClick, ...elementProps }, + forwardedRef +) { + const { menu, state, stateAttrMap } = useMenuContext(); + const { value: groupValue, onValueChange } = useMenuRadioGroupContext(); + const subMenuCtx = useSubMenuContext(); + const parentMenu = subMenuCtx?.parentMenu.menu ?? null; + const elementRef = useRef(null); + const checked = groupValue === value; + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + return menu.registerItem(element); + }, [menu]); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (disabled) return; + onClick?.(event); + onValueChange(value); + completeMenuItemSelection(menu, parentMenu); + }, + [disabled, onClick, onValueChange, value, menu, parentMenu] + ); + + const handlePointerEnter = useCallback(() => { + const element = elementRef.current; + if (!element || disabled) return; + menu.highlight(element); + }, [menu, disabled]); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef, elementRef], + props: [ + { + role: 'menuitemradio' as const, + 'aria-checked': checked, + 'aria-disabled': disabled ? true : undefined, + onClick: handleClick, + onPointerEnter: handlePointerEnter, + }, + elementProps, + ], + } + ); +}); + +export namespace MenuRadioItem { + export type Props = MenuRadioItemProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-root.tsx b/packages/react/src/ui/menu/menu-root.tsx new file mode 100644 index 00000000..c5552df7 --- /dev/null +++ b/packages/react/src/ui/menu/menu-root.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { MenuCore, MenuDataAttrs } from '@videojs/core'; +import { createMenu, createTransition, type MenuChangeDetails } from '@videojs/core/dom'; +import { useSnapshot } from '@videojs/store/react'; +import type { ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +import { useDestroy } from '../../utils/use-destroy'; +import { useLatestRef } from '../../utils/use-latest-ref'; +import { useSafeId } from '../../utils/use-safe-id'; +import { MenuContextProvider, SubMenuContextProvider, useOptionalMenuContext } from './context'; + +export interface MenuRootProps extends MenuCore.Props { + /** Called when the menu open state changes (fires immediately, before animations). */ + onOpenChange?: (open: boolean, details: MenuChangeDetails) => void; + /** Called after open/close animations complete. */ + onOpenChangeComplete?: (open: boolean) => void; + children?: ReactNode; +} + +export function MenuRoot({ + open: controlledOpen, + defaultOpen = MenuCore.defaultProps.defaultOpen, + onOpenChange: onOpenChangeProp, + onOpenChangeComplete: onOpenChangeCompleteProp, + children, + ...coreProps +}: MenuRootProps): ReactNode { + // Detect if we are nested inside a parent Menu.Content — if so, operate as + // a submenu: no popover positioning, Trigger acts as a parent item. + const parentMenu = useOptionalMenuContext(); + const isSubmenu = parentMenu !== null; + const { side, align, closeOnEscape, closeOnOutsideClick } = coreProps; + + const [core] = useState(() => new MenuCore({ ...coreProps, isSubmenu })); + + const isControlled = controlledOpen !== undefined; + + const onOpenChangeRef = useLatestRef(onOpenChangeProp); + const onOpenChangeCompleteRef = useLatestRef(onOpenChangeCompleteProp); + const closeOnEscapeRef = useLatestRef(closeOnEscape); + const closeOnOutsideClickRef = useLatestRef(closeOnOutsideClick); + + const [menu] = useState(() => { + const instance = createMenu({ + transition: createTransition(), + onOpenChange(nextOpen, details) { + onOpenChangeRef.current?.(nextOpen, details); + }, + onOpenChangeComplete(nextOpen) { + onOpenChangeCompleteRef.current?.(nextOpen); + }, + closeOnEscape: () => closeOnEscapeRef.current ?? MenuCore.defaultProps.closeOnEscape, + closeOnOutsideClick: () => closeOnOutsideClickRef.current ?? MenuCore.defaultProps.closeOnOutsideClick, + }); + + if (!isControlled && defaultOpen) { + instance.open(); + } + + return instance; + }); + + const anchorName = useSafeId(); + const contentId = useSafeId('menu'); + + // Sync controlled open prop → internal state. + useEffect(() => { + if (controlledOpen === undefined) return; + + const { active: inputOpen } = menu.input.current; + if (controlledOpen === inputOpen) return; + + if (controlledOpen) { + menu.open('click'); + } else { + menu.close('click'); + } + }, [controlledOpen, menu]); + + useDestroy(menu); + + const input = useSnapshot(menu.input); + const state = useMemo(() => { + core.setProps({ side, align, closeOnEscape, closeOnOutsideClick, isSubmenu }); + core.setInput(input); + return core.getState(); + }, [core, input, side, align, closeOnEscape, closeOnOutsideClick, isSubmenu]); + + // Subscribe to navigation state — used by Content/Trigger when this is a root menu. + const navigationInput = useSnapshot(menu.navigationInput); + const topEntry = navigationInput.stack[navigationInput.stack.length - 1]; + const activeSubMenuId = topEntry?.menuId ?? null; + const activeSubMenuTriggerId = topEntry?.triggerId ?? null; + const navigationDirection = navigationInput.direction; + + const contextValue = useMemo( + () => ({ + core, + menu, + state, + stateAttrMap: MenuDataAttrs, + contentId, + anchorName, + activeSubMenuId, + activeSubMenuTriggerId, + navigationDirection, + push: menu.push, + pop: menu.pop, + }), + [core, menu, state, contentId, anchorName, activeSubMenuId, activeSubMenuTriggerId, navigationDirection] + ); + + const subMenuContextValue = useMemo( + () => (parentMenu ? { subMenuId: contentId, parentMenu } : null), + [contentId, parentMenu] + ); + + // When acting as a submenu, expose its content ID and the parent menu context + // through SubMenuContext so Trigger can register/push and Content can show/hide. + if (subMenuContextValue) { + return ( + + {children} + + ); + } + + return {children}; +} + +export namespace MenuRoot { + export type Props = MenuRootProps; +} diff --git a/packages/react/src/ui/menu/menu-separator.tsx b/packages/react/src/ui/menu/menu-separator.tsx new file mode 100644 index 00000000..b0fc7dbd --- /dev/null +++ b/packages/react/src/ui/menu/menu-separator.tsx @@ -0,0 +1,34 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useMenuContext } from './context'; + +export interface MenuSeparatorProps extends UIComponentProps<'div', MenuState> {} + +/** Visual divider between groups of items. Renders a `
` with `role="separator"`. */ +export const MenuSeparator = forwardRef(function MenuSeparator( + { render, className, style, ...elementProps }, + forwardedRef +) { + const { state, stateAttrMap } = useMenuContext(); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap, + ref: [forwardedRef], + props: [{ role: 'separator' as const }, elementProps], + } + ); +}); + +export namespace MenuSeparator { + export type Props = MenuSeparatorProps; + export type State = MenuState; +} diff --git a/packages/react/src/ui/menu/menu-trigger.tsx b/packages/react/src/ui/menu/menu-trigger.tsx new file mode 100644 index 00000000..904bc637 --- /dev/null +++ b/packages/react/src/ui/menu/menu-trigger.tsx @@ -0,0 +1,131 @@ +'use client'; + +import type { MenuState } from '@videojs/core'; +import { supportsAnchorPositioning } from '@videojs/utils/dom'; +import { forwardRef, useCallback, useEffect, useRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; +import { useSafeId } from '../../utils/use-safe-id'; +import { useMenuContext, useSubMenuContext } from './context'; + +export interface MenuTriggerProps extends UIComponentProps<'button', MenuState> { + /** Disables the trigger. Only meaningful when used as a submenu trigger inside a parent menu. */ + disabled?: boolean; +} + +/** + * Button that toggles the menu visibility. At root level renders a ` + + ); +} + +describe('MenuContent', () => { + it('marks the root view inactive while a submenu view is active', async () => { + render(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('root-view').getAttribute('data-menu-view-state')).toBe('inactive'); + }); + }); + + it('portals submenu content into the parent content viewport', async () => { + render(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('submenu-content').parentElement).toBe(screen.getByTestId('root-content')); + }); + }); + + it('portals deeper submenu content into the active parent submenu viewport', async () => { + render(); + + fireEvent.click(screen.getByTestId('first-submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('first-submenu-content').parentElement).toBe(screen.getByTestId('root-content')); + }); + + fireEvent.click(screen.getByTestId('second-submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('second-submenu-content').parentElement).toBe( + screen.getByTestId('first-submenu-content') + ); + expect(screen.getByTestId('second-submenu-content').parentElement).not.toBe( + screen.getByTestId('first-submenu-view') + ); + }); + }); + + it('marks root content and view with viewport data attributes', () => { + render(); + + expect(screen.getByTestId('root-content').hasAttribute('data-menu-viewport')).toBe(true); + expect(screen.getByTestId('root-view').hasAttribute('data-menu-root-view')).toBe(true); + expect(screen.getByTestId('root-view').hasAttribute('data-menu-view')).toBe(true); + }); + + it('forces layout while the submenu starting style is applied', async () => { + const startingStyleMeasurements: boolean[] = []; + const getBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + + HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRectMock() { + if (this.hasAttribute('data-submenu')) { + startingStyleMeasurements.push(this.hasAttribute('data-starting-style')); + } + + return getBoundingClientRect.call(this); + }; + + try { + render(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('submenu-content').hasAttribute('data-menu-view')).toBe(true); + expect(startingStyleMeasurements).toContain(true); + }); + } finally { + HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; + } + }); + + it('can reopen a submenu while its previous exit transition is pending', async () => { + render(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + await waitFor(() => { + expect(screen.queryByTestId('submenu-content')).not.toBeNull(); + }); + + fireEvent.click(screen.getByTestId('submenu-back')); + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('root-view').getAttribute('data-menu-view-state')).toBe('inactive'); + expect(screen.getByTestId('submenu-content').hasAttribute('hidden')).toBe(false); + }); + }); + + it('handles keyboard navigation in the active submenu view', async () => { + render(); + + fireEvent.pointerEnter(screen.getByTestId('submenu-trigger')); + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.queryByTestId('submenu-content')).not.toBeNull(); + }); + + fireEvent.keyDown(screen.getByTestId('submenu-content'), { key: 'ArrowDown' }); + + expect(screen.getByTestId('submenu-item').hasAttribute('data-highlighted')).toBe(true); + expect(screen.getByTestId('root-item').hasAttribute('data-highlighted')).toBe(false); + }); + + it('highlights the first item when a submenu view becomes active', async () => { + render(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('submenu-item').hasAttribute('data-highlighted')).toBe(true); + }); + }); + + it('returns to the parent view when selecting an item in a submenu', async () => { + const onSelect = vi.fn(); + + render(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('root-view').getAttribute('data-menu-view-state')).toBe('inactive'); + }); + + fireEvent.click(screen.getByTestId('submenu-item')); + + expect(onSelect).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByTestId('root-view').getAttribute('data-menu-view-state')).toBe('active'); + }); + }); + + it('returns to the parent view without closing the root menu when Escape is pressed in a submenu', async () => { + const onRootOpenChange = vi.fn(); + + render(); + onRootOpenChange.mockClear(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('root-view').getAttribute('data-menu-view-state')).toBe('inactive'); + }); + + fireEvent.keyDown(screen.getByTestId('submenu-content'), { key: 'Escape' }); + + await waitFor(() => { + expect(screen.getByTestId('root-view').getAttribute('data-menu-view-state')).toBe('active'); + }); + + expect(onRootOpenChange).not.toHaveBeenCalledWith(false, expect.anything()); + expect(screen.getByTestId('root-content').hasAttribute('data-open')).toBe(true); + }); + + it('allows Escape from an inactive sibling submenu view to close the root menu', async () => { + const onRootOpenChange = vi.fn(); + + render(); + onRootOpenChange.mockClear(); + + fireEvent.click(screen.getByTestId('quality-trigger')); + + await waitFor(() => { + expect(screen.getByTestId('quality-content').getAttribute('data-menu-view-state')).toBe('active'); + }); + + const exitingContent = screen.getByTestId('quality-content'); + + fireEvent.click(screen.getByTestId('speed-trigger')); + fireEvent.keyDown(exitingContent, { key: 'Escape' }); + + await waitFor(() => { + expect(onRootOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'escape' })); + }); + }); + + it('only stops propagation for submenu-owned keyboard events', async () => { + const onRootKeyDown = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('submenu-trigger')); + + await waitFor(() => { + expect(screen.queryByTestId('submenu-content')).not.toBeNull(); + }); + + fireEvent.keyDown(screen.getByTestId('submenu-content'), { key: 'ArrowDown' }); + expect(onRootKeyDown).not.toHaveBeenCalled(); + + fireEvent.keyDown(screen.getByTestId('submenu-content'), { key: 'Tab' }); + expect(onRootKeyDown).toHaveBeenCalledTimes(1); + }); + + it('uses DOM order for keyboard navigation', () => { + render(); + + fireEvent.keyDown(screen.getByTestId('content'), { key: 'ArrowDown' }); + + expect(screen.getByTestId('first-item').hasAttribute('data-highlighted')).toBe(true); + expect(screen.getByTestId('third-item').hasAttribute('data-highlighted')).toBe(false); + }); + + it('keeps the menu open when a checkbox item is toggled', () => { + const onCheckedChange = vi.fn(); + const onRootOpenChange = vi.fn(); + + render(); + onRootOpenChange.mockClear(); + + fireEvent.click(screen.getByTestId('checkbox-item')); + + expect(onCheckedChange).toHaveBeenCalledWith(true); + expect(onRootOpenChange).not.toHaveBeenCalledWith(false, expect.anything()); + expect(screen.queryByTestId('content')).not.toBeNull(); + }); + + it('closes when focus moves outside the root menu', async () => { + const onRootOpenChange = vi.fn(); + + render(); + onRootOpenChange.mockClear(); + + fireEvent.focusOut(screen.getByTestId('root-content'), { relatedTarget: screen.getByTestId('outside') }); + + await waitFor(() => { + expect(onRootOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'blur' })); + }); + }); +});