@dorsk/tsumikit 0.71.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.
Files changed (84) hide show
  1. package/README.md +54 -5
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +4 -2
  5. package/dist/components/atoms/Avatar.svelte +4 -3
  6. package/dist/components/atoms/Badge.svelte +13 -0
  7. package/dist/components/atoms/Button.svelte +25 -9
  8. package/dist/components/atoms/Card.svelte +2 -0
  9. package/dist/components/atoms/Dot.svelte +1 -1
  10. package/dist/components/atoms/Icon.svelte +7 -0
  11. package/dist/components/atoms/Icon.svelte.d.ts +7 -0
  12. package/dist/components/atoms/Kbd.svelte +28 -16
  13. package/dist/components/atoms/Link.svelte +6 -4
  14. package/dist/components/atoms/Scrim.svelte +18 -1
  15. package/dist/components/atoms/Slider.svelte +11 -1
  16. package/dist/components/atoms/Swatch.svelte +12 -0
  17. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  18. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  19. package/dist/components/layouts/AppShell.svelte +72 -40
  20. package/dist/components/layouts/Container.svelte +25 -3
  21. package/dist/components/layouts/MasterDetail.svelte +59 -33
  22. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  23. package/dist/components/layouts/NavItem.svelte +8 -4
  24. package/dist/components/layouts/NavSection.svelte +8 -2
  25. package/dist/components/layouts/ResizablePanel.svelte +19 -4
  26. package/dist/components/layouts/TabBar.svelte +217 -0
  27. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  28. package/dist/components/layouts/Toolbar.svelte +5 -0
  29. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  30. package/dist/components/layouts/toolbar-roving.js +47 -1
  31. package/dist/components/molecules/Accordion.svelte +2 -1
  32. package/dist/components/molecules/AttachmentList.svelte +1 -0
  33. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  34. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  35. package/dist/components/molecules/CapBar.svelte +3 -1
  36. package/dist/components/molecules/Composer.svelte +27 -5
  37. package/dist/components/molecules/Drawer.svelte +10 -1
  38. package/dist/components/molecules/Dropzone.svelte +2 -1
  39. package/dist/components/molecules/FilterInput.svelte +17 -1
  40. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  41. package/dist/components/molecules/Metric.svelte +28 -3
  42. package/dist/components/molecules/Modal.svelte +20 -4
  43. package/dist/components/molecules/Popover.svelte +7 -3
  44. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  45. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  46. package/dist/components/molecules/SectionHeader.svelte +29 -14
  47. package/dist/components/molecules/SeekSlider.svelte +370 -0
  48. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  49. package/dist/components/molecules/SegmentedControl.svelte +10 -6
  50. package/dist/components/molecules/SelectButton.svelte +33 -22
  51. package/dist/components/molecules/SwipeRow.svelte +349 -0
  52. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  53. package/dist/components/molecules/Tabs.svelte +11 -5
  54. package/dist/components/molecules/Timestamp.svelte +1 -1
  55. package/dist/components/molecules/Toaster.svelte +70 -13
  56. package/dist/components/molecules/Tooltip.svelte +19 -10
  57. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  58. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  59. package/dist/components/molecules/combobox-keyboard.js +2 -1
  60. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  61. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  62. package/dist/components/organisms/DataTable.svelte +22 -5
  63. package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
  64. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  65. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.js +7 -0
  68. package/dist/markdown.js +15 -7
  69. package/dist/media-time.d.ts +6 -0
  70. package/dist/media-time.js +25 -0
  71. package/dist/query/edit.js +1 -16
  72. package/dist/query/parser.js +68 -28
  73. package/dist/query/query.js +9 -5
  74. package/dist/query/quotes.d.ts +24 -0
  75. package/dist/query/quotes.js +78 -0
  76. package/dist/query/single.js +1 -3
  77. package/dist/query/suggest.js +25 -32
  78. package/dist/stores/fontscale.svelte.js +3 -2
  79. package/dist/stores/storage.d.ts +2 -0
  80. package/dist/stores/storage.js +16 -0
  81. package/dist/stores/theme.svelte.js +4 -7
  82. package/dist/styles/reset.css +30 -0
  83. package/dist/styles/syntax.css +0 -29
  84. package/package.json +1 -1
@@ -0,0 +1,370 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Media scrubber. Dragging previews (`oninput`) without seeking; the player
4
+ * seeks only on `onchange`, fired once per release or keyboard step. The
5
+ * hit slab is tall enough for touch while the visible track stays thin.
6
+ */
7
+ import type { Snippet } from 'svelte';
8
+ import type { HTMLAttributes } from 'svelte/elements';
9
+ import { describeMediaTime, formatMediaTime, isLiveDuration } from '../../media-time';
10
+
11
+ type Own = {
12
+ /** Position in seconds. Updated only on commit, never while dragging. */
13
+ value?: number;
14
+ /** Duration in seconds. `null`/`Infinity`/`0` = live or unknown: the track is disabled. */
15
+ max?: number | null;
16
+ /** Buffered `[start, end]` ranges in seconds, drawn as a muted fill. */
17
+ buffered?: [number, number][];
18
+ /** Seconds per arrow key. */
19
+ step?: number;
20
+ /** Fraction of the duration per PageUp/PageDown. */
21
+ bigStep?: number;
22
+ disabled?: boolean;
23
+ size?: 'sm' | 'md';
24
+ label?: string;
25
+ formatTime?: (seconds: number, ref?: number) => string;
26
+ /** Fired on every pointer move while scrubbing with the previewed time. */
27
+ oninput?: (seconds: number) => void;
28
+ /** Fired once on release or per keyboard step with the committed time. */
29
+ onchange?: (seconds: number) => void;
30
+ onscrubstart?: () => void;
31
+ onscrubend?: () => void;
32
+ /** Rendered above the time in the hover/drag tooltip (thumbnail). */
33
+ previewSnippet?: Snippet<[number]>;
34
+ class?: string;
35
+ style?: string;
36
+ };
37
+
38
+ let {
39
+ value = $bindable(0),
40
+ max = null,
41
+ buffered = [],
42
+ step = 5,
43
+ bigStep = 0.1,
44
+ disabled = false,
45
+ size = 'md',
46
+ label = 'Seek',
47
+ formatTime = formatMediaTime,
48
+ oninput,
49
+ onchange,
50
+ onscrubstart,
51
+ onscrubend,
52
+ previewSnippet,
53
+ class: klass = '',
54
+ style: styleProp,
55
+ ...rest
56
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
57
+
58
+ let dragging = $state(false);
59
+ let preview = $state(0);
60
+ let hoverFrac = $state<number | null>(null);
61
+ let pointerId: number | null = null;
62
+
63
+ const live = $derived(isLiveDuration(max));
64
+ const duration = $derived(live ? 0 : (max as number));
65
+ const inert = $derived(disabled || live);
66
+ const clamp = (v: number) => (live ? Math.max(0, v) : Math.min(duration, Math.max(0, v)));
67
+ const shown = $derived(clamp(dragging ? preview : value));
68
+ const frac = (v: number) => (duration > 0 ? clamp(v) / duration : 0);
69
+ const pct = $derived(frac(shown) * 100);
70
+ const tipValue = $derived(dragging ? shown : hoverFrac === null ? null : hoverFrac * duration);
71
+ const tipPct = $derived(tipValue === null ? 0 : frac(tipValue) * 100);
72
+ const ranges = $derived(
73
+ live
74
+ ? []
75
+ : buffered
76
+ .map(([a, b]) => [frac(a), frac(b)] as const)
77
+ .filter(([a, b]) => b > a)
78
+ );
79
+ const valueText = $derived(describeMediaTime(shown, live ? null : duration, formatTime));
80
+
81
+ function fracAt(el: HTMLElement, clientX: number) {
82
+ const r = el.getBoundingClientRect();
83
+ if (!r.width) return 0;
84
+ let f = (clientX - r.left) / r.width;
85
+ if (getComputedStyle(el).direction === 'rtl') f = 1 - f;
86
+ return Math.min(1, Math.max(0, f));
87
+ }
88
+
89
+ function commit(v: number) {
90
+ const next = clamp(v);
91
+ value = next;
92
+ onchange?.(next);
93
+ }
94
+
95
+ function endDrag(el: HTMLElement, fire: boolean) {
96
+ if (pointerId !== null && typeof el.releasePointerCapture === 'function') {
97
+ try {
98
+ el.releasePointerCapture(pointerId);
99
+ } catch {}
100
+ }
101
+ pointerId = null;
102
+ dragging = false;
103
+ onscrubend?.();
104
+ if (fire) commit(preview);
105
+ }
106
+
107
+ function onpointerdown(e: PointerEvent) {
108
+ if (inert || e.button !== 0 || pointerId !== null) return;
109
+ const el = e.currentTarget as HTMLElement;
110
+ e.preventDefault();
111
+ pointerId = e.pointerId;
112
+ dragging = true;
113
+ preview = fracAt(el, e.clientX) * duration;
114
+ if (typeof el.setPointerCapture === 'function') {
115
+ try {
116
+ el.setPointerCapture(e.pointerId);
117
+ } catch {}
118
+ }
119
+ el.focus?.({ preventScroll: true });
120
+ onscrubstart?.();
121
+ oninput?.(preview);
122
+ }
123
+
124
+ function onpointermove(e: PointerEvent) {
125
+ const el = e.currentTarget as HTMLElement;
126
+ if (pointerId === e.pointerId) {
127
+ preview = fracAt(el, e.clientX) * duration;
128
+ oninput?.(preview);
129
+ return;
130
+ }
131
+ if (inert || pointerId !== null) return;
132
+ hoverFrac = fracAt(el, e.clientX);
133
+ }
134
+
135
+ function onpointerup(e: PointerEvent) {
136
+ if (pointerId !== e.pointerId) return;
137
+ endDrag(e.currentTarget as HTMLElement, true);
138
+ }
139
+
140
+ function onpointercancel(e: PointerEvent) {
141
+ if (pointerId !== e.pointerId) return;
142
+ endDrag(e.currentTarget as HTMLElement, false);
143
+ }
144
+
145
+ function onlostpointercapture(e: PointerEvent) {
146
+ if (pointerId === null || pointerId !== e.pointerId) return;
147
+ pointerId = null;
148
+ dragging = false;
149
+ onscrubend?.();
150
+ commit(preview);
151
+ }
152
+
153
+ function onpointerleave() {
154
+ hoverFrac = null;
155
+ }
156
+
157
+ function onkeydown(e: KeyboardEvent) {
158
+ if (inert) return;
159
+ const base = dragging ? preview : value;
160
+ let next: number | null = null;
161
+ switch (e.key) {
162
+ case 'ArrowRight':
163
+ case 'ArrowUp':
164
+ next = base + step;
165
+ break;
166
+ case 'ArrowLeft':
167
+ case 'ArrowDown':
168
+ next = base - step;
169
+ break;
170
+ case 'PageUp':
171
+ next = base + bigStep * duration;
172
+ break;
173
+ case 'PageDown':
174
+ next = base - bigStep * duration;
175
+ break;
176
+ case 'Home':
177
+ next = 0;
178
+ break;
179
+ case 'End':
180
+ next = duration;
181
+ break;
182
+ }
183
+ if (next === null) return;
184
+ e.preventDefault();
185
+ commit(next);
186
+ }
187
+ </script>
188
+
189
+ <div
190
+ data-tsu="SeekSlider"
191
+ class="seek seek--{size} {klass}"
192
+ class:seek--dragging={dragging}
193
+ class:seek--inert={inert}
194
+ class:seek--tip={tipValue !== null}
195
+ style="--pct: {pct}; --tip: {tipPct};{styleProp ?? ''}"
196
+ role="slider"
197
+ tabindex={inert ? -1 : 0}
198
+ aria-label={label}
199
+ aria-orientation="horizontal"
200
+ aria-valuemin={0}
201
+ aria-valuemax={duration}
202
+ aria-valuenow={shown}
203
+ aria-valuetext={valueText}
204
+ aria-disabled={inert ? 'true' : undefined}
205
+ {onpointerdown}
206
+ {onpointermove}
207
+ {onpointerup}
208
+ {onpointercancel}
209
+ {onlostpointercapture}
210
+ {onpointerleave}
211
+ {onkeydown}
212
+ {...rest}
213
+ >
214
+ <span class="seek-track" data-part="track">
215
+ {#each ranges as [a, b], i (i)}
216
+ <i class="seek-buffered" data-part="buffered" style="--a: {a * 100}; --b: {b * 100}"></i>
217
+ {/each}
218
+ <i class="seek-played" data-part="played"></i>
219
+ </span>
220
+ <span class="seek-anchor seek-anchor--thumb" data-part="thumb-anchor">
221
+ <i class="seek-thumb" data-part="thumb"></i>
222
+ </span>
223
+ {#if tipValue !== null}
224
+ <span class="seek-anchor seek-anchor--tip" aria-hidden="true">
225
+ <span class="seek-tip" data-part="tip">
226
+ {#if previewSnippet}{@render previewSnippet(tipValue)}{/if}
227
+ <span class="seek-tip-time">{formatTime(tipValue, duration)}</span>
228
+ </span>
229
+ </span>
230
+ {/if}
231
+ </div>
232
+
233
+ <style>
234
+ .seek {
235
+ --seek-track: 3px;
236
+ --seek-track-active: 5px;
237
+ --seek-thumb: 12px;
238
+ --seek-slab: 1.25rem;
239
+ --seek-accent: var(--slider-accent, var(--accent));
240
+ position: relative;
241
+ display: block;
242
+ width: 100%;
243
+ min-height: var(--seek-slab);
244
+ cursor: pointer;
245
+ touch-action: none;
246
+ user-select: none;
247
+ -webkit-user-select: none;
248
+ outline: none;
249
+ }
250
+ .seek--md {
251
+ --seek-track: 4px;
252
+ --seek-track-active: 6px;
253
+ --seek-thumb: 14px;
254
+ --seek-slab: 1.5rem;
255
+ }
256
+ @media (pointer: coarse) {
257
+ .seek {
258
+ min-height: var(--touch-target-compact);
259
+ }
260
+ }
261
+ .seek--inert {
262
+ cursor: default;
263
+ opacity: 0.45;
264
+ }
265
+ .seek-track {
266
+ position: absolute;
267
+ inset-inline: 0;
268
+ top: 50%;
269
+ height: var(--seek-track);
270
+ margin-top: calc(var(--seek-track) / -2);
271
+ border-radius: var(--r-pill);
272
+ background: var(--bg-elevated-2);
273
+ overflow: hidden;
274
+ transition:
275
+ height 120ms var(--ease),
276
+ margin-top 120ms var(--ease);
277
+ }
278
+ .seek:hover .seek-track,
279
+ .seek:focus-visible .seek-track,
280
+ .seek--dragging .seek-track {
281
+ height: var(--seek-track-active);
282
+ margin-top: calc(var(--seek-track-active) / -2);
283
+ }
284
+ .seek--inert:hover .seek-track {
285
+ height: var(--seek-track);
286
+ margin-top: calc(var(--seek-track) / -2);
287
+ }
288
+ .seek-buffered,
289
+ .seek-played {
290
+ position: absolute;
291
+ inset-block: 0;
292
+ inset-inline-start: 0;
293
+ }
294
+ .seek-buffered {
295
+ inset-inline-start: calc(var(--a) * 1%);
296
+ width: calc((var(--b) - var(--a)) * 1%);
297
+ background: var(--border-strong);
298
+ }
299
+ .seek-played {
300
+ width: calc(var(--pct) * 1%);
301
+ background: var(--seek-accent);
302
+ }
303
+ .seek-anchor {
304
+ position: absolute;
305
+ top: 50%;
306
+ width: 0;
307
+ height: 0;
308
+ pointer-events: none;
309
+ }
310
+ .seek-anchor--thumb {
311
+ inset-inline-start: calc(var(--pct) * 1%);
312
+ }
313
+ .seek-anchor--tip {
314
+ inset-inline-start: calc(var(--tip) * 1%);
315
+ }
316
+ .seek-thumb {
317
+ position: absolute;
318
+ left: 0;
319
+ top: 0;
320
+ width: var(--seek-thumb);
321
+ height: var(--seek-thumb);
322
+ border-radius: 50%;
323
+ background: var(--seek-accent);
324
+ border: 2px solid var(--bg);
325
+ box-shadow: var(--shadow-sm);
326
+ transform: translate(-50%, -50%) scale(0.6);
327
+ opacity: 0;
328
+ transition:
329
+ opacity 120ms var(--ease),
330
+ transform 120ms var(--ease);
331
+ }
332
+ .seek:hover .seek-thumb,
333
+ .seek:focus-visible .seek-thumb,
334
+ .seek--dragging .seek-thumb {
335
+ opacity: 1;
336
+ transform: translate(-50%, -50%) scale(1);
337
+ }
338
+ .seek--inert .seek-thumb {
339
+ opacity: 0;
340
+ }
341
+ .seek:focus-visible .seek-thumb {
342
+ outline: var(--focus-ring);
343
+ outline-offset: 2px;
344
+ }
345
+ .seek-tip {
346
+ position: absolute;
347
+ left: 0;
348
+ bottom: calc(var(--seek-thumb) / 2 + var(--sp-2));
349
+ transform: translateX(-50%);
350
+ display: flex;
351
+ flex-direction: column;
352
+ align-items: center;
353
+ gap: var(--sp-1);
354
+ padding: var(--sp-1) var(--sp-2);
355
+ border-radius: var(--r-sm);
356
+ background: var(--bg-elevated);
357
+ border: 1px solid var(--border);
358
+ box-shadow: var(--shadow-md);
359
+ color: var(--text);
360
+ font-size: var(--fs-xs);
361
+ font-variant-numeric: tabular-nums;
362
+ white-space: nowrap;
363
+ }
364
+ @media (prefers-reduced-motion: reduce) {
365
+ .seek-track,
366
+ .seek-thumb {
367
+ transition: none;
368
+ }
369
+ }
370
+ </style>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Media scrubber. Dragging previews (`oninput`) without seeking; the player
3
+ * seeks only on `onchange`, fired once per release or keyboard step. The
4
+ * hit slab is tall enough for touch while the visible track stays thin.
5
+ */
6
+ import type { Snippet } from 'svelte';
7
+ import type { HTMLAttributes } from 'svelte/elements';
8
+ type Own = {
9
+ /** Position in seconds. Updated only on commit, never while dragging. */
10
+ value?: number;
11
+ /** Duration in seconds. `null`/`Infinity`/`0` = live or unknown: the track is disabled. */
12
+ max?: number | null;
13
+ /** Buffered `[start, end]` ranges in seconds, drawn as a muted fill. */
14
+ buffered?: [number, number][];
15
+ /** Seconds per arrow key. */
16
+ step?: number;
17
+ /** Fraction of the duration per PageUp/PageDown. */
18
+ bigStep?: number;
19
+ disabled?: boolean;
20
+ size?: 'sm' | 'md';
21
+ label?: string;
22
+ formatTime?: (seconds: number, ref?: number) => string;
23
+ /** Fired on every pointer move while scrubbing with the previewed time. */
24
+ oninput?: (seconds: number) => void;
25
+ /** Fired once on release or per keyboard step with the committed time. */
26
+ onchange?: (seconds: number) => void;
27
+ onscrubstart?: () => void;
28
+ onscrubend?: () => void;
29
+ /** Rendered above the time in the hover/drag tooltip (thumbnail). */
30
+ previewSnippet?: Snippet<[number]>;
31
+ class?: string;
32
+ style?: string;
33
+ };
34
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
35
+ declare const SeekSlider: import("svelte").Component<$$ComponentProps, {}, "value">;
36
+ type SeekSlider = ReturnType<typeof SeekSlider>;
37
+ export default SeekSlider;
@@ -88,19 +88,23 @@
88
88
 
89
89
  let listEl = $state<HTMLDivElement | null>(null);
90
90
  const baseId = `seg-${Math.random().toString(36).slice(2, 8)}`;
91
+ // The roving tab stop: the selected option, or the first enabled one when the
92
+ // selection is unknown or disabled, so the group never drops out of the Tab order.
93
+ const stop = $derived(
94
+ options.find((o) => o.value === value && !o.disabled) ?? options.find((o) => !o.disabled)
95
+ );
96
+ const segId = (val: string) => `${baseId}-${val}`;
91
97
 
92
98
  function select(val: string, focus = false) {
93
99
  if (options.find((o) => o.value === val)?.disabled) return;
94
100
  value = val;
95
101
  onchange?.(val);
96
102
  if (focus) {
97
- queueMicrotask(() =>
98
- listEl?.querySelector<HTMLButtonElement>(`#${baseId}-${val}`)?.focus()
99
- );
103
+ queueMicrotask(() => listEl?.ownerDocument.getElementById(segId(val))?.focus());
100
104
  }
101
105
  }
102
106
  function onkeydown(e: KeyboardEvent) {
103
- const i = options.findIndex((o) => o.value === value);
107
+ const i = stop ? options.indexOf(stop) : -1;
104
108
  const next = nextEnabledSegment(options, i, e.key);
105
109
  if (next === undefined) return;
106
110
  e.preventDefault();
@@ -130,11 +134,11 @@
130
134
  {@const selected = value === o.value}
131
135
  <button
132
136
  type="button"
133
- id="{baseId}-{o.value}"
137
+ id={segId(o.value)}
134
138
  role="radio"
135
139
  aria-checked={selected}
136
140
  aria-label={variant === 'icon' && !o.label ? o.value : o.label}
137
- tabindex={selected ? 0 : -1}
141
+ tabindex={stop?.value === o.value ? 0 : -1}
138
142
  class="seg-item"
139
143
  class:has-label={!!o.label}
140
144
  class:selected
@@ -66,7 +66,8 @@
66
66
  </script>
67
67
 
68
68
  <!-- The wrapper (owned here, so styled scoped) is the positioning context that
69
- clips the transparent overlaid <select>; the icon Button 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);