Skip to content

Commit 2a590de

Browse files
sampottsclaude
andcommitted
feat(react,html): menu UI layers
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e1254ae commit 2a590de

56 files changed

Lines changed: 5006 additions & 165 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/plans/menus.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,7 @@ PR 1 (Core + DOM) → PR 2 (UI flat) → PR 3 (Submenus)
5050

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

53-
**Status:** PENDING — branch off `feat/menu-core-dom`
54-
55-
**Status:** PENDING
53+
**Status:** OPEN — `feat/menu-react-html` (PR #1504)
5654

5755
### React files (`packages/react/src/ui/menu/`)
5856
- `context.tsx`, `index.parts.ts`, `index.ts`
@@ -78,34 +76,40 @@ PR 1 (Core + DOM) → PR 2 (UI flat) → PR 3 (Submenus)
7876

7977
## PR 3 — Submenu navigation
8078

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

8381
### Files
8482

8583
**New DOM:**
86-
- `packages/core/src/dom/ui/menu/create-sub-menu-transition.ts`
84+
- `packages/core/src/dom/ui/menu/create-menu-view-transition.ts`
85+
- `packages/core/src/dom/ui/menu/menu-viewport-transition.ts`
8786

8887
**New React:**
8988
- `packages/react/src/ui/menu/menu-back.tsx`
89+
- `packages/react/src/ui/menu/menu-view.tsx`
9090

9191
**New HTML:**
9292
- `packages/html/src/ui/menu/menu-back-element.ts`
93+
- `packages/html/src/ui/menu/menu-view-element.ts`
9394

9495
**Modified:**
9596
- `packages/core/src/dom/ui/menu/create-menu.ts` — add `push`/`pop` to `MenuApi`, `NavigationState`, wire transition
9697
- `packages/react/src/ui/menu/menu-root.tsx` — nested Root detects parent context → submenu mode
9798
- `packages/react/src/ui/menu/menu-content.tsx``data-submenu`, `data-direction`, slide transition wiring
99+
- `packages/react/src/ui/menu/index.parts.ts` — export `Menu.Back` and `Menu.View`
98100
- `packages/html/src/ui/menu/menu-element.ts` — nested `<media-menu>` + `commandfor` support
99101
- `packages/html/src/ui/menu/menu-item-element.ts``commandfor` attribute handling
100-
- `packages/core/src/dom/index.ts` — add transition export
101-
- `packages/react/src/ui/index.ts` — add Back to Menu exports
102-
- `packages/html/src/define/ui/menu.ts` — register `<media-menu-back>`
102+
- `packages/core/src/dom/index.ts` — add submenu and viewport transition exports
103+
- `packages/html/src/define/ui/menu.ts` — register `<media-menu-back>` and `<media-menu-view>`
103104

104-
**Status:** PENDINGbranch off `feat/menu-react-html`
105+
**Status:** OPENbranched off `feat/menu-react-html`
105106

106107
### Scope
107108
- `NavigationState`: stack of `{ menuId, triggerId }`, direction, exitingMenuId, transitioning
108-
- `createSubMenuTransition()`: double-RAF lifecycle, `--media-menu-width/height` measurement, `getAnimations()` settle
109+
- `createMenuViewTransition()`: generic menu view double-RAF lifecycle, data attribute hooks, `getAnimations()` settle
110+
- `menu-viewport-transition.ts`: shared root/child view measurement and `--media-menu-width/height` sizing
111+
- `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
112+
- Traditional flyout submenus are out of scope for this PR and should not require `Menu.View` / `<media-menu-view>` when added later
109113
- Nested `Menu.Root` detection via parent `MenuContext``isSubmenu: true` prop, Trigger registers as parent item
110114
- `Menu.Back` / `<media-menu-back>`: pops stack, focus restoration to trigger
111115
- Auto-back on `RadioItem` selection in submenu
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Sandbox — HTML Menu</title>
7+
<link rel="preconnect" href="https://rsms.me/" />
8+
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
9+
10+
</head>
11+
<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">
12+
<div id="root"></div>
13+
<script type="module" src="./main.ts"></script>
14+
</body>
15+
</html>
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// HTML Menu sandbox
2+
// http://localhost:5173/html-menu/
3+
import '@app/styles.css';
4+
5+
import '@videojs/html/ui/menu';
6+
7+
// ── Class constants ───────────────────────────────────────────────────────────
8+
9+
const menuContentSurfaceClass =
10+
'bg-white border-none ring-1 ring-black/10 shadow-sm rounded-md p-1 min-w-[10rem] overflow-hidden outline-none';
11+
12+
const menuNavSurfaceClass =
13+
'bg-white border-none ring-1 ring-black/10 shadow-sm rounded-md min-w-[10rem] overflow-hidden outline-none';
14+
15+
const menuContentPlacementClass = [
16+
'data-side=bottom:origin-top data-side=top:origin-bottom',
17+
'data-side=left:origin-right data-side=right:origin-left',
18+
'data-starting-style:opacity-0 data-starting-style:scale-95 data-starting-style:-translate-y-1 data-starting-style:blur-sm',
19+
'data-ending-style:opacity-0 data-ending-style:scale-95 data-ending-style:-translate-y-1 data-ending-style:blur-sm',
20+
].join(' ');
21+
22+
const menuContentClass = [
23+
menuContentSurfaceClass,
24+
'transition-[opacity,scale,translate,filter] duration-150',
25+
menuContentPlacementClass,
26+
].join(' ');
27+
28+
const menuLabelClass = 'block px-2 pt-1.5 pb-0.5 text-xs font-semibold text-slate-500 select-none';
29+
30+
const menuSeparatorClass = 'block h-px bg-slate-200 -mx-1 my-1';
31+
32+
const menuItemClass = [
33+
'relative flex items-center gap-2 rounded-[calc(0.375rem-2px)] px-2 py-1.5',
34+
'text-sm text-slate-900 cursor-default select-none outline-none transition-colors',
35+
'data-[highlighted]:bg-slate-100',
36+
'aria-disabled:opacity-50 aria-disabled:pointer-events-none',
37+
].join(' ');
38+
39+
const radioItemClass = [menuItemClass, 'pl-8'].join(' ');
40+
const checkboxItemClass = [menuItemClass, 'pl-8'].join(' ');
41+
42+
// Submenu trigger — same as a regular item but with space-between layout
43+
const subMenuTriggerClass = [menuItemClass, 'justify-between'].join(' ');
44+
45+
const subMenuContentClass = [
46+
'absolute inset-0 z-10 bg-white rounded-[inherit] p-1 outline-none overflow-hidden translate-x-0',
47+
'transition-transform duration-300 ease-in-out will-change-transform',
48+
'[&[data-starting-style][data-direction=forward]]:translate-x-full',
49+
'[&[data-ending-style][data-direction=forward]]:-translate-x-full',
50+
'[&[data-starting-style][data-direction=back]]:-translate-x-full',
51+
'[&[data-ending-style][data-direction=back]]:translate-x-full',
52+
].join(' ');
53+
54+
// Root and submenu views share the same viewport so they can slide over each other.
55+
const rootViewClass = [
56+
'absolute inset-0 p-1 translate-x-0',
57+
'transition-transform duration-300 ease-in-out will-change-transform',
58+
'data-[menu-view-state=inactive]:-translate-x-full',
59+
].join(' ');
60+
61+
const backButtonClass = [
62+
'flex items-center gap-1.5 w-full rounded-[calc(0.375rem-2px)] px-2 py-1.5 mb-0.5',
63+
'text-sm font-medium text-slate-500 cursor-default select-none outline-none transition-colors',
64+
'hover:bg-slate-100 hover:text-slate-900',
65+
].join(' ');
66+
67+
const menuNavPopupClass = [
68+
'group relative',
69+
menuNavSurfaceClass,
70+
'w-(--media-menu-width) h-(--media-menu-height)',
71+
'transition-[opacity,scale,translate,filter,width,height] duration-300 ease-in-out',
72+
menuContentPlacementClass,
73+
].join(' ');
74+
75+
// ── Render ────────────────────────────────────────────────────────────────────
76+
77+
const root = document.getElementById('root')!;
78+
79+
root.innerHTML = `
80+
<div class="flex flex-col items-center gap-12">
81+
82+
<header class="text-center flex flex-col gap-1.5">
83+
<h1 class="text-2xl font-semibold tracking-tight">HTML Menu</h1>
84+
<p class="text-sm text-slate-500">Custom elements — <code>&lt;media-menu&gt;</code> and friends</p>
85+
</header>
86+
87+
<div class="flex gap-4 flex-wrap justify-center">
88+
89+
<!-- Radio group -->
90+
<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)]">
91+
<span class="text-xs font-medium text-slate-500 uppercase tracking-widest">Radio group</span>
92+
<button
93+
commandfor="quality-menu"
94+
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"
95+
>
96+
Quality
97+
<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>
98+
</button>
99+
<media-menu id="quality-menu" class="${menuContentClass}">
100+
<media-menu-label class="${menuLabelClass}">Resolution</media-menu-label>
101+
<media-menu-radio-group id="quality-group" value="auto">
102+
<media-menu-radio-item value="auto" class="${radioItemClass}">Auto</media-menu-radio-item>
103+
<media-menu-radio-item value="1080p" class="${radioItemClass}">1080p</media-menu-radio-item>
104+
<media-menu-radio-item value="720p" class="${radioItemClass}">720p</media-menu-radio-item>
105+
<media-menu-radio-item value="480p" class="${radioItemClass}">480p</media-menu-radio-item>
106+
<media-menu-radio-item value="360p" disabled class="${radioItemClass}">360p (unavailable)</media-menu-radio-item>
107+
</media-menu-radio-group>
108+
</media-menu>
109+
<p class="text-[0.8125rem] text-slate-500">Selected: <strong id="quality-output" class="text-slate-900 font-medium">auto</strong></p>
110+
</div>
111+
112+
<!-- Mixed items -->
113+
<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)]">
114+
<span class="text-xs font-medium text-slate-500 uppercase tracking-widest">Mixed items</span>
115+
<button
116+
commandfor="settings-menu"
117+
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"
118+
>
119+
Settings
120+
<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>
121+
</button>
122+
<media-menu id="settings-menu" class="${menuContentClass}">
123+
<media-menu-label class="${menuLabelClass}">Playback</media-menu-label>
124+
<media-menu-checkbox-item id="loop-item" class="${checkboxItemClass}">Loop</media-menu-checkbox-item>
125+
<media-menu-checkbox-item id="autoplay-item" class="${checkboxItemClass}">Autoplay</media-menu-checkbox-item>
126+
<media-menu-separator class="${menuSeparatorClass}"></media-menu-separator>
127+
<media-menu-item id="copy-item" class="${menuItemClass}">Copy link</media-menu-item>
128+
<media-menu-item id="report-item" class="${menuItemClass}">Report issue</media-menu-item>
129+
</media-menu>
130+
<p class="text-[0.8125rem] text-slate-500">Loop: <strong id="loop-output" class="text-slate-900 font-medium">off</strong></p>
131+
</div>
132+
133+
<!-- Submenu navigation -->
134+
<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)]">
135+
<span class="text-xs font-medium text-slate-500 uppercase tracking-widest">Submenu</span>
136+
<button
137+
commandfor="nav-menu"
138+
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"
139+
>
140+
Settings
141+
<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>
142+
</button>
143+
144+
<media-menu id="nav-menu" class="${menuNavPopupClass}">
145+
<!-- Root list view — slides left when a submenu is active. -->
146+
<media-menu-view id="nav-root-view" class="${rootViewClass}">
147+
148+
<!-- Quality submenu trigger -->
149+
<media-menu-item id="nav-quality-trigger" commandfor="nav-quality-sub" class="${subMenuTriggerClass}">
150+
<span>Quality</span>
151+
<span class="flex items-center gap-1">
152+
<span id="nav-quality-hint" class="text-xs text-slate-400">auto</span>
153+
<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>
154+
</span>
155+
</media-menu-item>
156+
157+
<!-- Speed submenu trigger -->
158+
<media-menu-item id="nav-speed-trigger" commandfor="nav-speed-sub" class="${subMenuTriggerClass}">
159+
<span>Speed</span>
160+
<span class="flex items-center gap-1">
161+
<span id="nav-speed-hint" class="text-xs text-slate-400">Normal</span>
162+
<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>
163+
</span>
164+
</media-menu-item>
165+
166+
<media-menu-separator class="${menuSeparatorClass}"></media-menu-separator>
167+
<media-menu-item id="nav-copy-item" class="${menuItemClass}">Copy link</media-menu-item>
168+
169+
</media-menu-view>
170+
171+
<media-menu id="nav-quality-sub" class="${subMenuContentClass}">
172+
<media-menu-back class="${backButtonClass}">
173+
<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>
174+
Quality
175+
</media-menu-back>
176+
<media-menu-radio-group id="nav-quality-group" value="auto">
177+
<media-menu-radio-item value="auto" class="${radioItemClass}">Auto</media-menu-radio-item>
178+
<media-menu-radio-item value="1080p" class="${radioItemClass}">1080p</media-menu-radio-item>
179+
<media-menu-radio-item value="720p" class="${radioItemClass}">720p</media-menu-radio-item>
180+
<media-menu-radio-item value="480p" class="${radioItemClass}">480p</media-menu-radio-item>
181+
</media-menu-radio-group>
182+
</media-menu>
183+
184+
<media-menu id="nav-speed-sub" class="${subMenuContentClass}">
185+
<media-menu-back class="${backButtonClass}">
186+
<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>
187+
Speed
188+
</media-menu-back>
189+
<media-menu-radio-group id="nav-speed-group" value="1">
190+
<media-menu-radio-item value="0.5" class="${radioItemClass}">0.5x</media-menu-radio-item>
191+
<media-menu-radio-item value="0.75" class="${radioItemClass}">0.75x</media-menu-radio-item>
192+
<media-menu-radio-item value="1" class="${radioItemClass}">Normal</media-menu-radio-item>
193+
<media-menu-radio-item value="1.25" class="${radioItemClass}">1.25x</media-menu-radio-item>
194+
<media-menu-radio-item value="1.5" class="${radioItemClass}">1.5x</media-menu-radio-item>
195+
<media-menu-radio-item value="2" class="${radioItemClass}">2x</media-menu-radio-item>
196+
</media-menu-radio-group>
197+
</media-menu>
198+
</media-menu>
199+
200+
<p class="text-[0.8125rem] text-slate-500">
201+
Quality: <strong id="nav-quality-output" class="text-slate-900 font-medium">auto</strong>
202+
&nbsp;·&nbsp;
203+
Speed: <strong id="nav-speed-output" class="text-slate-900 font-medium">Normal</strong>
204+
</p>
205+
</div>
206+
207+
</div>
208+
209+
</div>
210+
`;
211+
212+
// ── Event listeners ───────────────────────────────────────────────────────────
213+
214+
document.getElementById('quality-group')!.addEventListener('value-change', (e) => {
215+
const { value } = (e as CustomEvent).detail;
216+
(e.target as HTMLElement).setAttribute('value', value);
217+
document.getElementById('quality-output')!.textContent = value;
218+
});
219+
220+
document.getElementById('loop-item')!.addEventListener('checked-change', (e) => {
221+
const { checked } = (e as CustomEvent).detail;
222+
(e.target as HTMLElement).toggleAttribute('checked', checked);
223+
document.getElementById('loop-output')!.textContent = checked ? 'on' : 'off';
224+
});
225+
226+
document.getElementById('autoplay-item')!.addEventListener('checked-change', (e) => {
227+
const checked = (e as CustomEvent).detail.checked;
228+
(e.target as HTMLElement).toggleAttribute('checked', checked);
229+
});
230+
231+
document.getElementById('copy-item')!.addEventListener('select', () => {
232+
console.log('copy link');
233+
});
234+
235+
document.getElementById('report-item')!.addEventListener('select', () => {
236+
console.log('report issue');
237+
});
238+
239+
document.getElementById('nav-quality-group')!.addEventListener('value-change', (event) => {
240+
const { value } = (event as CustomEvent).detail;
241+
(event.target as HTMLElement).setAttribute('value', value);
242+
document.getElementById('nav-quality-output')!.textContent = value;
243+
document.getElementById('nav-quality-hint')!.textContent = value;
244+
});
245+
246+
document.getElementById('nav-speed-group')!.addEventListener('value-change', (event) => {
247+
const { value } = (event as CustomEvent).detail;
248+
(event.target as HTMLElement).setAttribute('value', value);
249+
const label = value === '1' ? 'Normal' : `${value}x`;
250+
document.getElementById('nav-speed-output')!.textContent = label;
251+
document.getElementById('nav-speed-hint')!.textContent = label;
252+
});
253+
254+
document.getElementById('nav-copy-item')!.addEventListener('select', () => {
255+
console.log('nav: copy link');
256+
});

apps/sandbox/templates/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Sandbox</title>
7+
<link rel="preconnect" href="https://rsms.me/" />
8+
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
9+
</head>
10+
<body class="font-sans text-zinc-950 antialiased h-screen overflow-hidden">
11+
<div id="root" class="h-full"></div>
12+
<script type="module" src="../app/main.tsx"></script>
13+
</body>
14+
</html>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Sandbox — React Menu</title>
7+
<link rel="preconnect" href="https://rsms.me/" />
8+
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
9+
</head>
10+
<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">
11+
<div id="root"></div>
12+
<script type="module" src="./main.tsx"></script>
13+
</body>
14+
</html>

0 commit comments

Comments
 (0)