@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
package/README.md
CHANGED
|
@@ -890,6 +890,27 @@ with `label` ("Refreshing…") announced politely until it settles. It starts on
|
|
|
890
890
|
`scrollTop === 0`, fires once past `threshold` (72px), and contains overscroll so the
|
|
891
891
|
browser's own pull does not fight it. Keep a visible refresh control for mouse users.
|
|
892
892
|
|
|
893
|
+
### Player: SeekSlider, VolumeSlider, TimeDisplay
|
|
894
|
+
|
|
895
|
+
`SeekSlider` is a media scrubber: `bind:value` (seconds), `max` (duration;
|
|
896
|
+
`null`/`Infinity`/`0` = live, track disabled), `buffered` (`[start, end][]` drawn
|
|
897
|
+
as a muted fill), `step` (5 s arrows), `bigStep` (10 % PageUp/Down), `size="sm" | "md"`,
|
|
898
|
+
`formatTime`, `previewSnippet(time)` for a thumbnail in the hover tooltip. `oninput`
|
|
899
|
+
fires while dragging and never seeks; `onchange` fires once per release or keyboard
|
|
900
|
+
step, so seek the player only there. `onscrubstart`/`onscrubend` bracket a drag. The
|
|
901
|
+
hit slab is `--touch-target-compact` tall on coarse pointers while the track stays
|
|
902
|
+
3–4 px, and horizontal scrubbing never scrolls the page. `aria-valuetext` reads
|
|
903
|
+
"1:02 of 23:45".
|
|
904
|
+
|
|
905
|
+
`VolumeSlider` pairs a mute toggle (glyph follows the level) with a short slider:
|
|
906
|
+
`bind:value` (0–1), `bind:muted`, `orientation="vertical"`, `step` (5 %, also the
|
|
907
|
+
wheel notch), `onchange`/`oninput`/`onmutechange`. Changing the level unmutes.
|
|
908
|
+
`hideOnTouch` (default) hides the slider, not the mute button, on touch screens.
|
|
909
|
+
|
|
910
|
+
`TimeDisplay` renders "1:02 / 23:45" in tabular numbers; clicking toggles
|
|
911
|
+
`bind:remaining`. `formatMediaTime`, `describeMediaTime` and `isLiveDuration` are
|
|
912
|
+
exported for custom readouts.
|
|
913
|
+
|
|
893
914
|
AppShell exposes `--shell-header-h` (the header plus the top safe-area inset) for
|
|
894
915
|
sticky offsets under `viewport-fit=cover`.
|
|
895
916
|
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />',
|
|
46
46
|
pause: '<rect x="14" y="3" width="5" height="18" rx="1" /><rect x="5" y="3" width="5" height="18" rx="1" />',
|
|
47
47
|
stop: '<rect width="18" height="18" x="3" y="3" rx="2" />',
|
|
48
|
+
volume: '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />',
|
|
49
|
+
'volume-low': '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" /><path d="M16 9a5 5 0 0 1 0 6" />',
|
|
50
|
+
'volume-high': '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" /><path d="M16 9a5 5 0 0 1 0 6" /><path d="M19.364 18.364a9 9 0 0 0 0-12.728" />',
|
|
51
|
+
'volume-mute': '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" /><path d="m22 9-6 6" /><path d="m16 9 6 6" />',
|
|
52
|
+
'volume-off': '<path d="M16 9a5 5 0 0 1 .95 2.293" /><path d="M19.364 5.636a9 9 0 0 1 1.889 9.96" /><path d="m2 2 20 20" /><path d="m7 7-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11" /><path d="M9.828 4.172A.686.686 0 0 1 11 4.657v.686" />',
|
|
48
53
|
|
|
49
54
|
// — media types —
|
|
50
55
|
music: '<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />',
|
|
@@ -34,6 +34,11 @@ declare const ICONS: {
|
|
|
34
34
|
readonly play: "<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\" />";
|
|
35
35
|
readonly pause: "<rect x=\"14\" y=\"3\" width=\"5\" height=\"18\" rx=\"1\" /><rect x=\"5\" y=\"3\" width=\"5\" height=\"18\" rx=\"1\" />";
|
|
36
36
|
readonly stop: "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />";
|
|
37
|
+
readonly volume: "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" />";
|
|
38
|
+
readonly 'volume-low': "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" /><path d=\"M16 9a5 5 0 0 1 0 6\" />";
|
|
39
|
+
readonly 'volume-high': "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" /><path d=\"M16 9a5 5 0 0 1 0 6\" /><path d=\"M19.364 18.364a9 9 0 0 0 0-12.728\" />";
|
|
40
|
+
readonly 'volume-mute': "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" /><path d=\"m22 9-6 6\" /><path d=\"m16 9 6 6\" />";
|
|
41
|
+
readonly 'volume-off': "<path d=\"M16 9a5 5 0 0 1 .95 2.293\" /><path d=\"M19.364 5.636a9 9 0 0 1 1.889 9.96\" /><path d=\"m2 2 20 20\" /><path d=\"m7 7-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11\" /><path d=\"M9.828 4.172A.686.686 0 0 1 11 4.657v.686\" />";
|
|
37
42
|
readonly music: "<path d=\"M9 18V5l12-2v13\" /><circle cx=\"6\" cy=\"18\" r=\"3\" /><circle cx=\"18\" cy=\"16\" r=\"3\" />";
|
|
38
43
|
readonly tv: "<path d=\"m17 2-5 5-5-5\" /><rect width=\"20\" height=\"15\" x=\"2\" y=\"7\" rx=\"2\" />";
|
|
39
44
|
readonly film: "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" /><path d=\"M7 3v18\" /><path d=\"M3 7.5h4\" /><path d=\"M3 12h18\" /><path d=\"M3 16.5h4\" /><path d=\"M17 3v18\" /><path d=\"M17 7.5h4\" /><path d=\"M17 16.5h4\" />";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* "1:02 / 23:45" readout with tabular numbers. Clicking it shows the
|
|
4
|
+
* remaining time instead ("-22:43 / 23:45"). Live or unknown durations
|
|
5
|
+
* render the position only and cannot be toggled.
|
|
6
|
+
*/
|
|
7
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
8
|
+
import { formatMediaTime, isLiveDuration } from '../../media-time';
|
|
9
|
+
|
|
10
|
+
type Own = {
|
|
11
|
+
/** Position in seconds. */
|
|
12
|
+
current?: number;
|
|
13
|
+
/** Duration in seconds; `null`/`Infinity` = live or unknown. */
|
|
14
|
+
duration?: number | null;
|
|
15
|
+
/** Show time left instead of the position. */
|
|
16
|
+
remaining?: boolean;
|
|
17
|
+
/** Allow clicking to toggle `remaining`. */
|
|
18
|
+
toggle?: boolean;
|
|
19
|
+
size?: 'sm' | 'md';
|
|
20
|
+
formatTime?: (seconds: number, ref?: number) => string;
|
|
21
|
+
toggleLabel?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
current = 0,
|
|
27
|
+
duration = null,
|
|
28
|
+
remaining = $bindable(false),
|
|
29
|
+
toggle = true,
|
|
30
|
+
size = 'sm',
|
|
31
|
+
formatTime = formatMediaTime,
|
|
32
|
+
toggleLabel = 'Toggle remaining time',
|
|
33
|
+
class: klass = '',
|
|
34
|
+
...rest
|
|
35
|
+
}: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
|
|
36
|
+
|
|
37
|
+
const live = $derived(isLiveDuration(duration));
|
|
38
|
+
const total = $derived(live ? 0 : (duration as number));
|
|
39
|
+
const pos = $derived(Math.min(live ? Number.POSITIVE_INFINITY : total, Math.max(0, current)));
|
|
40
|
+
const head = $derived(live ? formatTime(pos) : remaining ? `-${formatTime(total - pos, total)}` : formatTime(pos, total));
|
|
41
|
+
const tail = $derived(live ? null : formatTime(total, total));
|
|
42
|
+
const interactive = $derived(toggle && !live);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
{#if interactive}
|
|
46
|
+
<button
|
|
47
|
+
data-tsu="TimeDisplay"
|
|
48
|
+
type="button"
|
|
49
|
+
class="td td--{size} td--button {klass}"
|
|
50
|
+
class:td--remaining={remaining}
|
|
51
|
+
title={toggleLabel}
|
|
52
|
+
aria-pressed={remaining}
|
|
53
|
+
onclick={() => (remaining = !remaining)}
|
|
54
|
+
{...rest}
|
|
55
|
+
>
|
|
56
|
+
<span data-part="position">{head}</span><span class="td-sep" aria-hidden="true">{' / '}</span><span data-part="duration">{tail}</span>
|
|
57
|
+
</button>
|
|
58
|
+
{:else}
|
|
59
|
+
<span data-tsu="TimeDisplay" class="td td--{size} {klass}" {...rest}>
|
|
60
|
+
<span data-part="position">{head}</span>{#if tail !== null}<span class="td-sep" aria-hidden="true">{' / '}</span><span data-part="duration">{tail}</span>{/if}
|
|
61
|
+
</span>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.td {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: baseline;
|
|
68
|
+
font-size: var(--fs-sm);
|
|
69
|
+
font-variant-numeric: tabular-nums;
|
|
70
|
+
color: var(--text-muted);
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
}
|
|
73
|
+
.td--md {
|
|
74
|
+
font-size: var(--fs-base);
|
|
75
|
+
}
|
|
76
|
+
.td--button {
|
|
77
|
+
border: 0;
|
|
78
|
+
background: none;
|
|
79
|
+
padding: 0;
|
|
80
|
+
margin: 0;
|
|
81
|
+
font: inherit;
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
color: inherit;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
border-radius: var(--r-sm);
|
|
86
|
+
}
|
|
87
|
+
.td--button:hover {
|
|
88
|
+
color: var(--text);
|
|
89
|
+
}
|
|
90
|
+
.td--button:focus-visible {
|
|
91
|
+
outline: var(--focus-ring);
|
|
92
|
+
outline-offset: var(--focus-ring-offset);
|
|
93
|
+
}
|
|
94
|
+
.td-sep {
|
|
95
|
+
color: var(--text-faint);
|
|
96
|
+
white-space: pre;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "1:02 / 23:45" readout with tabular numbers. Clicking it shows the
|
|
3
|
+
* remaining time instead ("-22:43 / 23:45"). Live or unknown durations
|
|
4
|
+
* render the position only and cannot be toggled.
|
|
5
|
+
*/
|
|
6
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
7
|
+
type Own = {
|
|
8
|
+
/** Position in seconds. */
|
|
9
|
+
current?: number;
|
|
10
|
+
/** Duration in seconds; `null`/`Infinity` = live or unknown. */
|
|
11
|
+
duration?: number | null;
|
|
12
|
+
/** Show time left instead of the position. */
|
|
13
|
+
remaining?: boolean;
|
|
14
|
+
/** Allow clicking to toggle `remaining`. */
|
|
15
|
+
toggle?: boolean;
|
|
16
|
+
size?: 'sm' | 'md';
|
|
17
|
+
formatTime?: (seconds: number, ref?: number) => string;
|
|
18
|
+
toggleLabel?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
};
|
|
21
|
+
type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
|
|
22
|
+
declare const TimeDisplay: import("svelte").Component<$$ComponentProps, {}, "remaining">;
|
|
23
|
+
type TimeDisplay = ReturnType<typeof TimeDisplay>;
|
|
24
|
+
export default TimeDisplay;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// `--shell-header-h` is the rendered header height (incl. the top inset).
|
|
16
16
|
import { untrack, type Snippet } from 'svelte';
|
|
17
17
|
import { browser } from '../../env';
|
|
18
|
+
import { readStorage, writeStorage } from '../../stores/storage';
|
|
18
19
|
import IconButton from '../molecules/IconButton.svelte';
|
|
19
20
|
import { resizeHandle } from './resizable-panel-frame.js';
|
|
20
21
|
|
|
@@ -103,13 +104,13 @@
|
|
|
103
104
|
// --- resizable sidebar (desktop) ---
|
|
104
105
|
function loadW(): number | null {
|
|
105
106
|
if (!browser || !sidebarWidthKey) return null;
|
|
106
|
-
const n = Number(
|
|
107
|
+
const n = Number(readStorage(sidebarWidthKey));
|
|
107
108
|
return Number.isFinite(n) && n >= minSidebar ? Math.min(n, maxSidebar) : null;
|
|
108
109
|
}
|
|
109
110
|
let sideW = $state<number | null>(loadW());
|
|
110
111
|
let dragging = $state(false);
|
|
111
112
|
function persistW(w: number) {
|
|
112
|
-
if (browser && sidebarWidthKey)
|
|
113
|
+
if (browser && sidebarWidthKey) writeStorage(sidebarWidthKey, String(w));
|
|
113
114
|
}
|
|
114
115
|
const measureW = () => sideW ?? aside?.getBoundingClientRect().width ?? 0;
|
|
115
116
|
// The effective desktop width: a resized px value wins over the prop default.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { untrack } from 'svelte';
|
|
4
4
|
import { browser } from '../../env';
|
|
5
|
+
import { readStorage, writeStorage } from '../../stores/storage';
|
|
5
6
|
import Icon from '../atoms/Icon.svelte';
|
|
6
7
|
import Scrim from '../atoms/Scrim.svelte';
|
|
7
8
|
import { resizeHandle, resolveLength } from './resizable-panel-frame.js';
|
|
@@ -152,7 +153,7 @@
|
|
|
152
153
|
if (!widthKey) return;
|
|
153
154
|
|
|
154
155
|
const savedWidth = parseStoredWidth(
|
|
155
|
-
|
|
156
|
+
readStorage(widthKey),
|
|
156
157
|
boundedMin,
|
|
157
158
|
boundedMax
|
|
158
159
|
);
|
|
@@ -160,7 +161,7 @@
|
|
|
160
161
|
|
|
161
162
|
if (persistCollapsed) {
|
|
162
163
|
const savedCollapsed = parseStoredCollapsed(
|
|
163
|
-
|
|
164
|
+
readStorage(`${widthKey}:collapsed`)
|
|
164
165
|
);
|
|
165
166
|
if (savedCollapsed !== undefined) collapsed = savedCollapsed;
|
|
166
167
|
}
|
|
@@ -212,7 +213,7 @@
|
|
|
212
213
|
});
|
|
213
214
|
|
|
214
215
|
function persistWidth(nextWidth: number) {
|
|
215
|
-
if (browser && widthKey)
|
|
216
|
+
if (browser && widthKey) writeStorage(widthKey, String(nextWidth));
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
function setWidth(nextWidth: number) {
|
|
@@ -292,7 +293,7 @@
|
|
|
292
293
|
}
|
|
293
294
|
collapsed = !collapsed;
|
|
294
295
|
if (browser && widthKey && persistCollapsed) {
|
|
295
|
-
|
|
296
|
+
writeStorage(`${widthKey}:collapsed`, String(collapsed));
|
|
296
297
|
}
|
|
297
298
|
}
|
|
298
299
|
</script>
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
style?: string;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
const uid = $props.id();
|
|
59
60
|
let local = $state<Record<string, boolean>>({});
|
|
60
61
|
let seen: Record<string, boolean | undefined> = {};
|
|
61
62
|
|
|
@@ -98,7 +99,7 @@
|
|
|
98
99
|
{#each items as item (item.id)}
|
|
99
100
|
<Disclosure
|
|
100
101
|
class="acc-item"
|
|
101
|
-
id={
|
|
102
|
+
id={`${uid}-acc-${item.id}`}
|
|
102
103
|
open={isOpen(item)}
|
|
103
104
|
onchange={(o) => set(item, o)}
|
|
104
105
|
disabled={item.disabled}
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
</script>
|
|
7
7
|
|
|
8
8
|
<script lang="ts">
|
|
9
|
-
// Navigation trail (e.g. "Musique > Artist > Album > Track"). The last item
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// Navigation trail (e.g. "Musique > Artist > Album > Track"). The last item
|
|
10
|
+
// renders unlinked with aria-current="page"; other items without an `href`
|
|
11
|
+
// render as plain text. When the trail exceeds `maxItems` the middle collapses
|
|
12
12
|
// to an ellipsis, always keeping the first item and the tail. `separator` is a
|
|
13
13
|
// snippet for custom markup, else a char via the `char` prop.
|
|
14
14
|
import type { Snippet } from 'svelte';
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
char?: string;
|
|
31
31
|
separator?: Snippet;
|
|
32
32
|
/** Collapse the middle to an ellipsis when the trail is longer than this
|
|
33
|
-
* (0 = never collapse). Keeps the first item and the last `maxItems - 1
|
|
33
|
+
* (0 = never collapse). Keeps the first item and the last `maxItems - 1`;
|
|
34
|
+
* values below 2 behave as 2 so the current page always shows. */
|
|
34
35
|
maxItems?: number;
|
|
35
36
|
size?: 'sm' | 'md';
|
|
36
37
|
/** `false` keeps the trail on one line: ancestors keep their width and the
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
|
|
47
48
|
const shown = $derived<Crumb[]>(
|
|
48
49
|
maxItems > 0 && items.length > maxItems
|
|
49
|
-
? [items[0], { ellipsis: true }, ...items.slice(items.length - (maxItems - 1))]
|
|
50
|
+
? [items[0], { ellipsis: true }, ...items.slice(items.length - (Math.max(2, maxItems) - 1))]
|
|
50
51
|
: items
|
|
51
52
|
);
|
|
52
53
|
</script>
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
{:else if item.href && i < shown.length - 1}
|
|
68
69
|
<a class="link" href={item.href}>{item.label}</a>
|
|
69
70
|
{:else}
|
|
70
|
-
<span class="current" aria-current=
|
|
71
|
+
<span class="current" aria-current={i === shown.length - 1 ? 'page' : undefined}>{item.label}</span>
|
|
71
72
|
{/if}
|
|
72
73
|
{#if i < shown.length - 1}
|
|
73
74
|
<span class="sep" aria-hidden="true">
|
|
@@ -9,7 +9,8 @@ type $$ComponentProps = {
|
|
|
9
9
|
char?: string;
|
|
10
10
|
separator?: Snippet;
|
|
11
11
|
/** Collapse the middle to an ellipsis when the trail is longer than this
|
|
12
|
-
* (0 = never collapse). Keeps the first item and the last `maxItems - 1
|
|
12
|
+
* (0 = never collapse). Keeps the first item and the last `maxItems - 1`;
|
|
13
|
+
* values below 2 behave as 2 so the current page always shows. */
|
|
13
14
|
maxItems?: number;
|
|
14
15
|
size?: 'sm' | 'md';
|
|
15
16
|
/** `false` keeps the trail on one line: ancestors keep their width and the
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
let limit = $state(0);
|
|
113
113
|
let dragging = $state(false);
|
|
114
114
|
let keying = $state(false);
|
|
115
|
-
let committed =
|
|
115
|
+
let committed: number | null = null;
|
|
116
116
|
|
|
117
117
|
const stacked = $derived(isStacked(layout, width, limit));
|
|
118
118
|
|
|
@@ -151,6 +151,7 @@
|
|
|
151
151
|
if (readonly || !trackEl || e.button !== 0) return;
|
|
152
152
|
e.preventDefault();
|
|
153
153
|
dragging = true;
|
|
154
|
+
committed = cap;
|
|
154
155
|
trackEl.setPointerCapture(e.pointerId);
|
|
155
156
|
setCap(capFromPointer(e.clientX, trackEl.getBoundingClientRect(), step, min, max));
|
|
156
157
|
trackEl.querySelector<HTMLElement>('.handle')?.focus();
|
|
@@ -169,6 +170,7 @@
|
|
|
169
170
|
const next = capKeyStep(e.key, e.shiftKey, snapCap(cap ?? max, step, min, max), step, min, max);
|
|
170
171
|
if (next === null) return;
|
|
171
172
|
e.preventDefault();
|
|
173
|
+
if (!keying) committed = cap;
|
|
172
174
|
keying = true;
|
|
173
175
|
setCap(next);
|
|
174
176
|
}
|
|
@@ -129,8 +129,32 @@
|
|
|
129
129
|
addFiles(files);
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
// Only file drags are handled; native text drag-and-drop into the field is
|
|
133
|
+
// left to the browser. The depth counter stops dragleave on child crossings
|
|
134
|
+
// from flickering the `dragging` state.
|
|
135
|
+
let dragDepth = 0;
|
|
136
|
+
function hasFiles(e: DragEvent): boolean {
|
|
137
|
+
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
138
|
+
}
|
|
139
|
+
function ondragenter(e: DragEvent) {
|
|
140
|
+
if (!hasFiles(e)) return;
|
|
141
|
+
dragDepth++;
|
|
142
|
+
dragging = true;
|
|
143
|
+
}
|
|
144
|
+
function ondragleave(e: DragEvent) {
|
|
145
|
+
if (!hasFiles(e)) return;
|
|
146
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
147
|
+
if (dragDepth === 0) dragging = false;
|
|
148
|
+
}
|
|
149
|
+
function ondragover(e: DragEvent) {
|
|
150
|
+
if (!hasFiles(e)) return;
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
153
|
+
}
|
|
132
154
|
function ondrop(e: DragEvent) {
|
|
155
|
+
if (!hasFiles(e)) return;
|
|
133
156
|
e.preventDefault();
|
|
157
|
+
dragDepth = 0;
|
|
134
158
|
dragging = false;
|
|
135
159
|
addFiles(Array.from(e.dataTransfer?.files ?? []));
|
|
136
160
|
}
|
|
@@ -156,11 +180,9 @@
|
|
|
156
180
|
class:busy
|
|
157
181
|
style={styleProp}
|
|
158
182
|
role="group"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}}
|
|
163
|
-
ondragleave={() => (dragging = false)}
|
|
183
|
+
{ondragenter}
|
|
184
|
+
{ondragleave}
|
|
185
|
+
{ondragover}
|
|
164
186
|
{ondrop}
|
|
165
187
|
>
|
|
166
188
|
<AttachmentList files={attachments} onremove={removeAt} />
|
|
@@ -85,8 +85,16 @@
|
|
|
85
85
|
onclose?.();
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// A drag that starts in the panel and ends on the backdrop fires click on the
|
|
89
|
+
// dialog too, so the press must have landed on the backdrop as well.
|
|
90
|
+
let pressOnBackdrop = false;
|
|
91
|
+
function onDialogPointerDown(e: PointerEvent) {
|
|
92
|
+
pressOnBackdrop = e.target === dialogEl;
|
|
93
|
+
}
|
|
88
94
|
function onDialogClick(e: MouseEvent) {
|
|
89
|
-
|
|
95
|
+
const outside = pressOnBackdrop && e.target === dialogEl;
|
|
96
|
+
pressOnBackdrop = false;
|
|
97
|
+
if (outside) requestClose();
|
|
90
98
|
}
|
|
91
99
|
</script>
|
|
92
100
|
|
|
@@ -104,6 +112,7 @@
|
|
|
104
112
|
onclose={() => {
|
|
105
113
|
if (controlled) open = false;
|
|
106
114
|
}}
|
|
115
|
+
onpointerdown={onDialogPointerDown}
|
|
107
116
|
onclick={onDialogClick}
|
|
108
117
|
>
|
|
109
118
|
<div
|
|
@@ -91,12 +91,13 @@
|
|
|
91
91
|
if (depth === 0) setOver(false);
|
|
92
92
|
}
|
|
93
93
|
function onDragOver(e: DragEvent) {
|
|
94
|
-
if (disabled) return;
|
|
94
|
+
if (disabled || !hasFiles(e)) return;
|
|
95
95
|
// preventDefault is required or `drop` never fires.
|
|
96
96
|
e.preventDefault();
|
|
97
97
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
98
98
|
}
|
|
99
99
|
function onDrop(e: DragEvent) {
|
|
100
|
+
if (!hasFiles(e)) return;
|
|
100
101
|
e.preventDefault();
|
|
101
102
|
depth = 0;
|
|
102
103
|
setOver(false);
|
|
@@ -148,6 +148,8 @@
|
|
|
148
148
|
};
|
|
149
149
|
|
|
150
150
|
const field = getFieldContext();
|
|
151
|
+
const uid = $props.id();
|
|
152
|
+
const listboxId = `${uid}-lb`;
|
|
151
153
|
let el = $state<HTMLInputElement | null>(null);
|
|
152
154
|
let menuEl = $state<HTMLDivElement | null>(null);
|
|
153
155
|
let menu = $state<SuggestState | null>(null);
|
|
@@ -378,6 +380,11 @@
|
|
|
378
380
|
id={id ?? field?.id}
|
|
379
381
|
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
380
382
|
aria-invalid={ariaInvalid ?? (field?.invalid ? 'true' : undefined)}
|
|
383
|
+
role="combobox"
|
|
384
|
+
aria-autocomplete="list"
|
|
385
|
+
aria-expanded={open && !!menu}
|
|
386
|
+
aria-controls={open && menu ? listboxId : undefined}
|
|
387
|
+
aria-activedescendant={open && menu ? `${uid}-opt-${active}` : undefined}
|
|
381
388
|
spellcheck="false"
|
|
382
389
|
autocomplete="off"
|
|
383
390
|
placeholder={hint}
|
|
@@ -420,14 +427,16 @@
|
|
|
420
427
|
</div>
|
|
421
428
|
|
|
422
429
|
{#if open && menu}
|
|
423
|
-
<div bind:this={menuEl} class="fi__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
|
|
430
|
+
<div bind:this={menuEl} id={listboxId} class="fi__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
|
|
424
431
|
<div class="fi__menu-head">{kindLabel[menu.kind]}</div>
|
|
425
432
|
{#each menu.items as item, i (item.insert + i)}
|
|
426
433
|
<button
|
|
427
434
|
type="button"
|
|
435
|
+
id="{uid}-opt-{i}"
|
|
428
436
|
class="fi__opt"
|
|
429
437
|
class:is-active={i === active}
|
|
430
438
|
role="option"
|
|
439
|
+
tabindex="-1"
|
|
431
440
|
aria-selected={i === active}
|
|
432
441
|
onmousedown={(e) => e.preventDefault()}
|
|
433
442
|
onmouseenter={() => {
|
|
@@ -98,20 +98,27 @@
|
|
|
98
98
|
{/snippet}
|
|
99
99
|
|
|
100
100
|
{#if layout === 'inline'}
|
|
101
|
-
<
|
|
101
|
+
<svelte:element
|
|
102
|
+
this={href ? 'a' : onclick ? 'button' : 'div'}
|
|
102
103
|
data-tsu="Metric"
|
|
103
104
|
class="metric metric-inline metric-{canonicalTone(tone)} {tintValue ? 'metric-tint' : ''} {klass}"
|
|
104
105
|
class:metric-sm={size === 'sm'}
|
|
106
|
+
class:metric-tap={!!(href || onclick)}
|
|
107
|
+
href={href || undefined}
|
|
108
|
+
target={href && external ? '_blank' : undefined}
|
|
109
|
+
rel={href && external ? 'noopener noreferrer' : undefined}
|
|
110
|
+
type={!href && onclick ? 'button' : undefined}
|
|
111
|
+
{onclick}
|
|
105
112
|
{...rest}
|
|
106
113
|
>
|
|
107
|
-
<span class="metric-label">{label}</span>
|
|
114
|
+
<span class="metric-label">{label}{#if href && external}<Icon name="external" />{/if}</span>
|
|
108
115
|
<span class="metric-value">
|
|
109
116
|
<span class="metric-num">{value}</span>
|
|
110
117
|
{#if unit}<span class="metric-unit">{unit}</span>{/if}
|
|
111
118
|
</span>
|
|
112
119
|
{@render segmentRow()}
|
|
113
120
|
{#if sub}<span class="metric-sub">{sub}</span>{/if}
|
|
114
|
-
</
|
|
121
|
+
</svelte:element>
|
|
115
122
|
{:else}
|
|
116
123
|
<Card
|
|
117
124
|
data-tsu="Metric"
|
|
@@ -242,6 +249,24 @@
|
|
|
242
249
|
.metric-inline .metric-num {
|
|
243
250
|
font-size: var(--fs-md);
|
|
244
251
|
}
|
|
252
|
+
.metric-inline.metric-tap {
|
|
253
|
+
padding: 0;
|
|
254
|
+
border: 0;
|
|
255
|
+
background: none;
|
|
256
|
+
font: inherit;
|
|
257
|
+
color: inherit;
|
|
258
|
+
text-align: start;
|
|
259
|
+
text-decoration: none;
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
border-radius: var(--r-sm);
|
|
262
|
+
}
|
|
263
|
+
.metric-inline.metric-tap:hover .metric-label {
|
|
264
|
+
color: var(--text);
|
|
265
|
+
}
|
|
266
|
+
.metric-inline.metric-tap:focus-visible {
|
|
267
|
+
outline: 2px solid var(--accent);
|
|
268
|
+
outline-offset: 2px;
|
|
269
|
+
}
|
|
245
270
|
.metric-segments {
|
|
246
271
|
display: flex;
|
|
247
272
|
flex-wrap: wrap;
|
|
@@ -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
|
|