@joewinke/jatui 0.55.0 → 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 CHANGED
@@ -86,9 +86,9 @@ Then import:
86
86
  | `ActivityHeatmap` | GitHub-style activity-per-day calendar (calls/day, tasks closed/day): 7 weekday rows × week columns, default range = last 12 months, `weekStart`, 4-step `color-mix` ramp from one theme `tone` (no hex), one floating tooltip, legend + summary ("1,204 calls in the last year"). `role="grid"` with ONE tab stop: arrows move by day (↑↓) / week (←→), PgUp/PgDn 4 weeks, Home/End row ends, mod+Home/End range ends, Enter/Space select. Pure layout helpers exported (`buildHeatmap`, `moveHeatFocus`). | `data` (`{date, count}[]` or `getDate`/`getCount`), `start?`/`end?`, `weekStart?`, `unit?` ('call' or ['call','calls']), `formatLabel?`, `tone?`, `levels?`, `levelOf?`, `compact?`, `showLegend?`/`showWeekdays?`/`showMonths?`, `summary?`, `selected` (bindable), `onSelectDay?(date, count)`, `locale?` |
87
87
  | `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
88
88
  | `Card` | Content card container | `title`, `children` (snippet) |
89
- | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **Escape is owned by the dialog element itself** (not the window handler) — Modal focuses the dialog on open, so `trapKeys`' stopPropagation would otherwise swallow Esc before the window listener sees it; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?` |
89
+ | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **On open, focus goes to the first fillable field** (WAI-ARIA APG): a `[data-autofocus]`/`autofocus` element, else the first enabled **rendered** text-entry control (readOnly/disabled textareas excluded, unrendered candidates skipped), else the dialog itself — `initialFocus` overrides the order (`'dialog'` restores the old container focus); the focus landing is verified, else the container is focused. When the dialog is the fallback focus, **Escape is owned by the dialog element itself** (not the window handler), so `trapKeys`' stopPropagation can't swallow Esc; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?`, `initialFocus?` |
90
90
  | `ConfirmModal` | Yes/no confirmation dialog | `open` (bindable), `title`, `message`, `onConfirm`, `onCancel` |
91
- | `Drawer` | Slide-out side panel. Header ✕ is a `CloseButton`; the body carries `use:scrollEdge` (opt out with `scrollEdge={false}`). | `open` (bindable), `side`, `scrollEdge?`, `children` (snippet) |
91
+ | `Drawer` | Slide-out side panel. Header ✕ is a `CloseButton`; the body carries `use:scrollEdge` (opt out with `scrollEdge={false}`). On open it focuses the first fillable field ([data-autofocus]/autofocus, else first enabled **rendered** text-entry control, else the panel) — `initialFocus` (`null` opts out). | `open` (bindable), `side`, `initialFocus?`, `scrollEdge?`, `children` (snippet) |
92
92
  | `LoadingSpinner` | Spinner animation | `size`, `color` |
93
93
  | `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
94
94
  | `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
@@ -106,7 +106,7 @@ Then import:
106
106
  | `BatchJobTable` | Generic queue runner (harvested from vid-gen's BatchGenerator) — `<BatchJobTable generics T>` over bindable `JobRow<T>[]`; host supplies async `run(data, {signal, attempt, reportProgress})`. Concurrency cap, optional per-run `processLimit` (quota budget), rolling-average ETA, per-row status badge / progress / elapsed / retry / skip / cancel / reorder — each cell a snippet override (`statusCell`, `progressCell`, `elapsedCell`, `actionsCell`), host data columns via `headers` + `cells`, extra toolbar content via `toolbar`. Uses `showToast` / `showConfirm` (mount `ToastHost` + `ConfirmDialog`). Exposes `start()`/`pause()`/`cancel()`/`retryFailed()`/`clearFinished()`. Pure scheduling helpers exported too (`nextJobsToStart`, `moveJob`, `summarizeJobs`, `toJobRows`). | `jobs` (bindable), `run`, `concurrencyLimit?` (3), `processLimit?`, `autoStart?`, `notify?`, `confirmDestructive?`, `onJobDone?`, `onJobFailed?`, `onBatchDone?` |
107
107
  | `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
108
108
  | `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
109
- | `BottomSheet` | Mobile bottom sheet (upstreamed from the JAT IDE — jatui-rl7w6.2): drag handle with pointer capture, backdrop close, optional header. `snap='dual'` enables the inspector pattern — peek → expanded snap heights with flick-momentum release (fast flick ≥ 0.5 px/ms wins over position; flick down from peek dismisses) and `visualViewport` keyboard avoidance so content clears the soft keyboard. | `open` (bindable), `title?`, `zIndex?` (70), `fullHeight?`, `snap?` ('single'\|'dual'), `peekFraction?` (0.4), `expandedFraction?` (0.9), `onclose?`, `children` (snippet) |
109
+ | `BottomSheet` | Mobile bottom sheet (upstreamed from the JAT IDE — jatui-rl7w6.2): drag handle with pointer capture, backdrop close, optional header. `snap='dual'` enables the inspector pattern — peek → expanded snap heights with flick-momentum release (fast flick ≥ 0.5 px/ms wins over position; flick down from peek dismisses) and `visualViewport` keyboard avoidance so content clears the soft keyboard. | `open` (bindable), `title?`, `zIndex?` (70), `fullHeight?`, `snap?` ('single'\|'dual'), `peekFraction?` (0.4), `expandedFraction?` (0.9), `onclose?`, `initialFocus?` ('first-input': [data-autofocus]/autofocus, else first enabled **rendered** text-entry control, else the panel; `null` opts out), `children` (snippet) |
110
110
 
111
111
  ### Agent surfaces (BoardUI wave 2)
112
112
 
@@ -25,6 +25,11 @@
25
25
  * relayed — we never proxy a third-party stream.
26
26
  * - A playing playlist shows its current track's attribution (title,
27
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.
28
33
  *
29
34
  * A11y: the trigger is `aria-pressed` while playing and carries the full
30
35
  * menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
@@ -129,6 +134,65 @@
129
134
  const x = Math.max(margin, Math.min(panel.left + 16, viewport.width - margin - width));
130
135
  return { x, y: row.bottom + 2, side: 'over' };
131
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
+ }
132
196
  </script>
133
197
 
134
198
  <script lang="ts">
@@ -190,10 +254,18 @@
190
254
  let panelRef = $state<HTMLDivElement | null>(null);
191
255
  let subRef = $state<HTMLDivElement | null>(null);
192
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);
193
259
 
194
260
  let playError = $state(false);
195
261
  let track = $state<PlaylistState['track']>(null);
196
262
 
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
+
197
269
  const scapes = $derived(items.filter((i): i is Scape => !isAmbienceLink(i)));
198
270
  const currentItem = $derived(items.find((s) => s.id === currentId) ?? null);
199
271
  const currentLabel = $derived(currentItem?.label ?? currentId);
@@ -260,9 +332,10 @@
260
332
  });
261
333
 
262
334
  // While open: click-outside dismissal (capture phase, so a press that
263
- // begins outside never reaches the page first) and APG focus movement into
264
- // the menu — focus the first nav item once the panel exists. A resize
265
- // closes an open submenu (its position is stale).
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).
266
339
  $effect(() => {
267
340
  if (!open) return;
268
341
  const panel = panelRef;
@@ -276,12 +349,26 @@
276
349
  const onResize = () => closeSubmenu(false);
277
350
  document.addEventListener('pointerdown', onDocPointerDown, true);
278
351
  window.addEventListener('resize', onResize);
352
+ panel.addEventListener('wheel', onPanelWheel, { passive: false });
279
353
  return () => {
280
354
  document.removeEventListener('pointerdown', onDocPointerDown, true);
281
355
  window.removeEventListener('resize', onResize);
356
+ panel.removeEventListener('wheel', onPanelWheel);
282
357
  };
283
358
  });
284
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
+
285
372
  // Keep an open submenu inside the viewport vertically once its real
286
373
  // height is known.
287
374
  $effect(() => {
@@ -328,6 +415,7 @@
328
415
  items = snapshot(); // freshest registry snapshot for the menu
329
416
  effectivePlacement = resolvePlacement();
330
417
  subGroup = null;
418
+ wheelAcc.value = 0; // a fresh menu starts from no pending wheel delta
331
419
  open = true;
332
420
  }
333
421
 
@@ -433,6 +521,74 @@
433
521
  engine.setVolume(v / 100);
434
522
  }
435
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
+
436
592
  // --- pointer path to the menu: press-and-hold ----------------------------
437
593
  function onPointerDown(e: PointerEvent) {
438
594
  longPressed = false;
@@ -657,7 +813,7 @@
657
813
  onscroll={() => closeSubmenu(false)}
658
814
  >
659
815
  <!-- Volume: a real slider in the roving set; it owns its own arrows. -->
660
- <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">
661
817
  <div class="mb-1.5 flex items-center justify-between">
662
818
  <span class="text-jatui-label text-base-content/60">Volume</span>
663
819
  <span class="text-2xs text-base-content/50 tabular-nums">{sliderValue}%</span>
@@ -24,6 +24,11 @@
24
24
  * relayed — we never proxy a third-party stream.
25
25
  * - A playing playlist shows its current track's attribution (title,
26
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.
27
32
  *
28
33
  * A11y: the trigger is `aria-pressed` while playing and carries the full
29
34
  * menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
@@ -91,6 +96,37 @@ export declare function placeSubmenu(row: {
91
96
  y: number;
92
97
  side: 'left' | 'right' | 'over';
93
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;
94
130
  import type { Ambience } from '../ambience/types';
95
131
  type $$ComponentProps = {
96
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. */
@@ -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;
@@ -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;
@@ -0,0 +1,153 @@
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
+ const TEXT_INPUT_TYPES = new Set([
36
+ 'text',
37
+ 'search',
38
+ 'url',
39
+ 'tel',
40
+ 'email',
41
+ 'password',
42
+ 'number',
43
+ '',
44
+ undefined
45
+ ]);
46
+ /** True for an element a user can type into: text-like input, enabled textarea, contenteditable. */
47
+ export function isTextEntryControl(el) {
48
+ if (el instanceof HTMLTextAreaElement) {
49
+ if (el.disabled || el.readOnly)
50
+ return false;
51
+ return true;
52
+ }
53
+ if (el instanceof HTMLInputElement) {
54
+ if (el.disabled || el.readOnly)
55
+ return false;
56
+ return TEXT_INPUT_TYPES.has(el.getAttribute('type') ?? undefined);
57
+ }
58
+ if (el.getAttribute?.('contenteditable') === 'true' || el.isContentEditable)
59
+ return true;
60
+ return false;
61
+ }
62
+ /** True when the element is not actually rendered, so focusing it is a
63
+ * silent no-op. Checks that cannot be evaluated (non-DOM test fakes) are
64
+ * treated as rendered. */
65
+ function isUnrendered(el) {
66
+ if (typeof el.getClientRects === 'function' && el.getClientRects().length === 0)
67
+ return true;
68
+ if (typeof el.closest === 'function' && el.closest('[hidden], [inert]'))
69
+ return true;
70
+ if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
71
+ if (window.getComputedStyle(el).visibility === 'hidden')
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ /** Enabled + attribute-level enabled (not disabled/aria-disabled/hidden). */
77
+ function isEnabled(el) {
78
+ if (el.hasAttribute('disabled'))
79
+ return false;
80
+ if (el.getAttribute('aria-disabled') === 'true')
81
+ return false;
82
+ if (el.hasAttribute('hidden'))
83
+ return false;
84
+ return true;
85
+ }
86
+ /**
87
+ * Resolve the element a dialog should focus when it opens, per the order
88
+ * above. Returns null when nothing matches and there is no fallback.
89
+ */
90
+ export function resolveDialogFocusTarget(container, opts = {}) {
91
+ if (!container)
92
+ return null;
93
+ const { initialFocus: initialFocusProp = 'first-input', fallback } = opts;
94
+ let initialFocus = initialFocusProp;
95
+ // Explicit 'dialog' — the old behaviour, verbatim.
96
+ if (initialFocus === 'dialog')
97
+ return container;
98
+ // Explicit element.
99
+ if (initialFocus instanceof HTMLElement)
100
+ return initialFocus;
101
+ // Explicit selector (any string other than the 'first-input' sentinel),
102
+ // resolved INSIDE the container. A miss falls through
103
+ // to the default first-input order (a consumer selector targeting a
104
+ // conditionally-rendered field shouldn't strand focus on the dialog).
105
+ if (typeof initialFocus === 'string' && initialFocus !== 'first-input') {
106
+ const found = container.querySelector(initialFocus);
107
+ if (found)
108
+ return found;
109
+ initialFocus = 'first-input';
110
+ }
111
+ // null opts out of auto-focus entirely (Drawer/BottomSheet): return only
112
+ // the explicit fallback, never the container.
113
+ if (initialFocus === null)
114
+ return fallback ?? null;
115
+ if (initialFocus !== 'first-input' && initialFocus != null)
116
+ return null;
117
+ // 1. Consumer's explicit autofocus opt-ins.
118
+ const autofocused = container.querySelector('[data-autofocus], [autofocus]:not(input[autofocus][type="hidden"])');
119
+ if (autofocused && isEnabled(autofocused) && !isUnrendered(autofocused))
120
+ return autofocused;
121
+ // 2. First enabled, rendered text-entry control in the body. Buttons
122
+ // (including the header close ✕) never qualify, so the header is safe
123
+ // to include.
124
+ const candidates = container.querySelectorAll('input, textarea, [contenteditable="true"], [contenteditable=""]');
125
+ for (const el of candidates) {
126
+ if (isEnabled(el) && !isUnrendered(el) && isTextEntryControl(el))
127
+ return el;
128
+ }
129
+ // 3. The dialog/panel itself.
130
+ return fallback ?? container;
131
+ }
132
+ /**
133
+ * Focus a resolved target on the next frame (after the open transition has
134
+ * mounted the content). Mirrors the previous `requestAnimationFrame` timing
135
+ * so focus lands on real DOM, not a mounting tree.
136
+ */
137
+ export function focusDialogTarget(container, opts = {}, rq = requestAnimationFrame.bind(window)) {
138
+ rq(() => {
139
+ const target = resolveDialogFocusTarget(container, opts);
140
+ if (!target)
141
+ return;
142
+ target.focus();
143
+ // Verify the focus actually landed — a focus() on an unrendered or
144
+ // otherwise unfocusable element is a silent no-op that leaves focus on
145
+ // <body>, which breaks the dialog's own Escape handling (Modal owns
146
+ // Escape on the dialog element). Fall back to the container.
147
+ if (typeof document !== 'undefined' && document.activeElement !== target) {
148
+ const fallbackEl = opts.fallback ?? container;
149
+ if (fallbackEl)
150
+ fallbackEl.focus();
151
+ }
152
+ });
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",