@joewinke/jatui 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,16 +6,25 @@
6
6
  *
7
7
  * Interaction contract:
8
8
  * - Click (or Space/Enter) = play / stop the last scape. A gesture-gated
9
- * engine call, made synchronously inside the click handler.
10
- * - The scape menu (menu button pattern) opens with ArrowDown/ArrowUp,
11
- * a press-and-hold (~450 ms) or a right-click (Android/Chrome fire
9
+ * engine call, made synchronously inside the click handler. With no
10
+ * playable scape registered (links only) the click opens the menu.
11
+ * - The menu (menu button pattern) opens with ArrowDown/ArrowUp, a
12
+ * press-and-hold (~450 ms) or a right-click (Android/Chrome fire
12
13
  * contextmenu on touch holds; iOS may cancel the pointer instead —
13
- * the keyboard path always works). It lists every
14
- * registered scape grouped meditation / focus / noise / nature / music
15
- * (`menuitemradio` items, current scape checked), carries a volume
16
- * slider wired to `engine.setVolume`, and shows each scape's
17
- * `description` as its secondary line — playlist scapes put their
18
- * track attribution there.
14
+ * the keyboard path always works). Top level: a volume slider wired to
15
+ * `engine.setVolume`, then one row per section — Radio / Focus /
16
+ * Noise / Meditation … in the host's order (`AmbienceOptions.groups`,
17
+ * jatui-mdq2p) — each opening a submenu of its items. Right / Enter /
18
+ * Space open a submenu and move focus into it; Left / Escape close it
19
+ * and return focus to its row (the ContextMenu submenu pattern); a
20
+ * mouse hover opens a row's submenu without moving focus.
21
+ * - Scapes are `menuitemradio` (current one checked; its section row
22
+ * carries a dot and the scape's name). Link items (radio stations) are
23
+ * real `<a target="_blank">` menu items with an external-link glyph:
24
+ * the station's own site or player opens in a new tab. Nothing is
25
+ * relayed — we never proxy a third-party stream.
26
+ * - A playing playlist shows its current track's attribution (title,
27
+ * composer, license) under the playlist item and its section row.
19
28
  *
20
29
  * A11y: the trigger is `aria-pressed` while playing and carries the full
21
30
  * menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
@@ -28,58 +37,41 @@
28
37
  * Token-only theming (DaisyUI + `--jatui-*` + `--motion-*`). SSR-safe: the
29
38
  * engine is lazy and nothing here touches `window` at import.
30
39
  */
31
- import type { Ambience, Scape, ScapeGroup } from '../ambience/types';
32
-
33
- /** Canonical menu group order (the Scape contract's groups). */
34
- export const AMBIENCE_GROUP_ORDER: readonly ScapeGroup[] = [
35
- 'meditation',
36
- 'focus',
37
- 'noise',
38
- 'nature',
39
- 'music'
40
- ];
41
-
42
- /** Human labels for the canonical groups. */
43
- export const GROUP_LABELS: Readonly<Record<ScapeGroup, string>> = {
44
- meditation: 'Meditation',
45
- focus: 'Focus',
46
- noise: 'Noise',
47
- nature: 'Nature',
48
- music: 'Music'
49
- };
50
-
51
- /** One grouped section of the scape menu. */
52
- export interface AmbienceScapeGroup {
53
- group: ScapeGroup;
54
- label: string;
55
- scapes: Scape[];
56
- }
40
+ import type { AmbienceGroupDef, AmbienceItem, ScapeGroup } from '../ambience/types';
41
+ import {
42
+ AMBIENCE_DEFAULT_GROUP_LABELS,
43
+ AMBIENCE_DEFAULT_GROUP_ORDER,
44
+ groupAmbienceItems,
45
+ type AmbienceSection
46
+ } from '../ambience/groups';
47
+ import { trackAttribution, type PlaylistTrack } from '../ambience/scapes/playlist';
48
+
49
+ /** Default menu section order (when the host configures no `groups`). */
50
+ export const AMBIENCE_GROUP_ORDER: readonly ScapeGroup[] = AMBIENCE_DEFAULT_GROUP_ORDER;
51
+
52
+ /** Human labels for the library's own groups. */
53
+ export const GROUP_LABELS: Readonly<Record<string, string>> = AMBIENCE_DEFAULT_GROUP_LABELS;
54
+
55
+ /** One section of the menu: a top-level row and its submenu. */
56
+ export type AmbienceScapeGroup = AmbienceSection;
57
57
 
58
58
  /**
59
- * Group scapes for the menu: canonical group order, registration order
60
- * within a group, empty groups omitted. A group outside the union (a
61
- * registry from a newer engine) still renders, after the known ones, in
62
- * first-seen order — a menu never silently drops a registered scape.
59
+ * Group items for the menu: the host's section order (or the default),
60
+ * registration order within a section, empty sections omitted. A group
61
+ * missing from the order still renders, after the ordered ones, in
62
+ * first-seen order — a menu never silently drops a registered item.
63
63
  */
64
- export function groupScapes(scapes: Scape[]): AmbienceScapeGroup[] {
65
- const byGroup = new Map<string, Scape[]>();
66
- for (const s of scapes) {
67
- const list = byGroup.get(s.group) ?? [];
68
- list.push(s);
69
- byGroup.set(s.group, list);
70
- }
71
- const out: AmbienceScapeGroup[] = [];
72
- for (const g of AMBIENCE_GROUP_ORDER) {
73
- const list = byGroup.get(g);
74
- if (!list?.length) continue;
75
- out.push({ group: g, label: GROUP_LABELS[g], scapes: list });
76
- byGroup.delete(g);
77
- }
78
- for (const [g, list] of byGroup) {
79
- const label = g ? g.charAt(0).toUpperCase() + g.slice(1) : 'Other';
80
- out.push({ group: g as ScapeGroup, label, scapes: list });
81
- }
82
- return out;
64
+ export function groupScapes(
65
+ items: AmbienceItem[],
66
+ groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>
67
+ ): AmbienceScapeGroup[] {
68
+ return groupAmbienceItems(items, groups);
69
+ }
70
+
71
+ /** "Gymnopédie No. 1 — Erik Satie · Public domain": a playing track's line. */
72
+ export function nowPlayingLine(track: PlaylistTrack | null | undefined): string {
73
+ if (!track) return '';
74
+ return `${track.title} — ${trackAttribution(track)}`;
83
75
  }
84
76
 
85
77
  /**
@@ -106,17 +98,52 @@
106
98
  const v = Math.min(1, Math.max(0, value));
107
99
  return 1 + 0.06 * v;
108
100
  }
101
+
102
+ /** Submenu width (px) — matches the `w-64` class on the submenu panel. */
103
+ export const SUBMENU_WIDTH = 256;
104
+
105
+ /**
106
+ * Where a submenu goes, in viewport px. Beside the menu panel on the
107
+ * preferred side (the side the menu already extends to), else the other
108
+ * side, else — a phone, no room either side — overlapping the panel just
109
+ * below its row, clamped inside the viewport.
110
+ */
111
+ export function placeSubmenu(
112
+ row: { top: number; bottom: number },
113
+ panel: { left: number; right: number },
114
+ viewport: { width: number },
115
+ prefer: 'left' | 'right',
116
+ width = SUBMENU_WIDTH,
117
+ gap = 4,
118
+ margin = 8
119
+ ): { x: number; y: number; side: 'left' | 'right' | 'over' } {
120
+ const right = panel.right + gap;
121
+ const left = panel.left - gap - width;
122
+ const fitsRight = right + width <= viewport.width - margin;
123
+ const fitsLeft = left >= margin;
124
+ const y = row.top - 6;
125
+ if (prefer === 'left' ? fitsLeft : fitsRight)
126
+ return { x: prefer === 'left' ? left : right, y, side: prefer };
127
+ if (prefer === 'left' ? fitsRight : fitsLeft)
128
+ return { x: prefer === 'left' ? right : left, y, side: prefer === 'left' ? 'right' : 'left' };
129
+ const x = Math.max(margin, Math.min(panel.left + 16, viewport.width - margin - width));
130
+ return { x, y: row.bottom + 2, side: 'over' };
131
+ }
109
132
  </script>
110
133
 
111
134
  <script lang="ts">
135
+ import { untrack } from 'svelte';
112
136
  import { getMotionContext } from '../motion/level';
137
+ import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
138
+ import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
139
+ import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
113
140
 
114
141
  let {
115
142
  engine,
116
143
  compact = false,
117
144
  placement = 'bottom-end'
118
145
  }: {
119
- /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. */
146
+ /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
120
147
  engine: Ambience;
121
148
  /** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
122
149
  compact?: boolean;
@@ -126,6 +153,7 @@
126
153
 
127
154
  const menuId = $props.id();
128
155
  const hintId = `${menuId}-hint`;
156
+ const subId = `${menuId}-sub`;
129
157
 
130
158
  /** Long-press duration for the pointer path to the menu. */
131
159
  const LONG_PRESS_MS = 450;
@@ -135,37 +163,56 @@
135
163
  let playing = $state(false);
136
164
  let currentId = $state<string | null>(null);
137
165
  let volume = $state(0.6);
138
- let scapes = $state<Scape[]>([]);
166
+ let items = $state<AmbienceItem[]>([]);
139
167
  let open = $state(false);
140
168
  // svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
141
169
  let effectivePlacement = $state<'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'>(
142
170
  placement
143
171
  );
144
172
 
173
+ /** The section whose submenu is open (null = none), and where it sits
174
+ * relative to the wrapper (the submenu's offset parent). */
175
+ let subGroup = $state<string | null>(null);
176
+ let subPos = $state<{ left: number; top: number; side: 'left' | 'right' | 'over' }>({
177
+ left: 0,
178
+ top: 0,
179
+ side: 'right'
180
+ });
181
+
145
182
  /** Set by a long-press so the click that follows the release is swallowed —
146
183
  * but only the mouse click (detail > 0), never a keyboard activation. */
147
184
  let longPressed = false;
148
185
  let pressTimer: ReturnType<typeof setTimeout> | null = null;
149
186
  let pressStart: { x: number; y: number } | null = null;
150
187
 
188
+ let wrapRef = $state<HTMLDivElement | null>(null);
151
189
  let btnRef = $state<HTMLButtonElement | null>(null);
152
190
  let panelRef = $state<HTMLDivElement | null>(null);
191
+ let subRef = $state<HTMLDivElement | null>(null);
153
192
  let glyphRef = $state<HTMLSpanElement | null>(null);
154
193
 
155
194
  let playError = $state(false);
156
-
157
- const currentScape = $derived(scapes.find((s) => s.id === currentId) ?? null);
158
- const currentLabel = $derived(currentScape?.label ?? currentId);
159
- const groups = $derived(groupScapes(scapes));
195
+ let track = $state<PlaylistState['track']>(null);
196
+
197
+ const scapes = $derived(items.filter((i): i is Scape => !isAmbienceLink(i)));
198
+ const currentItem = $derived(items.find((s) => s.id === currentId) ?? null);
199
+ const currentLabel = $derived(currentItem?.label ?? currentId);
200
+ const groups = $derived(groupScapes(items, engine.groups?.()));
201
+ const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
202
+ const trackLine = $derived(playing ? nowPlayingLine(track) : '');
160
203
  const liveLabel = $derived(
161
204
  playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
162
205
  );
163
- const disabled = $derived(scapes.length === 0);
206
+ const disabled = $derived(items.length === 0);
164
207
  const sliderValue = $derived(Math.round(volume * 100));
165
208
 
166
209
  const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
167
210
  const ICON_SIZE = $derived(compact ? 18 : 20);
168
211
 
212
+ function snapshot(): AmbienceItem[] {
213
+ return engine.items?.() ?? engine.list();
214
+ }
215
+
169
216
  // Engine state → component state. subscribe() fires once immediately, so
170
217
  // this also fills the initial (persisted-preselect) state at mount.
171
218
  $effect(() => {
@@ -173,12 +220,24 @@
173
220
  playing = s.playing;
174
221
  currentId = s.currentId;
175
222
  volume = s.volume;
176
- scapes = engine.list();
223
+ items = snapshot();
177
224
  // A successful play (state flips to playing) clears the failure.
178
225
  if (s.playing) playError = false;
179
226
  });
180
227
  });
181
228
 
229
+ // The current scape is a playlist → follow its track for the attribution line.
230
+ $effect(() => {
231
+ const item = currentItem;
232
+ if (!isPlaylistScape(item)) {
233
+ track = null;
234
+ return;
235
+ }
236
+ return item.subscribe((s) => {
237
+ track = s.status === 'playing' ? s.track : null;
238
+ });
239
+ });
240
+
182
241
  // Breathing glyph: follow the engine's shared breath clock while playing,
183
242
  // and only where ambient motion is allowed (getMotionContext().ambient is
184
243
  // false under prefers-reduced-motion AND under data-motion="calm"). The
@@ -202,7 +261,8 @@
202
261
 
203
262
  // While open: click-outside dismissal (capture phase, so a press that
204
263
  // begins outside never reaches the page first) and APG focus movement into
205
- // the menu — focus the first nav item once the panel exists.
264
+ // the menu — focus the first nav item once the panel exists. A resize
265
+ // closes an open submenu (its position is stale).
206
266
  $effect(() => {
207
267
  if (!open) return;
208
268
  const panel = panelRef;
@@ -210,17 +270,46 @@
210
270
  navItems()[0]?.focus();
211
271
  const onDocPointerDown = (e: PointerEvent) => {
212
272
  const t = e.target as Node | null;
213
- if (btnRef?.contains(t) || panelRef?.contains(t)) return;
273
+ if (btnRef?.contains(t) || panelRef?.contains(t) || subRef?.contains(t)) return;
214
274
  closeMenu(false);
215
275
  };
276
+ const onResize = () => closeSubmenu(false);
216
277
  document.addEventListener('pointerdown', onDocPointerDown, true);
217
- return () => document.removeEventListener('pointerdown', onDocPointerDown, true);
278
+ window.addEventListener('resize', onResize);
279
+ return () => {
280
+ document.removeEventListener('pointerdown', onDocPointerDown, true);
281
+ window.removeEventListener('resize', onResize);
282
+ };
218
283
  });
219
284
 
285
+ // Keep an open submenu inside the viewport vertically once its real
286
+ // height is known.
287
+ $effect(() => {
288
+ const sub = subRef;
289
+ const wrap = wrapRef;
290
+ if (!sub || !wrap || !subGroup) return;
291
+ const r = sub.getBoundingClientRect();
292
+ const overflow = r.bottom - (window.innerHeight - 8);
293
+ if (overflow > 0) {
294
+ const wrapTop = wrap.getBoundingClientRect().top;
295
+ const pos = untrack(() => subPos);
296
+ subPos = { ...pos, top: Math.max(8 - wrapTop, pos.top - overflow) };
297
+ }
298
+ });
299
+
300
+ /** Top-level roving set: the volume slider and the section rows. */
220
301
  function navItems(): HTMLElement[] {
221
302
  return panelRef ? Array.from(panelRef.querySelectorAll<HTMLElement>('[data-ambience-nav]')) : [];
222
303
  }
223
304
 
305
+ function subItems(): HTMLElement[] {
306
+ return subRef ? Array.from(subRef.querySelectorAll<HTMLElement>('[data-ambience-sub]')) : [];
307
+ }
308
+
309
+ function rowFor(group: string): HTMLElement | null {
310
+ return panelRef?.querySelector<HTMLElement>(`[data-ambience-group="${CSS.escape(group)}"]`) ?? null;
311
+ }
312
+
224
313
  function resolvePlacement(): 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' {
225
314
  const el = btnRef;
226
315
  if (!el || typeof window === 'undefined') return placement;
@@ -236,17 +325,56 @@
236
325
 
237
326
  function openMenu() {
238
327
  if (disabled) return;
239
- scapes = engine.list(); // freshest registry snapshot for the menu
328
+ items = snapshot(); // freshest registry snapshot for the menu
240
329
  effectivePlacement = resolvePlacement();
330
+ subGroup = null;
241
331
  open = true;
242
332
  }
243
333
 
244
334
  function closeMenu(refocus = true) {
245
335
  if (!open) return;
246
336
  open = false;
337
+ subGroup = null;
247
338
  if (refocus) btnRef?.focus();
248
339
  }
249
340
 
341
+ function openSubmenu(group: string, focusFirst: boolean) {
342
+ const row = rowFor(group);
343
+ const wrap = wrapRef;
344
+ if (!row || !wrap || !panelRef) return;
345
+ const rr = row.getBoundingClientRect();
346
+ const pr = panelRef.getBoundingClientRect();
347
+ const wr = wrap.getBoundingClientRect();
348
+ const prefer = effectivePlacement.endsWith('end') ? 'left' : 'right';
349
+ const p = placeSubmenu(rr, pr, { width: window.innerWidth }, prefer);
350
+ subPos = { left: p.x - wr.left, top: p.y - wr.top, side: p.side };
351
+ subGroup = group;
352
+ if (focusFirst) queueMicrotask(() => subItems()[0]?.focus());
353
+ }
354
+
355
+ function closeSubmenu(refocusRow = true) {
356
+ const g = subGroup;
357
+ if (g === null) return;
358
+ subGroup = null;
359
+ if (refocusRow) rowFor(g)?.focus();
360
+ }
361
+
362
+ function onRowClick(e: MouseEvent, group: string) {
363
+ // Keyboard activation (Enter / Space → detail 0) always opens and enters;
364
+ // a mouse click on the open row's own submenu toggles it shut.
365
+ if (e.detail > 0 && subGroup === group) {
366
+ closeSubmenu(true);
367
+ return;
368
+ }
369
+ openSubmenu(group, true);
370
+ }
371
+
372
+ function onRowPointerEnter(e: PointerEvent, group: string) {
373
+ // Hover opens (mouse/pen only — a touch tap is a click). Focus stays put.
374
+ if (e.pointerType === 'touch' || subGroup === group) return;
375
+ openSubmenu(group, false);
376
+ }
377
+
250
378
  function togglePlay(e: MouseEvent) {
251
379
  // A long-press already opened the menu — don't also fire the toggle
252
380
  // (keyboard activations, detail 0, are never suppressed).
@@ -255,13 +383,22 @@
255
383
  return;
256
384
  }
257
385
  longPressed = false;
258
- if (open) closeMenu(false);
386
+ if (open) {
387
+ closeMenu(false);
388
+ if (!engine.list().length) return;
389
+ }
259
390
  if (playing) {
260
391
  engine.stop();
261
392
  return;
262
393
  }
263
- const id = engine.state.currentId ?? engine.list()[0]?.id;
264
- if (!id) return;
394
+ const playable = engine.list();
395
+ const last = engine.state.currentId;
396
+ const id = last && playable.some((s) => s.id === last) ? last : playable[0]?.id;
397
+ if (!id) {
398
+ // Links only (e.g. a radio-only shell): the trigger opens the menu.
399
+ openMenu();
400
+ return;
401
+ }
265
402
  attemptPlay(id);
266
403
  }
267
404
 
@@ -286,6 +423,11 @@
286
423
  attemptPlay(scape.id);
287
424
  }
288
425
 
426
+ function onLinkClick() {
427
+ // The anchor's own default action opens the new tab; just get out of the way.
428
+ closeMenu(false);
429
+ }
430
+
289
431
  function onVolumeInput(e: Event) {
290
432
  const v = Number((e.currentTarget as HTMLInputElement).value);
291
433
  engine.setVolume(v / 100);
@@ -331,11 +473,25 @@
331
473
  }
332
474
  }
333
475
 
334
- // --- menu keyboard: APG roving among the nav items -----------------------
476
+ /** APG roving: next index for an arrow / Home / End key, wrapping. */
477
+ function rove(list: HTMLElement[], key: string): void {
478
+ if (!list.length) return;
479
+ const current = list.indexOf(document.activeElement as HTMLElement);
480
+ let next: number;
481
+ if (key === 'Home') next = 0;
482
+ else if (key === 'End') next = list.length - 1;
483
+ else if (current === -1) next = key === 'ArrowUp' ? list.length - 1 : 0;
484
+ else if (key === 'ArrowDown') next = (current + 1) % list.length;
485
+ else next = (current - 1 + list.length) % list.length;
486
+ list[next]?.focus();
487
+ }
488
+
489
+ // --- top-level menu keyboard ---------------------------------------------
335
490
  function onMenuKeydown(e: KeyboardEvent) {
336
491
  if (e.key === 'Escape') {
337
492
  e.preventDefault();
338
- closeMenu(true);
493
+ if (subGroup !== null) closeSubmenu(true);
494
+ else closeMenu(true);
339
495
  return;
340
496
  }
341
497
  if (e.key === 'Tab') {
@@ -345,18 +501,56 @@
345
501
  }
346
502
  // The volume slider owns its own arrows/Home/End.
347
503
  if (e.target instanceof HTMLInputElement) return;
504
+ const group = (e.target as HTMLElement | null)?.dataset?.ambienceGroup;
505
+ // Enter / Space reach onRowClick as a native click (detail 0).
506
+ if (group && e.key === 'ArrowRight') {
507
+ e.preventDefault();
508
+ openSubmenu(group, true);
509
+ return;
510
+ }
511
+ if (e.key === 'ArrowLeft' && subGroup !== null) {
512
+ e.preventDefault();
513
+ closeSubmenu(true);
514
+ return;
515
+ }
348
516
  if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
349
517
  e.preventDefault();
350
- const items = navItems();
351
- if (!items.length) return;
352
- const current = items.indexOf(document.activeElement as HTMLElement);
353
- let next: number;
354
- if (e.key === 'Home') next = 0;
355
- else if (e.key === 'End') next = items.length - 1;
356
- else if (current === -1) next = e.key === 'ArrowUp' ? items.length - 1 : 0;
357
- else if (e.key === 'ArrowDown') next = (current + 1) % items.length;
358
- else next = (current - 1 + items.length) % items.length;
359
- items[next]?.focus();
518
+ // Moving between rows closes a hover-opened submenu.
519
+ if (subGroup !== null) subGroup = null;
520
+ rove(navItems(), e.key);
521
+ }
522
+
523
+ // --- submenu keyboard -----------------------------------------------------
524
+ function onSubKeydown(e: KeyboardEvent) {
525
+ if (e.key === 'Escape' || e.key === 'ArrowLeft') {
526
+ e.preventDefault();
527
+ closeSubmenu(true);
528
+ return;
529
+ }
530
+ if (e.key === 'Tab') {
531
+ closeMenu(false);
532
+ return;
533
+ }
534
+ if (e.key === ' ' && e.target instanceof HTMLAnchorElement) {
535
+ // Links activate on Enter natively; Space is the menuitem convention.
536
+ e.preventDefault();
537
+ e.target.click();
538
+ return;
539
+ }
540
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
541
+ e.preventDefault();
542
+ rove(subItems(), e.key);
543
+ }
544
+
545
+ /** The secondary line under a section row: what's current in it. */
546
+ function rowDetail(g: AmbienceScapeGroup): string {
547
+ if (!currentItem || currentItem.group !== g.group || isAmbienceLink(currentItem)) return '';
548
+ if (trackLine && isPlaylistScape(currentItem)) return `${currentItem.label} · ${trackLine}`;
549
+ return playing ? `${currentItem.label} · playing` : currentItem.label;
550
+ }
551
+
552
+ function linkHref(item: AmbienceLink): string | null {
553
+ return safeAmbienceHref(item.href);
360
554
  }
361
555
 
362
556
  const PLACEMENT_CLASS = {
@@ -365,9 +559,14 @@
365
559
  'top-start': 'bottom-full left-0 mb-2',
366
560
  'top-end': 'bottom-full right-0 mb-2'
367
561
  } as const;
562
+
563
+ const ITEM_CLASS = `flex w-full items-start gap-2 rounded px-2 py-1.5 text-left
564
+ transition-colors duration-[var(--motion-instant,80ms)]
565
+ hover:bg-base-content/10 focus-visible:bg-base-content/10
566
+ focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary`;
368
567
  </script>
369
568
 
370
- <div class="relative inline-flex shrink-0">
569
+ <div bind:this={wrapRef} class="relative inline-flex shrink-0">
371
570
  <button
372
571
  bind:this={btnRef}
373
572
  type="button"
@@ -455,6 +654,7 @@
455
654
  max-h-[min(24rem,calc(100vh-1rem))] menu-pop
456
655
  {PLACEMENT_CLASS[effectivePlacement]}"
457
656
  onkeydown={onMenuKeydown}
657
+ onscroll={() => closeSubmenu(false)}
458
658
  >
459
659
  <!-- Volume: a real slider in the roving set; it owns its own arrows. -->
460
660
  <div role="none" class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
@@ -478,32 +678,124 @@
478
678
  </div>
479
679
 
480
680
  {#each groups as g (g.group)}
481
- <div role="group" aria-label={g.label} class="py-1">
482
- <div class="text-jatui-label text-base-content/45 px-2 py-1" aria-hidden="true">
483
- {g.label}
484
- </div>
485
- {#each g.scapes as s (s.id)}
681
+ {@const detail = rowDetail(g)}
682
+ {@const hasCurrent = !!currentItem && currentItem.group === g.group}
683
+ <button
684
+ type="button"
685
+ role="menuitem"
686
+ tabindex="-1"
687
+ data-ambience-nav
688
+ data-ambience-group={g.group}
689
+ aria-haspopup="menu"
690
+ aria-expanded={subGroup === g.group}
691
+ aria-controls={subGroup === g.group ? subId : undefined}
692
+ class="{ITEM_CLASS} items-center {subGroup === g.group ? 'bg-base-content/10' : ''}"
693
+ onclick={(e) => onRowClick(e, g.group)}
694
+ onpointerenter={(e) => onRowPointerEnter(e, g.group)}
695
+ >
696
+ <span class="min-w-0 flex-1">
697
+ <span class="block truncate text-sm font-medium">{g.label}</span>
698
+ {#if detail}
699
+ <span class="text-2xs text-base-content/55 block truncate">{detail}</span>
700
+ {/if}
701
+ </span>
702
+ {#if hasCurrent}
703
+ <!-- Current-scape marker: the section that holds it. -->
704
+ <span class="size-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true"></span>
705
+ <span class="sr-only">(current)</span>
706
+ {/if}
707
+ <svg
708
+ xmlns="http://www.w3.org/2000/svg"
709
+ viewBox="0 0 24 24"
710
+ fill="none"
711
+ stroke="currentColor"
712
+ stroke-width="2"
713
+ stroke-linecap="round"
714
+ stroke-linejoin="round"
715
+ class="size-4 shrink-0 text-base-content/50 {subPos.side === 'left' && subGroup === g.group ? 'rotate-180' : ''}"
716
+ aria-hidden="true"
717
+ >
718
+ <path d="m9 18 6-6-6-6" />
719
+ </svg>
720
+ </button>
721
+ {/each}
722
+ </div>
723
+
724
+ {#if openSection}
725
+ <!-- Submenu: a sibling of the panel (never clipped by its scroll box),
726
+ positioned against the wrapper. -->
727
+ <div
728
+ bind:this={subRef}
729
+ id={subId}
730
+ role="menu"
731
+ tabindex="-1"
732
+ aria-label={openSection.label}
733
+ data-side={subPos.side}
734
+ class="jatui-ambience-submenu absolute z-[51] w-64 max-w-[calc(100vw-1rem)] overflow-y-auto
735
+ rounded-box border border-base-300 bg-base-100 p-1.5 text-base-content shadow-xl
736
+ max-h-[min(22rem,calc(100vh-1rem))] menu-pop"
737
+ style="left: {subPos.left}px; top: {subPos.top}px"
738
+ onkeydown={onSubKeydown}
739
+ >
740
+ {#each openSection.scapes as s (s.id)}
741
+ {#if isAmbienceLink(s)}
742
+ {@const href = linkHref(s)}
743
+ <a
744
+ role="menuitem"
745
+ tabindex="-1"
746
+ data-ambience-sub
747
+ href={href ?? undefined}
748
+ target="_blank"
749
+ rel="noopener noreferrer"
750
+ aria-disabled={href ? undefined : 'true'}
751
+ class="{ITEM_CLASS} {href ? '' : 'pointer-events-none opacity-50'}"
752
+ onclick={onLinkClick}
753
+ >
754
+ <span class="min-w-0 flex-1">
755
+ <span class="block truncate text-sm font-medium">{s.label}</span>
756
+ {#if s.description}
757
+ <span class="text-2xs text-base-content/55 block truncate">{s.description}</span>
758
+ {/if}
759
+ <span class="sr-only">(opens in a new tab)</span>
760
+ </span>
761
+ <!-- External link, lucide external-link geometry. -->
762
+ <svg
763
+ xmlns="http://www.w3.org/2000/svg"
764
+ viewBox="0 0 24 24"
765
+ fill="none"
766
+ stroke="currentColor"
767
+ stroke-width="2"
768
+ stroke-linecap="round"
769
+ stroke-linejoin="round"
770
+ class="mt-1 size-3.5 shrink-0 text-base-content/55"
771
+ aria-hidden="true"
772
+ >
773
+ <path d="M15 3h6v6" />
774
+ <path d="M10 14 21 3" />
775
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
776
+ </svg>
777
+ </a>
778
+ {:else}
779
+ {@const isCurrent = s.id === currentId}
780
+ {@const line = isCurrent && trackLine && isPlaylistScape(s) ? trackLine : s.description}
486
781
  <button
487
782
  type="button"
488
783
  role="menuitemradio"
489
- aria-checked={s.id === currentId}
784
+ aria-checked={isCurrent}
490
785
  tabindex="-1"
491
- data-ambience-nav
492
- class="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left
493
- transition-colors duration-[var(--motion-instant,80ms)]
494
- hover:bg-base-content/10 focus-visible:bg-base-content/10
495
- focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary
496
- {s.id === currentId ? 'bg-base-content/5' : ''}"
786
+ data-ambience-sub
787
+ class="{ITEM_CLASS} {isCurrent ? 'bg-base-content/5' : ''}"
497
788
  onclick={() => selectScape(s)}
498
789
  >
499
790
  <span class="min-w-0 flex-1">
500
791
  <span class="block truncate text-sm font-medium">{s.label}</span>
501
- {#if s.description}
502
- <!-- Secondary line: for playlist scapes this is the track attribution. -->
503
- <span class="text-2xs text-base-content/55 block truncate">{s.description}</span>
792
+ {#if line}
793
+ <!-- Secondary line: a playing playlist's track attribution
794
+ (title — composer · license), else the description. -->
795
+ <span class="text-2xs text-base-content/55 block truncate" title={line}>{line}</span>
504
796
  {/if}
505
797
  </span>
506
- {#if s.id === currentId}
798
+ {#if isCurrent}
507
799
  <svg
508
800
  xmlns="http://www.w3.org/2000/svg"
509
801
  viewBox="0 0 24 24"
@@ -517,10 +809,10 @@
517
809
  <span class="mt-1 size-3.5 shrink-0" aria-hidden="true"></span>
518
810
  {/if}
519
811
  </button>
520
- {/each}
521
- </div>
522
- {/each}
523
- </div>
812
+ {/if}
813
+ {/each}
814
+ </div>
815
+ {/if}
524
816
  {/if}
525
817
 
526
818
  <!-- Live status: announces play/stop/switch without stealing focus. -->