@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
|
@@ -5,16 +5,30 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Interaction contract:
|
|
7
7
|
* - Click (or Space/Enter) = play / stop the last scape. A gesture-gated
|
|
8
|
-
* engine call, made synchronously inside the click handler.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* engine call, made synchronously inside the click handler. With no
|
|
9
|
+
* playable scape registered (links only) the click opens the menu.
|
|
10
|
+
* - The menu (menu button pattern) opens with ArrowDown/ArrowUp, a
|
|
11
|
+
* press-and-hold (~450 ms) or a right-click (Android/Chrome fire
|
|
11
12
|
* contextmenu on touch holds; iOS may cancel the pointer instead —
|
|
12
|
-
* the keyboard path always works).
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* the keyboard path always works). Top level: a volume slider wired to
|
|
14
|
+
* `engine.setVolume`, then one row per section — Radio / Focus /
|
|
15
|
+
* Noise / Meditation … in the host's order (`AmbienceOptions.groups`,
|
|
16
|
+
* jatui-mdq2p) — each opening a submenu of its items. Right / Enter /
|
|
17
|
+
* Space open a submenu and move focus into it; Left / Escape close it
|
|
18
|
+
* and return focus to its row (the ContextMenu submenu pattern); a
|
|
19
|
+
* mouse hover opens a row's submenu without moving focus.
|
|
20
|
+
* - Scapes are `menuitemradio` (current one checked; its section row
|
|
21
|
+
* carries a dot and the scape's name). Link items (radio stations) are
|
|
22
|
+
* real `<a target="_blank">` menu items with an external-link glyph:
|
|
23
|
+
* the station's own site or player opens in a new tab. Nothing is
|
|
24
|
+
* relayed — we never proxy a third-party stream.
|
|
25
|
+
* - A playing playlist shows its current track's attribution (title,
|
|
26
|
+
* composer, license) under the playlist item and its section row.
|
|
27
|
+
* - With a menu or submenu open, the mouse wheel over that surface
|
|
28
|
+
* adjusts volume (wheel up = louder, ~5% per notch; the list still
|
|
29
|
+
* scrolls while it has room) — the wheel escapes to the page the moment
|
|
30
|
+
* the pointer leaves the surface. Each surface (panel and any open
|
|
31
|
+
* submenu) carries its own non-passive listener; never window.
|
|
18
32
|
*
|
|
19
33
|
* A11y: the trigger is `aria-pressed` while playing and carries the full
|
|
20
34
|
* menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
|
|
@@ -27,24 +41,24 @@
|
|
|
27
41
|
* Token-only theming (DaisyUI + `--jatui-*` + `--motion-*`). SSR-safe: the
|
|
28
42
|
* engine is lazy and nothing here touches `window` at import.
|
|
29
43
|
*/
|
|
30
|
-
import type {
|
|
31
|
-
|
|
44
|
+
import type { AmbienceGroupDef, AmbienceItem, ScapeGroup } from '../ambience/types';
|
|
45
|
+
import { type AmbienceSection } from '../ambience/groups';
|
|
46
|
+
import { type PlaylistTrack } from '../ambience/scapes/playlist';
|
|
47
|
+
/** Default menu section order (when the host configures no `groups`). */
|
|
32
48
|
export declare const AMBIENCE_GROUP_ORDER: readonly ScapeGroup[];
|
|
33
|
-
/** Human labels for the
|
|
34
|
-
export declare const GROUP_LABELS: Readonly<Record<
|
|
35
|
-
/** One
|
|
36
|
-
export
|
|
37
|
-
group: ScapeGroup;
|
|
38
|
-
label: string;
|
|
39
|
-
scapes: Scape[];
|
|
40
|
-
}
|
|
49
|
+
/** Human labels for the library's own groups. */
|
|
50
|
+
export declare const GROUP_LABELS: Readonly<Record<string, string>>;
|
|
51
|
+
/** One section of the menu: a top-level row and its submenu. */
|
|
52
|
+
export type AmbienceScapeGroup = AmbienceSection;
|
|
41
53
|
/**
|
|
42
|
-
* Group
|
|
43
|
-
* within a
|
|
44
|
-
*
|
|
45
|
-
* first-seen order — a menu never silently drops a registered
|
|
54
|
+
* Group items for the menu: the host's section order (or the default),
|
|
55
|
+
* registration order within a section, empty sections omitted. A group
|
|
56
|
+
* missing from the order still renders, after the ordered ones, in
|
|
57
|
+
* first-seen order — a menu never silently drops a registered item.
|
|
46
58
|
*/
|
|
47
|
-
export declare function groupScapes(
|
|
59
|
+
export declare function groupScapes(items: AmbienceItem[], groups?: ReadonlyArray<ScapeGroup | AmbienceGroupDef>): AmbienceScapeGroup[];
|
|
60
|
+
/** "Gymnopédie No. 1 — Erik Satie · Public domain": a playing track's line. */
|
|
61
|
+
export declare function nowPlayingLine(track: PlaylistTrack | null | undefined): string;
|
|
48
62
|
/**
|
|
49
63
|
* The live label — "Ambience: Hibiscus, playing" while sounding,
|
|
50
64
|
* "Ambience: Hibiscus, stopped" once a scape has been selected but is not
|
|
@@ -61,8 +75,61 @@ export declare const PLAY_ERROR_LABEL = "Ambience: couldn't start \u2014 tap aga
|
|
|
61
75
|
* and settles on the exhale — 6% total, clamped against out-of-range input.
|
|
62
76
|
*/
|
|
63
77
|
export declare function breathScale(value: number): number;
|
|
78
|
+
/** Submenu width (px) — matches the `w-64` class on the submenu panel. */
|
|
79
|
+
export declare const SUBMENU_WIDTH = 256;
|
|
80
|
+
/**
|
|
81
|
+
* Where a submenu goes, in viewport px. Beside the menu panel on the
|
|
82
|
+
* preferred side (the side the menu already extends to), else the other
|
|
83
|
+
* side, else — a phone, no room either side — overlapping the panel just
|
|
84
|
+
* below its row, clamped inside the viewport.
|
|
85
|
+
*/
|
|
86
|
+
export declare function placeSubmenu(row: {
|
|
87
|
+
top: number;
|
|
88
|
+
bottom: number;
|
|
89
|
+
}, panel: {
|
|
90
|
+
left: number;
|
|
91
|
+
right: number;
|
|
92
|
+
}, viewport: {
|
|
93
|
+
width: number;
|
|
94
|
+
}, prefer: 'left' | 'right', width?: number, gap?: number, margin?: number): {
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
side: 'left' | 'right' | 'over';
|
|
98
|
+
};
|
|
99
|
+
/** One mouse-wheel notch in pixel mode — Chrome/WebKit ship ±100 per notch. */
|
|
100
|
+
export declare const WHEEL_NOTCH_PX = 100;
|
|
101
|
+
/** One mouse-wheel notch in line mode — Firefox ships ±3 lines per notch. */
|
|
102
|
+
export declare const WHEEL_NOTCH_LINES = 3;
|
|
103
|
+
/** Volume change per wheel notch, on the 0-100 slider scale. */
|
|
104
|
+
export declare const WHEEL_STEP_PCT = 5;
|
|
105
|
+
/**
|
|
106
|
+
* A wheel delta normalized into notch units (~1 = one physical notch):
|
|
107
|
+
* pixel deltas divide by ~100 px, Firefox's line mode by 3 lines, and a
|
|
108
|
+
* page-mode delta counts as one notch. Trackpads emit small pixel deltas
|
|
109
|
+
* continuously — those are meant to accumulate (see wheelVolumeSteps),
|
|
110
|
+
* not to step on every event.
|
|
111
|
+
*/
|
|
112
|
+
export declare function wheelNotches(deltaY: number, deltaMode: number): number;
|
|
113
|
+
/**
|
|
114
|
+
* Accumulate a wheel delta into `acc` and return the WHOLE volume steps it
|
|
115
|
+
* adds up to, in volume terms — positive = louder (wheel up), negative =
|
|
116
|
+
* quieter (wheel down) — 0 while a partial notch is still pending. The
|
|
117
|
+
* remainder stays in `acc.value` across events, so trackpad pixel deltas
|
|
118
|
+
* accumulate and one wheel notch lands as about one step.
|
|
119
|
+
*/
|
|
120
|
+
export declare function wheelVolumeSteps(acc: {
|
|
121
|
+
value: number;
|
|
122
|
+
}, deltaY: number, deltaMode: number): number;
|
|
123
|
+
/**
|
|
124
|
+
* The volume (0..1) after `steps` wheel steps: ±`stepPct` per step on the
|
|
125
|
+
* 0-100 slider scale, clamped to 0-100. The math runs on the rounded
|
|
126
|
+
* slider percent, so the result never carries float dust past what the
|
|
127
|
+
* slider itself would produce for the same value.
|
|
128
|
+
*/
|
|
129
|
+
export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
|
|
130
|
+
import type { Ambience } from '../ambience/types';
|
|
64
131
|
type $$ComponentProps = {
|
|
65
|
-
/** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. */
|
|
132
|
+
/** 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. */
|
|
66
133
|
engine: Ambience;
|
|
67
134
|
/** Smaller app-shell footprint: 40px trigger with an 18px glyph (default 44px / 20px). Never below the 40px touch floor. */
|
|
68
135
|
compact?: boolean;
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { scrollEdge as scrollEdgeAction } from '../actions/scrollEdge';
|
|
31
31
|
import { onMount, untrack } from 'svelte';
|
|
32
32
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
33
|
+
import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
|
|
33
34
|
import type { Snippet } from 'svelte';
|
|
34
35
|
import { fly, fade } from 'svelte/transition';
|
|
35
36
|
import { cubicOut } from 'svelte/easing';
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
shortcutRegistry,
|
|
53
54
|
shortcutContext = 'bottom-sheet',
|
|
54
55
|
scrollEdge = true,
|
|
56
|
+
initialFocus = 'first-input',
|
|
55
57
|
children
|
|
56
58
|
}: {
|
|
57
59
|
open?: boolean;
|
|
@@ -75,9 +77,27 @@
|
|
|
75
77
|
shortcutContext?: string;
|
|
76
78
|
/** Soft progressive-blur fade at the top of the content once it scrolls (`use:scrollEdge`). Ignored with `fullHeight` (the slot owns scrolling). Default true. */
|
|
77
79
|
scrollEdge?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* What to focus when the sheet opens. Default `'first-input'`:
|
|
82
|
+
* [data-autofocus]/[autofocus] inside the panel, else the first enabled
|
|
83
|
+
* text-entry control, else the panel itself. `'dialog'` focuses the
|
|
84
|
+
* panel without searching; an HTMLElement or CSS selector targets
|
|
85
|
+
* exactly that; `null` disables auto-focus entirely.
|
|
86
|
+
*/
|
|
87
|
+
initialFocus?: InitialFocus | null;
|
|
78
88
|
children?: Snippet;
|
|
79
89
|
} = $props();
|
|
80
90
|
|
|
91
|
+
// On open, focus the first fillable field — WAI-ARIA APG dialog pattern:
|
|
92
|
+
// focus the first field the user is expected to fill. See
|
|
93
|
+
// utils/dialogFocus.ts for the resolution order. `initialFocus = null`
|
|
94
|
+
// opts out entirely.
|
|
95
|
+
let panelElement: HTMLElement | undefined = $state();
|
|
96
|
+
$effect(() => {
|
|
97
|
+
if (!open || initialFocus === null) return;
|
|
98
|
+
focusDialogTarget(panelElement ?? null, { initialFocus, fallback: panelElement ?? null });
|
|
99
|
+
});
|
|
100
|
+
|
|
81
101
|
// While open, a modal shortcut context keeps the host page's registry
|
|
82
102
|
// shortcuts from firing behind it.
|
|
83
103
|
$effect(() => {
|
|
@@ -234,6 +254,7 @@
|
|
|
234
254
|
class="fixed bottom-0 inset-x-0 flex flex-col bg-base-100 rounded-t-2xl border-t border-base-300 shadow-2xl"
|
|
235
255
|
style="z-index: {zIndex + 1}; {panelHeightStyle} transform: translateY({dragCurrentY}px); opacity: {dragOpacity}; transition: {isDragging ? 'none' : 'transform 0.15s ease, opacity 0.15s ease, height 0.2s ease'};"
|
|
236
256
|
role="dialog"
|
|
257
|
+
bind:this={panelElement}
|
|
237
258
|
tabindex="-1"
|
|
238
259
|
aria-modal="true"
|
|
239
260
|
aria-label={title || 'Bottom sheet'}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
2
|
+
import { type InitialFocus } from '../utils/dialogFocus';
|
|
2
3
|
import type { Snippet } from 'svelte';
|
|
3
4
|
type $$ComponentProps = {
|
|
4
5
|
open?: boolean;
|
|
@@ -22,6 +23,14 @@ type $$ComponentProps = {
|
|
|
22
23
|
shortcutContext?: string;
|
|
23
24
|
/** Soft progressive-blur fade at the top of the content once it scrolls (`use:scrollEdge`). Ignored with `fullHeight` (the slot owns scrolling). Default true. */
|
|
24
25
|
scrollEdge?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* What to focus when the sheet opens. Default `'first-input'`:
|
|
28
|
+
* [data-autofocus]/[autofocus] inside the panel, else the first enabled
|
|
29
|
+
* text-entry control, else the panel itself. `'dialog'` focuses the
|
|
30
|
+
* panel without searching; an HTMLElement or CSS selector targets
|
|
31
|
+
* exactly that; `null` disables auto-focus entirely.
|
|
32
|
+
*/
|
|
33
|
+
initialFocus?: InitialFocus | null;
|
|
25
34
|
children?: Snippet;
|
|
26
35
|
};
|
|
27
36
|
declare const BottomSheet: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { cubicOut } from 'svelte/easing';
|
|
4
4
|
import { untrack } from 'svelte';
|
|
5
5
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
6
|
+
import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
|
|
6
7
|
|
|
7
8
|
let {
|
|
8
9
|
open = $bindable(true),
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
oncancel,
|
|
18
19
|
shortcutRegistry,
|
|
19
20
|
shortcutContext = 'confirm-modal',
|
|
21
|
+
initialFocus = 'first-input',
|
|
20
22
|
} = $props<{
|
|
21
23
|
open?: boolean
|
|
22
24
|
title?: string
|
|
@@ -32,6 +34,14 @@
|
|
|
32
34
|
shortcutRegistry?: ShortcutRegistry
|
|
33
35
|
/** Context name pushed while open. Default `'confirm-modal'`. */
|
|
34
36
|
shortcutContext?: string
|
|
37
|
+
/**
|
|
38
|
+
* What to focus when the modal opens (see utils/dialogFocus.ts).
|
|
39
|
+
* Default `'first-input'` resolves [data-autofocus]/[autofocus], then a
|
|
40
|
+
* text-entry control, then falls back to the dialog box itself — a
|
|
41
|
+
* confirm dialog has no text field, so it lands on the safe dialog
|
|
42
|
+
* element, never the destructive button.
|
|
43
|
+
*/
|
|
44
|
+
initialFocus?: InitialFocus
|
|
35
45
|
}>()
|
|
36
46
|
|
|
37
47
|
// While open, a modal shortcut context keeps the host page's registry
|
|
@@ -59,11 +69,7 @@
|
|
|
59
69
|
if (typeof document === 'undefined') return
|
|
60
70
|
if (open) {
|
|
61
71
|
previousFocus = document.activeElement as HTMLElement
|
|
62
|
-
|
|
63
|
-
if (modalBox?.contains(document.activeElement)) return
|
|
64
|
-
const first = modalBox?.querySelector<HTMLElement>(FOCUSABLE)
|
|
65
|
-
;(first ?? modalBox)?.focus()
|
|
66
|
-
})
|
|
72
|
+
focusDialogTarget(modalBox, { initialFocus, fallback: modalBox })
|
|
67
73
|
} else if (previousFocus) {
|
|
68
74
|
previousFocus.focus?.()
|
|
69
75
|
previousFocus = null
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
2
|
+
import { type InitialFocus } from '../utils/dialogFocus';
|
|
2
3
|
type $$ComponentProps = {
|
|
3
4
|
open?: boolean;
|
|
4
5
|
title?: string;
|
|
@@ -14,6 +15,14 @@ type $$ComponentProps = {
|
|
|
14
15
|
shortcutRegistry?: ShortcutRegistry;
|
|
15
16
|
/** Context name pushed while open. Default `'confirm-modal'`. */
|
|
16
17
|
shortcutContext?: string;
|
|
18
|
+
/**
|
|
19
|
+
* What to focus when the modal opens (see utils/dialogFocus.ts).
|
|
20
|
+
* Default `'first-input'` resolves [data-autofocus]/[autofocus], then a
|
|
21
|
+
* text-entry control, then falls back to the dialog box itself — a
|
|
22
|
+
* confirm dialog has no text field, so it lands on the safe dialog
|
|
23
|
+
* element, never the destructive button.
|
|
24
|
+
*/
|
|
25
|
+
initialFocus?: InitialFocus;
|
|
17
26
|
};
|
|
18
27
|
declare const ConfirmModal: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
19
28
|
type ConfirmModal = ReturnType<typeof ConfirmModal>;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { scrollEdge as scrollEdgeAction } from '../actions/scrollEdge';
|
|
13
13
|
import { untrack, type Snippet } from 'svelte';
|
|
14
14
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
15
|
+
import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
|
|
15
16
|
|
|
16
17
|
interface Props {
|
|
17
18
|
open?: boolean;
|
|
@@ -45,6 +46,15 @@
|
|
|
45
46
|
shortcutRegistry?: ShortcutRegistry;
|
|
46
47
|
/** Context name pushed while open. Default `'drawer'`. */
|
|
47
48
|
shortcutContext?: string;
|
|
49
|
+
/**
|
|
50
|
+
* What to focus when the drawer opens. Default `'first-input'`:
|
|
51
|
+
* [data-autofocus]/[autofocus] inside the panel, else the first enabled
|
|
52
|
+
* text-entry control (text-like input, textarea, contenteditable —
|
|
53
|
+
* never the header close button), else the panel itself. `'dialog'`
|
|
54
|
+
* focuses the panel without searching for a field. An HTMLElement or
|
|
55
|
+
* CSS selector targets exactly that; `null` disables auto-focus entirely.
|
|
56
|
+
*/
|
|
57
|
+
initialFocus?: InitialFocus | null;
|
|
48
58
|
onclose?: () => void;
|
|
49
59
|
class?: string;
|
|
50
60
|
headerActions?: Snippet;
|
|
@@ -67,6 +77,7 @@
|
|
|
67
77
|
trapKeys = true,
|
|
68
78
|
shortcutRegistry,
|
|
69
79
|
shortcutContext = 'drawer',
|
|
80
|
+
initialFocus = 'first-input',
|
|
70
81
|
onclose,
|
|
71
82
|
class: className = '',
|
|
72
83
|
headerActions,
|
|
@@ -96,6 +107,17 @@
|
|
|
96
107
|
onclose?.();
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
let panelElement: HTMLElement | undefined = $state();
|
|
111
|
+
|
|
112
|
+
// On open, focus the first fillable field — WAI-ARIA APG dialog pattern:
|
|
113
|
+
// focus the first field the user is expected to fill. See
|
|
114
|
+
// utils/dialogFocus.ts for the resolution order. `initialFocus = null`
|
|
115
|
+
// opts out entirely.
|
|
116
|
+
$effect(() => {
|
|
117
|
+
if (!open || initialFocus === null) return;
|
|
118
|
+
focusDialogTarget(panelElement ?? null, { initialFocus, fallback: panelElement ?? null });
|
|
119
|
+
});
|
|
120
|
+
|
|
99
121
|
function handleKeydown(e: KeyboardEvent) {
|
|
100
122
|
if (e.key === 'Escape' && open) {
|
|
101
123
|
e.stopImmediatePropagation();
|
|
@@ -140,6 +162,7 @@
|
|
|
140
162
|
? 'z-[101] translate-x-0'
|
|
141
163
|
: '-z-10 ' + (position === 'right' ? 'translate-x-full' : '-translate-x-full')} {className}"
|
|
142
164
|
role="dialog"
|
|
165
|
+
bind:this={panelElement}
|
|
143
166
|
aria-modal={open}
|
|
144
167
|
aria-label={title}
|
|
145
168
|
aria-hidden={!open}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Snippet } from 'svelte';
|
|
2
2
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
3
|
+
import { type InitialFocus } from '../utils/dialogFocus';
|
|
3
4
|
interface Props {
|
|
4
5
|
open?: boolean;
|
|
5
6
|
title?: string;
|
|
@@ -32,6 +33,15 @@ interface Props {
|
|
|
32
33
|
shortcutRegistry?: ShortcutRegistry;
|
|
33
34
|
/** Context name pushed while open. Default `'drawer'`. */
|
|
34
35
|
shortcutContext?: string;
|
|
36
|
+
/**
|
|
37
|
+
* What to focus when the drawer opens. Default `'first-input'`:
|
|
38
|
+
* [data-autofocus]/[autofocus] inside the panel, else the first enabled
|
|
39
|
+
* text-entry control (text-like input, textarea, contenteditable —
|
|
40
|
+
* never the header close button), else the panel itself. `'dialog'`
|
|
41
|
+
* focuses the panel without searching for a field. An HTMLElement or
|
|
42
|
+
* CSS selector targets exactly that; `null` disables auto-focus entirely.
|
|
43
|
+
*/
|
|
44
|
+
initialFocus?: InitialFocus | null;
|
|
35
45
|
onclose?: () => void;
|
|
36
46
|
class?: string;
|
|
37
47
|
headerActions?: Snippet;
|
|
@@ -30,7 +30,12 @@
|
|
|
30
30
|
if (visible && opts) {
|
|
31
31
|
value = '';
|
|
32
32
|
error = null;
|
|
33
|
-
|
|
33
|
+
// Focus AND select the input (jatui-fo6o8) so a replacement value
|
|
34
|
+
// can be typed over immediately without a select-all gesture.
|
|
35
|
+
tick().then(() => {
|
|
36
|
+
inputRef?.focus();
|
|
37
|
+
inputRef?.select();
|
|
38
|
+
});
|
|
34
39
|
}
|
|
35
40
|
});
|
|
36
41
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import CloseButton from './CloseButton.svelte';
|
|
10
10
|
import { onMount, untrack } from 'svelte';
|
|
11
11
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
12
|
+
import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
|
|
12
13
|
import { fly, fade } from 'svelte/transition';
|
|
13
14
|
import { cubicOut } from 'svelte/easing';
|
|
14
15
|
|
|
@@ -51,6 +52,15 @@
|
|
|
51
52
|
shortcutRegistry?: ShortcutRegistry;
|
|
52
53
|
/** Context name pushed while open. Default `'modal'`. */
|
|
53
54
|
shortcutContext?: string;
|
|
55
|
+
/**
|
|
56
|
+
* What to focus when the modal opens. Default `'first-input'`:
|
|
57
|
+
* [data-autofocus]/[autofocus] inside the dialog, else the first enabled
|
|
58
|
+
* text-entry control (text-like input, textarea, contenteditable —
|
|
59
|
+
* never the header close button), else the dialog itself. `'dialog'`
|
|
60
|
+
* restores the old container-focus behaviour for read-only/confirm
|
|
61
|
+
* dialogs. An HTMLElement or CSS selector targets exactly that.
|
|
62
|
+
*/
|
|
63
|
+
initialFocus?: InitialFocus;
|
|
54
64
|
onclose?: () => void;
|
|
55
65
|
onopen?: () => void;
|
|
56
66
|
header?: import('svelte').Snippet;
|
|
@@ -75,6 +85,7 @@
|
|
|
75
85
|
trapKeys = true,
|
|
76
86
|
shortcutRegistry,
|
|
77
87
|
shortcutContext = 'modal',
|
|
88
|
+
initialFocus = 'first-input',
|
|
78
89
|
onclose,
|
|
79
90
|
onopen,
|
|
80
91
|
header,
|
|
@@ -112,7 +123,11 @@
|
|
|
112
123
|
if (open) {
|
|
113
124
|
previousFocus = document.activeElement as HTMLElement;
|
|
114
125
|
document.body.style.overflow = 'hidden';
|
|
115
|
-
|
|
126
|
+
// Focus the first fillable field, not the dialog box — see
|
|
127
|
+
// utils/dialogFocus.ts for the resolution order. Falls back to the
|
|
128
|
+
// dialog itself when there is no text field, so Escape handling
|
|
129
|
+
// (owned on the dialog element) keeps working as before.
|
|
130
|
+
focusDialogTarget(modalElement, { initialFocus, fallback: modalElement });
|
|
116
131
|
onopen?.();
|
|
117
132
|
} else {
|
|
118
133
|
document.body.style.overflow = '';
|
|
@@ -143,13 +158,13 @@
|
|
|
143
158
|
|
|
144
159
|
function handleKeyDown(event: KeyboardEvent) {
|
|
145
160
|
// Escape is owned HERE on the dialog element, not by <svelte:window>.
|
|
146
|
-
// Modal focuses the dialog (tabindex=-1) on
|
|
147
|
-
// keydown dispatches from the dialog itself — and
|
|
148
|
-
// stopPropagation below (which under Svelte 5 delegated
|
|
149
|
-
// blocks every window listener) would swallow Escape before
|
|
150
|
-
// handler could act
|
|
151
|
-
//
|
|
152
|
-
//
|
|
161
|
+
// When no text field matched, Modal focuses the dialog (tabindex=-1) on
|
|
162
|
+
// open, so in that state keydown dispatches from the dialog itself — and
|
|
163
|
+
// the trapKeys stopPropagation below (which under Svelte 5 delegated
|
|
164
|
+
// events also blocks every window listener) would swallow Escape before
|
|
165
|
+
// the window handler could act. Handling it at element level also
|
|
166
|
+
// guarantees a Modal opened inside a host Drawer closes before the
|
|
167
|
+
// drawer does.
|
|
153
168
|
if (open && event.key === 'Escape' && closeOnEscape) {
|
|
154
169
|
event.preventDefault();
|
|
155
170
|
event.stopPropagation();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
|
|
2
|
+
import { type InitialFocus } from '../utils/dialogFocus';
|
|
2
3
|
interface Props {
|
|
3
4
|
open?: boolean;
|
|
4
5
|
title?: string;
|
|
@@ -38,6 +39,15 @@ interface Props {
|
|
|
38
39
|
shortcutRegistry?: ShortcutRegistry;
|
|
39
40
|
/** Context name pushed while open. Default `'modal'`. */
|
|
40
41
|
shortcutContext?: string;
|
|
42
|
+
/**
|
|
43
|
+
* What to focus when the modal opens. Default `'first-input'`:
|
|
44
|
+
* [data-autofocus]/[autofocus] inside the dialog, else the first enabled
|
|
45
|
+
* text-entry control (text-like input, textarea, contenteditable —
|
|
46
|
+
* never the header close button), else the dialog itself. `'dialog'`
|
|
47
|
+
* restores the old container-focus behaviour for read-only/confirm
|
|
48
|
+
* dialogs. An HTMLElement or CSS selector targets exactly that.
|
|
49
|
+
*/
|
|
50
|
+
initialFocus?: InitialFocus;
|
|
41
51
|
onclose?: () => void;
|
|
42
52
|
onopen?: () => void;
|
|
43
53
|
header?: import('svelte').Snippet;
|
package/dist/index.d.ts
CHANGED
|
@@ -408,8 +408,9 @@ export { default as ImageReveal } from './components/ImageReveal.svelte';
|
|
|
408
408
|
export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize, imageRevealTone, IMAGE_REVEAL_SIZES, IMAGE_REVEAL_RADIUS } from './components/imageReveal';
|
|
409
409
|
export type { ImageRevealPhase, ImageRevealShape, ImageRevealSizeName, ImageRevealToneName, ImageRevealState } from './components/imageReveal';
|
|
410
410
|
export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
|
|
411
|
-
export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves } from './ambience';
|
|
412
|
-
export type { DefaultAmbienceOptions } from './ambience';
|
|
413
|
-
export type { Ambience, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
|
|
411
|
+
export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
|
|
412
|
+
export type { DefaultAmbienceOptions, AmbienceSection } from './ambience';
|
|
413
|
+
export type { Ambience, AmbienceGroupDef, AmbienceItem, AmbienceLink, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './ambience';
|
|
414
414
|
export { createPlaylistScape } from './ambience';
|
|
415
|
+
export { isPlaylistScape, trackAttribution } from './ambience';
|
|
415
416
|
export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './ambience';
|
package/dist/index.js
CHANGED
|
@@ -457,7 +457,8 @@ export { imageRevealPhase, imageRevealBusy, imageRevealInitials, imageRevealSize
|
|
|
457
457
|
// navigation; createDefaultAmbience() pre-registers every shipped scape
|
|
458
458
|
// (hibiscus, focus, brown/pink/white noise, rain, waves).
|
|
459
459
|
export { default as AmbienceToggle, AMBIENCE_GROUP_ORDER } from './components/AmbienceToggle.svelte';
|
|
460
|
-
export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves } from './ambience';
|
|
460
|
+
export { createAmbience, createDefaultAmbience, DEFAULT_SCAPES, BreathClock, BREATHS_PER_MINUTE, MASTER, ScapeRegistry, hibiscusScape, focusScape, noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape, rain, waves, AMBIENCE_DEFAULT_GROUP_ORDER, AMBIENCE_DEFAULT_GROUP_LABELS, groupAmbienceItems, ambienceGroupLabel, isAmbienceLink, normalizeAmbienceGroups, radioStation, safeAmbienceHref } from './ambience';
|
|
461
461
|
// Playlist scape (phase 2) — per-host, never in DEFAULT_SCAPES: build with
|
|
462
462
|
// createPlaylistScape() and register on the engine yourself.
|
|
463
463
|
export { createPlaylistScape } from './ambience';
|
|
464
|
+
export { isPlaylistScape, trackAttribution } from './ambience';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog focus resolution (jatui-fo6o8)
|
|
3
|
+
*
|
|
4
|
+
* Shared rule for every surface that opens an overlay dialog/panel
|
|
5
|
+
* (Modal, Drawer, BottomSheet, Confirm*, InputDialog): on open, focus in
|
|
6
|
+
* this order —
|
|
7
|
+
*
|
|
8
|
+
* 1. an element with [data-autofocus] or the `autofocus` attribute inside
|
|
9
|
+
* the container (a consumer's explicit opt-in);
|
|
10
|
+
* 2. else the first ENABLED, RENDERED text-entry control in the body
|
|
11
|
+
* (not the header close button — buttons never qualify anyway): a
|
|
12
|
+
* text-like <input>, an enabled <textarea>, or a contenteditable
|
|
13
|
+
* element;
|
|
14
|
+
* 3. else the container element itself (today's behaviour — keeps Escape
|
|
15
|
+
* working for read-only / confirm dialogs, see Modal's Escape note).
|
|
16
|
+
*
|
|
17
|
+
* "Rendered" matters: a focus() on an element that is not actually visible
|
|
18
|
+
* (display:none, inside a collapsed <details>, a [hidden]/[inert] ancestor,
|
|
19
|
+
* or visibility:hidden) is a silent no-op that leaves focus on <body>, which
|
|
20
|
+
* breaks the dialog's own Escape handling (Modal owns Escape on the dialog
|
|
21
|
+
* element). Unrendered candidates are skipped, and after focusing the target
|
|
22
|
+
* is verified to actually hold focus — else the fallback/container is
|
|
23
|
+
* focused instead.
|
|
24
|
+
*
|
|
25
|
+
* WAI-ARIA APG dialog pattern: focus the first field the user is expected
|
|
26
|
+
* to fill. `initialFocus` overrides the whole order —
|
|
27
|
+
*
|
|
28
|
+
* - `HTMLElement` / `string` (a CSS selector, resolved inside the
|
|
29
|
+
* container): focus exactly that; falls through to the default order
|
|
30
|
+
* when the selector matches nothing.
|
|
31
|
+
* - `'dialog'`: always focus the container (old behaviour — right for
|
|
32
|
+
* read-only / confirm dialogs).
|
|
33
|
+
* - `'first-input'` (default): the 1→2→3 order above.
|
|
34
|
+
*/
|
|
35
|
+
export type InitialFocus = HTMLElement | string | 'dialog' | 'first-input';
|
|
36
|
+
export interface FocusOptions {
|
|
37
|
+
/** Explicit target. Default `'first-input'`. */
|
|
38
|
+
initialFocus?: InitialFocus | null;
|
|
39
|
+
/** Element to focus when nothing matches. Default the container itself. */
|
|
40
|
+
fallback?: HTMLElement | null;
|
|
41
|
+
}
|
|
42
|
+
/** True for an element a user can type into: text-like input, enabled textarea, contenteditable. */
|
|
43
|
+
export declare function isTextEntryControl(el: Element): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the element a dialog should focus when it opens, per the order
|
|
46
|
+
* above. Returns null when nothing matches and there is no fallback.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveDialogFocusTarget(container: HTMLElement | null, opts?: FocusOptions): HTMLElement | null;
|
|
49
|
+
/**
|
|
50
|
+
* Focus a resolved target on the next frame (after the open transition has
|
|
51
|
+
* mounted the content). Mirrors the previous `requestAnimationFrame` timing
|
|
52
|
+
* so focus lands on real DOM, not a mounting tree.
|
|
53
|
+
*/
|
|
54
|
+
export declare function focusDialogTarget(container: HTMLElement | null, opts?: FocusOptions, rq?: typeof requestAnimationFrame): void;
|