@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.
@@ -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
- * - The scape menu (menu button pattern) opens with ArrowDown/ArrowUp,
10
- * a press-and-hold (~450 ms) or a right-click (Android/Chrome fire
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). It lists every
13
- * registered scape grouped meditation / focus / noise / nature / music
14
- * (`menuitemradio` items, current scape checked), carries a volume
15
- * slider wired to `engine.setVolume`, and shows each scape's
16
- * `description` as its secondary line — playlist scapes put their
17
- * track attribution there.
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 { Ambience, Scape, ScapeGroup } from '../ambience/types';
31
- /** Canonical menu group order (the Scape contract's groups). */
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 canonical groups. */
34
- export declare const GROUP_LABELS: Readonly<Record<ScapeGroup, string>>;
35
- /** One grouped section of the scape menu. */
36
- export interface AmbienceScapeGroup {
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 scapes for the menu: canonical group order, registration order
43
- * within a group, empty groups omitted. A group outside the union (a
44
- * registry from a newer engine) still renders, after the known ones, in
45
- * first-seen order — a menu never silently drops a registered scape.
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(scapes: Scape[]): AmbienceScapeGroup[];
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
- requestAnimationFrame(() => {
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
- tick().then(() => inputRef?.focus());
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
- requestAnimationFrame(() => modalElement?.focus());
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 open, so in the default state
147
- // keydown dispatches from the dialog itself — and the trapKeys
148
- // stopPropagation below (which under Svelte 5 delegated events also
149
- // blocks every window listener) would swallow Escape before the window
150
- // handler could act: Esc did nothing unless focus was in a text field.
151
- // Handling it at element level also guarantees a Modal opened inside a
152
- // host Drawer closes before the drawer does.
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;