@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.
- package/README.md +21 -0
- package/dist/components/atoms/Icon.svelte +5 -0
- package/dist/components/atoms/Icon.svelte.d.ts +5 -0
- package/dist/components/atoms/TimeDisplay.svelte +98 -0
- package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
- package/dist/components/layouts/AppShell.svelte +3 -2
- package/dist/components/layouts/ResizablePanel.svelte +5 -4
- package/dist/components/molecules/Accordion.svelte +2 -1
- package/dist/components/molecules/AttachmentList.svelte +1 -0
- package/dist/components/molecules/Breadcrumb.svelte +7 -6
- package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
- package/dist/components/molecules/CapBar.svelte +3 -1
- package/dist/components/molecules/Composer.svelte +27 -5
- package/dist/components/molecules/Drawer.svelte +10 -1
- package/dist/components/molecules/Dropzone.svelte +2 -1
- package/dist/components/molecules/FilterInput.svelte +10 -1
- package/dist/components/molecules/Metric.svelte +28 -3
- package/dist/components/molecules/Modal.svelte +20 -4
- package/dist/components/molecules/Popover.svelte +7 -3
- package/dist/components/molecules/SectionHeader.svelte +29 -14
- package/dist/components/molecules/SeekSlider.svelte +370 -0
- package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
- package/dist/components/molecules/SegmentedControl.svelte +10 -6
- package/dist/components/molecules/SelectButton.svelte +33 -22
- package/dist/components/molecules/Tabs.svelte +11 -5
- package/dist/components/molecules/Timestamp.svelte +1 -1
- package/dist/components/molecules/Toaster.svelte +70 -13
- package/dist/components/molecules/Tooltip.svelte +19 -10
- package/dist/components/molecules/VolumeSlider.svelte +367 -0
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
- package/dist/components/molecules/combobox-keyboard.js +2 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/media-time.d.ts +6 -0
- package/dist/media-time.js +25 -0
- package/dist/query/edit.js +1 -16
- package/dist/query/parser.js +68 -28
- package/dist/query/query.js +9 -5
- package/dist/query/quotes.d.ts +24 -0
- package/dist/query/quotes.js +78 -0
- package/dist/query/single.js +1 -3
- package/dist/query/suggest.js +25 -32
- package/dist/stores/fontscale.svelte.js +3 -2
- package/dist/stores/storage.d.ts +2 -0
- package/dist/stores/storage.js +16 -0
- package/dist/stores/theme.svelte.js +4 -7
- 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`
|
|
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
|
-
<
|
|
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
|
|
129
|
-
<
|
|
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.
|
|
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=
|
|
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={
|
|
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
|
|
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}
|
|
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?.
|
|
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.
|
|
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={
|
|
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={
|
|
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="
|
|
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}
|