@joewinke/jatui 0.54.1 → 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.
- package/CLAUDE.md +4 -0
- package/README.md +3 -1
- package/dist/ambience/default.d.ts +3 -17
- package/dist/ambience/default.js +3 -2
- package/dist/ambience/engine.js +14 -3
- package/dist/ambience/groups.d.ts +45 -0
- package/dist/ambience/groups.js +98 -0
- package/dist/ambience/index.d.ts +4 -2
- package/dist/ambience/index.js +3 -1
- package/dist/ambience/registry.d.ts +6 -6
- package/dist/ambience/registry.js +31 -8
- package/dist/ambience/scapes/playlist.d.ts +16 -3
- package/dist/ambience/scapes/playlist.js +22 -1
- package/dist/ambience/types.d.ts +47 -3
- package/dist/components/AmbienceToggle.svelte +398 -106
- package/dist/components/AmbienceToggle.svelte.d.ts +56 -25
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
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).
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
60
|
-
* within a
|
|
61
|
-
*
|
|
62
|
-
* first-seen order — a menu never silently drops a registered
|
|
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(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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={
|
|
784
|
+
aria-checked={isCurrent}
|
|
490
785
|
tabindex="-1"
|
|
491
|
-
data-ambience-
|
|
492
|
-
class="
|
|
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
|
|
502
|
-
<!-- Secondary line:
|
|
503
|
-
|
|
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
|
|
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
|
-
{/
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
812
|
+
{/if}
|
|
813
|
+
{/each}
|
|
814
|
+
</div>
|
|
815
|
+
{/if}
|
|
524
816
|
{/if}
|
|
525
817
|
|
|
526
818
|
<!-- Live status: announces play/stop/switch without stealing focus. -->
|