@dorsk/tsumikit 0.71.0 → 0.73.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.
Files changed (84) hide show
  1. package/README.md +54 -5
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +4 -2
  5. package/dist/components/atoms/Avatar.svelte +4 -3
  6. package/dist/components/atoms/Badge.svelte +13 -0
  7. package/dist/components/atoms/Button.svelte +25 -9
  8. package/dist/components/atoms/Card.svelte +2 -0
  9. package/dist/components/atoms/Dot.svelte +1 -1
  10. package/dist/components/atoms/Icon.svelte +7 -0
  11. package/dist/components/atoms/Icon.svelte.d.ts +7 -0
  12. package/dist/components/atoms/Kbd.svelte +28 -16
  13. package/dist/components/atoms/Link.svelte +6 -4
  14. package/dist/components/atoms/Scrim.svelte +18 -1
  15. package/dist/components/atoms/Slider.svelte +11 -1
  16. package/dist/components/atoms/Swatch.svelte +12 -0
  17. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  18. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  19. package/dist/components/layouts/AppShell.svelte +72 -40
  20. package/dist/components/layouts/Container.svelte +25 -3
  21. package/dist/components/layouts/MasterDetail.svelte +59 -33
  22. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  23. package/dist/components/layouts/NavItem.svelte +8 -4
  24. package/dist/components/layouts/NavSection.svelte +8 -2
  25. package/dist/components/layouts/ResizablePanel.svelte +19 -4
  26. package/dist/components/layouts/TabBar.svelte +217 -0
  27. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  28. package/dist/components/layouts/Toolbar.svelte +5 -0
  29. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  30. package/dist/components/layouts/toolbar-roving.js +47 -1
  31. package/dist/components/molecules/Accordion.svelte +2 -1
  32. package/dist/components/molecules/AttachmentList.svelte +1 -0
  33. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  34. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  35. package/dist/components/molecules/CapBar.svelte +3 -1
  36. package/dist/components/molecules/Composer.svelte +27 -5
  37. package/dist/components/molecules/Drawer.svelte +10 -1
  38. package/dist/components/molecules/Dropzone.svelte +2 -1
  39. package/dist/components/molecules/FilterInput.svelte +17 -1
  40. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  41. package/dist/components/molecules/Metric.svelte +28 -3
  42. package/dist/components/molecules/Modal.svelte +20 -4
  43. package/dist/components/molecules/Popover.svelte +7 -3
  44. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  45. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  46. package/dist/components/molecules/SectionHeader.svelte +29 -14
  47. package/dist/components/molecules/SeekSlider.svelte +370 -0
  48. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  49. package/dist/components/molecules/SegmentedControl.svelte +10 -6
  50. package/dist/components/molecules/SelectButton.svelte +33 -22
  51. package/dist/components/molecules/SwipeRow.svelte +349 -0
  52. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  53. package/dist/components/molecules/Tabs.svelte +11 -5
  54. package/dist/components/molecules/Timestamp.svelte +1 -1
  55. package/dist/components/molecules/Toaster.svelte +70 -13
  56. package/dist/components/molecules/Tooltip.svelte +19 -10
  57. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  58. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  59. package/dist/components/molecules/combobox-keyboard.js +2 -1
  60. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  61. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  62. package/dist/components/organisms/DataTable.svelte +22 -5
  63. package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
  64. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  65. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.js +7 -0
  68. package/dist/markdown.js +15 -7
  69. package/dist/media-time.d.ts +6 -0
  70. package/dist/media-time.js +25 -0
  71. package/dist/query/edit.js +1 -16
  72. package/dist/query/parser.js +68 -28
  73. package/dist/query/query.js +9 -5
  74. package/dist/query/quotes.d.ts +24 -0
  75. package/dist/query/quotes.js +78 -0
  76. package/dist/query/single.js +1 -3
  77. package/dist/query/suggest.js +25 -32
  78. package/dist/stores/fontscale.svelte.js +3 -2
  79. package/dist/stores/storage.d.ts +2 -0
  80. package/dist/stores/storage.js +16 -0
  81. package/dist/stores/theme.svelte.js +4 -7
  82. package/dist/styles/reset.css +30 -0
  83. package/dist/styles/syntax.css +0 -29
  84. package/package.json +1 -1
@@ -0,0 +1,349 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from '../atoms/Icon.svelte';
3
+ import type { Tone } from '../../tone';
4
+
5
+ export interface SwipeAction {
6
+ label: string;
7
+ icon?: IconName;
8
+ tone?: Tone;
9
+ onaction: () => void;
10
+ }
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ /**
15
+ * List row with a hidden action on each side, revealed by a horizontal swipe.
16
+ * A swipe past `threshold` (fraction of the row width) fires the action on
17
+ * release; a shorter one snaps back. The axis is locked after a few pixels:
18
+ * a drag that is more vertical than horizontal is left to the scroller.
19
+ * The same actions are reachable as visually hidden buttons for keyboard
20
+ * and screen-reader users.
21
+ */
22
+ import type { Snippet } from 'svelte';
23
+ import type { HTMLAttributes } from 'svelte/elements';
24
+ import Icon from '../atoms/Icon.svelte';
25
+ import { canonicalTone } from '../../tone';
26
+
27
+ type Own = {
28
+ children: Snippet;
29
+ /** Revealed on a rightward swipe. */
30
+ leadingAction?: SwipeAction;
31
+ /** Revealed on a leftward swipe. */
32
+ trailingAction?: SwipeAction;
33
+ /** Custom pane content; defaults to the action's icon and label. */
34
+ leading?: Snippet<[SwipeAction, boolean]>;
35
+ trailing?: Snippet<[SwipeAction, boolean]>;
36
+ /** Fraction of the row width the content must travel to fire. */
37
+ threshold?: number;
38
+ disabled?: boolean;
39
+ class?: string;
40
+ style?: string;
41
+ };
42
+
43
+ let {
44
+ children,
45
+ leadingAction,
46
+ trailingAction,
47
+ leading,
48
+ trailing,
49
+ threshold = 0.35,
50
+ disabled = false,
51
+ class: klass = '',
52
+ style: styleProp,
53
+ ...rest
54
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
55
+
56
+ const SLOP = 8;
57
+ const FALLBACK_WIDTH = 320;
58
+
59
+ let offset = $state(0);
60
+ let dragging = $state(false);
61
+ let settling = $state(false);
62
+
63
+ let pointerId: number | null = null;
64
+ let startX = 0;
65
+ let startY = 0;
66
+ let width = $state(0);
67
+ let locked: 'x' | 'y' | null = null;
68
+ let swallowClick = false;
69
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
70
+
71
+ const armed = $derived(width > 0 && Math.abs(offset) >= width * threshold);
72
+ const side = $derived(offset > 0 ? 'leading' : offset < 0 ? 'trailing' : null);
73
+ const active = $derived(side === 'leading' ? leadingAction : side === 'trailing' ? trailingAction : undefined);
74
+ const tone = $derived(canonicalTone(active?.tone ?? 'neutral'));
75
+
76
+ function reducedMotion() {
77
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
78
+ }
79
+
80
+ function release(el: HTMLElement) {
81
+ if (pointerId !== null && typeof el.releasePointerCapture === 'function') {
82
+ try {
83
+ el.releasePointerCapture(pointerId);
84
+ } catch {}
85
+ }
86
+ pointerId = null;
87
+ locked = null;
88
+ dragging = false;
89
+ }
90
+
91
+ function settle() {
92
+ clearTimeout(settleTimer);
93
+ if (reducedMotion()) {
94
+ offset = 0;
95
+ return;
96
+ }
97
+ settling = true;
98
+ offset = 0;
99
+ settleTimer = setTimeout(() => (settling = false), 220);
100
+ }
101
+
102
+ function onpointerdown(e: PointerEvent) {
103
+ swallowClick = false;
104
+ if (disabled || e.button !== 0 || pointerId !== null) return;
105
+ if (!leadingAction && !trailingAction) return;
106
+ const el = e.currentTarget as HTMLElement;
107
+ pointerId = e.pointerId;
108
+ startX = e.clientX;
109
+ startY = e.clientY;
110
+ width = el.clientWidth || FALLBACK_WIDTH;
111
+ locked = null;
112
+ clearTimeout(settleTimer);
113
+ settling = false;
114
+ }
115
+
116
+ function onpointermove(e: PointerEvent) {
117
+ if (pointerId !== e.pointerId) return;
118
+ const el = e.currentTarget as HTMLElement;
119
+ const dx = e.clientX - startX;
120
+ const dy = e.clientY - startY;
121
+ if (locked === null) {
122
+ if (Math.hypot(dx, dy) < SLOP) return;
123
+ if (Math.abs(dy) >= Math.abs(dx)) {
124
+ locked = 'y';
125
+ release(el);
126
+ return;
127
+ }
128
+ locked = 'x';
129
+ dragging = true;
130
+ swallowClick = true;
131
+ if (typeof el.setPointerCapture === 'function') {
132
+ try {
133
+ el.setPointerCapture(e.pointerId);
134
+ } catch {}
135
+ }
136
+ }
137
+ if (locked !== 'x') return;
138
+ const max = leadingAction ? width : 0;
139
+ const min = trailingAction ? -width : 0;
140
+ offset = Math.max(min, Math.min(max, dx));
141
+ }
142
+
143
+ function onpointerup(e: PointerEvent) {
144
+ if (pointerId !== e.pointerId) return;
145
+ const el = e.currentTarget as HTMLElement;
146
+ const fire = locked === 'x' && armed ? active : undefined;
147
+ release(el);
148
+ settle();
149
+ fire?.onaction();
150
+ }
151
+
152
+ function onpointercancel(e: PointerEvent) {
153
+ if (pointerId !== e.pointerId) return;
154
+ release(e.currentTarget as HTMLElement);
155
+ settle();
156
+ }
157
+
158
+ function onclickcapture(e: MouseEvent) {
159
+ if (!swallowClick) return;
160
+ swallowClick = false;
161
+ e.stopPropagation();
162
+ e.preventDefault();
163
+ }
164
+
165
+ function onlostpointercapture() {
166
+ if (pointerId === null) return;
167
+ pointerId = null;
168
+ locked = null;
169
+ dragging = false;
170
+ settle();
171
+ }
172
+ </script>
173
+
174
+ <div
175
+ data-tsu="SwipeRow"
176
+ class="swiperow {klass}"
177
+ class:swiperow--dragging={dragging}
178
+ class:swiperow--settling={settling}
179
+ class:swiperow--armed={armed}
180
+ class:swiperow--leading={side === 'leading'}
181
+ class:swiperow--trailing={side === 'trailing'}
182
+ class:swiperow-tone-accent={tone === 'accent'}
183
+ class:swiperow-tone-success={tone === 'ok'}
184
+ class:swiperow-tone-info={tone === 'info'}
185
+ class:swiperow-tone-warn={tone === 'warn'}
186
+ class:swiperow-tone-danger={tone === 'danger'}
187
+ style="--swiperow-x: {offset}px;{styleProp ?? ''}"
188
+ {onpointerdown}
189
+ {onpointermove}
190
+ {onpointerup}
191
+ {onpointercancel}
192
+ {onlostpointercapture}
193
+ {onclickcapture}
194
+ {...rest}
195
+ >
196
+ {#if leadingAction}
197
+ <div class="swiperow-pane swiperow-pane--leading" aria-hidden="true" data-part="leading">
198
+ {#if leading}
199
+ {@render leading(leadingAction, side === 'leading' && armed)}
200
+ {:else}
201
+ {#if leadingAction.icon}<Icon name={leadingAction.icon} />{/if}
202
+ <span class="swiperow-pane-label">{leadingAction.label}</span>
203
+ {/if}
204
+ </div>
205
+ {/if}
206
+ {#if trailingAction}
207
+ <div class="swiperow-pane swiperow-pane--trailing" aria-hidden="true" data-part="trailing">
208
+ {#if trailing}
209
+ {@render trailing(trailingAction, side === 'trailing' && armed)}
210
+ {:else}
211
+ {#if trailingAction.icon}<Icon name={trailingAction.icon} />{/if}
212
+ <span class="swiperow-pane-label">{trailingAction.label}</span>
213
+ {/if}
214
+ </div>
215
+ {/if}
216
+ <div class="swiperow-content" data-part="content">
217
+ {@render children()}
218
+ </div>
219
+ {#if leadingAction || trailingAction}
220
+ <div class="swiperow-actions" data-part="actions">
221
+ {#if leadingAction}
222
+ <button type="button" class="swiperow-sr" data-part="leading-button" {disabled} onclick={() => leadingAction?.onaction()}>
223
+ {leadingAction.label}
224
+ </button>
225
+ {/if}
226
+ {#if trailingAction}
227
+ <button type="button" class="swiperow-sr" data-part="trailing-button" {disabled} onclick={() => trailingAction?.onaction()}>
228
+ {trailingAction.label}
229
+ </button>
230
+ {/if}
231
+ </div>
232
+ {/if}
233
+ </div>
234
+
235
+ <style>
236
+ .swiperow {
237
+ --swiperow-tone: var(--bg-elevated-2);
238
+ --swiperow-ink: var(--text);
239
+ position: relative;
240
+ overflow: hidden;
241
+ touch-action: pan-y;
242
+ user-select: none;
243
+ -webkit-user-select: none;
244
+ }
245
+ .swiperow-tone-accent {
246
+ --swiperow-tone: var(--accent);
247
+ --swiperow-ink: var(--text-on-accent);
248
+ }
249
+ .swiperow-tone-success {
250
+ --swiperow-tone: var(--ok);
251
+ --swiperow-ink: var(--bg);
252
+ }
253
+ .swiperow-tone-info {
254
+ --swiperow-tone: var(--info);
255
+ --swiperow-ink: var(--bg);
256
+ }
257
+ .swiperow-tone-warn {
258
+ --swiperow-tone: var(--warn);
259
+ --swiperow-ink: var(--bg);
260
+ }
261
+ .swiperow-tone-danger {
262
+ --swiperow-tone: var(--danger);
263
+ --swiperow-ink: var(--bg);
264
+ }
265
+ .swiperow-pane {
266
+ position: absolute;
267
+ inset: 0;
268
+ display: flex;
269
+ align-items: center;
270
+ gap: var(--sp-2);
271
+ padding: 0 var(--sp-4);
272
+ background: var(--swiperow-tone);
273
+ color: var(--swiperow-ink);
274
+ font-size: var(--fs-sm);
275
+ font-weight: 600;
276
+ visibility: hidden;
277
+ pointer-events: none;
278
+ }
279
+ .swiperow-pane--leading {
280
+ justify-content: flex-start;
281
+ }
282
+ .swiperow-pane--trailing {
283
+ justify-content: flex-end;
284
+ }
285
+ .swiperow--leading .swiperow-pane--leading,
286
+ .swiperow--trailing .swiperow-pane--trailing {
287
+ visibility: visible;
288
+ }
289
+ .swiperow-pane :global(svg) {
290
+ transition: transform 120ms var(--ease);
291
+ }
292
+ .swiperow--armed .swiperow-pane :global(svg) {
293
+ transform: scale(1.2);
294
+ }
295
+ .swiperow-content {
296
+ position: relative;
297
+ background: var(--bg);
298
+ transform: translate3d(var(--swiperow-x), 0, 0);
299
+ will-change: transform;
300
+ }
301
+ .swiperow--settling .swiperow-content {
302
+ transition: transform 200ms var(--ease);
303
+ }
304
+ .swiperow--dragging .swiperow-content {
305
+ cursor: grabbing;
306
+ }
307
+ .swiperow-actions {
308
+ position: absolute;
309
+ inset: 0 0 0 auto;
310
+ display: flex;
311
+ align-items: center;
312
+ gap: var(--sp-1);
313
+ padding: 0 var(--sp-2);
314
+ pointer-events: none;
315
+ }
316
+ .swiperow-sr {
317
+ position: absolute;
318
+ width: 1px;
319
+ height: 1px;
320
+ padding: 0;
321
+ margin: -1px;
322
+ overflow: hidden;
323
+ clip: rect(0, 0, 0, 0);
324
+ white-space: nowrap;
325
+ border: 0;
326
+ }
327
+ .swiperow-sr:focus-visible {
328
+ position: static;
329
+ width: auto;
330
+ height: auto;
331
+ margin: 0;
332
+ padding: var(--sp-1) var(--sp-2);
333
+ clip: auto;
334
+ pointer-events: auto;
335
+ font: inherit;
336
+ font-size: var(--fs-sm);
337
+ color: var(--swiperow-ink);
338
+ background: var(--swiperow-tone);
339
+ border-radius: var(--r-sm);
340
+ outline: 2px solid var(--accent);
341
+ outline-offset: 1px;
342
+ }
343
+ @media (prefers-reduced-motion: reduce) {
344
+ .swiperow--settling .swiperow-content,
345
+ .swiperow-pane :global(svg) {
346
+ transition: none;
347
+ }
348
+ }
349
+ </style>
@@ -0,0 +1,37 @@
1
+ import type { IconName } from '../atoms/Icon.svelte';
2
+ import type { Tone } from '../../tone';
3
+ export interface SwipeAction {
4
+ label: string;
5
+ icon?: IconName;
6
+ tone?: Tone;
7
+ onaction: () => void;
8
+ }
9
+ /**
10
+ * List row with a hidden action on each side, revealed by a horizontal swipe.
11
+ * A swipe past `threshold` (fraction of the row width) fires the action on
12
+ * release; a shorter one snaps back. The axis is locked after a few pixels:
13
+ * a drag that is more vertical than horizontal is left to the scroller.
14
+ * The same actions are reachable as visually hidden buttons for keyboard
15
+ * and screen-reader users.
16
+ */
17
+ import type { Snippet } from 'svelte';
18
+ import type { HTMLAttributes } from 'svelte/elements';
19
+ type Own = {
20
+ children: Snippet;
21
+ /** Revealed on a rightward swipe. */
22
+ leadingAction?: SwipeAction;
23
+ /** Revealed on a leftward swipe. */
24
+ trailingAction?: SwipeAction;
25
+ /** Custom pane content; defaults to the action's icon and label. */
26
+ leading?: Snippet<[SwipeAction, boolean]>;
27
+ trailing?: Snippet<[SwipeAction, boolean]>;
28
+ /** Fraction of the row width the content must travel to fire. */
29
+ threshold?: number;
30
+ disabled?: boolean;
31
+ class?: string;
32
+ style?: string;
33
+ };
34
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
35
+ declare const SwipeRow: import("svelte").Component<$$ComponentProps, {}, "">;
36
+ type SwipeRow = ReturnType<typeof SwipeRow>;
37
+ export default SwipeRow;
@@ -86,6 +86,12 @@
86
86
  let listEl = $state<HTMLDivElement | null>(null);
87
87
  const baseId = `tabs-${Math.random().toString(36).slice(2, 8)}`;
88
88
  const panelId = $derived(panel ? `${baseId}-panel` : undefined);
89
+ // The roving tab stop: the selected tab, or the first enabled one when the
90
+ // selection is unknown or disabled, so the strip never drops out of the Tab order.
91
+ const stop = $derived(
92
+ tabs.find((t) => t.id === value && !t.disabled) ?? tabs.find((t) => !t.disabled)
93
+ );
94
+ const tabId = (id: string) => `${baseId}-tab-${id}`;
89
95
 
90
96
  function canClose(t: TabItem): boolean {
91
97
  return t.closable ?? closable;
@@ -95,7 +101,7 @@
95
101
  if (tabs.find((t) => t.id === id)?.disabled) return;
96
102
  value = id;
97
103
  queueMicrotask(() => {
98
- const el = listEl?.querySelector<HTMLElement>(`#${baseId}-tab-${id}`);
104
+ const el = listEl?.ownerDocument.getElementById(tabId(id));
99
105
  // The list scrolls when it is too narrow, so keyboard selection has to
100
106
  // bring the tab back into view or it moves somewhere unseen.
101
107
  el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
@@ -129,7 +135,7 @@
129
135
  close(id);
130
136
  }
131
137
  function onkeydown(e: KeyboardEvent) {
132
- const i = tabs.findIndex((t) => t.id === value);
138
+ const i = stop ? tabs.indexOf(stop) : -1;
133
139
  if (i < 0) return;
134
140
  if (e.key === 'Delete') {
135
141
  if (!canClose(tabs[i])) return;
@@ -169,7 +175,7 @@
169
175
  aria-selected={value === t.id}
170
176
  aria-controls={panelId}
171
177
  aria-disabled={t.disabled || undefined}
172
- tabindex={value === t.id ? 0 : -1}
178
+ tabindex={stop?.id === t.id ? 0 : -1}
173
179
  class="tab closable"
174
180
  class:selected={value === t.id}
175
181
  class:disabled={t.disabled}
@@ -202,7 +208,7 @@
202
208
  aria-selected={value === t.id}
203
209
  aria-controls={panelId}
204
210
  disabled={t.disabled}
205
- tabindex={value === t.id ? 0 : -1}
211
+ tabindex={stop?.id === t.id ? 0 : -1}
206
212
  class="tab"
207
213
  class:selected={value === t.id}
208
214
  onclick={() => select(t.id)}
@@ -217,7 +223,7 @@
217
223
  {/if}
218
224
  </div>
219
225
  {#if panel}
220
- <div role="tabpanel" id={panelId} tabindex="0" class="tabpanel {panelClass}" class:pad-none={panelPadding === 'none'} class:pad-sm={panelPadding === 'sm'}>
226
+ <div role="tabpanel" id={panelId} aria-labelledby={value === undefined ? undefined : tabId(value)} tabindex="0" class="tabpanel {panelClass}" class:pad-none={panelPadding === 'none'} class:pad-sm={panelPadding === 'sm'}>
221
227
  {#if value !== undefined}{@render panel(value)}{/if}
222
228
  </div>
223
229
  {/if}
@@ -130,7 +130,7 @@
130
130
  <!-- Unparseable input: degrade to an inert dash rather than empty text. -->
131
131
  <time class="{cls} ts-invalid" data-tsu="Timestamp" style={styleProp} style:font-size={sizeVar}>—</time>
132
132
  {:else if showPopover}
133
- <Popover label="Timestamp details" placement="bottom-start" bare>
133
+ <Popover label="{label}, details" placement="bottom-start" bare>
134
134
  {#snippet trigger()}
135
135
  <time class="{cls} ts-trigger" data-tsu="Timestamp" style={styleProp} style:font-size={sizeVar} datetime={machine}>{label}</time>
136
136
  {/snippet}
@@ -1,9 +1,11 @@
1
1
  <script lang="ts">
2
2
  // Renders the toast queue bottom-centered in two live regions: polite for
3
3
  // ok/info/neutral, assertive (role=alert) for errors. Mount once near the
4
- // app root. The stack lives in a manual popover so it paints in the
5
- // top layer above an open Modal <dialog>; without the Popover API the
6
- // attribute is ignored and the fixed/z-index positioning applies instead.
4
+ // app root. The stack lives in a manual popover so it paints in the top
5
+ // layer; without the Popover API the attribute is ignored and the
6
+ // fixed/z-index positioning applies instead. While a modal <dialog> is open
7
+ // everything outside it is inert, top layer included, so the stack moves
8
+ // into the topmost modal dialog and back to its host when it closes.
7
9
  import { toasts } from '../../stores/toast.svelte';
8
10
  import Button from '../atoms/Button.svelte';
9
11
  import Card from '../atoms/Card.svelte';
@@ -11,16 +13,61 @@
11
13
 
12
14
  let { class: klass = '', style: styleProp = '' }: { class?: string; style?: string } = $props();
13
15
 
16
+ let host: HTMLDivElement | undefined = $state();
14
17
  let el: HTMLDivElement | undefined = $state();
15
18
  const polite = $derived(toasts.items.filter((t) => t.tone !== 'error'));
16
19
  const assertive = $derived(toasts.items.filter((t) => t.tone === 'error'));
17
20
 
18
- $effect(() => {
19
- if (!el || typeof el.showPopover !== 'function') return;
21
+ function topModal(): HTMLDialogElement | null {
22
+ let found: HTMLDialogElement | null = null;
23
+ for (const d of document.querySelectorAll<HTMLDialogElement>('dialog[open]')) {
24
+ try {
25
+ if (d.matches(':modal')) found = d;
26
+ } catch {}
27
+ }
28
+ return found;
29
+ }
30
+
31
+ let shown = false;
32
+ function show(stack: HTMLDivElement) {
33
+ if (typeof stack.showPopover !== 'function') return;
20
34
  try {
21
- if (toasts.items.length) el.showPopover();
22
- else el.hidePopover();
35
+ stack.showPopover();
36
+ shown = true;
23
37
  } catch {}
38
+ }
39
+ function hide(stack: HTMLDivElement) {
40
+ if (!shown || typeof stack.hidePopover !== 'function') return;
41
+ shown = false;
42
+ try {
43
+ stack.hidePopover();
44
+ } catch {}
45
+ }
46
+
47
+ $effect(() => {
48
+ const stack = el;
49
+ const home = host;
50
+ if (!stack || !home) return;
51
+ if (!toasts.items.length) {
52
+ hide(stack);
53
+ return;
54
+ }
55
+ const place = () => {
56
+ const target = topModal() ?? home;
57
+ if (stack.parentNode !== target) {
58
+ hide(stack);
59
+ target.appendChild(stack);
60
+ }
61
+ show(stack);
62
+ };
63
+ place();
64
+ const observer = new MutationObserver(place);
65
+ observer.observe(document.body, { attributes: true, attributeFilter: ['open'], subtree: true });
66
+ return () => observer.disconnect();
67
+ });
68
+
69
+ $effect(() => () => {
70
+ if (el && host && el.parentNode !== host) host.appendChild(el);
24
71
  });
25
72
  </script>
26
73
 
@@ -62,16 +109,21 @@
62
109
  {/if}
63
110
  {/snippet}
64
111
 
65
- <div class="toaster {klass}" style={styleProp} bind:this={el} popover="manual" data-tsu="Toaster">
66
- <div class="region" role="status" aria-live="polite">
67
- {#each polite as t (t.id)}{@render toast(t)}{/each}
68
- </div>
69
- <div class="region" role="alert" aria-live="assertive">
70
- {#each assertive as t (t.id)}{@render toast(t)}{/each}
112
+ <div class="toaster-host" bind:this={host}>
113
+ <div class="toaster {klass}" style={styleProp} bind:this={el} popover="manual" data-tsu="Toaster">
114
+ <div class="region" role="status" aria-live="polite">
115
+ {#each polite as t (t.id)}{@render toast(t)}{/each}
116
+ </div>
117
+ <div class="region" role="alert" aria-live="assertive">
118
+ {#each assertive as t (t.id)}{@render toast(t)}{/each}
119
+ </div>
71
120
  </div>
72
121
  </div>
73
122
 
74
123
  <style>
124
+ .toaster-host {
125
+ display: contents;
126
+ }
75
127
  .toaster {
76
128
  position: fixed;
77
129
  inset: auto;
@@ -142,4 +194,9 @@
142
194
  opacity: 0;
143
195
  }
144
196
  }
197
+ @media (prefers-reduced-motion: reduce) {
198
+ .toaster :global(.toast) {
199
+ animation: none;
200
+ }
201
+ }
145
202
  </style>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  // Lightweight tooltip. Shows on hover (after a short delay) and on keyboard
3
- // focus of the trigger; hides on blur / mouseleave / Escape. The trigger is
3
+ // focus of the trigger; hides on blur / mouseleave / Escape (anywhere). The trigger is
4
4
  // any snippet; an action wires `aria-describedby` onto its first focusable
5
5
  // element so screen readers announce the text.
6
6
  //
@@ -63,6 +63,11 @@
63
63
  if (wrapEl && tipEl) place(wrapEl, tipEl, `${placement}-center`, 6);
64
64
  }
65
65
 
66
+ // Escape must dismiss a hover-opened bubble even while focus sits elsewhere.
67
+ function onEscape(e: KeyboardEvent) {
68
+ if (e.key === 'Escape') hideNow();
69
+ }
70
+
66
71
  function show() {
67
72
  clearTimeout(hideTimer); // pointer came back within the grace period
68
73
  clearTimeout(showTimer);
@@ -72,15 +77,20 @@
72
77
  reposition();
73
78
  addEventListener('scroll', reposition, true);
74
79
  addEventListener('resize', reposition);
80
+ document.addEventListener('keydown', onEscape);
75
81
  }, delay);
76
82
  }
77
83
  function hideNow() {
78
84
  clearTimeout(showTimer);
79
85
  clearTimeout(hideTimer);
80
- if (tipEl?.matches(':popover-open')) tipEl.hidePopover();
86
+ try {
87
+ if (tipEl?.matches(':popover-open')) tipEl.hidePopover();
88
+ } catch {}
81
89
  removeEventListener('scroll', reposition, true);
82
90
  removeEventListener('resize', reposition);
91
+ document.removeEventListener('keydown', onEscape);
83
92
  }
93
+ $effect(() => () => hideNow());
84
94
  // Debounced close: cancelled if the pointer/focus enters the panel (rich
85
95
  // mode) or returns to the trigger before `closeDelay` elapses.
86
96
  function scheduleHide() {
@@ -104,21 +114,23 @@
104
114
  node.tabIndex = 0;
105
115
  target = node;
106
116
  }
107
- target.setAttribute('aria-describedby', id);
108
- const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
117
+ // The trigger may already be described (a Field hint/error): append our
118
+ // id to the token list and take back only that token on destroy.
119
+ const describedBy = () => (target.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
120
+ target.setAttribute('aria-describedby', [...describedBy().filter((t) => t !== id), id].join(' '));
109
121
  node.addEventListener('mouseenter', show);
110
122
  node.addEventListener('mouseleave', scheduleHide);
111
123
  node.addEventListener('focusin', show);
112
124
  node.addEventListener('focusout', scheduleHide);
113
- node.addEventListener('keydown', onkey);
114
125
  return {
115
126
  destroy() {
116
- target.removeAttribute('aria-describedby');
127
+ const rest = describedBy().filter((t) => t !== id);
128
+ if (rest.length) target.setAttribute('aria-describedby', rest.join(' '));
129
+ else target.removeAttribute('aria-describedby');
117
130
  node.removeEventListener('mouseenter', show);
118
131
  node.removeEventListener('mouseleave', scheduleHide);
119
132
  node.removeEventListener('focusin', show);
120
133
  node.removeEventListener('focusout', scheduleHide);
121
- node.removeEventListener('keydown', onkey);
122
134
  }
123
135
  };
124
136
  }
@@ -126,19 +138,16 @@
126
138
  // Action for the panel: in rich mode it accepts the pointer/focus, which
127
139
  // cancels the pending close (hovercard persistence).
128
140
  function panel(node: HTMLElement) {
129
- const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
130
141
  node.addEventListener('mouseenter', cancelHide);
131
142
  node.addEventListener('mouseleave', scheduleHide);
132
143
  node.addEventListener('focusin', cancelHide);
133
144
  node.addEventListener('focusout', scheduleHide);
134
- node.addEventListener('keydown', onkey);
135
145
  return {
136
146
  destroy() {
137
147
  node.removeEventListener('mouseenter', cancelHide);
138
148
  node.removeEventListener('mouseleave', scheduleHide);
139
149
  node.removeEventListener('focusin', cancelHide);
140
150
  node.removeEventListener('focusout', scheduleHide);
141
- node.removeEventListener('keydown', onkey);
142
151
  }
143
152
  };
144
153
  }