Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions .claude/plans/menus.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,34 +76,40 @@ PR 1 (Core + DOM) → PR 2 (UI flat) → PR 3 (Submenus)

## PR 3 — Submenu navigation

**Status:** PENDING
**Status:** OPEN — `feat/menu-sub` (PR pending)

### Files

**New DOM:**
- `packages/core/src/dom/ui/menu/create-sub-menu-transition.ts`
- `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 `<media-menu>` + `commandfor` support
- `packages/html/src/ui/menu/menu-item-element.ts` — `commandfor` attribute handling
- `packages/core/src/dom/index.ts` — add transition export
- `packages/react/src/ui/index.ts` — add Back to Menu exports
- `packages/html/src/define/ui/menu.ts` — register `<media-menu-back>`
- `packages/core/src/dom/index.ts` — add submenu and viewport transition exports
- `packages/html/src/define/ui/menu.ts` — register `<media-menu-back>` and `<media-menu-view>`

**Status:** PENDINGbranch off `feat/menu-react-html`
**Status:** OPENbranched off `feat/menu-react-html`

### Scope
- `NavigationState`: stack of `{ menuId, triggerId }`, direction, exitingMenuId, transitioning
- `createSubMenuTransition()`: double-RAF lifecycle, `--media-menu-width/height` measurement, `getAnimations()` settle
- `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` / `<media-menu-view>`: optional root view wrapper for in-place view navigation; receives `data-menu-root-view` while root `Content` / `<media-menu>` acts as the shared viewport
- Traditional flyout submenus are out of scope for this PR and should not require `Menu.View` / `<media-menu-view>` when added later
- Nested `Menu.Root` detection via parent `MenuContext` → `isSubmenu: true` prop, Trigger registers as parent item
- `Menu.Back` / `<media-menu-back>`: pops stack, focus restoration to trigger
- Auto-back on `RadioItem` selection in submenu
Expand Down
15 changes: 15 additions & 0 deletions apps/sandbox/templates/html-menu/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — HTML Menu</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />

</head>
<body class="bg-slate-50 text-slate-900 min-h-screen flex items-center justify-center p-8 font-[Inter,system-ui,sans-serif] antialiased">
<div id="root"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
256 changes: 256 additions & 0 deletions apps/sandbox/templates/html-menu/main.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="flex flex-col items-center gap-12">

<header class="text-center flex flex-col gap-1.5">
<h1 class="text-2xl font-semibold tracking-tight">HTML Menu</h1>
<p class="text-sm text-slate-500">Custom elements — <code>&lt;media-menu&gt;</code> and friends</p>
</header>

<div class="flex gap-4 flex-wrap justify-center">

<!-- Radio group -->
<div class="bg-white border border-slate-200 rounded-xl p-6 flex flex-col items-start gap-3.5 min-w-[200px] shadow-[0_1px_3px_0_rgb(0_0_0/.05)]">
<span class="text-xs font-medium text-slate-500 uppercase tracking-widest">Radio group</span>
<button
commandfor="quality-menu"
class="inline-flex items-center gap-1.5 h-9 px-3.5 border border-slate-200 rounded-md bg-white text-slate-900 text-sm font-medium cursor-pointer select-none shadow-[0_1px_2px_0_rgb(0_0_0/.04)] transition-colors hover:bg-slate-50"
>
Quality
<svg class="w-3.5 h-3.5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<media-menu id="quality-menu" class="${menuContentClass}">
<media-menu-label class="${menuLabelClass}">Resolution</media-menu-label>
<media-menu-radio-group id="quality-group" value="auto">
<media-menu-radio-item value="auto" class="${radioItemClass}">Auto</media-menu-radio-item>
<media-menu-radio-item value="1080p" class="${radioItemClass}">1080p</media-menu-radio-item>
<media-menu-radio-item value="720p" class="${radioItemClass}">720p</media-menu-radio-item>
<media-menu-radio-item value="480p" class="${radioItemClass}">480p</media-menu-radio-item>
<media-menu-radio-item value="360p" disabled class="${radioItemClass}">360p (unavailable)</media-menu-radio-item>
</media-menu-radio-group>
</media-menu>
<p class="text-[0.8125rem] text-slate-500">Selected: <strong id="quality-output" class="text-slate-900 font-medium">auto</strong></p>
</div>

<!-- Mixed items -->
<div class="bg-white border border-slate-200 rounded-xl p-6 flex flex-col items-start gap-3.5 min-w-[200px] shadow-[0_1px_3px_0_rgb(0_0_0/.05)]">
<span class="text-xs font-medium text-slate-500 uppercase tracking-widest">Mixed items</span>
<button
commandfor="settings-menu"
class="inline-flex items-center gap-1.5 h-9 px-3.5 border border-slate-200 rounded-md bg-white text-slate-900 text-sm font-medium cursor-pointer select-none shadow-[0_1px_2px_0_rgb(0_0_0/.04)] transition-colors hover:bg-slate-50"
>
Settings
<svg class="w-3.5 h-3.5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<media-menu id="settings-menu" class="${menuContentClass}">
<media-menu-label class="${menuLabelClass}">Playback</media-menu-label>
<media-menu-checkbox-item id="loop-item" class="${checkboxItemClass}">Loop</media-menu-checkbox-item>
<media-menu-checkbox-item id="autoplay-item" class="${checkboxItemClass}">Autoplay</media-menu-checkbox-item>
<media-menu-separator class="${menuSeparatorClass}"></media-menu-separator>
<media-menu-item id="copy-item" class="${menuItemClass}">Copy link</media-menu-item>
<media-menu-item id="report-item" class="${menuItemClass}">Report issue</media-menu-item>
</media-menu>
<p class="text-[0.8125rem] text-slate-500">Loop: <strong id="loop-output" class="text-slate-900 font-medium">off</strong></p>
</div>

<!-- Submenu navigation -->
<div class="bg-white border border-slate-200 rounded-xl p-6 flex flex-col items-start gap-3.5 min-w-[200px] shadow-[0_1px_3px_0_rgb(0_0_0/.05)]">
<span class="text-xs font-medium text-slate-500 uppercase tracking-widest">Submenu</span>
<button
commandfor="nav-menu"
class="inline-flex items-center gap-1.5 h-9 px-3.5 border border-slate-200 rounded-md bg-white text-slate-900 text-sm font-medium cursor-pointer select-none shadow-[0_1px_2px_0_rgb(0_0_0/.04)] transition-colors hover:bg-slate-50"
>
Settings
<svg class="w-3.5 h-3.5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>

<media-menu id="nav-menu" class="${menuNavPopupClass}">
<!-- Root list view — slides left when a submenu is active. -->
<media-menu-view id="nav-root-view" class="${rootViewClass}">

<!-- Quality submenu trigger -->
<media-menu-item id="nav-quality-trigger" commandfor="nav-quality-sub" class="${subMenuTriggerClass}">
<span>Quality</span>
<span class="flex items-center gap-1">
<span id="nav-quality-hint" class="text-xs text-slate-400">auto</span>
<svg class="w-3.5 h-3.5 text-slate-400 -mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
</span>
</media-menu-item>

<!-- Speed submenu trigger -->
<media-menu-item id="nav-speed-trigger" commandfor="nav-speed-sub" class="${subMenuTriggerClass}">
<span>Speed</span>
<span class="flex items-center gap-1">
<span id="nav-speed-hint" class="text-xs text-slate-400">Normal</span>
<svg class="w-3.5 h-3.5 text-slate-400 -mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
</span>
</media-menu-item>

<media-menu-separator class="${menuSeparatorClass}"></media-menu-separator>
<media-menu-item id="nav-copy-item" class="${menuItemClass}">Copy link</media-menu-item>

</media-menu-view>

<media-menu id="nav-quality-sub" class="${subMenuContentClass}">
<media-menu-back class="${backButtonClass}">
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
Quality
</media-menu-back>
<media-menu-radio-group id="nav-quality-group" value="auto">
<media-menu-radio-item value="auto" class="${radioItemClass}">Auto</media-menu-radio-item>
<media-menu-radio-item value="1080p" class="${radioItemClass}">1080p</media-menu-radio-item>
<media-menu-radio-item value="720p" class="${radioItemClass}">720p</media-menu-radio-item>
<media-menu-radio-item value="480p" class="${radioItemClass}">480p</media-menu-radio-item>
</media-menu-radio-group>
</media-menu>

<media-menu id="nav-speed-sub" class="${subMenuContentClass}">
<media-menu-back class="${backButtonClass}">
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
Speed
</media-menu-back>
<media-menu-radio-group id="nav-speed-group" value="1">
<media-menu-radio-item value="0.5" class="${radioItemClass}">0.5x</media-menu-radio-item>
<media-menu-radio-item value="0.75" class="${radioItemClass}">0.75x</media-menu-radio-item>
<media-menu-radio-item value="1" class="${radioItemClass}">Normal</media-menu-radio-item>
<media-menu-radio-item value="1.25" class="${radioItemClass}">1.25x</media-menu-radio-item>
<media-menu-radio-item value="1.5" class="${radioItemClass}">1.5x</media-menu-radio-item>
<media-menu-radio-item value="2" class="${radioItemClass}">2x</media-menu-radio-item>
</media-menu-radio-group>
</media-menu>
</media-menu>

<p class="text-[0.8125rem] text-slate-500">
Quality: <strong id="nav-quality-output" class="text-slate-900 font-medium">auto</strong>
&nbsp;·&nbsp;
Speed: <strong id="nav-speed-output" class="text-slate-900 font-medium">Normal</strong>
</p>
</div>

</div>

</div>
`;

// ── 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');
});
14 changes: 14 additions & 0 deletions apps/sandbox/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans text-zinc-950 antialiased h-screen overflow-hidden">
<div id="root" class="h-full"></div>
<script type="module" src="../app/main.tsx"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions apps/sandbox/templates/react-menu/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — React Menu</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="bg-slate-50 text-slate-900 min-h-screen flex items-center justify-center p-8 font-[Inter,system-ui,sans-serif] antialiased">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
Loading
Loading