@dorsk/tsumikit 0.72.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 (47) hide show
  1. package/README.md +21 -0
  2. package/dist/components/atoms/Icon.svelte +5 -0
  3. package/dist/components/atoms/Icon.svelte.d.ts +5 -0
  4. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  5. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  6. package/dist/components/layouts/AppShell.svelte +3 -2
  7. package/dist/components/layouts/ResizablePanel.svelte +5 -4
  8. package/dist/components/molecules/Accordion.svelte +2 -1
  9. package/dist/components/molecules/AttachmentList.svelte +1 -0
  10. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  11. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  12. package/dist/components/molecules/CapBar.svelte +3 -1
  13. package/dist/components/molecules/Composer.svelte +27 -5
  14. package/dist/components/molecules/Drawer.svelte +10 -1
  15. package/dist/components/molecules/Dropzone.svelte +2 -1
  16. package/dist/components/molecules/FilterInput.svelte +10 -1
  17. package/dist/components/molecules/Metric.svelte +28 -3
  18. package/dist/components/molecules/Modal.svelte +20 -4
  19. package/dist/components/molecules/Popover.svelte +7 -3
  20. package/dist/components/molecules/SectionHeader.svelte +29 -14
  21. package/dist/components/molecules/SeekSlider.svelte +370 -0
  22. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  23. package/dist/components/molecules/SegmentedControl.svelte +10 -6
  24. package/dist/components/molecules/SelectButton.svelte +33 -22
  25. package/dist/components/molecules/Tabs.svelte +11 -5
  26. package/dist/components/molecules/Timestamp.svelte +1 -1
  27. package/dist/components/molecules/Toaster.svelte +70 -13
  28. package/dist/components/molecules/Tooltip.svelte +19 -10
  29. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  30. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  31. package/dist/components/molecules/combobox-keyboard.js +2 -1
  32. package/dist/index.d.ts +4 -0
  33. package/dist/index.js +4 -0
  34. package/dist/media-time.d.ts +6 -0
  35. package/dist/media-time.js +25 -0
  36. package/dist/query/edit.js +1 -16
  37. package/dist/query/parser.js +68 -28
  38. package/dist/query/query.js +9 -5
  39. package/dist/query/quotes.d.ts +24 -0
  40. package/dist/query/quotes.js +78 -0
  41. package/dist/query/single.js +1 -3
  42. package/dist/query/suggest.js +25 -32
  43. package/dist/stores/fontscale.svelte.js +3 -2
  44. package/dist/stores/storage.d.ts +2 -0
  45. package/dist/stores/storage.js +16 -0
  46. package/dist/stores/theme.svelte.js +4 -7
  47. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  // swatch and right-aligned actions on one wrapping row. `divider` underlines
5
5
  // it, `sticky` pins it under the app header and publishes its height as
6
6
  // `--section-header-h` on the parent so sibling sticky content can offset
7
- // itself. `collapsible` turns the title into a disclosure button that
7
+ // itself. `collapsible` puts a disclosure button inside the heading that
8
8
  // toggles the `children` block rendered beneath. `variant="group"` is the
9
9
  // non-wrapping list-group row: `lead` · title · count · flexible rule · actions.
10
10
  import type { Snippet } from 'svelte';
@@ -90,16 +90,7 @@
90
90
  >
91
91
  <div class="sh-row">
92
92
  {#if collapsible}
93
- <button
94
- type="button"
95
- class="sh-toggle"
96
- aria-expanded={open}
97
- aria-controls={panelId}
98
- onclick={() => (open = !open)}
99
- >
100
- <Icon name="chevron-right" class="sh-chevron" />
101
- {@render head()}
102
- </button>
93
+ <span class="sh-head">{@render head()}</span>
103
94
  {:else}
104
95
  {@render head()}
105
96
  {/if}
@@ -125,10 +116,28 @@
125
116
  {#if hue !== undefined}
126
117
  <span class="sh-swatch" style:--sh-hue={hue} aria-hidden="true"></span>
127
118
  {/if}
128
- {#if icon}
129
- <span class="sh-icon" aria-hidden="true"><Icon name={icon} /></span>
119
+ {#if collapsible}
120
+ <Heading {level} {size} class="sh-title">
121
+ <button
122
+ type="button"
123
+ class="sh-toggle"
124
+ aria-expanded={open}
125
+ aria-controls={open ? panelId : undefined}
126
+ onclick={() => (open = !open)}
127
+ >
128
+ <Icon name="chevron-right" class="sh-chevron" />
129
+ {#if icon}
130
+ <span class="sh-icon" aria-hidden="true"><Icon name={icon} /></span>
131
+ {/if}
132
+ {text}
133
+ </button>
134
+ </Heading>
135
+ {:else}
136
+ {#if icon}
137
+ <span class="sh-icon" aria-hidden="true"><Icon name={icon} /></span>
138
+ {/if}
139
+ <Heading {level} {size} class="sh-title">{text}</Heading>
130
140
  {/if}
131
- <Heading {level} {size} class="sh-title">{text}</Heading>
132
141
  {#if count !== undefined}
133
142
  <Text tone="faint" weight="normal" numeric class="sh-count">{count}</Text>
134
143
  {/if}
@@ -233,6 +242,12 @@
233
242
  gap: var(--sp-2);
234
243
  margin-inline-start: auto;
235
244
  }
245
+ .sh-head {
246
+ display: inline-flex;
247
+ align-items: center;
248
+ gap: var(--sp-2);
249
+ min-width: 0;
250
+ }
236
251
  .sh-toggle {
237
252
  display: inline-flex;
238
253
  align-items: center;
@@ -0,0 +1,370 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Media scrubber. Dragging previews (`oninput`) without seeking; the player
4
+ * seeks only on `onchange`, fired once per release or keyboard step. The
5
+ * hit slab is tall enough for touch while the visible track stays thin.
6
+ */
7
+ import type { Snippet } from 'svelte';
8
+ import type { HTMLAttributes } from 'svelte/elements';
9
+ import { describeMediaTime, formatMediaTime, isLiveDuration } from '../../media-time';
10
+
11
+ type Own = {
12
+ /** Position in seconds. Updated only on commit, never while dragging. */
13
+ value?: number;
14
+ /** Duration in seconds. `null`/`Infinity`/`0` = live or unknown: the track is disabled. */
15
+ max?: number | null;
16
+ /** Buffered `[start, end]` ranges in seconds, drawn as a muted fill. */
17
+ buffered?: [number, number][];
18
+ /** Seconds per arrow key. */
19
+ step?: number;
20
+ /** Fraction of the duration per PageUp/PageDown. */
21
+ bigStep?: number;
22
+ disabled?: boolean;
23
+ size?: 'sm' | 'md';
24
+ label?: string;
25
+ formatTime?: (seconds: number, ref?: number) => string;
26
+ /** Fired on every pointer move while scrubbing with the previewed time. */
27
+ oninput?: (seconds: number) => void;
28
+ /** Fired once on release or per keyboard step with the committed time. */
29
+ onchange?: (seconds: number) => void;
30
+ onscrubstart?: () => void;
31
+ onscrubend?: () => void;
32
+ /** Rendered above the time in the hover/drag tooltip (thumbnail). */
33
+ previewSnippet?: Snippet<[number]>;
34
+ class?: string;
35
+ style?: string;
36
+ };
37
+
38
+ let {
39
+ value = $bindable(0),
40
+ max = null,
41
+ buffered = [],
42
+ step = 5,
43
+ bigStep = 0.1,
44
+ disabled = false,
45
+ size = 'md',
46
+ label = 'Seek',
47
+ formatTime = formatMediaTime,
48
+ oninput,
49
+ onchange,
50
+ onscrubstart,
51
+ onscrubend,
52
+ previewSnippet,
53
+ class: klass = '',
54
+ style: styleProp,
55
+ ...rest
56
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
57
+
58
+ let dragging = $state(false);
59
+ let preview = $state(0);
60
+ let hoverFrac = $state<number | null>(null);
61
+ let pointerId: number | null = null;
62
+
63
+ const live = $derived(isLiveDuration(max));
64
+ const duration = $derived(live ? 0 : (max as number));
65
+ const inert = $derived(disabled || live);
66
+ const clamp = (v: number) => (live ? Math.max(0, v) : Math.min(duration, Math.max(0, v)));
67
+ const shown = $derived(clamp(dragging ? preview : value));
68
+ const frac = (v: number) => (duration > 0 ? clamp(v) / duration : 0);
69
+ const pct = $derived(frac(shown) * 100);
70
+ const tipValue = $derived(dragging ? shown : hoverFrac === null ? null : hoverFrac * duration);
71
+ const tipPct = $derived(tipValue === null ? 0 : frac(tipValue) * 100);
72
+ const ranges = $derived(
73
+ live
74
+ ? []
75
+ : buffered
76
+ .map(([a, b]) => [frac(a), frac(b)] as const)
77
+ .filter(([a, b]) => b > a)
78
+ );
79
+ const valueText = $derived(describeMediaTime(shown, live ? null : duration, formatTime));
80
+
81
+ function fracAt(el: HTMLElement, clientX: number) {
82
+ const r = el.getBoundingClientRect();
83
+ if (!r.width) return 0;
84
+ let f = (clientX - r.left) / r.width;
85
+ if (getComputedStyle(el).direction === 'rtl') f = 1 - f;
86
+ return Math.min(1, Math.max(0, f));
87
+ }
88
+
89
+ function commit(v: number) {
90
+ const next = clamp(v);
91
+ value = next;
92
+ onchange?.(next);
93
+ }
94
+
95
+ function endDrag(el: HTMLElement, fire: boolean) {
96
+ if (pointerId !== null && typeof el.releasePointerCapture === 'function') {
97
+ try {
98
+ el.releasePointerCapture(pointerId);
99
+ } catch {}
100
+ }
101
+ pointerId = null;
102
+ dragging = false;
103
+ onscrubend?.();
104
+ if (fire) commit(preview);
105
+ }
106
+
107
+ function onpointerdown(e: PointerEvent) {
108
+ if (inert || e.button !== 0 || pointerId !== null) return;
109
+ const el = e.currentTarget as HTMLElement;
110
+ e.preventDefault();
111
+ pointerId = e.pointerId;
112
+ dragging = true;
113
+ preview = fracAt(el, e.clientX) * duration;
114
+ if (typeof el.setPointerCapture === 'function') {
115
+ try {
116
+ el.setPointerCapture(e.pointerId);
117
+ } catch {}
118
+ }
119
+ el.focus?.({ preventScroll: true });
120
+ onscrubstart?.();
121
+ oninput?.(preview);
122
+ }
123
+
124
+ function onpointermove(e: PointerEvent) {
125
+ const el = e.currentTarget as HTMLElement;
126
+ if (pointerId === e.pointerId) {
127
+ preview = fracAt(el, e.clientX) * duration;
128
+ oninput?.(preview);
129
+ return;
130
+ }
131
+ if (inert || pointerId !== null) return;
132
+ hoverFrac = fracAt(el, e.clientX);
133
+ }
134
+
135
+ function onpointerup(e: PointerEvent) {
136
+ if (pointerId !== e.pointerId) return;
137
+ endDrag(e.currentTarget as HTMLElement, true);
138
+ }
139
+
140
+ function onpointercancel(e: PointerEvent) {
141
+ if (pointerId !== e.pointerId) return;
142
+ endDrag(e.currentTarget as HTMLElement, false);
143
+ }
144
+
145
+ function onlostpointercapture(e: PointerEvent) {
146
+ if (pointerId === null || pointerId !== e.pointerId) return;
147
+ pointerId = null;
148
+ dragging = false;
149
+ onscrubend?.();
150
+ commit(preview);
151
+ }
152
+
153
+ function onpointerleave() {
154
+ hoverFrac = null;
155
+ }
156
+
157
+ function onkeydown(e: KeyboardEvent) {
158
+ if (inert) return;
159
+ const base = dragging ? preview : value;
160
+ let next: number | null = null;
161
+ switch (e.key) {
162
+ case 'ArrowRight':
163
+ case 'ArrowUp':
164
+ next = base + step;
165
+ break;
166
+ case 'ArrowLeft':
167
+ case 'ArrowDown':
168
+ next = base - step;
169
+ break;
170
+ case 'PageUp':
171
+ next = base + bigStep * duration;
172
+ break;
173
+ case 'PageDown':
174
+ next = base - bigStep * duration;
175
+ break;
176
+ case 'Home':
177
+ next = 0;
178
+ break;
179
+ case 'End':
180
+ next = duration;
181
+ break;
182
+ }
183
+ if (next === null) return;
184
+ e.preventDefault();
185
+ commit(next);
186
+ }
187
+ </script>
188
+
189
+ <div
190
+ data-tsu="SeekSlider"
191
+ class="seek seek--{size} {klass}"
192
+ class:seek--dragging={dragging}
193
+ class:seek--inert={inert}
194
+ class:seek--tip={tipValue !== null}
195
+ style="--pct: {pct}; --tip: {tipPct};{styleProp ?? ''}"
196
+ role="slider"
197
+ tabindex={inert ? -1 : 0}
198
+ aria-label={label}
199
+ aria-orientation="horizontal"
200
+ aria-valuemin={0}
201
+ aria-valuemax={duration}
202
+ aria-valuenow={shown}
203
+ aria-valuetext={valueText}
204
+ aria-disabled={inert ? 'true' : undefined}
205
+ {onpointerdown}
206
+ {onpointermove}
207
+ {onpointerup}
208
+ {onpointercancel}
209
+ {onlostpointercapture}
210
+ {onpointerleave}
211
+ {onkeydown}
212
+ {...rest}
213
+ >
214
+ <span class="seek-track" data-part="track">
215
+ {#each ranges as [a, b], i (i)}
216
+ <i class="seek-buffered" data-part="buffered" style="--a: {a * 100}; --b: {b * 100}"></i>
217
+ {/each}
218
+ <i class="seek-played" data-part="played"></i>
219
+ </span>
220
+ <span class="seek-anchor seek-anchor--thumb" data-part="thumb-anchor">
221
+ <i class="seek-thumb" data-part="thumb"></i>
222
+ </span>
223
+ {#if tipValue !== null}
224
+ <span class="seek-anchor seek-anchor--tip" aria-hidden="true">
225
+ <span class="seek-tip" data-part="tip">
226
+ {#if previewSnippet}{@render previewSnippet(tipValue)}{/if}
227
+ <span class="seek-tip-time">{formatTime(tipValue, duration)}</span>
228
+ </span>
229
+ </span>
230
+ {/if}
231
+ </div>
232
+
233
+ <style>
234
+ .seek {
235
+ --seek-track: 3px;
236
+ --seek-track-active: 5px;
237
+ --seek-thumb: 12px;
238
+ --seek-slab: 1.25rem;
239
+ --seek-accent: var(--slider-accent, var(--accent));
240
+ position: relative;
241
+ display: block;
242
+ width: 100%;
243
+ min-height: var(--seek-slab);
244
+ cursor: pointer;
245
+ touch-action: none;
246
+ user-select: none;
247
+ -webkit-user-select: none;
248
+ outline: none;
249
+ }
250
+ .seek--md {
251
+ --seek-track: 4px;
252
+ --seek-track-active: 6px;
253
+ --seek-thumb: 14px;
254
+ --seek-slab: 1.5rem;
255
+ }
256
+ @media (pointer: coarse) {
257
+ .seek {
258
+ min-height: var(--touch-target-compact);
259
+ }
260
+ }
261
+ .seek--inert {
262
+ cursor: default;
263
+ opacity: 0.45;
264
+ }
265
+ .seek-track {
266
+ position: absolute;
267
+ inset-inline: 0;
268
+ top: 50%;
269
+ height: var(--seek-track);
270
+ margin-top: calc(var(--seek-track) / -2);
271
+ border-radius: var(--r-pill);
272
+ background: var(--bg-elevated-2);
273
+ overflow: hidden;
274
+ transition:
275
+ height 120ms var(--ease),
276
+ margin-top 120ms var(--ease);
277
+ }
278
+ .seek:hover .seek-track,
279
+ .seek:focus-visible .seek-track,
280
+ .seek--dragging .seek-track {
281
+ height: var(--seek-track-active);
282
+ margin-top: calc(var(--seek-track-active) / -2);
283
+ }
284
+ .seek--inert:hover .seek-track {
285
+ height: var(--seek-track);
286
+ margin-top: calc(var(--seek-track) / -2);
287
+ }
288
+ .seek-buffered,
289
+ .seek-played {
290
+ position: absolute;
291
+ inset-block: 0;
292
+ inset-inline-start: 0;
293
+ }
294
+ .seek-buffered {
295
+ inset-inline-start: calc(var(--a) * 1%);
296
+ width: calc((var(--b) - var(--a)) * 1%);
297
+ background: var(--border-strong);
298
+ }
299
+ .seek-played {
300
+ width: calc(var(--pct) * 1%);
301
+ background: var(--seek-accent);
302
+ }
303
+ .seek-anchor {
304
+ position: absolute;
305
+ top: 50%;
306
+ width: 0;
307
+ height: 0;
308
+ pointer-events: none;
309
+ }
310
+ .seek-anchor--thumb {
311
+ inset-inline-start: calc(var(--pct) * 1%);
312
+ }
313
+ .seek-anchor--tip {
314
+ inset-inline-start: calc(var(--tip) * 1%);
315
+ }
316
+ .seek-thumb {
317
+ position: absolute;
318
+ left: 0;
319
+ top: 0;
320
+ width: var(--seek-thumb);
321
+ height: var(--seek-thumb);
322
+ border-radius: 50%;
323
+ background: var(--seek-accent);
324
+ border: 2px solid var(--bg);
325
+ box-shadow: var(--shadow-sm);
326
+ transform: translate(-50%, -50%) scale(0.6);
327
+ opacity: 0;
328
+ transition:
329
+ opacity 120ms var(--ease),
330
+ transform 120ms var(--ease);
331
+ }
332
+ .seek:hover .seek-thumb,
333
+ .seek:focus-visible .seek-thumb,
334
+ .seek--dragging .seek-thumb {
335
+ opacity: 1;
336
+ transform: translate(-50%, -50%) scale(1);
337
+ }
338
+ .seek--inert .seek-thumb {
339
+ opacity: 0;
340
+ }
341
+ .seek:focus-visible .seek-thumb {
342
+ outline: var(--focus-ring);
343
+ outline-offset: 2px;
344
+ }
345
+ .seek-tip {
346
+ position: absolute;
347
+ left: 0;
348
+ bottom: calc(var(--seek-thumb) / 2 + var(--sp-2));
349
+ transform: translateX(-50%);
350
+ display: flex;
351
+ flex-direction: column;
352
+ align-items: center;
353
+ gap: var(--sp-1);
354
+ padding: var(--sp-1) var(--sp-2);
355
+ border-radius: var(--r-sm);
356
+ background: var(--bg-elevated);
357
+ border: 1px solid var(--border);
358
+ box-shadow: var(--shadow-md);
359
+ color: var(--text);
360
+ font-size: var(--fs-xs);
361
+ font-variant-numeric: tabular-nums;
362
+ white-space: nowrap;
363
+ }
364
+ @media (prefers-reduced-motion: reduce) {
365
+ .seek-track,
366
+ .seek-thumb {
367
+ transition: none;
368
+ }
369
+ }
370
+ </style>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Media scrubber. Dragging previews (`oninput`) without seeking; the player
3
+ * seeks only on `onchange`, fired once per release or keyboard step. The
4
+ * hit slab is tall enough for touch while the visible track stays thin.
5
+ */
6
+ import type { Snippet } from 'svelte';
7
+ import type { HTMLAttributes } from 'svelte/elements';
8
+ type Own = {
9
+ /** Position in seconds. Updated only on commit, never while dragging. */
10
+ value?: number;
11
+ /** Duration in seconds. `null`/`Infinity`/`0` = live or unknown: the track is disabled. */
12
+ max?: number | null;
13
+ /** Buffered `[start, end]` ranges in seconds, drawn as a muted fill. */
14
+ buffered?: [number, number][];
15
+ /** Seconds per arrow key. */
16
+ step?: number;
17
+ /** Fraction of the duration per PageUp/PageDown. */
18
+ bigStep?: number;
19
+ disabled?: boolean;
20
+ size?: 'sm' | 'md';
21
+ label?: string;
22
+ formatTime?: (seconds: number, ref?: number) => string;
23
+ /** Fired on every pointer move while scrubbing with the previewed time. */
24
+ oninput?: (seconds: number) => void;
25
+ /** Fired once on release or per keyboard step with the committed time. */
26
+ onchange?: (seconds: number) => void;
27
+ onscrubstart?: () => void;
28
+ onscrubend?: () => void;
29
+ /** Rendered above the time in the hover/drag tooltip (thumbnail). */
30
+ previewSnippet?: Snippet<[number]>;
31
+ class?: string;
32
+ style?: string;
33
+ };
34
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
35
+ declare const SeekSlider: import("svelte").Component<$$ComponentProps, {}, "value">;
36
+ type SeekSlider = ReturnType<typeof SeekSlider>;
37
+ export default SeekSlider;
@@ -88,19 +88,23 @@
88
88
 
89
89
  let listEl = $state<HTMLDivElement | null>(null);
90
90
  const baseId = `seg-${Math.random().toString(36).slice(2, 8)}`;
91
+ // The roving tab stop: the selected option, or the first enabled one when the
92
+ // selection is unknown or disabled, so the group never drops out of the Tab order.
93
+ const stop = $derived(
94
+ options.find((o) => o.value === value && !o.disabled) ?? options.find((o) => !o.disabled)
95
+ );
96
+ const segId = (val: string) => `${baseId}-${val}`;
91
97
 
92
98
  function select(val: string, focus = false) {
93
99
  if (options.find((o) => o.value === val)?.disabled) return;
94
100
  value = val;
95
101
  onchange?.(val);
96
102
  if (focus) {
97
- queueMicrotask(() =>
98
- listEl?.querySelector<HTMLButtonElement>(`#${baseId}-${val}`)?.focus()
99
- );
103
+ queueMicrotask(() => listEl?.ownerDocument.getElementById(segId(val))?.focus());
100
104
  }
101
105
  }
102
106
  function onkeydown(e: KeyboardEvent) {
103
- const i = options.findIndex((o) => o.value === value);
107
+ const i = stop ? options.indexOf(stop) : -1;
104
108
  const next = nextEnabledSegment(options, i, e.key);
105
109
  if (next === undefined) return;
106
110
  e.preventDefault();
@@ -130,11 +134,11 @@
130
134
  {@const selected = value === o.value}
131
135
  <button
132
136
  type="button"
133
- id="{baseId}-{o.value}"
137
+ id={segId(o.value)}
134
138
  role="radio"
135
139
  aria-checked={selected}
136
140
  aria-label={variant === 'icon' && !o.label ? o.value : o.label}
137
- tabindex={selected ? 0 : -1}
141
+ tabindex={stop?.value === o.value ? 0 : -1}
138
142
  class="seg-item"
139
143
  class:has-label={!!o.label}
140
144
  class:selected
@@ -66,7 +66,8 @@
66
66
  </script>
67
67
 
68
68
  <!-- The wrapper (owned here, so styled scoped) is the positioning context that
69
- clips the transparent overlaid <select>; the icon Button shows through. -->
69
+ clips the transparent overlaid <select>; the inert icon Button is the face
70
+ behind it, so the select is the only focusable control. -->
70
71
  <span
71
72
  class="select-button {klass}"
72
73
  class:row={variant === 'row'}
@@ -77,32 +78,33 @@
77
78
  class:no-shrink={!shrink}
78
79
  class:block={block || variant === 'row'}
79
80
  >
80
- <Button variant="ghost" icon={variant === 'square'} block={variant === 'row'} {box} {hitArea} {title} aria-label={label}>
81
+ <Button variant="ghost" icon={variant === 'square'} block={variant === 'row'} {box} {hitArea} inert tabindex={-1}>
81
82
  <span aria-hidden="true" class="sb-glyph">
82
83
  {#if iconChildren}<Icon>{@render iconChildren()}</Icon>{:else if icon}<Icon name={icon} />{:else}{glyph}{/if}
83
84
  </span>
84
85
  {#if variant === 'row'}<span aria-hidden="true" class="sb-label">{label}</span>{/if}
85
- <Select
86
- variant="ghost"
87
- aria-label={label}
88
- {value}
89
- onchange={(e) => onchange((e.currentTarget as HTMLSelectElement).value)}
90
- >
91
- {#if groups}
92
- {#each groups as g (g.label)}
93
- <optgroup label={g.label}>
94
- {#each g.options as o (o.value)}
95
- <option value={o.value}>{o.label}</option>
96
- {/each}
97
- </optgroup>
98
- {/each}
99
- {:else}
100
- {#each options ?? [] as o (o.value)}
101
- <option value={o.value}>{o.label}</option>
102
- {/each}
103
- {/if}
104
- </Select>
105
86
  </Button>
87
+ <Select
88
+ variant="ghost"
89
+ aria-label={label}
90
+ {title}
91
+ {value}
92
+ onchange={(e) => onchange((e.currentTarget as HTMLSelectElement).value)}
93
+ >
94
+ {#if groups}
95
+ {#each groups as g (g.label)}
96
+ <optgroup label={g.label}>
97
+ {#each g.options as o (o.value)}
98
+ <option value={o.value}>{o.label}</option>
99
+ {/each}
100
+ </optgroup>
101
+ {/each}
102
+ {:else}
103
+ {#each options ?? [] as o (o.value)}
104
+ <option value={o.value}>{o.label}</option>
105
+ {/each}
106
+ {/if}
107
+ </Select>
106
108
  </span>
107
109
 
108
110
  <style>
@@ -127,6 +129,15 @@
127
129
  .select-button.active {
128
130
  color: var(--accent);
129
131
  }
132
+ /* The inert face takes no pointer events: mirror ghost hover and the
133
+ focus ring from the select overlaying it. */
134
+ .select-button:hover :global(.btn-ghost) {
135
+ background: var(--bg-elevated-2);
136
+ }
137
+ .select-button:has(:focus-visible) {
138
+ outline: 2px solid var(--accent);
139
+ outline-offset: 2px;
140
+ }
130
141
  .select-button.row :global(.btn) {
131
142
  justify-content: flex-start;
132
143
  gap: var(--sp-2);
@@ -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}