@dorsk/tsumikit 0.72.0 → 0.73.1

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 (53) hide show
  1. package/README.md +25 -0
  2. package/dist/components/atoms/Button.svelte +12 -1
  3. package/dist/components/atoms/Checkbox.svelte +12 -0
  4. package/dist/components/atoms/Icon.svelte +5 -0
  5. package/dist/components/atoms/Icon.svelte.d.ts +5 -0
  6. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  7. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  8. package/dist/components/layouts/AppShell.svelte +3 -2
  9. package/dist/components/layouts/ResizablePanel.svelte +5 -4
  10. package/dist/components/layouts/TabBar.svelte +3 -1
  11. package/dist/components/molecules/Accordion.svelte +2 -1
  12. package/dist/components/molecules/AttachmentList.svelte +1 -0
  13. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  14. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  15. package/dist/components/molecules/CapBar.svelte +3 -1
  16. package/dist/components/molecules/Composer.svelte +27 -5
  17. package/dist/components/molecules/Drawer.svelte +10 -1
  18. package/dist/components/molecules/Dropzone.svelte +2 -1
  19. package/dist/components/molecules/FilterInput.svelte +10 -1
  20. package/dist/components/molecules/Metric.svelte +28 -3
  21. package/dist/components/molecules/Modal.svelte +20 -4
  22. package/dist/components/molecules/Popover.svelte +7 -3
  23. package/dist/components/molecules/SectionHeader.svelte +29 -14
  24. package/dist/components/molecules/SeekSlider.svelte +370 -0
  25. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  26. package/dist/components/molecules/SegmentedControl.svelte +54 -6
  27. package/dist/components/molecules/SelectButton.svelte +33 -22
  28. package/dist/components/molecules/SwipeRow.svelte +6 -2
  29. package/dist/components/molecules/SwipeRow.svelte.d.ts +4 -0
  30. package/dist/components/molecules/Tabs.svelte +11 -5
  31. package/dist/components/molecules/Timestamp.svelte +1 -1
  32. package/dist/components/molecules/Toaster.svelte +70 -13
  33. package/dist/components/molecules/Tooltip.svelte +19 -10
  34. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  35. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  36. package/dist/components/molecules/combobox-keyboard.js +2 -1
  37. package/dist/components/organisms/DataTable.svelte +12 -0
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.js +4 -0
  40. package/dist/media-time.d.ts +6 -0
  41. package/dist/media-time.js +25 -0
  42. package/dist/query/edit.js +1 -16
  43. package/dist/query/parser.js +68 -28
  44. package/dist/query/query.js +9 -5
  45. package/dist/query/quotes.d.ts +24 -0
  46. package/dist/query/quotes.js +78 -0
  47. package/dist/query/single.js +1 -3
  48. package/dist/query/suggest.js +25 -32
  49. package/dist/stores/fontscale.svelte.js +3 -2
  50. package/dist/stores/storage.d.ts +2 -0
  51. package/dist/stores/storage.js +16 -0
  52. package/dist/stores/theme.svelte.js +4 -7
  53. package/package.json +1 -1
@@ -9,6 +9,7 @@
9
9
  // `resizeKey`).
10
10
  import type { Snippet } from 'svelte';
11
11
  import { browser } from '../../env';
12
+ import { readStorage, writeStorage } from '../../stores/storage';
12
13
  import Icon, { type IconName } from '../atoms/Icon.svelte';
13
14
  import Spinner from '../atoms/Spinner.svelte';
14
15
  import IconButton from './IconButton.svelte';
@@ -76,7 +77,7 @@
76
77
 
77
78
  function loadWidth(): number | null {
78
79
  if (!browser || !resizeKey) return null;
79
- const n = Number(localStorage.getItem(resizeKey));
80
+ const n = Number(readStorage(resizeKey));
80
81
  return Number.isFinite(n) && n >= MIN_W ? Math.min(n, MAX_W) : null;
81
82
  }
82
83
  let width = $state<number | null>(loadWidth());
@@ -103,9 +104,17 @@
103
104
  }
104
105
 
105
106
  // Click outside: <dialog fills the viewport; clicks on its padding-free self
106
- // (not the inner .sheet) are backdrop clicks.
107
+ // (not the inner .sheet) are backdrop clicks. A drag that starts in the sheet
108
+ // and ends on the backdrop also fires click on the dialog, so the press must
109
+ // have landed on the backdrop too.
110
+ let pressOnBackdrop = false;
111
+ function onDialogPointerDown(e: PointerEvent) {
112
+ pressOnBackdrop = e.target === dialogEl;
113
+ }
107
114
  function onDialogClick(e: MouseEvent) {
108
- if (e.target === dialogEl) requestClose();
115
+ const outside = pressOnBackdrop && e.target === dialogEl;
116
+ pressOnBackdrop = false;
117
+ if (outside) requestClose();
109
118
  }
110
119
 
111
120
  // --- resize ---
@@ -146,7 +155,7 @@
146
155
  } catch {
147
156
  /* already released */
148
157
  }
149
- if (browser && resizeKey && width != null) localStorage.setItem(resizeKey, String(width));
158
+ if (browser && resizeKey && width != null) writeStorage(resizeKey, String(width));
150
159
  }
151
160
  </script>
152
161
 
@@ -166,6 +175,7 @@
166
175
  onclose={() => {
167
176
  if (controlled) open = false;
168
177
  }}
178
+ onpointerdown={onDialogPointerDown}
169
179
  onclick={onDialogClick}
170
180
  >
171
181
  <div
@@ -283,6 +293,12 @@
283
293
  opacity: 0.4;
284
294
  }
285
295
  }
296
+ @media (prefers-reduced-motion: reduce) {
297
+ .modal::backdrop,
298
+ .sheet {
299
+ animation: none;
300
+ }
301
+ }
286
302
  .modal.resizing .sheet {
287
303
  user-select: none;
288
304
  /* Make per-frame width changes cheap to paint: hint the animated property,
@@ -186,7 +186,11 @@
186
186
  }
187
187
 
188
188
  const hover = createHoverIntent({ open: show, close });
189
- $effect(() => () => hover.cancel());
189
+ $effect(() => () => {
190
+ hover.cancel();
191
+ removeEventListener('scroll', reposition, true);
192
+ removeEventListener('resize', reposition);
193
+ });
190
194
 
191
195
  function onPointerEnter(e: PointerEvent) {
192
196
  if (disabled || !opensOnHover(openOn, e.pointerType)) return;
@@ -194,8 +198,8 @@
194
198
  else hover.enter(hoverDelay);
195
199
  }
196
200
 
197
- function onPointerLeave() {
198
- if (openOn !== 'hover') return;
201
+ function onPointerLeave(e: PointerEvent) {
202
+ if (!opensOnHover(openOn, e.pointerType)) return;
199
203
  hover.leave(HOVER_CLOSE_GRACE);
200
204
  }
201
205
 
@@ -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;
@@ -24,6 +24,7 @@
24
24
  // browser-free keyboard model matches Tabs: ←/→ and Home/End move selection,
25
25
  // Space/Enter (or click) activate. `value` is bindable.
26
26
  import type { Snippet } from 'svelte';
27
+ import type { Attachment } from 'svelte/attachments';
27
28
  import Icon from '../atoms/Icon.svelte';
28
29
  import { formatSegmentCount } from './segmented-control-count.js';
29
30
  import { nextEnabledSegment } from './segmented-control-keyboard.js';
@@ -88,19 +89,43 @@
88
89
 
89
90
  let listEl = $state<HTMLDivElement | null>(null);
90
91
  const baseId = `seg-${Math.random().toString(36).slice(2, 8)}`;
92
+ // The roving tab stop: the selected option, or the first enabled one when the
93
+ // selection is unknown or disabled, so the group never drops out of the Tab order.
94
+ const stop = $derived(
95
+ options.find((o) => o.value === value && !o.disabled) ?? options.find((o) => !o.disabled)
96
+ );
97
+ const segId = (val: string) => `${baseId}-${val}`;
98
+
99
+ // A pill wider than its container falls back to the `scroll` layout; the
100
+ // class is dropped again once the row fits so the touch slab stays unclipped.
101
+ let overflowing = $state(false);
102
+ const watchOverflow: Attachment<HTMLElement> = (node) => {
103
+ if (wrap || typeof ResizeObserver === 'undefined') {
104
+ overflowing = false;
105
+ return;
106
+ }
107
+ const check = () => {
108
+ overflowing = node.scrollWidth - node.clientWidth > 1;
109
+ };
110
+ const ro = new ResizeObserver(check);
111
+ ro.observe(node);
112
+ check();
113
+ return () => {
114
+ ro.disconnect();
115
+ overflowing = false;
116
+ };
117
+ };
91
118
 
92
119
  function select(val: string, focus = false) {
93
120
  if (options.find((o) => o.value === val)?.disabled) return;
94
121
  value = val;
95
122
  onchange?.(val);
96
123
  if (focus) {
97
- queueMicrotask(() =>
98
- listEl?.querySelector<HTMLButtonElement>(`#${baseId}-${val}`)?.focus()
99
- );
124
+ queueMicrotask(() => listEl?.ownerDocument.getElementById(segId(val))?.focus());
100
125
  }
101
126
  }
102
127
  function onkeydown(e: KeyboardEvent) {
103
- const i = options.findIndex((o) => o.value === value);
128
+ const i = stop ? options.indexOf(stop) : -1;
104
129
  const next = nextEnabledSegment(options, i, e.key);
105
130
  if (next === undefined) return;
106
131
  e.preventDefault();
@@ -123,18 +148,20 @@
123
148
  class:collapse-container={collapseLabels === 'container'}
124
149
  class:seg-scroll={scroll}
125
150
  class:seg-block={block}
151
+ class:seg-overflow={overflowing}
126
152
  data-tsu="SegmentedControl"
127
153
  {onkeydown}
154
+ {@attach watchOverflow}
128
155
  >
129
156
  {#each options as o (o.value)}
130
157
  {@const selected = value === o.value}
131
158
  <button
132
159
  type="button"
133
- id="{baseId}-{o.value}"
160
+ id={segId(o.value)}
134
161
  role="radio"
135
162
  aria-checked={selected}
136
163
  aria-label={variant === 'icon' && !o.label ? o.value : o.label}
137
- tabindex={selected ? 0 : -1}
164
+ tabindex={stop?.value === o.value ? 0 : -1}
138
165
  class="seg-item"
139
166
  class:has-label={!!o.label}
140
167
  class:selected
@@ -161,6 +188,8 @@
161
188
  background: var(--bg-elevated-2);
162
189
  border: 1px solid var(--border);
163
190
  border-radius: var(--r-pill);
191
+ max-width: 100%;
192
+ min-width: 0;
164
193
  }
165
194
  .seg-icon {
166
195
  border-radius: var(--r-md);
@@ -189,6 +218,19 @@
189
218
  .seg-scroll .seg-item {
190
219
  flex: none;
191
220
  }
221
+ .seg-overflow {
222
+ flex-wrap: nowrap;
223
+ overflow-x: auto;
224
+ overflow-y: hidden;
225
+ scrollbar-width: none;
226
+ -webkit-overflow-scrolling: touch;
227
+ }
228
+ .seg-overflow::-webkit-scrollbar {
229
+ display: none;
230
+ }
231
+ .seg-overflow .seg-item {
232
+ flex: none;
233
+ }
192
234
  .seg-block {
193
235
  display: flex;
194
236
  width: 100%;
@@ -196,6 +238,12 @@
196
238
  .seg-block .seg-item {
197
239
  flex: 1 1 0;
198
240
  justify-content: center;
241
+ min-width: 0;
242
+ }
243
+ .seg-block .seg-label {
244
+ min-width: 0;
245
+ overflow: hidden;
246
+ text-overflow: ellipsis;
199
247
  }
200
248
 
201
249
  .seg-item {