@joewinke/jatui 0.55.0 → 0.57.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 +5 -4
- package/dist/components/AmbienceToggle.svelte +160 -4
- package/dist/components/AmbienceToggle.svelte.d.ts +36 -0
- 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/components/messaging/ChatTranscript.svelte +414 -0
- package/dist/components/messaging/ChatTranscript.svelte.d.ts +36 -0
- package/dist/components/messaging/chatTranscript.d.ts +68 -0
- package/dist/components/messaging/chatTranscript.js +72 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/utils/dialogFocus.d.ts +54 -0
- package/dist/utils/dialogFocus.js +153 -0
- package/package.json +1 -1
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)"`. **
|
|
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,11 +106,11 @@ 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
|
|
|
113
|
-
"Agent is working" UI. Free BoardUI items (agent-thinking, agent-log, composer-loader, notification-center) were ported under MIT — see `THIRD_PARTY_NOTICES.md`; AgentTaskList, Questionnaire and ContextMeter are clean-room. Demos: `/components/agent-thinking`, `agent-log`, `agent-task-list`, `questionnaire`, `context-meter`, `notification-center`, `composer-glow`, `streamed-text`.
|
|
113
|
+
"Agent is working" UI. Free BoardUI items (agent-thinking, agent-log, composer-loader, notification-center) were ported under MIT — see `THIRD_PARTY_NOTICES.md`; AgentTaskList, Questionnaire and ContextMeter are clean-room. Demos: `/components/agent-thinking`, `agent-log`, `agent-task-list`, `questionnaire`, `context-meter`, `notification-center`, `composer-glow`, `streamed-text`, `chat-transcript`.
|
|
114
114
|
|
|
115
115
|
| Component | Purpose | Key Props |
|
|
116
116
|
|-----------|---------|-----------|
|
|
@@ -124,6 +124,7 @@ Then import:
|
|
|
124
124
|
| `NotificationCenter` | Inbox panel: title + "N unread" + "Mark all read"; a tab list with count chips (default All / Mentions / System by `category`; `bind:tab` or uncontrolled `defaultTab`; arrows / Home / End switch tabs). Items are newest first under Today / Yesterday / This week / Earlier headers (`groupNotificationsByDate`, `now` injectable). Roving focus via `createListNav`, scoped to the list: j/k/arrows move, Enter opens → `onOpen(item)` or follows `href`. Opening an item or clicking one of its actions marks it read. The host owns read state (`read`); the component also tracks read ids locally, so it works uncontrolled. The unread dot is announced (sr-only "Unread"). Status glyph or `Avatar`; `<time>` via `formatRelativeTime`, refreshed each minute. Empty state via `Empty` ("You're all caught up"). | `items: NotificationItem[]`, `tabs?: {id, label, filter?}[]`, `tab` (bindable), `defaultTab?`, `onTabChange?`, `onOpen?`, `onMarkRead?(ids)`, `onMarkAllRead?` (omitted → `onMarkRead` gets every unread id), `groupByDate?` (true), `now?`, `maxHeight?` ('32rem'), `title?`, `emptyTitle?`, `emptyDescription?` |
|
|
125
125
|
| `ComposerGlow` | Opt-in ambient rim light around a composer while the agent works: an SVG `<rect rx>` stroke with a pathLength=100 dash laps the box in three layers (crisp line, glow, bloom clipped inside; SVG `feGaussianBlur`, so iOS stays soft); a ResizeObserver keeps the viewBox equal to the px box. Token colours only (info → primary → `color-mix` midpoint → secondary → accent). Fades in/out on `active`; the lap runs only while visible. Under `data-motion="calm"` / reduced motion there is no frozen arc: it shows a static primary focus-ring tint. Paints a `--color-base-100` surface, so the wrapped composer must be transparent. | `active`, `speed?` (4.5 s/lap), `intensity?` ('subtle'\|'normal'\|'vivid'), `radius?` (px or CSS; default box radius), `class?`, `children` |
|
|
126
126
|
| `StreamedText` | Streamed assistant text revealed per paragraph/line, never per token: an append-only splitter (`splitStreamedText`, index keys, code fences kept whole) mounts each block once with a short fade + un-blur on motion tokens, after which it only grows. History doesn't animate; reduced motion is static; a subtle caret shows while `streaming`. `renderBlock` lets the host render markdown per block. Recipe: show `AgentThinking` until the first *text* part exists, not while the request status is "submitted". | `text`, `streaming?`, `split?` ('paragraph'\|'line'), `as?`, `class?`, `renderBlock?: Snippet<[string, number]>` |
|
|
127
|
+
| `ChatTranscript` | Read-only, two-sided transcript of NAMED speakers — a live phone call (caller vs. the assistant's persona), a Scribe dictation, a meeting recording. Not a chat client (no composer/reactions/edit/delete, no HTML in the words; for a channel use `MessageThread`). Speakers sit on opposite sides (first listed `start`, second `end`, or `side`); consecutive turns group into a run (name + avatar once, tail on the last bubble; a grouped bubble carries the name sr-only). Label speakers by real names ("Dana Testwell", "Ruby"), never role words. `streaming` reveals the LAST turn per line through `StreamedText` with a caret. New turns slide in from their own side on motion tokens (history doesn't animate; calm/reduced motion honoured). Follows the live edge via `createStickToBottom`, `JumpToLatest` when scrolled away. Polite `role="log"`, keyboard-scrollable, `<time>` stamps (offset ms → "1:05", ISO → clock). Bubble tints are `color-mix` of a token family (`tone`: neutral/primary/secondary/accent/info). `meta` snippet (latency, flags — in words), `actions` snippet (hover/focus toolbar, none by default), `onTurnClick` (bubble becomes a button, e.g. seek) + `activeId`. Pure helpers: `buildTranscriptRows`, `resolveSpeakerSides`, `formatTurnTime`. | `turns: TranscriptTurn[]` (`{id, speaker, text \| null, at?, placeholder?}`), `speakers: TranscriptSpeaker[]` (`{id, name, side?, avatarUrl?, tone?}`), `streaming?`, `group?` (true), `avatars?` (true), `live?` (true), `follow?` (true), `ariaLabel?`, `formatTime?`, `emptyText?`, `meta?`/`actions?` (snippets), `onTurnClick?`, `activeId?`, `maxHeight?`, `class?` |
|
|
127
128
|
|
|
128
129
|
**Upgrades in this wave (all additive):**
|
|
129
130
|
- **`Steps`** (`progress/Steps.svelte`) — `collapsible?`, `collapsed?` (bindable), `summary?: string | Snippet` (default "3 steps left" / "All steps done", live while folded; the header is a real `aria-expanded` / `aria-controls` button), `orientation?` ('horizontal'\|'vertical'; vertical also shows descriptions), `activeProgress?` (0–1, a mini ProgressRing on the active node and in the folded header), `completionDelay?` + `onFinished?` (fires once after `current` passes the last step; re-arms if `current` drops back). Unset, the markup is unchanged. Helpers: `stepsSummary`, `stepsRemaining`, `isStepsFinished`, `clampStepProgress`.
|
|
@@ -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)
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
-
|
|
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;
|