@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
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
 
@@ -1012,6 +1033,10 @@ half the gap sideways, which stays inside the group's padding, so neighbours
1012
1033
  never overlap. `Input size="sm"` cannot host a pseudo-element and grows to 40px
1013
1034
  instead, as should any control that clips its own overflow:
1014
1035
  `min-height: var(--touch-target-compact)`.
1036
+ `Checkbox`, `Button variant="link"` and the `DataTable` sort button take the same
1037
+ slab; inside a DataTable it is clipped at the table's top edge. A `SegmentedControl`
1038
+ wider than its container switches to the `scroll` layout until it fits again
1039
+ (`wrap` wraps instead), and `block` segments ellipsise their labels.
1015
1040
 
1016
1041
  `--control-height-*` and `--box-*` are **required tokens**: a consumer that
1017
1042
  forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
@@ -561,7 +561,8 @@
561
561
  overflow counts towards the scrollable overflow of every scroll ancestor, and
562
562
  in a row that half lies under the next sibling anyway. Growing further towards
563
563
  the start would let each slab cover the previous sibling's centre. Text
564
- buttons at size sm take the same shape sized by --touch-slab. */
564
+ buttons at size sm and link buttons take the same shape sized by
565
+ --touch-slab. */
565
566
  @media (pointer: coarse) {
566
567
  .btn-icon:not(.hit-compact),
567
568
  .btn-icon-inline:not(.hit-compact),
@@ -593,6 +594,16 @@
593
594
  inset-inline-start: var(--touch-slab);
594
595
  inset-inline-end: 0;
595
596
  }
597
+ .btn-link:not(.hit-compact) {
598
+ position: relative;
599
+ }
600
+ .btn-link:not(.hit-compact)::before {
601
+ content: '';
602
+ position: absolute;
603
+ inset-block: var(--touch-slab);
604
+ inset-inline-start: var(--touch-slab);
605
+ inset-inline-end: 0;
606
+ }
596
607
  }
597
608
 
598
609
  /* Two-state (toggle) buttons — e.g. an IconButton with `pressed`. Reacts to
@@ -141,6 +141,18 @@
141
141
  .checkbox:has(input:disabled) {
142
142
  cursor: not-allowed;
143
143
  }
144
+ @media (pointer: coarse) {
145
+ .checkbox {
146
+ position: relative;
147
+ }
148
+ .checkbox::before {
149
+ content: '';
150
+ position: absolute;
151
+ inset-block: var(--touch-slab);
152
+ inset-inline-start: var(--touch-slab);
153
+ inset-inline-end: 0;
154
+ }
155
+ }
144
156
  .sr-only {
145
157
  position: absolute;
146
158
  width: 1px;
@@ -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(localStorage.getItem(sidebarWidthKey));
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) localStorage.setItem(sidebarWidthKey, String(w));
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
- localStorage.getItem(widthKey),
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
- localStorage.getItem(`${widthKey}:collapsed`)
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) localStorage.setItem(widthKey, String(nextWidth));
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
- localStorage.setItem(`${widthKey}:collapsed`, String(collapsed));
296
+ writeStorage(`${widthKey}:collapsed`, String(collapsed));
296
297
  }
297
298
  }
298
299
  </script>
@@ -207,7 +207,9 @@
207
207
  .tabbar :global(.tabbar-badge) {
208
208
  position: absolute;
209
209
  top: -0.4rem;
210
- left: calc(100% - 0.5rem);
210
+ left: calc(100% - 2px);
211
+ max-width: none;
212
+ padding-inline: 0.3rem;
211
213
  }
212
214
  @media (prefers-reduced-motion: reduce) {
213
215
  .tabbar-item {
@@ -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={`acc-${item.id}`}
102
+ id={`${uid}-acc-${item.id}`}
102
103
  open={isOpen(item)}
103
104
  onchange={(o) => set(item, o)}
104
105
  disabled={item.disabled}
@@ -103,6 +103,7 @@
103
103
  maxWidth="16rem"
104
104
  removable={!!onremove}
105
105
  onremove={() => onremove?.(i)}
106
+ actionLabel="{removeLabel} {f.name}"
106
107
  title={f.name}
107
108
  >
108
109
  {@const src = thumb(f)}
@@ -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
- // or any item without an `href` — renders unlinked and is marked
11
- // aria-current="page". When the trail exceeds `maxItems` the middle collapses
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="page">{item.label}</span>
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 = cap;
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
- ondragover={(e) => {
160
- e.preventDefault();
161
- dragging = true;
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
- if (e.target === dialogEl) requestClose();
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
- <div
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
- </div>
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;