@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.
- package/README.md +25 -0
- package/dist/components/atoms/Button.svelte +12 -1
- package/dist/components/atoms/Checkbox.svelte +12 -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/layouts/TabBar.svelte +3 -1
- 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 +54 -6
- package/dist/components/molecules/SelectButton.svelte +33 -22
- package/dist/components/molecules/SwipeRow.svelte +6 -2
- package/dist/components/molecules/SwipeRow.svelte.d.ts +4 -0
- 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/components/organisms/DataTable.svelte +12 -0
- 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
|
@@ -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(
|
|
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
|
-
|
|
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)
|
|
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(() => () =>
|
|
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
|
|
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`
|
|
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;
|
|
@@ -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.
|
|
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=
|
|
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={
|
|
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 {
|