@joewinke/jatui 0.54.1 → 0.56.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 +7 -3
- 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 +555 -107
- package/dist/components/AmbienceToggle.svelte.d.ts +92 -25
- package/dist/components/BottomSheet.svelte +21 -0
- package/dist/components/BottomSheet.svelte.d.ts +9 -0
- package/dist/components/ConfirmModal.svelte +11 -5
- package/dist/components/ConfirmModal.svelte.d.ts +9 -0
- package/dist/components/Drawer.svelte +23 -0
- package/dist/components/Drawer.svelte.d.ts +10 -0
- package/dist/components/InputDialog.svelte +6 -1
- package/dist/components/Modal.svelte +23 -8
- package/dist/components/Modal.svelte.d.ts +10 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/utils/dialogFocus.d.ts +54 -0
- package/dist/utils/dialogFocus.js +153 -0
- package/package.json +1 -1
|
@@ -6,16 +6,30 @@
|
|
|
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.
|
|
28
|
+
* - With a menu or submenu open, the mouse wheel over that surface
|
|
29
|
+
* adjusts volume (wheel up = louder, ~5% per notch; the list still
|
|
30
|
+
* scrolls while it has room) — the wheel escapes to the page the moment
|
|
31
|
+
* the pointer leaves the surface. Each surface (panel and any open
|
|
32
|
+
* submenu) carries its own non-passive listener; never window.
|
|
19
33
|
*
|
|
20
34
|
* A11y: the trigger is `aria-pressed` while playing and carries the full
|
|
21
35
|
* menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
|
|
@@ -28,58 +42,41 @@
|
|
|
28
42
|
* Token-only theming (DaisyUI + `--jatui-*` + `--motion-*`). SSR-safe: the
|
|
29
43
|
* engine is lazy and nothing here touches `window` at import.
|
|
30
44
|
*/
|
|
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
|
-
}
|
|
45
|
+
import type { AmbienceGroupDef, AmbienceItem, ScapeGroup } from '../ambience/types';
|
|
46
|
+
import {
|
|
47
|
+
AMBIENCE_DEFAULT_GROUP_LABELS,
|
|
48
|
+
AMBIENCE_DEFAULT_GROUP_ORDER,
|
|
49
|
+
groupAmbienceItems,
|
|
50
|
+
type AmbienceSection
|
|
51
|
+
} from '../ambience/groups';
|
|
52
|
+
import { trackAttribution, type PlaylistTrack } from '../ambience/scapes/playlist';
|
|
53
|
+
|
|
54
|
+
/** Default menu section order (when the host configures no `groups`). */
|
|
55
|
+
export const AMBIENCE_GROUP_ORDER: readonly ScapeGroup[] = AMBIENCE_DEFAULT_GROUP_ORDER;
|
|
56
|
+
|
|
57
|
+
/** Human labels for the library's own groups. */
|
|
58
|
+
export const GROUP_LABELS: Readonly<Record<string, string>> = AMBIENCE_DEFAULT_GROUP_LABELS;
|
|
59
|
+
|
|
60
|
+
/** One section of the menu: a top-level row and its submenu. */
|
|
61
|
+
export type AmbienceScapeGroup = AmbienceSection;
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
|
-
* Group
|
|
60
|
-
* within a
|
|
61
|
-
*
|
|
62
|
-
* first-seen order — a menu never silently drops a registered
|
|
64
|
+
* Group items for the menu: the host's section order (or the default),
|
|
65
|
+
* registration order within a section, empty sections omitted. A group
|
|
66
|
+
* missing from the order still renders, after the ordered ones, in
|
|
67
|
+
* first-seen order — a menu never silently drops a registered item.
|
|
63
68
|
*/
|
|
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;
|
|
69
|
+
export function groupScapes(
|
|
70
|
+
items: AmbienceItem[],
|
|
71
|
+
groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>
|
|
72
|
+
): AmbienceScapeGroup[] {
|
|
73
|
+
return groupAmbienceItems(items, groups);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** "Gymnopédie No. 1 — Erik Satie · Public domain": a playing track's line. */
|
|
77
|
+
export function nowPlayingLine(track: PlaylistTrack | null | undefined): string {
|
|
78
|
+
if (!track) return '';
|
|
79
|
+
return `${track.title} — ${trackAttribution(track)}`;
|
|
83
80
|
}
|
|
84
81
|
|
|
85
82
|
/**
|
|
@@ -106,17 +103,111 @@
|
|
|
106
103
|
const v = Math.min(1, Math.max(0, value));
|
|
107
104
|
return 1 + 0.06 * v;
|
|
108
105
|
}
|
|
106
|
+
|
|
107
|
+
/** Submenu width (px) — matches the `w-64` class on the submenu panel. */
|
|
108
|
+
export const SUBMENU_WIDTH = 256;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Where a submenu goes, in viewport px. Beside the menu panel on the
|
|
112
|
+
* preferred side (the side the menu already extends to), else the other
|
|
113
|
+
* side, else — a phone, no room either side — overlapping the panel just
|
|
114
|
+
* below its row, clamped inside the viewport.
|
|
115
|
+
*/
|
|
116
|
+
export function placeSubmenu(
|
|
117
|
+
row: { top: number; bottom: number },
|
|
118
|
+
panel: { left: number; right: number },
|
|
119
|
+
viewport: { width: number },
|
|
120
|
+
prefer: 'left' | 'right',
|
|
121
|
+
width = SUBMENU_WIDTH,
|
|
122
|
+
gap = 4,
|
|
123
|
+
margin = 8
|
|
124
|
+
): { x: number; y: number; side: 'left' | 'right' | 'over' } {
|
|
125
|
+
const right = panel.right + gap;
|
|
126
|
+
const left = panel.left - gap - width;
|
|
127
|
+
const fitsRight = right + width <= viewport.width - margin;
|
|
128
|
+
const fitsLeft = left >= margin;
|
|
129
|
+
const y = row.top - 6;
|
|
130
|
+
if (prefer === 'left' ? fitsLeft : fitsRight)
|
|
131
|
+
return { x: prefer === 'left' ? left : right, y, side: prefer };
|
|
132
|
+
if (prefer === 'left' ? fitsRight : fitsLeft)
|
|
133
|
+
return { x: prefer === 'left' ? right : left, y, side: prefer === 'left' ? 'right' : 'left' };
|
|
134
|
+
const x = Math.max(margin, Math.min(panel.left + 16, viewport.width - margin - width));
|
|
135
|
+
return { x, y: row.bottom + 2, side: 'over' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- wheel volume over the open surfaces (jatui-xomny) -------------------
|
|
139
|
+
|
|
140
|
+
/** One mouse-wheel notch in pixel mode — Chrome/WebKit ship ±100 per notch. */
|
|
141
|
+
export const WHEEL_NOTCH_PX = 100;
|
|
142
|
+
/** One mouse-wheel notch in line mode — Firefox ships ±3 lines per notch. */
|
|
143
|
+
export const WHEEL_NOTCH_LINES = 3;
|
|
144
|
+
/** Volume change per wheel notch, on the 0-100 slider scale. */
|
|
145
|
+
export const WHEEL_STEP_PCT = 5;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A wheel delta normalized into notch units (~1 = one physical notch):
|
|
149
|
+
* pixel deltas divide by ~100 px, Firefox's line mode by 3 lines, and a
|
|
150
|
+
* page-mode delta counts as one notch. Trackpads emit small pixel deltas
|
|
151
|
+
* continuously — those are meant to accumulate (see wheelVolumeSteps),
|
|
152
|
+
* not to step on every event.
|
|
153
|
+
*/
|
|
154
|
+
export function wheelNotches(deltaY: number, deltaMode: number): number {
|
|
155
|
+
if (deltaY === 0) return 0;
|
|
156
|
+
if (deltaMode === 1) return deltaY / WHEEL_NOTCH_LINES; // lines
|
|
157
|
+
if (deltaMode === 2) return deltaY; // one page ≈ one notch
|
|
158
|
+
return deltaY / WHEEL_NOTCH_PX; // pixels (deltaMode 0)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Accumulate a wheel delta into `acc` and return the WHOLE volume steps it
|
|
163
|
+
* adds up to, in volume terms — positive = louder (wheel up), negative =
|
|
164
|
+
* quieter (wheel down) — 0 while a partial notch is still pending. The
|
|
165
|
+
* remainder stays in `acc.value` across events, so trackpad pixel deltas
|
|
166
|
+
* accumulate and one wheel notch lands as about one step.
|
|
167
|
+
*/
|
|
168
|
+
export function wheelVolumeSteps(
|
|
169
|
+
acc: { value: number },
|
|
170
|
+
deltaY: number,
|
|
171
|
+
deltaMode: number
|
|
172
|
+
): number {
|
|
173
|
+
acc.value += wheelNotches(deltaY, deltaMode);
|
|
174
|
+
// Wheel down (positive delta) quiets — flip the sign into volume steps.
|
|
175
|
+
// +0 normalizes Math.trunc's negative zero, which toBe/toEqual treat as
|
|
176
|
+
// a different value from 0.
|
|
177
|
+
const steps = Math.trunc(-acc.value) + 0;
|
|
178
|
+
if (steps !== 0) acc.value += steps; // consume the whole steps
|
|
179
|
+
return steps;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The volume (0..1) after `steps` wheel steps: ±`stepPct` per step on the
|
|
184
|
+
* 0-100 slider scale, clamped to 0-100. The math runs on the rounded
|
|
185
|
+
* slider percent, so the result never carries float dust past what the
|
|
186
|
+
* slider itself would produce for the same value.
|
|
187
|
+
*/
|
|
188
|
+
export function volumeAfterSteps(
|
|
189
|
+
volume: number,
|
|
190
|
+
steps: number,
|
|
191
|
+
stepPct = WHEEL_STEP_PCT
|
|
192
|
+
): number {
|
|
193
|
+
const next = Math.round(volume * 100) + steps * stepPct;
|
|
194
|
+
return Math.min(100, Math.max(0, next)) / 100;
|
|
195
|
+
}
|
|
109
196
|
</script>
|
|
110
197
|
|
|
111
198
|
<script lang="ts">
|
|
199
|
+
import { untrack } from 'svelte';
|
|
112
200
|
import { getMotionContext } from '../motion/level';
|
|
201
|
+
import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
|
|
202
|
+
import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
|
|
203
|
+
import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
|
|
113
204
|
|
|
114
205
|
let {
|
|
115
206
|
engine,
|
|
116
207
|
compact = false,
|
|
117
208
|
placement = 'bottom-end'
|
|
118
209
|
}: {
|
|
119
|
-
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. */
|
|
210
|
+
/** 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
211
|
engine: Ambience;
|
|
121
212
|
/** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
|
|
122
213
|
compact?: boolean;
|
|
@@ -126,6 +217,7 @@
|
|
|
126
217
|
|
|
127
218
|
const menuId = $props.id();
|
|
128
219
|
const hintId = `${menuId}-hint`;
|
|
220
|
+
const subId = `${menuId}-sub`;
|
|
129
221
|
|
|
130
222
|
/** Long-press duration for the pointer path to the menu. */
|
|
131
223
|
const LONG_PRESS_MS = 450;
|
|
@@ -135,37 +227,64 @@
|
|
|
135
227
|
let playing = $state(false);
|
|
136
228
|
let currentId = $state<string | null>(null);
|
|
137
229
|
let volume = $state(0.6);
|
|
138
|
-
let
|
|
230
|
+
let items = $state<AmbienceItem[]>([]);
|
|
139
231
|
let open = $state(false);
|
|
140
232
|
// svelte-ignore state_referenced_locally -- only the seed; resolvePlacement() reads the live prop
|
|
141
233
|
let effectivePlacement = $state<'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'>(
|
|
142
234
|
placement
|
|
143
235
|
);
|
|
144
236
|
|
|
237
|
+
/** The section whose submenu is open (null = none), and where it sits
|
|
238
|
+
* relative to the wrapper (the submenu's offset parent). */
|
|
239
|
+
let subGroup = $state<string | null>(null);
|
|
240
|
+
let subPos = $state<{ left: number; top: number; side: 'left' | 'right' | 'over' }>({
|
|
241
|
+
left: 0,
|
|
242
|
+
top: 0,
|
|
243
|
+
side: 'right'
|
|
244
|
+
});
|
|
245
|
+
|
|
145
246
|
/** Set by a long-press so the click that follows the release is swallowed —
|
|
146
247
|
* but only the mouse click (detail > 0), never a keyboard activation. */
|
|
147
248
|
let longPressed = false;
|
|
148
249
|
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
149
250
|
let pressStart: { x: number; y: number } | null = null;
|
|
150
251
|
|
|
252
|
+
let wrapRef = $state<HTMLDivElement | null>(null);
|
|
151
253
|
let btnRef = $state<HTMLButtonElement | null>(null);
|
|
152
254
|
let panelRef = $state<HTMLDivElement | null>(null);
|
|
255
|
+
let subRef = $state<HTMLDivElement | null>(null);
|
|
153
256
|
let glyphRef = $state<HTMLSpanElement | null>(null);
|
|
257
|
+
/** The volume section (header + slider) at the top of the panel. */
|
|
258
|
+
let volumeRef = $state<HTMLDivElement | null>(null);
|
|
154
259
|
|
|
155
260
|
let playError = $state(false);
|
|
261
|
+
let track = $state<PlaylistState['track']>(null);
|
|
156
262
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
263
|
+
// Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
|
|
264
|
+
// the pending partial notch between wheel events so trackpad pixel deltas
|
|
265
|
+
// accumulate into whole steps. Reset every time the menu opens; shared by
|
|
266
|
+
// the panel and its submenus for continuity.
|
|
267
|
+
const wheelAcc = { value: 0 };
|
|
268
|
+
|
|
269
|
+
const scapes = $derived(items.filter((i): i is Scape => !isAmbienceLink(i)));
|
|
270
|
+
const currentItem = $derived(items.find((s) => s.id === currentId) ?? null);
|
|
271
|
+
const currentLabel = $derived(currentItem?.label ?? currentId);
|
|
272
|
+
const groups = $derived(groupScapes(items, engine.groups?.()));
|
|
273
|
+
const openSection = $derived(groups.find((g) => g.group === subGroup) ?? null);
|
|
274
|
+
const trackLine = $derived(playing ? nowPlayingLine(track) : '');
|
|
160
275
|
const liveLabel = $derived(
|
|
161
276
|
playError ? PLAY_ERROR_LABEL : ambienceLiveLabel(currentLabel, playing)
|
|
162
277
|
);
|
|
163
|
-
const disabled = $derived(
|
|
278
|
+
const disabled = $derived(items.length === 0);
|
|
164
279
|
const sliderValue = $derived(Math.round(volume * 100));
|
|
165
280
|
|
|
166
281
|
const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
|
|
167
282
|
const ICON_SIZE = $derived(compact ? 18 : 20);
|
|
168
283
|
|
|
284
|
+
function snapshot(): AmbienceItem[] {
|
|
285
|
+
return engine.items?.() ?? engine.list();
|
|
286
|
+
}
|
|
287
|
+
|
|
169
288
|
// Engine state → component state. subscribe() fires once immediately, so
|
|
170
289
|
// this also fills the initial (persisted-preselect) state at mount.
|
|
171
290
|
$effect(() => {
|
|
@@ -173,12 +292,24 @@
|
|
|
173
292
|
playing = s.playing;
|
|
174
293
|
currentId = s.currentId;
|
|
175
294
|
volume = s.volume;
|
|
176
|
-
|
|
295
|
+
items = snapshot();
|
|
177
296
|
// A successful play (state flips to playing) clears the failure.
|
|
178
297
|
if (s.playing) playError = false;
|
|
179
298
|
});
|
|
180
299
|
});
|
|
181
300
|
|
|
301
|
+
// The current scape is a playlist → follow its track for the attribution line.
|
|
302
|
+
$effect(() => {
|
|
303
|
+
const item = currentItem;
|
|
304
|
+
if (!isPlaylistScape(item)) {
|
|
305
|
+
track = null;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
return item.subscribe((s) => {
|
|
309
|
+
track = s.status === 'playing' ? s.track : null;
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
182
313
|
// Breathing glyph: follow the engine's shared breath clock while playing,
|
|
183
314
|
// and only where ambient motion is allowed (getMotionContext().ambient is
|
|
184
315
|
// false under prefers-reduced-motion AND under data-motion="calm"). The
|
|
@@ -201,8 +332,10 @@
|
|
|
201
332
|
});
|
|
202
333
|
|
|
203
334
|
// While open: click-outside dismissal (capture phase, so a press that
|
|
204
|
-
// begins outside never reaches the page first)
|
|
205
|
-
//
|
|
335
|
+
// begins outside never reaches the page first), the non-passive wheel
|
|
336
|
+
// listener on the PANEL element (never window — see onSurfaceWheel), and
|
|
337
|
+
// APG focus movement into the menu — focus the first nav item once the
|
|
338
|
+
// panel exists. A resize closes an open submenu (its position is stale).
|
|
206
339
|
$effect(() => {
|
|
207
340
|
if (!open) return;
|
|
208
341
|
const panel = panelRef;
|
|
@@ -210,17 +343,60 @@
|
|
|
210
343
|
navItems()[0]?.focus();
|
|
211
344
|
const onDocPointerDown = (e: PointerEvent) => {
|
|
212
345
|
const t = e.target as Node | null;
|
|
213
|
-
if (btnRef?.contains(t) || panelRef?.contains(t)) return;
|
|
346
|
+
if (btnRef?.contains(t) || panelRef?.contains(t) || subRef?.contains(t)) return;
|
|
214
347
|
closeMenu(false);
|
|
215
348
|
};
|
|
349
|
+
const onResize = () => closeSubmenu(false);
|
|
216
350
|
document.addEventListener('pointerdown', onDocPointerDown, true);
|
|
217
|
-
|
|
351
|
+
window.addEventListener('resize', onResize);
|
|
352
|
+
panel.addEventListener('wheel', onPanelWheel, { passive: false });
|
|
353
|
+
return () => {
|
|
354
|
+
document.removeEventListener('pointerdown', onDocPointerDown, true);
|
|
355
|
+
window.removeEventListener('resize', onResize);
|
|
356
|
+
panel.removeEventListener('wheel', onPanelWheel);
|
|
357
|
+
};
|
|
218
358
|
});
|
|
219
359
|
|
|
360
|
+
// Non-passive wheel on any open submenu too (jatui-xomny): over the
|
|
361
|
+
// submenu the wheel is volume with the same rule — a list that can still
|
|
362
|
+
// scroll keeps scrolling. The submenu is a SIBLING of the panel (never
|
|
363
|
+
// clipped by its scroll box), so it carries its own listener.
|
|
364
|
+
$effect(() => {
|
|
365
|
+
const sub = subRef;
|
|
366
|
+
if (!open || !sub || subGroup === null) return;
|
|
367
|
+
const onSubWheel = (e: WheelEvent) => onSurfaceWheel(e, sub, false);
|
|
368
|
+
sub.addEventListener('wheel', onSubWheel, { passive: false });
|
|
369
|
+
return () => sub.removeEventListener('wheel', onSubWheel);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Keep an open submenu inside the viewport vertically once its real
|
|
373
|
+
// height is known.
|
|
374
|
+
$effect(() => {
|
|
375
|
+
const sub = subRef;
|
|
376
|
+
const wrap = wrapRef;
|
|
377
|
+
if (!sub || !wrap || !subGroup) return;
|
|
378
|
+
const r = sub.getBoundingClientRect();
|
|
379
|
+
const overflow = r.bottom - (window.innerHeight - 8);
|
|
380
|
+
if (overflow > 0) {
|
|
381
|
+
const wrapTop = wrap.getBoundingClientRect().top;
|
|
382
|
+
const pos = untrack(() => subPos);
|
|
383
|
+
subPos = { ...pos, top: Math.max(8 - wrapTop, pos.top - overflow) };
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/** Top-level roving set: the volume slider and the section rows. */
|
|
220
388
|
function navItems(): HTMLElement[] {
|
|
221
389
|
return panelRef ? Array.from(panelRef.querySelectorAll<HTMLElement>('[data-ambience-nav]')) : [];
|
|
222
390
|
}
|
|
223
391
|
|
|
392
|
+
function subItems(): HTMLElement[] {
|
|
393
|
+
return subRef ? Array.from(subRef.querySelectorAll<HTMLElement>('[data-ambience-sub]')) : [];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function rowFor(group: string): HTMLElement | null {
|
|
397
|
+
return panelRef?.querySelector<HTMLElement>(`[data-ambience-group="${CSS.escape(group)}"]`) ?? null;
|
|
398
|
+
}
|
|
399
|
+
|
|
224
400
|
function resolvePlacement(): 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' {
|
|
225
401
|
const el = btnRef;
|
|
226
402
|
if (!el || typeof window === 'undefined') return placement;
|
|
@@ -236,17 +412,57 @@
|
|
|
236
412
|
|
|
237
413
|
function openMenu() {
|
|
238
414
|
if (disabled) return;
|
|
239
|
-
|
|
415
|
+
items = snapshot(); // freshest registry snapshot for the menu
|
|
240
416
|
effectivePlacement = resolvePlacement();
|
|
417
|
+
subGroup = null;
|
|
418
|
+
wheelAcc.value = 0; // a fresh menu starts from no pending wheel delta
|
|
241
419
|
open = true;
|
|
242
420
|
}
|
|
243
421
|
|
|
244
422
|
function closeMenu(refocus = true) {
|
|
245
423
|
if (!open) return;
|
|
246
424
|
open = false;
|
|
425
|
+
subGroup = null;
|
|
247
426
|
if (refocus) btnRef?.focus();
|
|
248
427
|
}
|
|
249
428
|
|
|
429
|
+
function openSubmenu(group: string, focusFirst: boolean) {
|
|
430
|
+
const row = rowFor(group);
|
|
431
|
+
const wrap = wrapRef;
|
|
432
|
+
if (!row || !wrap || !panelRef) return;
|
|
433
|
+
const rr = row.getBoundingClientRect();
|
|
434
|
+
const pr = panelRef.getBoundingClientRect();
|
|
435
|
+
const wr = wrap.getBoundingClientRect();
|
|
436
|
+
const prefer = effectivePlacement.endsWith('end') ? 'left' : 'right';
|
|
437
|
+
const p = placeSubmenu(rr, pr, { width: window.innerWidth }, prefer);
|
|
438
|
+
subPos = { left: p.x - wr.left, top: p.y - wr.top, side: p.side };
|
|
439
|
+
subGroup = group;
|
|
440
|
+
if (focusFirst) queueMicrotask(() => subItems()[0]?.focus());
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function closeSubmenu(refocusRow = true) {
|
|
444
|
+
const g = subGroup;
|
|
445
|
+
if (g === null) return;
|
|
446
|
+
subGroup = null;
|
|
447
|
+
if (refocusRow) rowFor(g)?.focus();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function onRowClick(e: MouseEvent, group: string) {
|
|
451
|
+
// Keyboard activation (Enter / Space → detail 0) always opens and enters;
|
|
452
|
+
// a mouse click on the open row's own submenu toggles it shut.
|
|
453
|
+
if (e.detail > 0 && subGroup === group) {
|
|
454
|
+
closeSubmenu(true);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
openSubmenu(group, true);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function onRowPointerEnter(e: PointerEvent, group: string) {
|
|
461
|
+
// Hover opens (mouse/pen only — a touch tap is a click). Focus stays put.
|
|
462
|
+
if (e.pointerType === 'touch' || subGroup === group) return;
|
|
463
|
+
openSubmenu(group, false);
|
|
464
|
+
}
|
|
465
|
+
|
|
250
466
|
function togglePlay(e: MouseEvent) {
|
|
251
467
|
// A long-press already opened the menu — don't also fire the toggle
|
|
252
468
|
// (keyboard activations, detail 0, are never suppressed).
|
|
@@ -255,13 +471,22 @@
|
|
|
255
471
|
return;
|
|
256
472
|
}
|
|
257
473
|
longPressed = false;
|
|
258
|
-
if (open)
|
|
474
|
+
if (open) {
|
|
475
|
+
closeMenu(false);
|
|
476
|
+
if (!engine.list().length) return;
|
|
477
|
+
}
|
|
259
478
|
if (playing) {
|
|
260
479
|
engine.stop();
|
|
261
480
|
return;
|
|
262
481
|
}
|
|
263
|
-
const
|
|
264
|
-
|
|
482
|
+
const playable = engine.list();
|
|
483
|
+
const last = engine.state.currentId;
|
|
484
|
+
const id = last && playable.some((s) => s.id === last) ? last : playable[0]?.id;
|
|
485
|
+
if (!id) {
|
|
486
|
+
// Links only (e.g. a radio-only shell): the trigger opens the menu.
|
|
487
|
+
openMenu();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
265
490
|
attemptPlay(id);
|
|
266
491
|
}
|
|
267
492
|
|
|
@@ -286,11 +511,84 @@
|
|
|
286
511
|
attemptPlay(scape.id);
|
|
287
512
|
}
|
|
288
513
|
|
|
514
|
+
function onLinkClick() {
|
|
515
|
+
// The anchor's own default action opens the new tab; just get out of the way.
|
|
516
|
+
closeMenu(false);
|
|
517
|
+
}
|
|
518
|
+
|
|
289
519
|
function onVolumeInput(e: Event) {
|
|
290
520
|
const v = Number((e.currentTarget as HTMLInputElement).value);
|
|
291
521
|
engine.setVolume(v / 100);
|
|
292
522
|
}
|
|
293
523
|
|
|
524
|
+
// --- wheel volume over the open surfaces (jatui-xomny) -------------------
|
|
525
|
+
// While the menu (or one of its submenus) is open the wheel adjusts volume
|
|
526
|
+
// instead of scrolling the page — EXCEPT over a scrollable list that can
|
|
527
|
+
// still move in the wheel's direction, where the wheel stays a scroll. The
|
|
528
|
+
// listeners are non-passive on the surface elements (the panel and any
|
|
529
|
+
// open submenu — the submenu is a SIBLING of the panel, so it needs its
|
|
530
|
+
// own) and are only attached while open, so a wheel anywhere else scrolls
|
|
531
|
+
// the page exactly as before.
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The elements a native wheel scroll could move for a target inside
|
|
535
|
+
* `surface`: every HTMLElement from the target up to and including the
|
|
536
|
+
* surface itself (the panel and each submenu are their own
|
|
537
|
+
* `overflow-y-auto` scroll containers).
|
|
538
|
+
*/
|
|
539
|
+
function scrollablesBetween(target: Node, surface: HTMLElement): HTMLElement[] {
|
|
540
|
+
const out: HTMLElement[] = [];
|
|
541
|
+
let el: Node | null = target;
|
|
542
|
+
while (el && el !== surface) {
|
|
543
|
+
if (el instanceof HTMLElement) out.push(el);
|
|
544
|
+
el = el.parentNode;
|
|
545
|
+
}
|
|
546
|
+
out.push(surface);
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Can `el` still move in the wheel's direction? (1px epsilon: a list
|
|
551
|
+
* sitting at its edge with sub-pixel scrollTop is at the end.) */
|
|
552
|
+
function canScrollFurther(el: HTMLElement, down: boolean): boolean {
|
|
553
|
+
const style = getComputedStyle(el);
|
|
554
|
+
const scrolls =
|
|
555
|
+
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
|
556
|
+
el.scrollHeight > el.clientHeight;
|
|
557
|
+
if (!scrolls) return false;
|
|
558
|
+
return down ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollTop > 1;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The shared wheel handler for one open surface. `hasVolume` marks the
|
|
563
|
+
* panel — the surface that owns the volume section, where the wheel is
|
|
564
|
+
* ALWAYS volume (it is a control, not a list). Over the list rows the
|
|
565
|
+
* wheel stays a scroll while some scrollable under the pointer can still
|
|
566
|
+
* move that way; past the end (or when the list is shorter than the
|
|
567
|
+
* surface) it steps the volume instead of letting the scroll escape to
|
|
568
|
+
* the page.
|
|
569
|
+
*/
|
|
570
|
+
function onSurfaceWheel(e: WheelEvent, surface: HTMLElement, hasVolume: boolean) {
|
|
571
|
+
const target = e.target as Node | null;
|
|
572
|
+
if (!target || !surface.contains(target)) return;
|
|
573
|
+
// Vertical wheel only — shift+wheel is horizontal scrolling.
|
|
574
|
+
if (e.deltaY === 0) return;
|
|
575
|
+
const down = e.deltaY > 0;
|
|
576
|
+
const overVolume = hasVolume && (volumeRef?.contains(target) ?? false);
|
|
577
|
+
const hijack =
|
|
578
|
+
overVolume || !scrollablesBetween(target, surface).some((el) => canScrollFurther(el, down));
|
|
579
|
+
if (!hijack) return; // native scroll — never preventDefault, never step
|
|
580
|
+
|
|
581
|
+
e.preventDefault();
|
|
582
|
+
const steps = wheelVolumeSteps(wheelAcc, e.deltaY, e.deltaMode);
|
|
583
|
+
if (steps !== 0) engine.setVolume(volumeAfterSteps(volume, steps));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function onPanelWheel(e: WheelEvent) {
|
|
587
|
+
const panel = panelRef;
|
|
588
|
+
if (!panel) return;
|
|
589
|
+
onSurfaceWheel(e, panel, true);
|
|
590
|
+
}
|
|
591
|
+
|
|
294
592
|
// --- pointer path to the menu: press-and-hold ----------------------------
|
|
295
593
|
function onPointerDown(e: PointerEvent) {
|
|
296
594
|
longPressed = false;
|
|
@@ -331,11 +629,25 @@
|
|
|
331
629
|
}
|
|
332
630
|
}
|
|
333
631
|
|
|
334
|
-
|
|
632
|
+
/** APG roving: next index for an arrow / Home / End key, wrapping. */
|
|
633
|
+
function rove(list: HTMLElement[], key: string): void {
|
|
634
|
+
if (!list.length) return;
|
|
635
|
+
const current = list.indexOf(document.activeElement as HTMLElement);
|
|
636
|
+
let next: number;
|
|
637
|
+
if (key === 'Home') next = 0;
|
|
638
|
+
else if (key === 'End') next = list.length - 1;
|
|
639
|
+
else if (current === -1) next = key === 'ArrowUp' ? list.length - 1 : 0;
|
|
640
|
+
else if (key === 'ArrowDown') next = (current + 1) % list.length;
|
|
641
|
+
else next = (current - 1 + list.length) % list.length;
|
|
642
|
+
list[next]?.focus();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --- top-level menu keyboard ---------------------------------------------
|
|
335
646
|
function onMenuKeydown(e: KeyboardEvent) {
|
|
336
647
|
if (e.key === 'Escape') {
|
|
337
648
|
e.preventDefault();
|
|
338
|
-
|
|
649
|
+
if (subGroup !== null) closeSubmenu(true);
|
|
650
|
+
else closeMenu(true);
|
|
339
651
|
return;
|
|
340
652
|
}
|
|
341
653
|
if (e.key === 'Tab') {
|
|
@@ -345,18 +657,56 @@
|
|
|
345
657
|
}
|
|
346
658
|
// The volume slider owns its own arrows/Home/End.
|
|
347
659
|
if (e.target instanceof HTMLInputElement) return;
|
|
660
|
+
const group = (e.target as HTMLElement | null)?.dataset?.ambienceGroup;
|
|
661
|
+
// Enter / Space reach onRowClick as a native click (detail 0).
|
|
662
|
+
if (group && e.key === 'ArrowRight') {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
openSubmenu(group, true);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (e.key === 'ArrowLeft' && subGroup !== null) {
|
|
668
|
+
e.preventDefault();
|
|
669
|
+
closeSubmenu(true);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
348
672
|
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
349
673
|
e.preventDefault();
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
674
|
+
// Moving between rows closes a hover-opened submenu.
|
|
675
|
+
if (subGroup !== null) subGroup = null;
|
|
676
|
+
rove(navItems(), e.key);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// --- submenu keyboard -----------------------------------------------------
|
|
680
|
+
function onSubKeydown(e: KeyboardEvent) {
|
|
681
|
+
if (e.key === 'Escape' || e.key === 'ArrowLeft') {
|
|
682
|
+
e.preventDefault();
|
|
683
|
+
closeSubmenu(true);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (e.key === 'Tab') {
|
|
687
|
+
closeMenu(false);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (e.key === ' ' && e.target instanceof HTMLAnchorElement) {
|
|
691
|
+
// Links activate on Enter natively; Space is the menuitem convention.
|
|
692
|
+
e.preventDefault();
|
|
693
|
+
e.target.click();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
697
|
+
e.preventDefault();
|
|
698
|
+
rove(subItems(), e.key);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** The secondary line under a section row: what's current in it. */
|
|
702
|
+
function rowDetail(g: AmbienceScapeGroup): string {
|
|
703
|
+
if (!currentItem || currentItem.group !== g.group || isAmbienceLink(currentItem)) return '';
|
|
704
|
+
if (trackLine && isPlaylistScape(currentItem)) return `${currentItem.label} · ${trackLine}`;
|
|
705
|
+
return playing ? `${currentItem.label} · playing` : currentItem.label;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function linkHref(item: AmbienceLink): string | null {
|
|
709
|
+
return safeAmbienceHref(item.href);
|
|
360
710
|
}
|
|
361
711
|
|
|
362
712
|
const PLACEMENT_CLASS = {
|
|
@@ -365,9 +715,14 @@
|
|
|
365
715
|
'top-start': 'bottom-full left-0 mb-2',
|
|
366
716
|
'top-end': 'bottom-full right-0 mb-2'
|
|
367
717
|
} as const;
|
|
718
|
+
|
|
719
|
+
const ITEM_CLASS = `flex w-full items-start gap-2 rounded px-2 py-1.5 text-left
|
|
720
|
+
transition-colors duration-[var(--motion-instant,80ms)]
|
|
721
|
+
hover:bg-base-content/10 focus-visible:bg-base-content/10
|
|
722
|
+
focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary`;
|
|
368
723
|
</script>
|
|
369
724
|
|
|
370
|
-
<div class="relative inline-flex shrink-0">
|
|
725
|
+
<div bind:this={wrapRef} class="relative inline-flex shrink-0">
|
|
371
726
|
<button
|
|
372
727
|
bind:this={btnRef}
|
|
373
728
|
type="button"
|
|
@@ -455,9 +810,10 @@
|
|
|
455
810
|
max-h-[min(24rem,calc(100vh-1rem))] menu-pop
|
|
456
811
|
{PLACEMENT_CLASS[effectivePlacement]}"
|
|
457
812
|
onkeydown={onMenuKeydown}
|
|
813
|
+
onscroll={() => closeSubmenu(false)}
|
|
458
814
|
>
|
|
459
815
|
<!-- Volume: a real slider in the roving set; it owns its own arrows. -->
|
|
460
|
-
<div role="none" class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
|
|
816
|
+
<div role="none" bind:this={volumeRef} class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
|
|
461
817
|
<div class="mb-1.5 flex items-center justify-between">
|
|
462
818
|
<span class="text-jatui-label text-base-content/60">Volume</span>
|
|
463
819
|
<span class="text-2xs text-base-content/50 tabular-nums">{sliderValue}%</span>
|
|
@@ -478,32 +834,124 @@
|
|
|
478
834
|
</div>
|
|
479
835
|
|
|
480
836
|
{#each groups as g (g.group)}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
837
|
+
{@const detail = rowDetail(g)}
|
|
838
|
+
{@const hasCurrent = !!currentItem && currentItem.group === g.group}
|
|
839
|
+
<button
|
|
840
|
+
type="button"
|
|
841
|
+
role="menuitem"
|
|
842
|
+
tabindex="-1"
|
|
843
|
+
data-ambience-nav
|
|
844
|
+
data-ambience-group={g.group}
|
|
845
|
+
aria-haspopup="menu"
|
|
846
|
+
aria-expanded={subGroup === g.group}
|
|
847
|
+
aria-controls={subGroup === g.group ? subId : undefined}
|
|
848
|
+
class="{ITEM_CLASS} items-center {subGroup === g.group ? 'bg-base-content/10' : ''}"
|
|
849
|
+
onclick={(e) => onRowClick(e, g.group)}
|
|
850
|
+
onpointerenter={(e) => onRowPointerEnter(e, g.group)}
|
|
851
|
+
>
|
|
852
|
+
<span class="min-w-0 flex-1">
|
|
853
|
+
<span class="block truncate text-sm font-medium">{g.label}</span>
|
|
854
|
+
{#if detail}
|
|
855
|
+
<span class="text-2xs text-base-content/55 block truncate">{detail}</span>
|
|
856
|
+
{/if}
|
|
857
|
+
</span>
|
|
858
|
+
{#if hasCurrent}
|
|
859
|
+
<!-- Current-scape marker: the section that holds it. -->
|
|
860
|
+
<span class="size-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true"></span>
|
|
861
|
+
<span class="sr-only">(current)</span>
|
|
862
|
+
{/if}
|
|
863
|
+
<svg
|
|
864
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
865
|
+
viewBox="0 0 24 24"
|
|
866
|
+
fill="none"
|
|
867
|
+
stroke="currentColor"
|
|
868
|
+
stroke-width="2"
|
|
869
|
+
stroke-linecap="round"
|
|
870
|
+
stroke-linejoin="round"
|
|
871
|
+
class="size-4 shrink-0 text-base-content/50 {subPos.side === 'left' && subGroup === g.group ? 'rotate-180' : ''}"
|
|
872
|
+
aria-hidden="true"
|
|
873
|
+
>
|
|
874
|
+
<path d="m9 18 6-6-6-6" />
|
|
875
|
+
</svg>
|
|
876
|
+
</button>
|
|
877
|
+
{/each}
|
|
878
|
+
</div>
|
|
879
|
+
|
|
880
|
+
{#if openSection}
|
|
881
|
+
<!-- Submenu: a sibling of the panel (never clipped by its scroll box),
|
|
882
|
+
positioned against the wrapper. -->
|
|
883
|
+
<div
|
|
884
|
+
bind:this={subRef}
|
|
885
|
+
id={subId}
|
|
886
|
+
role="menu"
|
|
887
|
+
tabindex="-1"
|
|
888
|
+
aria-label={openSection.label}
|
|
889
|
+
data-side={subPos.side}
|
|
890
|
+
class="jatui-ambience-submenu absolute z-[51] w-64 max-w-[calc(100vw-1rem)] overflow-y-auto
|
|
891
|
+
rounded-box border border-base-300 bg-base-100 p-1.5 text-base-content shadow-xl
|
|
892
|
+
max-h-[min(22rem,calc(100vh-1rem))] menu-pop"
|
|
893
|
+
style="left: {subPos.left}px; top: {subPos.top}px"
|
|
894
|
+
onkeydown={onSubKeydown}
|
|
895
|
+
>
|
|
896
|
+
{#each openSection.scapes as s (s.id)}
|
|
897
|
+
{#if isAmbienceLink(s)}
|
|
898
|
+
{@const href = linkHref(s)}
|
|
899
|
+
<a
|
|
900
|
+
role="menuitem"
|
|
901
|
+
tabindex="-1"
|
|
902
|
+
data-ambience-sub
|
|
903
|
+
href={href ?? undefined}
|
|
904
|
+
target="_blank"
|
|
905
|
+
rel="noopener noreferrer"
|
|
906
|
+
aria-disabled={href ? undefined : 'true'}
|
|
907
|
+
class="{ITEM_CLASS} {href ? '' : 'pointer-events-none opacity-50'}"
|
|
908
|
+
onclick={onLinkClick}
|
|
909
|
+
>
|
|
910
|
+
<span class="min-w-0 flex-1">
|
|
911
|
+
<span class="block truncate text-sm font-medium">{s.label}</span>
|
|
912
|
+
{#if s.description}
|
|
913
|
+
<span class="text-2xs text-base-content/55 block truncate">{s.description}</span>
|
|
914
|
+
{/if}
|
|
915
|
+
<span class="sr-only">(opens in a new tab)</span>
|
|
916
|
+
</span>
|
|
917
|
+
<!-- External link, lucide external-link geometry. -->
|
|
918
|
+
<svg
|
|
919
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
920
|
+
viewBox="0 0 24 24"
|
|
921
|
+
fill="none"
|
|
922
|
+
stroke="currentColor"
|
|
923
|
+
stroke-width="2"
|
|
924
|
+
stroke-linecap="round"
|
|
925
|
+
stroke-linejoin="round"
|
|
926
|
+
class="mt-1 size-3.5 shrink-0 text-base-content/55"
|
|
927
|
+
aria-hidden="true"
|
|
928
|
+
>
|
|
929
|
+
<path d="M15 3h6v6" />
|
|
930
|
+
<path d="M10 14 21 3" />
|
|
931
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
932
|
+
</svg>
|
|
933
|
+
</a>
|
|
934
|
+
{:else}
|
|
935
|
+
{@const isCurrent = s.id === currentId}
|
|
936
|
+
{@const line = isCurrent && trackLine && isPlaylistScape(s) ? trackLine : s.description}
|
|
486
937
|
<button
|
|
487
938
|
type="button"
|
|
488
939
|
role="menuitemradio"
|
|
489
|
-
aria-checked={
|
|
940
|
+
aria-checked={isCurrent}
|
|
490
941
|
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' : ''}"
|
|
942
|
+
data-ambience-sub
|
|
943
|
+
class="{ITEM_CLASS} {isCurrent ? 'bg-base-content/5' : ''}"
|
|
497
944
|
onclick={() => selectScape(s)}
|
|
498
945
|
>
|
|
499
946
|
<span class="min-w-0 flex-1">
|
|
500
947
|
<span class="block truncate text-sm font-medium">{s.label}</span>
|
|
501
|
-
{#if
|
|
502
|
-
<!-- Secondary line:
|
|
503
|
-
|
|
948
|
+
{#if line}
|
|
949
|
+
<!-- Secondary line: a playing playlist's track attribution
|
|
950
|
+
(title — composer · license), else the description. -->
|
|
951
|
+
<span class="text-2xs text-base-content/55 block truncate" title={line}>{line}</span>
|
|
504
952
|
{/if}
|
|
505
953
|
</span>
|
|
506
|
-
{#if
|
|
954
|
+
{#if isCurrent}
|
|
507
955
|
<svg
|
|
508
956
|
xmlns="http://www.w3.org/2000/svg"
|
|
509
957
|
viewBox="0 0 24 24"
|
|
@@ -517,10 +965,10 @@
|
|
|
517
965
|
<span class="mt-1 size-3.5 shrink-0" aria-hidden="true"></span>
|
|
518
966
|
{/if}
|
|
519
967
|
</button>
|
|
520
|
-
{/
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
968
|
+
{/if}
|
|
969
|
+
{/each}
|
|
970
|
+
</div>
|
|
971
|
+
{/if}
|
|
524
972
|
{/if}
|
|
525
973
|
|
|
526
974
|
<!-- Live status: announces play/stop/switch without stealing focus. -->
|