@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
@@ -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 shows through. -->
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} {title} aria-label={label}>
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);
@@ -18,6 +18,10 @@
18
18
  * a drag that is more vertical than horizontal is left to the scroller.
19
19
  * The same actions are reachable as visually hidden buttons for keyboard
20
20
  * and screen-reader users.
21
+ *
22
+ * On touch the browser implicitly captures the pointer on the element under
23
+ * the finger; taking capture on the row makes that child fire a bubbling
24
+ * `lostpointercapture`, which must not be read as a cancel.
21
25
  */
22
26
  import type { Snippet } from 'svelte';
23
27
  import type { HTMLAttributes } from 'svelte/elements';
@@ -162,8 +166,8 @@
162
166
  e.preventDefault();
163
167
  }
164
168
 
165
- function onlostpointercapture() {
166
- if (pointerId === null) return;
169
+ function onlostpointercapture(e: PointerEvent) {
170
+ if (pointerId === null || e.pointerId !== pointerId || e.target !== e.currentTarget) return;
167
171
  pointerId = null;
168
172
  locked = null;
169
173
  dragging = false;
@@ -13,6 +13,10 @@ export interface SwipeAction {
13
13
  * a drag that is more vertical than horizontal is left to the scroller.
14
14
  * The same actions are reachable as visually hidden buttons for keyboard
15
15
  * and screen-reader users.
16
+ *
17
+ * On touch the browser implicitly captures the pointer on the element under
18
+ * the finger; taking capture on the row makes that child fire a bubbling
19
+ * `lostpointercapture`, which must not be read as a cancel.
16
20
  */
17
21
  import type { Snippet } from 'svelte';
18
22
  import type { HTMLAttributes } from 'svelte/elements';
@@ -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?.querySelector<HTMLElement>(`#${baseId}-tab-${id}`);
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.findIndex((t) => t.id === value);
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={value === t.id ? 0 : -1}
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={value === t.id ? 0 : -1}
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="Timestamp details" placement="bottom-start" bare>
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}
@@ -1,9 +1,11 @@
1
1
  <script lang="ts">
2
2
  // Renders the toast queue bottom-centered in two live regions: polite for
3
3
  // ok/info/neutral, assertive (role=alert) for errors. Mount once near the
4
- // app root. The stack lives in a manual popover so it paints in the
5
- // top layer above an open Modal <dialog>; without the Popover API the
6
- // attribute is ignored and the fixed/z-index positioning applies instead.
4
+ // app root. The stack lives in a manual popover so it paints in the top
5
+ // layer; without the Popover API the attribute is ignored and the
6
+ // fixed/z-index positioning applies instead. While a modal <dialog> is open
7
+ // everything outside it is inert, top layer included, so the stack moves
8
+ // into the topmost modal dialog and back to its host when it closes.
7
9
  import { toasts } from '../../stores/toast.svelte';
8
10
  import Button from '../atoms/Button.svelte';
9
11
  import Card from '../atoms/Card.svelte';
@@ -11,16 +13,61 @@
11
13
 
12
14
  let { class: klass = '', style: styleProp = '' }: { class?: string; style?: string } = $props();
13
15
 
16
+ let host: HTMLDivElement | undefined = $state();
14
17
  let el: HTMLDivElement | undefined = $state();
15
18
  const polite = $derived(toasts.items.filter((t) => t.tone !== 'error'));
16
19
  const assertive = $derived(toasts.items.filter((t) => t.tone === 'error'));
17
20
 
18
- $effect(() => {
19
- if (!el || typeof el.showPopover !== 'function') return;
21
+ function topModal(): HTMLDialogElement | null {
22
+ let found: HTMLDialogElement | null = null;
23
+ for (const d of document.querySelectorAll<HTMLDialogElement>('dialog[open]')) {
24
+ try {
25
+ if (d.matches(':modal')) found = d;
26
+ } catch {}
27
+ }
28
+ return found;
29
+ }
30
+
31
+ let shown = false;
32
+ function show(stack: HTMLDivElement) {
33
+ if (typeof stack.showPopover !== 'function') return;
20
34
  try {
21
- if (toasts.items.length) el.showPopover();
22
- else el.hidePopover();
35
+ stack.showPopover();
36
+ shown = true;
23
37
  } catch {}
38
+ }
39
+ function hide(stack: HTMLDivElement) {
40
+ if (!shown || typeof stack.hidePopover !== 'function') return;
41
+ shown = false;
42
+ try {
43
+ stack.hidePopover();
44
+ } catch {}
45
+ }
46
+
47
+ $effect(() => {
48
+ const stack = el;
49
+ const home = host;
50
+ if (!stack || !home) return;
51
+ if (!toasts.items.length) {
52
+ hide(stack);
53
+ return;
54
+ }
55
+ const place = () => {
56
+ const target = topModal() ?? home;
57
+ if (stack.parentNode !== target) {
58
+ hide(stack);
59
+ target.appendChild(stack);
60
+ }
61
+ show(stack);
62
+ };
63
+ place();
64
+ const observer = new MutationObserver(place);
65
+ observer.observe(document.body, { attributes: true, attributeFilter: ['open'], subtree: true });
66
+ return () => observer.disconnect();
67
+ });
68
+
69
+ $effect(() => () => {
70
+ if (el && host && el.parentNode !== host) host.appendChild(el);
24
71
  });
25
72
  </script>
26
73
 
@@ -62,16 +109,21 @@
62
109
  {/if}
63
110
  {/snippet}
64
111
 
65
- <div class="toaster {klass}" style={styleProp} bind:this={el} popover="manual" data-tsu="Toaster">
66
- <div class="region" role="status" aria-live="polite">
67
- {#each polite as t (t.id)}{@render toast(t)}{/each}
68
- </div>
69
- <div class="region" role="alert" aria-live="assertive">
70
- {#each assertive as t (t.id)}{@render toast(t)}{/each}
112
+ <div class="toaster-host" bind:this={host}>
113
+ <div class="toaster {klass}" style={styleProp} bind:this={el} popover="manual" data-tsu="Toaster">
114
+ <div class="region" role="status" aria-live="polite">
115
+ {#each polite as t (t.id)}{@render toast(t)}{/each}
116
+ </div>
117
+ <div class="region" role="alert" aria-live="assertive">
118
+ {#each assertive as t (t.id)}{@render toast(t)}{/each}
119
+ </div>
71
120
  </div>
72
121
  </div>
73
122
 
74
123
  <style>
124
+ .toaster-host {
125
+ display: contents;
126
+ }
75
127
  .toaster {
76
128
  position: fixed;
77
129
  inset: auto;
@@ -142,4 +194,9 @@
142
194
  opacity: 0;
143
195
  }
144
196
  }
197
+ @media (prefers-reduced-motion: reduce) {
198
+ .toaster :global(.toast) {
199
+ animation: none;
200
+ }
201
+ }
145
202
  </style>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  // Lightweight tooltip. Shows on hover (after a short delay) and on keyboard
3
- // focus of the trigger; hides on blur / mouseleave / Escape. The trigger is
3
+ // focus of the trigger; hides on blur / mouseleave / Escape (anywhere). The trigger is
4
4
  // any snippet; an action wires `aria-describedby` onto its first focusable
5
5
  // element so screen readers announce the text.
6
6
  //
@@ -63,6 +63,11 @@
63
63
  if (wrapEl && tipEl) place(wrapEl, tipEl, `${placement}-center`, 6);
64
64
  }
65
65
 
66
+ // Escape must dismiss a hover-opened bubble even while focus sits elsewhere.
67
+ function onEscape(e: KeyboardEvent) {
68
+ if (e.key === 'Escape') hideNow();
69
+ }
70
+
66
71
  function show() {
67
72
  clearTimeout(hideTimer); // pointer came back within the grace period
68
73
  clearTimeout(showTimer);
@@ -72,15 +77,20 @@
72
77
  reposition();
73
78
  addEventListener('scroll', reposition, true);
74
79
  addEventListener('resize', reposition);
80
+ document.addEventListener('keydown', onEscape);
75
81
  }, delay);
76
82
  }
77
83
  function hideNow() {
78
84
  clearTimeout(showTimer);
79
85
  clearTimeout(hideTimer);
80
- if (tipEl?.matches(':popover-open')) tipEl.hidePopover();
86
+ try {
87
+ if (tipEl?.matches(':popover-open')) tipEl.hidePopover();
88
+ } catch {}
81
89
  removeEventListener('scroll', reposition, true);
82
90
  removeEventListener('resize', reposition);
91
+ document.removeEventListener('keydown', onEscape);
83
92
  }
93
+ $effect(() => () => hideNow());
84
94
  // Debounced close: cancelled if the pointer/focus enters the panel (rich
85
95
  // mode) or returns to the trigger before `closeDelay` elapses.
86
96
  function scheduleHide() {
@@ -104,21 +114,23 @@
104
114
  node.tabIndex = 0;
105
115
  target = node;
106
116
  }
107
- target.setAttribute('aria-describedby', id);
108
- const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
117
+ // The trigger may already be described (a Field hint/error): append our
118
+ // id to the token list and take back only that token on destroy.
119
+ const describedBy = () => (target.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
120
+ target.setAttribute('aria-describedby', [...describedBy().filter((t) => t !== id), id].join(' '));
109
121
  node.addEventListener('mouseenter', show);
110
122
  node.addEventListener('mouseleave', scheduleHide);
111
123
  node.addEventListener('focusin', show);
112
124
  node.addEventListener('focusout', scheduleHide);
113
- node.addEventListener('keydown', onkey);
114
125
  return {
115
126
  destroy() {
116
- target.removeAttribute('aria-describedby');
127
+ const rest = describedBy().filter((t) => t !== id);
128
+ if (rest.length) target.setAttribute('aria-describedby', rest.join(' '));
129
+ else target.removeAttribute('aria-describedby');
117
130
  node.removeEventListener('mouseenter', show);
118
131
  node.removeEventListener('mouseleave', scheduleHide);
119
132
  node.removeEventListener('focusin', show);
120
133
  node.removeEventListener('focusout', scheduleHide);
121
- node.removeEventListener('keydown', onkey);
122
134
  }
123
135
  };
124
136
  }
@@ -126,19 +138,16 @@
126
138
  // Action for the panel: in rich mode it accepts the pointer/focus, which
127
139
  // cancels the pending close (hovercard persistence).
128
140
  function panel(node: HTMLElement) {
129
- const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
130
141
  node.addEventListener('mouseenter', cancelHide);
131
142
  node.addEventListener('mouseleave', scheduleHide);
132
143
  node.addEventListener('focusin', cancelHide);
133
144
  node.addEventListener('focusout', scheduleHide);
134
- node.addEventListener('keydown', onkey);
135
145
  return {
136
146
  destroy() {
137
147
  node.removeEventListener('mouseenter', cancelHide);
138
148
  node.removeEventListener('mouseleave', scheduleHide);
139
149
  node.removeEventListener('focusin', cancelHide);
140
150
  node.removeEventListener('focusout', scheduleHide);
141
- node.removeEventListener('keydown', onkey);
142
151
  }
143
152
  };
144
153
  }