@dorsk/tsumikit 0.75.0 → 0.77.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.
@@ -100,8 +100,9 @@
100
100
  background: var(--divider-color);
101
101
  }
102
102
  .divider-label {
103
+ min-width: 0;
103
104
  color: var(--text-muted);
104
105
  font-size: var(--fs-sm);
105
- white-space: nowrap;
106
+ text-align: center;
106
107
  }
107
108
  </style>
@@ -12,7 +12,7 @@
12
12
 
13
13
  // `size` shadows the native char-width attribute (unused in token-sized
14
14
  // layouts) to expose a height preset instead.
15
- type Props = Omit<HTMLInputAttributes, 'size'> & {
15
+ type Props = Omit<HTMLInputAttributes, 'size' | 'onsubmit'> & {
16
16
  mono?: boolean;
17
17
  size?: ControlSize;
18
18
  /** Fill the available width of a flex/Cluster row (flex: 1) instead of
@@ -1,7 +1,7 @@
1
1
  import type { ControlSize } from '../../size';
2
2
  import type { HTMLInputAttributes } from 'svelte/elements';
3
3
  import { type IconName } from './Icon.svelte';
4
- type Props = Omit<HTMLInputAttributes, 'size'> & {
4
+ type Props = Omit<HTMLInputAttributes, 'size' | 'onsubmit'> & {
5
5
  mono?: boolean;
6
6
  size?: ControlSize;
7
7
  /** Fill the available width of a flex/Cluster row (flex: 1) instead of
@@ -199,6 +199,7 @@
199
199
  .select-wrap.w-auto {
200
200
  display: inline-block;
201
201
  width: auto;
202
+ max-width: 100%;
202
203
  }
203
204
  .select-wrap.select-grow {
204
205
  flex: 1 1 0;
@@ -253,6 +254,8 @@
253
254
  }
254
255
  .select.w-auto {
255
256
  width: auto;
257
+ max-width: 100%;
258
+ text-overflow: ellipsis;
256
259
  }
257
260
  .select.embedded {
258
261
  background: var(--select-bg, var(--bg-elevated-2));
@@ -23,7 +23,7 @@
23
23
  import { getFieldContext, warnUnlabelled } from '../../field-context';
24
24
  import { getInputGroupContext } from '../../input-group-context';
25
25
 
26
- type Props = HTMLTextareaAttributes & {
26
+ type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
27
27
  mono?: boolean;
28
28
  autoresize?: boolean;
29
29
  size?: ControlSize;
@@ -1,6 +1,6 @@
1
1
  import type { ControlSize } from '../../size';
2
2
  import type { HTMLTextareaAttributes } from 'svelte/elements';
3
- type Props = HTMLTextareaAttributes & {
3
+ type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
4
4
  mono?: boolean;
5
5
  autoresize?: boolean;
6
6
  size?: ControlSize;
@@ -60,7 +60,7 @@
60
60
  {variant}
61
61
  {box}
62
62
  {hitArea}
63
- class="copy-btn {klass}"
63
+ class="copy-btn {labelShown && box !== undefined ? 'copy-labelled' : ''} {klass}"
64
64
  sound={false}
65
65
  onclick={copy}
66
66
  aria-label={copied ? copiedLabel : label}
@@ -78,6 +78,10 @@
78
78
  /* Both glyphs share one grid cell so the swap never changes the width. The
79
79
  check springs in (the kit's one bounce: a rare confirmation); the copy
80
80
  glyph fades out fast and back in over the base duration on revert. */
81
+ :global(.copy-btn.btn-box.copy-labelled) {
82
+ width: auto;
83
+ padding-inline: var(--sp-2);
84
+ }
81
85
  .glyphs {
82
86
  display: inline-grid;
83
87
  place-items: center;
@@ -73,6 +73,7 @@
73
73
  display: flex;
74
74
  flex-direction: column;
75
75
  gap: var(--sp-1);
76
+ min-width: 0;
76
77
  }
77
78
  .field-grow {
78
79
  flex: 1 1 0;
@@ -93,7 +93,7 @@
93
93
  {:else if emoji}
94
94
  <span class="emoji" aria-hidden="true">{emoji}</span>
95
95
  {/if}
96
- <span class:sr-only={onlyIcon}>{label}</span>
96
+ <span class="text" class:sr-only={onlyIcon}>{label}</span>
97
97
  </label>
98
98
 
99
99
  <style>
@@ -114,6 +114,7 @@
114
114
  font-weight: var(--fw-medium);
115
115
  font-size: var(--control-fs-md);
116
116
  line-height: var(--lh-none);
117
+ max-width: 100%;
117
118
  white-space: nowrap;
118
119
  cursor: pointer;
119
120
  user-select: none;
@@ -179,6 +180,11 @@
179
180
  }
180
181
  }
181
182
  /* An emoji reads small next to an SVG glyph of the same size: one icon tier up. */
183
+ .text:not(.sr-only) {
184
+ min-width: 0;
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ }
182
188
  .emoji {
183
189
  display: inline-flex;
184
190
  font-size: var(--icon-lg);
@@ -321,9 +321,17 @@
321
321
  font-size: var(--fs-xs);
322
322
  line-height: var(--lh-none);
323
323
  }
324
+ .stacked > :global(*) :global(:focus-visible),
325
+ .stacked > :global(:focus-visible) {
326
+ outline-offset: var(--focus-ring-offset-inset);
327
+ }
324
328
  .glyph-stack[data-expand] {
325
329
  cursor: pointer;
326
330
  }
331
+ /* An expandable stack is one target: presses land on the stack, not on 1rem cells. */
332
+ .glyph-stack[data-expand] > .cell {
333
+ pointer-events: none;
334
+ }
327
335
  .glyph-stack[data-expand='slide'] {
328
336
  touch-action: none;
329
337
  user-select: none;
@@ -219,6 +219,7 @@
219
219
 
220
220
  .rows {
221
221
  gap: var(--sp-1);
222
+ min-width: 0;
222
223
  }
223
224
  .row-wrap {
224
225
  min-width: 0;
@@ -250,8 +251,9 @@
250
251
  }
251
252
  .label-line {
252
253
  display: flex;
254
+ flex-wrap: wrap;
253
255
  align-items: baseline;
254
- gap: var(--sp-2);
256
+ gap: var(--sp-px) var(--sp-2);
255
257
  min-width: 0;
256
258
  }
257
259
  .row .label-text {
@@ -260,7 +262,6 @@
260
262
  .note {
261
263
  font-size: var(--fs-xs);
262
264
  color: var(--text-faint);
263
- white-space: nowrap;
264
265
  }
265
266
  .description {
266
267
  font-size: var(--fs-xs);
@@ -0,0 +1,592 @@
1
+ <script lang="ts" module>
2
+ export type RichSelectOptionContext<T> = {
3
+ option: T;
4
+ index: number;
5
+ selected: boolean;
6
+ active: boolean;
7
+ /** True for the copy rendered inside the closed trigger. */
8
+ inTrigger: boolean;
9
+ };
10
+ </script>
11
+
12
+ <script lang="ts" generics="T">
13
+ import { tick, type Snippet } from 'svelte';
14
+ import Icon from '../atoms/Icon.svelte';
15
+ import { playSound } from '../../sound';
16
+ import Popover from './Popover.svelte';
17
+ import { createTypeahead, richSelectAction, typeaheadIndex } from './rich-select-keyboard.js';
18
+ import { slotForTop } from './sortable-list-order.js';
19
+
20
+ type Key = string | number;
21
+
22
+ type Props = {
23
+ options: readonly T[];
24
+ /** Key of the selected option (`getKey`). */
25
+ value?: Key;
26
+ onchange?: (value: Key, option: T) => void;
27
+ getKey?: (option: T) => Key;
28
+ /** Text used by typeahead and announcements. */
29
+ getLabel?: (option: T) => string;
30
+ /** The card, rendered in the closed trigger and for every listbox row.
31
+ * It sits inside the trigger button, so it must not contain controls. */
32
+ option: Snippet<[RichSelectOptionContext<T>]>;
33
+ /** Per-row actions beside the card. They never select the option. */
34
+ trailing?: Snippet<[{ option: T; index: number; close: () => void }]>;
35
+ footer?: Snippet<[{ close: () => void }]>;
36
+ /** Enables the drag handles and Alt+ArrowUp/Down; reorder `options` in it. */
37
+ onreorder?: (from: number, to: number) => void;
38
+ placeholder?: Snippet | string;
39
+ /** Accessible name of the select. */
40
+ label: string;
41
+ disabled?: boolean;
42
+ box?: 'sm' | 'md' | 'lg';
43
+ placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
44
+ open?: boolean;
45
+ /** Play UI sounds when enabled globally; false silences this instance. */
46
+ sound?: boolean;
47
+ /** Spoken after a keyboard reorder. */
48
+ movedMessage?: (label: string, position: number, total: number) => string;
49
+ class?: string;
50
+ style?: string;
51
+ };
52
+
53
+ const fallbackKey = (o: T): Key => {
54
+ const r = o as { id?: Key; value?: Key; key?: Key };
55
+ return r?.key ?? r?.id ?? r?.value ?? String(o);
56
+ };
57
+ const fallbackLabel = (o: T): string => {
58
+ const r = o as { label?: string; name?: string };
59
+ return r?.label ?? r?.name ?? String(o);
60
+ };
61
+
62
+ let {
63
+ options,
64
+ value = $bindable(),
65
+ onchange,
66
+ getKey = fallbackKey,
67
+ getLabel = fallbackLabel,
68
+ option,
69
+ trailing,
70
+ footer,
71
+ onreorder,
72
+ placeholder = 'Select…',
73
+ label,
74
+ disabled = false,
75
+ box = 'md',
76
+ placement = 'bottom-start',
77
+ open = $bindable(false),
78
+ sound = true,
79
+ movedMessage = (name, position, total) => `${name} moved to position ${position} of ${total}`,
80
+ class: klass = '',
81
+ style = '',
82
+ }: Props = $props();
83
+
84
+ const uid = Math.random().toString(36).slice(2, 8);
85
+ const listId = `rs-list-${uid}`;
86
+ const optionId = (i: number) => `rs-opt-${uid}-${i}`;
87
+
88
+ let root = $state<HTMLDivElement | null>(null);
89
+ let active = $state(-1);
90
+ let announcement = $state('');
91
+ let triggerWidth = $state(0);
92
+ let dragFrom = $state(-1);
93
+ let dragTo = $state(-1);
94
+ let dragDy = $state(0);
95
+ let dragHeight = 0;
96
+
97
+ const selectedIndex = $derived(options.findIndex((o) => getKey(o) === value));
98
+ const selectedOption = $derived(selectedIndex >= 0 ? options[selectedIndex] : undefined);
99
+ const typeahead = createTypeahead();
100
+
101
+ const triggerEl = () => root?.querySelector<HTMLElement>('.rs-trigger') ?? null;
102
+ const rowEls = () => [...(root?.querySelectorAll<HTMLElement>('.rs-row') ?? [])];
103
+
104
+ $effect(() => {
105
+ const el = triggerEl();
106
+ if (!el || typeof ResizeObserver === 'undefined') return;
107
+ const ro = new ResizeObserver(() => (triggerWidth = el.offsetWidth));
108
+ ro.observe(el);
109
+ return () => ro.disconnect();
110
+ });
111
+
112
+ $effect(() => {
113
+ triggerEl()?.setAttribute('role', 'combobox');
114
+ });
115
+
116
+ $effect(() => {
117
+ if (!open) return;
118
+ if (active < 0 || active >= options.length) active = selectedIndex >= 0 ? selectedIndex : 0;
119
+ });
120
+
121
+ $effect(() => {
122
+ if (open && active >= 0) revealActive();
123
+ });
124
+
125
+ // Scrolls the listbox only: `scrollIntoView` on a top-layer panel also
126
+ // scrolls the document in Chromium.
127
+ function revealActive() {
128
+ const list = root?.querySelector<HTMLElement>('.rs-list');
129
+ const row = rowEls()[active];
130
+ if (!list || !row) return;
131
+ const top = row.offsetTop;
132
+ const bottom = top + row.offsetHeight;
133
+ if (top < list.scrollTop) list.scrollTop = top;
134
+ else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
135
+ }
136
+
137
+ function close() {
138
+ open = false;
139
+ }
140
+
141
+ function focusTrigger() {
142
+ triggerEl()?.focus();
143
+ }
144
+
145
+ function choose(index: number, closeAfter: boolean) {
146
+ const next = options[index];
147
+ if (next === undefined) return;
148
+ const key = getKey(next);
149
+ active = index;
150
+ if (key !== value) {
151
+ value = key;
152
+ onchange?.(key, next);
153
+ playSound(closeAfter ? 'click' : 'tick', sound);
154
+ }
155
+ if (closeAfter) {
156
+ close();
157
+ focusTrigger();
158
+ }
159
+ }
160
+
161
+ function reorder(from: number, to: number) {
162
+ if (!onreorder || from === to) return;
163
+ const moved = options[from];
164
+ onreorder(from, to);
165
+ active = to;
166
+ if (moved !== undefined) announcement = movedMessage(getLabel(moved), to + 1, options.length);
167
+ playSound('tick', sound);
168
+ }
169
+
170
+ function trailingFocusables(index: number) {
171
+ const row = rowEls()[index];
172
+ return [
173
+ ...(row?.querySelectorAll<HTMLElement>(
174
+ '.rs-trailing :is(button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])):not(:disabled)'
175
+ ) ?? []),
176
+ ];
177
+ }
178
+
179
+ function onTriggerKey(e: KeyboardEvent) {
180
+ const action = richSelectAction(e, {
181
+ open,
182
+ count: options.length,
183
+ index: open ? active : selectedIndex,
184
+ reorder: onreorder !== undefined,
185
+ trailing: open && active >= 0 && trailingFocusables(active).length > 0,
186
+ });
187
+ if (!action) return;
188
+ if (action.type === 'close' && e.key === 'Tab') {
189
+ close();
190
+ return;
191
+ }
192
+ e.preventDefault();
193
+ e.stopPropagation();
194
+ switch (action.type) {
195
+ case 'open':
196
+ active = selectedIndex >= 0 ? selectedIndex : 0;
197
+ open = true;
198
+ break;
199
+ case 'close':
200
+ close();
201
+ focusTrigger();
202
+ break;
203
+ case 'select':
204
+ choose(active, true);
205
+ break;
206
+ case 'move':
207
+ active = action.index;
208
+ break;
209
+ case 'change':
210
+ choose(action.index, false);
211
+ break;
212
+ case 'reorder':
213
+ reorder(action.from, action.to);
214
+ break;
215
+ case 'trailing':
216
+ trailingFocusables(active)[0]?.focus();
217
+ break;
218
+ case 'type': {
219
+ const from = open ? active : selectedIndex;
220
+ const hit = typeaheadIndex(options.map(getLabel), typeahead.push(action.char), from);
221
+ if (hit === undefined) break;
222
+ if (open) active = hit;
223
+ else choose(hit, false);
224
+ break;
225
+ }
226
+ }
227
+ }
228
+
229
+ function onPanelKey(e: KeyboardEvent, target: HTMLElement) {
230
+ if (!target.closest('.rs-trailing')) {
231
+ if (e.key === 'Escape') {
232
+ e.preventDefault();
233
+ e.stopPropagation();
234
+ close();
235
+ focusTrigger();
236
+ }
237
+ return;
238
+ }
239
+ const row = target.closest<HTMLElement>('.rs-row');
240
+ const index = row ? rowEls().indexOf(row) : -1;
241
+ const items = index >= 0 ? trailingFocusables(index) : [];
242
+ const at = items.indexOf(target);
243
+ const back = e.key === 'ArrowLeft' || (e.key === 'Tab' && e.shiftKey && at <= 0);
244
+ if (e.key === 'Escape' || back || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
245
+ e.preventDefault();
246
+ e.stopPropagation();
247
+ if (e.key === 'ArrowLeft' && at > 0) {
248
+ items[at - 1].focus();
249
+ return;
250
+ }
251
+ if (e.key === 'Escape') close();
252
+ else if (e.key === 'ArrowUp') active = Math.max(index - 1, 0);
253
+ else if (e.key === 'ArrowDown') active = Math.min(index + 1, options.length - 1);
254
+ focusTrigger();
255
+ return;
256
+ }
257
+ if (e.key === 'ArrowRight' && at >= 0 && at < items.length - 1) {
258
+ e.preventDefault();
259
+ e.stopPropagation();
260
+ items[at + 1].focus();
261
+ }
262
+ }
263
+
264
+ function onKeydownCapture(e: KeyboardEvent) {
265
+ if (disabled) return;
266
+ const target = e.target as HTMLElement;
267
+ if (target === triggerEl()) onTriggerKey(e);
268
+ else if (target.closest('.rs-panel')) onPanelKey(e, target);
269
+ }
270
+
271
+ function onGripDown(e: PointerEvent, index: number) {
272
+ if (!onreorder || e.button !== 0) return;
273
+ e.preventDefault();
274
+ const grip = e.currentTarget as HTMLElement;
275
+ grip.setPointerCapture?.(e.pointerId);
276
+ const rows = rowEls();
277
+ const startY = e.clientY;
278
+ const top = (rows[index]?.offsetTop ?? 0) - (rows[0]?.offsetTop ?? 0);
279
+ const others = rows.filter((_, i) => i !== index).map((r) => r.offsetHeight);
280
+ dragHeight = rows[index]?.offsetHeight ?? 0;
281
+ dragFrom = index;
282
+ dragTo = index;
283
+ dragDy = 0;
284
+ active = index;
285
+ const move = (ev: PointerEvent) => {
286
+ dragDy = ev.clientY - startY;
287
+ dragTo = slotForTop(top + dragDy, others);
288
+ };
289
+ const end = (ev: PointerEvent) => {
290
+ grip.removeEventListener('pointermove', move);
291
+ grip.removeEventListener('pointerup', end);
292
+ grip.removeEventListener('pointercancel', end);
293
+ const from = dragFrom;
294
+ const to = dragTo;
295
+ dragFrom = -1;
296
+ dragTo = -1;
297
+ dragDy = 0;
298
+ if (ev.type === 'pointerup') reorder(from, to);
299
+ };
300
+ grip.addEventListener('pointermove', move);
301
+ grip.addEventListener('pointerup', end);
302
+ grip.addEventListener('pointercancel', end);
303
+ }
304
+
305
+ function shiftFor(i: number) {
306
+ if (dragFrom < 0) return undefined;
307
+ if (i === dragFrom) return `0 ${dragDy}px`;
308
+ if (dragFrom < i && i <= dragTo) return `0 ${-dragHeight}px`;
309
+ if (dragTo <= i && i < dragFrom) return `0 ${dragHeight}px`;
310
+ return undefined;
311
+ }
312
+
313
+ async function onOpen() {
314
+ typeahead.reset();
315
+ await tick();
316
+ if (active >= 0) revealActive();
317
+ }
318
+
319
+ function onClose() {
320
+ typeahead.reset();
321
+ dragFrom = -1;
322
+ }
323
+
324
+ const panelStyle = $derived(
325
+ triggerWidth > 0
326
+ ? `min-width: max(10rem, ${triggerWidth}px); max-width: min(max(22rem, ${triggerWidth}px), calc(100vw - 2 * var(--sp-3)))`
327
+ : ''
328
+ );
329
+ </script>
330
+
331
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
332
+ <div
333
+ bind:this={root}
334
+ data-tsu="RichSelect"
335
+ class="rich-select box-{box} {klass}"
336
+ class:is-disabled={disabled}
337
+ class:is-dragging={dragFrom >= 0}
338
+ class:is-open={open}
339
+ {style}
340
+ onkeydowncapture={onKeydownCapture}
341
+ >
342
+ <Popover
343
+ bind:open
344
+ bare
345
+ {label}
346
+ {placement}
347
+ {disabled}
348
+ {sound}
349
+ role="group"
350
+ haspopup="listbox"
351
+ triggerClass="rs-trigger"
352
+ panelClass="rs-panel"
353
+ {panelStyle}
354
+ onopen={onOpen}
355
+ onclose={onClose}
356
+ aria-controls={listId}
357
+ aria-activedescendant={open && active >= 0 ? optionId(active) : undefined}
358
+ >
359
+ {#snippet trigger()}
360
+ <span class="rs-value">
361
+ {#if selectedOption !== undefined}
362
+ {@render option({ option: selectedOption, index: selectedIndex, selected: true, active: false, inTrigger: true })}
363
+ {:else if typeof placeholder === 'string'}
364
+ <span class="rs-placeholder">{placeholder}</span>
365
+ {:else}
366
+ {@render placeholder()}
367
+ {/if}
368
+ </span>
369
+ <span class="rs-chevron" aria-hidden="true"><Icon name="chevron-down" /></span>
370
+ {/snippet}
371
+ <div class="rs-list" role="listbox" id={listId} aria-label={label}>
372
+ {#each options as opt, i (getKey(opt))}
373
+ {@const selected = i === selectedIndex}
374
+ <div
375
+ class="rs-row"
376
+ role="presentation"
377
+ data-active={i === active ? '' : undefined}
378
+ data-selected={selected ? '' : undefined}
379
+ data-dragging={i === dragFrom ? '' : undefined}
380
+ style:translate={shiftFor(i)}
381
+ >
382
+ {#if onreorder}
383
+ <span class="rs-grip" aria-hidden="true" onpointerdown={(e) => onGripDown(e, i)}></span>
384
+ {/if}
385
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
386
+ <div
387
+ class="rs-option"
388
+ role="option"
389
+ id={optionId(i)}
390
+ aria-selected={selected}
391
+ tabindex="-1"
392
+ onclick={() => choose(i, true)}
393
+ onmousedown={(e) => e.preventDefault()}
394
+ onpointermove={() => {
395
+ if (dragFrom < 0) active = i;
396
+ }}
397
+ >
398
+ {@render option({ option: opt, index: i, selected, active: i === active, inTrigger: false })}
399
+ </div>
400
+ {#if trailing}
401
+ <div class="rs-trailing">{@render trailing({ option: opt, index: i, close })}</div>
402
+ {/if}
403
+ </div>
404
+ {/each}
405
+ </div>
406
+ {#if footer}
407
+ <div class="rs-footer">{@render footer({ close })}</div>
408
+ {/if}
409
+ </Popover>
410
+ <span class="sr-only" aria-live="polite">{announcement}</span>
411
+ </div>
412
+
413
+ <style>
414
+ .rich-select {
415
+ display: block;
416
+ min-width: 0;
417
+ --rs-height: var(--control-md);
418
+ }
419
+ .rich-select.box-sm {
420
+ --rs-height: var(--control-sm);
421
+ }
422
+ .rich-select.box-lg {
423
+ --rs-height: var(--control-lg);
424
+ }
425
+ .rich-select :global(.pop-trigger.rs-trigger) {
426
+ display: flex;
427
+ align-items: center;
428
+ gap: var(--sp-2);
429
+ box-sizing: border-box;
430
+ width: 100%;
431
+ min-width: 0;
432
+ min-height: var(--rs-height);
433
+ padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-1);
434
+ border: 1px solid var(--border-strong);
435
+ border-radius: var(--r-md);
436
+ background: var(--surface);
437
+ color: var(--text);
438
+ text-align: start;
439
+ cursor: pointer;
440
+ transition:
441
+ border-color var(--dur-fast) var(--ease),
442
+ scale var(--dur-fast) var(--ease-out);
443
+ }
444
+ .rich-select :global(.pop-trigger.rs-trigger:focus-visible) {
445
+ outline: var(--focus-ring);
446
+ outline-offset: var(--focus-ring-offset);
447
+ }
448
+ .rich-select :global(.pop-trigger.rs-trigger:active:not(:disabled)) {
449
+ scale: var(--press-scale-lg);
450
+ transition-duration: var(--dur-press);
451
+ }
452
+ .rich-select :global(.pop-trigger.rs-trigger[aria-expanded='true']) {
453
+ border-color: var(--accent);
454
+ }
455
+ .rich-select :global(.pop-trigger.rs-trigger:disabled) {
456
+ opacity: var(--disabled-opacity);
457
+ cursor: not-allowed;
458
+ }
459
+ @media (hover: hover) and (pointer: fine) {
460
+ .rich-select :global(.pop-trigger.rs-trigger:hover:not(:disabled)) {
461
+ border-color: var(--accent);
462
+ }
463
+ }
464
+ .rs-value {
465
+ flex: 1 1 auto;
466
+ min-width: 0;
467
+ overflow: hidden;
468
+ }
469
+ .rs-placeholder {
470
+ display: block;
471
+ color: var(--text-muted);
472
+ font-size: var(--control-fs-md);
473
+ line-height: var(--lh-tight);
474
+ overflow: hidden;
475
+ text-overflow: ellipsis;
476
+ white-space: nowrap;
477
+ }
478
+ .rs-chevron {
479
+ display: inline-flex;
480
+ flex: none;
481
+ color: var(--text-muted);
482
+ --icon-size: var(--control-icon-md);
483
+ transition: rotate var(--dur-fast) var(--ease-out);
484
+ }
485
+ .rich-select.is-open .rs-chevron {
486
+ rotate: 180deg;
487
+ }
488
+ .rich-select :global(.pop-panel.rs-panel) {
489
+ display: flex;
490
+ flex-direction: column;
491
+ gap: var(--sp-1);
492
+ box-sizing: border-box;
493
+ max-height: min(70vh, 28rem);
494
+ padding: var(--sp-1);
495
+ }
496
+ .rs-list {
497
+ position: relative;
498
+ display: flex;
499
+ flex-direction: column;
500
+ gap: var(--sp-0-5);
501
+ min-height: 0;
502
+ overflow-y: auto;
503
+ overscroll-behavior: contain;
504
+ }
505
+ .rs-row {
506
+ position: relative;
507
+ display: flex;
508
+ align-items: center;
509
+ gap: var(--sp-1);
510
+ min-height: var(--control-sm);
511
+ border-radius: var(--r-sm);
512
+ transition:
513
+ background-color var(--dur-fast) var(--ease),
514
+ translate var(--dur-fast) var(--ease-out);
515
+ }
516
+ .rs-row[data-active] {
517
+ background: var(--bg-elevated-2);
518
+ transition-duration: var(--dur-instant), var(--dur-fast);
519
+ }
520
+ .rs-row[data-selected] {
521
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
522
+ }
523
+ .rs-row[data-selected][data-active] {
524
+ background: color-mix(in srgb, var(--accent) 18%, var(--bg-elevated-2));
525
+ }
526
+ .rs-row[data-selected]::before {
527
+ content: '';
528
+ position: absolute;
529
+ inset-block: var(--sp-1);
530
+ inset-inline-start: 0;
531
+ width: 3px;
532
+ border-radius: var(--r-pill);
533
+ background: var(--accent);
534
+ }
535
+ .rs-row[data-dragging] {
536
+ z-index: 1;
537
+ box-shadow: var(--shadow-md);
538
+ background: var(--bg-elevated);
539
+ transition: none;
540
+ }
541
+ .rs-option {
542
+ flex: 1 1 auto;
543
+ min-width: 0;
544
+ padding: var(--sp-1);
545
+ border-radius: var(--r-sm);
546
+ cursor: pointer;
547
+ outline: none;
548
+ }
549
+ .rs-trailing {
550
+ display: flex;
551
+ flex: none;
552
+ align-items: center;
553
+ gap: var(--sp-1);
554
+ padding-inline-end: var(--sp-1);
555
+ }
556
+ .rs-grip {
557
+ flex: none;
558
+ align-self: stretch;
559
+ width: 24px;
560
+ min-height: 24px;
561
+ margin-inline-start: var(--sp-0-5);
562
+ border-radius: var(--r-sm);
563
+ display: grid;
564
+ place-items: center;
565
+ color: var(--text-muted);
566
+ cursor: grab;
567
+ touch-action: none;
568
+ opacity: 0.6;
569
+ transition: opacity var(--dur-fast) var(--ease);
570
+ }
571
+ .rs-grip::before {
572
+ content: '';
573
+ width: 10px;
574
+ height: 16px;
575
+ background: radial-gradient(circle, currentColor 1.5px, transparent 1.75px) 0 0 / 5px 5.333px;
576
+ }
577
+ @media (hover: hover) and (pointer: fine) {
578
+ .rs-grip:hover {
579
+ opacity: 1;
580
+ }
581
+ }
582
+ .is-dragging .rs-grip {
583
+ cursor: grabbing;
584
+ }
585
+ .rs-footer {
586
+ flex: none;
587
+ display: flex;
588
+ flex-direction: column;
589
+ padding-top: var(--sp-1);
590
+ border-top: 1px solid var(--border);
591
+ }
592
+ </style>
@@ -0,0 +1,68 @@
1
+ export type RichSelectOptionContext<T> = {
2
+ option: T;
3
+ index: number;
4
+ selected: boolean;
5
+ active: boolean;
6
+ /** True for the copy rendered inside the closed trigger. */
7
+ inTrigger: boolean;
8
+ };
9
+ import { type Snippet } from 'svelte';
10
+ declare function $$render<T>(): {
11
+ props: {
12
+ options: readonly T[];
13
+ /** Key of the selected option (`getKey`). */
14
+ value?: string | number;
15
+ onchange?: (value: string | number, option: T) => void;
16
+ getKey?: (option: T) => string | number;
17
+ /** Text used by typeahead and announcements. */
18
+ getLabel?: (option: T) => string;
19
+ /** The card, rendered in the closed trigger and for every listbox row.
20
+ * It sits inside the trigger button, so it must not contain controls. */
21
+ option: Snippet<[RichSelectOptionContext<T>]>;
22
+ /** Per-row actions beside the card. They never select the option. */
23
+ trailing?: Snippet<[{
24
+ option: T;
25
+ index: number;
26
+ close: () => void;
27
+ }]>;
28
+ footer?: Snippet<[{
29
+ close: () => void;
30
+ }]>;
31
+ /** Enables the drag handles and Alt+ArrowUp/Down; reorder `options` in it. */
32
+ onreorder?: (from: number, to: number) => void;
33
+ placeholder?: Snippet | string;
34
+ /** Accessible name of the select. */
35
+ label: string;
36
+ disabled?: boolean;
37
+ box?: "sm" | "md" | "lg";
38
+ placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
39
+ open?: boolean;
40
+ /** Play UI sounds when enabled globally; false silences this instance. */
41
+ sound?: boolean;
42
+ /** Spoken after a keyboard reorder. */
43
+ movedMessage?: (label: string, position: number, total: number) => string;
44
+ class?: string;
45
+ style?: string;
46
+ };
47
+ exports: {};
48
+ bindings: "open" | "value";
49
+ slots: {};
50
+ events: {};
51
+ };
52
+ declare class __sveltets_Render<T> {
53
+ props(): ReturnType<typeof $$render<T>>['props'];
54
+ events(): ReturnType<typeof $$render<T>>['events'];
55
+ slots(): ReturnType<typeof $$render<T>>['slots'];
56
+ bindings(): "open" | "value";
57
+ exports(): {};
58
+ }
59
+ interface $$IsomorphicComponent {
60
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
61
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
62
+ } & ReturnType<__sveltets_Render<T>['exports']>;
63
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
64
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
65
+ }
66
+ declare const RichSelect: $$IsomorphicComponent;
67
+ type RichSelect<T> = InstanceType<typeof RichSelect<T>>;
68
+ export default RichSelect;
@@ -30,6 +30,7 @@
30
30
  shrink = true,
31
31
  block = false,
32
32
  sound = true,
33
+ disabled = false,
33
34
  class: klass = '',
34
35
  ...rest
35
36
  }: {
@@ -64,6 +65,7 @@
64
65
  hitArea?: 'auto' | 'compact';
65
66
  /** Play UI sounds when enabled globally; false silences this instance. */
66
67
  sound?: boolean;
68
+ disabled?: boolean;
67
69
  class?: string;
68
70
  [key: string]: unknown;
69
71
  } = $props();
@@ -81,6 +83,7 @@
81
83
  class="select-button {klass}"
82
84
  class:row={variant === 'row'}
83
85
  class:active
86
+ class:disabled
84
87
  data-tsu="SelectButton"
85
88
  {...rest}
86
89
  class:grow={grow}
@@ -98,6 +101,7 @@
98
101
  aria-label={label}
99
102
  {title}
100
103
  {value}
104
+ {disabled}
101
105
  onchange={change}
102
106
  >
103
107
  {#if groups}
@@ -138,18 +142,25 @@
138
142
  .select-button.active {
139
143
  color: var(--accent);
140
144
  }
145
+ .select-button.disabled {
146
+ opacity: var(--disabled-opacity);
147
+ cursor: not-allowed;
148
+ }
141
149
  /* The inert face takes no pointer events: mirror ghost hover, press and the
142
150
  focus ring from the select overlaying it. */
143
151
  .select-button:active :global(.btn-ghost) {
144
152
  --btn-scale-dur: var(--dur-press);
145
153
  scale: var(--press-scale-sm);
146
154
  }
155
+ .select-button.disabled:active :global(.btn-ghost) {
156
+ scale: none;
157
+ }
147
158
  .select-button:has(:focus-visible) {
148
159
  outline: var(--focus-ring);
149
160
  outline-offset: var(--focus-ring-offset);
150
161
  }
151
162
  @media (hover: hover) and (pointer: fine) {
152
- .select-button:hover :global(.btn-ghost) {
163
+ .select-button:not(.disabled):hover :global(.btn-ghost) {
153
164
  background: var(--bg-elevated-2);
154
165
  }
155
166
  }
@@ -34,6 +34,7 @@ type $$ComponentProps = {
34
34
  hitArea?: 'auto' | 'compact';
35
35
  /** Play UI sounds when enabled globally; false silences this instance. */
36
36
  sound?: boolean;
37
+ disabled?: boolean;
37
38
  class?: string;
38
39
  [key: string]: unknown;
39
40
  };
@@ -63,7 +63,7 @@
63
63
  class:on={pressed}
64
64
  aria-pressed={pressed}
65
65
  >
66
- {@render children?.()}
66
+ <span class="label">{@render children?.()}</span>
67
67
  </button>
68
68
 
69
69
  <style>
@@ -106,10 +106,20 @@
106
106
  font-size: var(--control-fs-md);
107
107
  --icon-size: var(--control-icon-md);
108
108
  }
109
+ .label {
110
+ display: contents;
111
+ }
109
112
  .toggle.grow {
110
113
  flex: 1 1 0;
111
114
  min-width: 0;
112
115
  }
116
+ .toggle.grow .label {
117
+ display: block;
118
+ min-width: 0;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ text-align: center;
122
+ }
113
123
  .toggle.no-shrink {
114
124
  flex: none;
115
125
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @typedef {{ type: 'open', index?: number }
3
+ * | { type: 'close' }
4
+ * | { type: 'select' }
5
+ * | { type: 'move', index: number }
6
+ * | { type: 'change', index: number }
7
+ * | { type: 'reorder', from: number, to: number }
8
+ * | { type: 'trailing' }
9
+ * | { type: 'type', char: string }} RichSelectAction
10
+ */
11
+ /**
12
+ * Map a keydown on the trigger to what the select does with it. Closed, the
13
+ * arrows step the value like a native select and Enter/Space/Alt+ArrowDown open.
14
+ * Open, the arrows and Home/End move the active option, Enter/Space select,
15
+ * Escape closes, Alt+arrows reorder (when `reorder` is set), and Tab or
16
+ * ArrowRight reach the active option's trailing actions (when it has any).
17
+ * Printable characters feed the typeahead in both states. `undefined` leaves
18
+ * the key alone.
19
+ *
20
+ * @param {{ key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} e
21
+ * @param {{ open: boolean, count: number, index: number, reorder?: boolean, trailing?: boolean }} state
22
+ * @returns {RichSelectAction | undefined}
23
+ */
24
+ export function richSelectAction(e: {
25
+ key: string;
26
+ altKey?: boolean;
27
+ ctrlKey?: boolean;
28
+ metaKey?: boolean;
29
+ shiftKey?: boolean;
30
+ }, { open, count, index, reorder, trailing }: {
31
+ open: boolean;
32
+ count: number;
33
+ index: number;
34
+ reorder?: boolean;
35
+ trailing?: boolean;
36
+ }): RichSelectAction | undefined;
37
+ /**
38
+ * Index of the first label, searching forward from `from` and wrapping, that
39
+ * starts with `query` (case- and accent-insensitive). A query of one repeated
40
+ * character cycles through the labels starting with it, as native selects do.
41
+ *
42
+ * @param {readonly string[]} labels
43
+ * @param {string} query
44
+ * @param {number} from index of the current option, or -1
45
+ * @returns {number | undefined}
46
+ */
47
+ export function typeaheadIndex(labels: readonly string[], query: string, from: number): number | undefined;
48
+ /**
49
+ * Accumulates typed characters into a query that resets after `timeout` ms of
50
+ * silence.
51
+ *
52
+ * @param {number} [timeout]
53
+ * @param {() => number} [now]
54
+ */
55
+ export function createTypeahead(timeout?: number, now?: () => number): {
56
+ /** @param {string} char */
57
+ push(char: string): string;
58
+ reset(): void;
59
+ };
60
+ export type RichSelectAction = {
61
+ type: "open";
62
+ index?: number;
63
+ } | {
64
+ type: "close";
65
+ } | {
66
+ type: "select";
67
+ } | {
68
+ type: "move";
69
+ index: number;
70
+ } | {
71
+ type: "change";
72
+ index: number;
73
+ } | {
74
+ type: "reorder";
75
+ from: number;
76
+ to: number;
77
+ } | {
78
+ type: "trailing";
79
+ } | {
80
+ type: "type";
81
+ char: string;
82
+ };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @typedef {{ type: 'open', index?: number }
3
+ * | { type: 'close' }
4
+ * | { type: 'select' }
5
+ * | { type: 'move', index: number }
6
+ * | { type: 'change', index: number }
7
+ * | { type: 'reorder', from: number, to: number }
8
+ * | { type: 'trailing' }
9
+ * | { type: 'type', char: string }} RichSelectAction
10
+ */
11
+
12
+ /**
13
+ * Map a keydown on the trigger to what the select does with it. Closed, the
14
+ * arrows step the value like a native select and Enter/Space/Alt+ArrowDown open.
15
+ * Open, the arrows and Home/End move the active option, Enter/Space select,
16
+ * Escape closes, Alt+arrows reorder (when `reorder` is set), and Tab or
17
+ * ArrowRight reach the active option's trailing actions (when it has any).
18
+ * Printable characters feed the typeahead in both states. `undefined` leaves
19
+ * the key alone.
20
+ *
21
+ * @param {{ key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} e
22
+ * @param {{ open: boolean, count: number, index: number, reorder?: boolean, trailing?: boolean }} state
23
+ * @returns {RichSelectAction | undefined}
24
+ */
25
+ export function richSelectAction(e, { open, count, index, reorder = false, trailing = false }) {
26
+ const { key } = e;
27
+ if (e.ctrlKey || e.metaKey) return undefined;
28
+ const last = count - 1;
29
+ const cur = index >= 0 && index <= last ? index : -1;
30
+ if (!open) {
31
+ if (e.altKey) return key === 'ArrowDown' ? { type: 'open' } : undefined;
32
+ if (key === 'Enter' || key === ' ') return { type: 'open' };
33
+ if (count <= 0) return undefined;
34
+ if (key === 'ArrowDown')
35
+ return { type: 'change', index: cur < 0 ? 0 : Math.min(cur + 1, last) };
36
+ if (key === 'ArrowUp') return { type: 'change', index: cur < 0 ? last : Math.max(cur - 1, 0) };
37
+ if (key === 'Home') return { type: 'change', index: 0 };
38
+ if (key === 'End') return { type: 'change', index: last };
39
+ return printable(e) ? { type: 'type', char: key } : undefined;
40
+ }
41
+ if (key === 'Escape') return { type: 'close' };
42
+ if (e.altKey) {
43
+ if (key === 'ArrowUp' && reorder && cur > 0) return { type: 'reorder', from: cur, to: cur - 1 };
44
+ if (key === 'ArrowDown' && reorder && cur >= 0 && cur < last)
45
+ return { type: 'reorder', from: cur, to: cur + 1 };
46
+ if (key === 'ArrowUp' && !reorder) return { type: 'select' };
47
+ return undefined;
48
+ }
49
+ if ((key === 'Tab' && !e.shiftKey) || key === 'ArrowRight')
50
+ return trailing && cur >= 0 ? { type: 'trailing' } : undefined;
51
+ if (key === 'Tab') return { type: 'close' };
52
+ if (key === 'Enter' || key === ' ') return cur >= 0 ? { type: 'select' } : { type: 'close' };
53
+ if (count <= 0) return undefined;
54
+ if (key === 'ArrowDown') return { type: 'move', index: cur < 0 ? 0 : Math.min(cur + 1, last) };
55
+ if (key === 'ArrowUp') return { type: 'move', index: cur < 0 ? last : Math.max(cur - 1, 0) };
56
+ if (key === 'Home') return { type: 'move', index: 0 };
57
+ if (key === 'End') return { type: 'move', index: last };
58
+ return printable(e) ? { type: 'type', char: key } : undefined;
59
+ }
60
+
61
+ /** @param {{ key: string, altKey?: boolean }} e */
62
+ function printable(e) {
63
+ return e.key.length === 1 && !e.altKey && e.key !== ' ';
64
+ }
65
+
66
+ /**
67
+ * Index of the first label, searching forward from `from` and wrapping, that
68
+ * starts with `query` (case- and accent-insensitive). A query of one repeated
69
+ * character cycles through the labels starting with it, as native selects do.
70
+ *
71
+ * @param {readonly string[]} labels
72
+ * @param {string} query
73
+ * @param {number} from index of the current option, or -1
74
+ * @returns {number | undefined}
75
+ */
76
+ export function typeaheadIndex(labels, query, from) {
77
+ const n = labels.length;
78
+ if (n === 0 || query === '') return undefined;
79
+ const fold = (/** @type {string} */ s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
80
+ const q = fold(query);
81
+ const repeated = [...q].every((c) => c === q[0]);
82
+ const needle = repeated ? q[0] : q;
83
+ const start = repeated ? from + 1 : Math.max(from, 0);
84
+ for (let i = 0; i < n; i += 1) {
85
+ const at = (((start + i) % n) + n) % n;
86
+ if (fold(labels[at]).startsWith(needle)) return at;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * Accumulates typed characters into a query that resets after `timeout` ms of
93
+ * silence.
94
+ *
95
+ * @param {number} [timeout]
96
+ * @param {() => number} [now]
97
+ */
98
+ export function createTypeahead(timeout = 500, now = () => Date.now()) {
99
+ let query = '';
100
+ let at = -Infinity;
101
+ return {
102
+ /** @param {string} char */
103
+ push(char) {
104
+ const t = now();
105
+ query = t - at > timeout ? char : query + char;
106
+ at = t;
107
+ return query;
108
+ },
109
+ reset() {
110
+ query = '';
111
+ at = -Infinity;
112
+ },
113
+ };
114
+ }
@@ -64,6 +64,25 @@
64
64
  if (stuck) pending = false;
65
65
  }
66
66
 
67
+ // Keyboard focus at the scroll edge would cut its ring: bring it inside the scroll padding.
68
+ function onfocusin(e: FocusEvent) {
69
+ const el = scroller;
70
+ const target = e.target;
71
+ if (!el || !(target instanceof Element) || target === el) return;
72
+ try {
73
+ if (!target.matches(':focus-visible')) return;
74
+ } catch {
75
+ return;
76
+ }
77
+ const pad = Number.parseFloat(getComputedStyle(el).scrollPaddingTop) || 0;
78
+ const box = el.getBoundingClientRect();
79
+ const r = target.getBoundingClientRect();
80
+ const top = box.top + el.clientTop;
81
+ const bottom = top + el.clientHeight;
82
+ if (r.top - pad < top) el.scrollTop -= top - (r.top - pad);
83
+ else if (r.bottom + pad > bottom) el.scrollTop += r.bottom + pad - bottom;
84
+ }
85
+
67
86
  $effect.pre(() => {
68
87
  const el = scroller;
69
88
  const first = items.length ? key(items[0]) : undefined;
@@ -106,7 +125,7 @@
106
125
  </script>
107
126
 
108
127
  <div data-tsu="ConversationFeed" class="feed-wrap {klass}" style={styleProp}>
109
- <div class="feed" bind:this={scroller} {onscroll}>
128
+ <div class="feed" bind:this={scroller} {onscroll} {onfocusin}>
110
129
  {#if loadMore}
111
130
  <LoadMore pill state={loadMore} onload={onloadmore} label={loadMoreLabel} />
112
131
  {/if}
package/dist/floating.js CHANGED
@@ -20,9 +20,15 @@ function alignPos(align, start, size, fSize) {
20
20
  export function place(trigger, floating, placement = 'bottom-start', gap = 6) {
21
21
  placeAt(trigger.getBoundingClientRect(), floating, placement, gap);
22
22
  }
23
+ // The enter transition starts the panel at `scale: var(--enter-scale)`, so a
24
+ // client rect taken on open is ~4% short; offset sizes ignore transforms.
25
+ function layoutSize(el) {
26
+ const { width, height } = el.getBoundingClientRect();
27
+ return { width: el.offsetWidth || width, height: el.offsetHeight || height };
28
+ }
23
29
  /** `place` against a viewport rect instead of an element. */
24
30
  export function placeAt(t, floating, placement = 'bottom-start', gap = 6) {
25
- const f = floating.getBoundingClientRect();
31
+ const f = layoutSize(floating);
26
32
  const vw = document.documentElement.clientWidth;
27
33
  const vh = document.documentElement.clientHeight;
28
34
  const [side, align] = placement.split('-');
package/dist/index.d.ts CHANGED
@@ -85,6 +85,7 @@ export { default as Prose } from './components/molecules/Prose.svelte';
85
85
  export { default as PullToRefresh } from './components/molecules/PullToRefresh.svelte';
86
86
  export { default as RadialMenu, type RadialArc, } from './components/molecules/RadialMenu.svelte';
87
87
  export { default as RadioGroup, type RadioOption, } from './components/molecules/RadioGroup.svelte';
88
+ export { default as RichSelect, type RichSelectOptionContext, } from './components/molecules/RichSelect.svelte';
88
89
  export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
89
90
  export { default as SeekSlider } from './components/molecules/SeekSlider.svelte';
90
91
  export { default as SegmentedControl, type SegmentOption, } from './components/molecules/SegmentedControl.svelte';
package/dist/index.js CHANGED
@@ -92,6 +92,7 @@ export { default as Prose } from './components/molecules/Prose.svelte';
92
92
  export { default as PullToRefresh } from './components/molecules/PullToRefresh.svelte';
93
93
  export { default as RadialMenu, } from './components/molecules/RadialMenu.svelte';
94
94
  export { default as RadioGroup, } from './components/molecules/RadioGroup.svelte';
95
+ export { default as RichSelect, } from './components/molecules/RichSelect.svelte';
95
96
  export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
96
97
  export { default as SeekSlider } from './components/molecules/SeekSlider.svelte';
97
98
  export { default as SegmentedControl, } from './components/molecules/SegmentedControl.svelte';
@@ -51,9 +51,6 @@ a {
51
51
  color: var(--link);
52
52
  text-decoration: none;
53
53
  }
54
- a:hover {
55
- text-decoration: underline;
56
- }
57
54
 
58
55
  button {
59
56
  font: inherit;
@@ -96,6 +93,9 @@ pre {
96
93
  /* Slim scrollbars for mice only. On touch, a custom scrollbar replaces the
97
94
  native overlay bars with classic space-taking ones. */
98
95
  @media (hover: hover) and (pointer: fine) {
96
+ a:hover {
97
+ text-decoration: underline;
98
+ }
99
99
  ::-webkit-scrollbar {
100
100
  width: 8px;
101
101
  height: 8px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.75.0",
3
+ "version": "0.77.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",