@dorsk/tsumikit 0.75.0 → 0.76.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.
@@ -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;
@@ -0,0 +1,583 @@
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) return;
123
+ const el = root?.ownerDocument.getElementById(optionId(active));
124
+ el?.scrollIntoView?.({ block: 'nearest' });
125
+ });
126
+
127
+ function close() {
128
+ open = false;
129
+ }
130
+
131
+ function focusTrigger() {
132
+ triggerEl()?.focus();
133
+ }
134
+
135
+ function choose(index: number, closeAfter: boolean) {
136
+ const next = options[index];
137
+ if (next === undefined) return;
138
+ const key = getKey(next);
139
+ active = index;
140
+ if (key !== value) {
141
+ value = key;
142
+ onchange?.(key, next);
143
+ playSound(closeAfter ? 'click' : 'tick', sound);
144
+ }
145
+ if (closeAfter) {
146
+ close();
147
+ focusTrigger();
148
+ }
149
+ }
150
+
151
+ function reorder(from: number, to: number) {
152
+ if (!onreorder || from === to) return;
153
+ const moved = options[from];
154
+ onreorder(from, to);
155
+ active = to;
156
+ if (moved !== undefined) announcement = movedMessage(getLabel(moved), to + 1, options.length);
157
+ playSound('tick', sound);
158
+ }
159
+
160
+ function trailingFocusables(index: number) {
161
+ const row = rowEls()[index];
162
+ return [
163
+ ...(row?.querySelectorAll<HTMLElement>(
164
+ '.rs-trailing :is(button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])):not(:disabled)'
165
+ ) ?? []),
166
+ ];
167
+ }
168
+
169
+ function onTriggerKey(e: KeyboardEvent) {
170
+ const action = richSelectAction(e, {
171
+ open,
172
+ count: options.length,
173
+ index: open ? active : selectedIndex,
174
+ reorder: onreorder !== undefined,
175
+ trailing: open && active >= 0 && trailingFocusables(active).length > 0,
176
+ });
177
+ if (!action) return;
178
+ if (action.type === 'close' && e.key === 'Tab') {
179
+ close();
180
+ return;
181
+ }
182
+ e.preventDefault();
183
+ e.stopPropagation();
184
+ switch (action.type) {
185
+ case 'open':
186
+ active = selectedIndex >= 0 ? selectedIndex : 0;
187
+ open = true;
188
+ break;
189
+ case 'close':
190
+ close();
191
+ focusTrigger();
192
+ break;
193
+ case 'select':
194
+ choose(active, true);
195
+ break;
196
+ case 'move':
197
+ active = action.index;
198
+ break;
199
+ case 'change':
200
+ choose(action.index, false);
201
+ break;
202
+ case 'reorder':
203
+ reorder(action.from, action.to);
204
+ break;
205
+ case 'trailing':
206
+ trailingFocusables(active)[0]?.focus();
207
+ break;
208
+ case 'type': {
209
+ const from = open ? active : selectedIndex;
210
+ const hit = typeaheadIndex(options.map(getLabel), typeahead.push(action.char), from);
211
+ if (hit === undefined) break;
212
+ if (open) active = hit;
213
+ else choose(hit, false);
214
+ break;
215
+ }
216
+ }
217
+ }
218
+
219
+ function onPanelKey(e: KeyboardEvent, target: HTMLElement) {
220
+ if (!target.closest('.rs-trailing')) {
221
+ if (e.key === 'Escape') {
222
+ e.preventDefault();
223
+ e.stopPropagation();
224
+ close();
225
+ focusTrigger();
226
+ }
227
+ return;
228
+ }
229
+ const row = target.closest<HTMLElement>('.rs-row');
230
+ const index = row ? rowEls().indexOf(row) : -1;
231
+ const items = index >= 0 ? trailingFocusables(index) : [];
232
+ const at = items.indexOf(target);
233
+ const back = e.key === 'ArrowLeft' || (e.key === 'Tab' && e.shiftKey && at <= 0);
234
+ if (e.key === 'Escape' || back || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
235
+ e.preventDefault();
236
+ e.stopPropagation();
237
+ if (e.key === 'ArrowLeft' && at > 0) {
238
+ items[at - 1].focus();
239
+ return;
240
+ }
241
+ if (e.key === 'Escape') close();
242
+ else if (e.key === 'ArrowUp') active = Math.max(index - 1, 0);
243
+ else if (e.key === 'ArrowDown') active = Math.min(index + 1, options.length - 1);
244
+ focusTrigger();
245
+ return;
246
+ }
247
+ if (e.key === 'ArrowRight' && at >= 0 && at < items.length - 1) {
248
+ e.preventDefault();
249
+ e.stopPropagation();
250
+ items[at + 1].focus();
251
+ }
252
+ }
253
+
254
+ function onKeydownCapture(e: KeyboardEvent) {
255
+ if (disabled) return;
256
+ const target = e.target as HTMLElement;
257
+ if (target === triggerEl()) onTriggerKey(e);
258
+ else if (target.closest('.rs-panel')) onPanelKey(e, target);
259
+ }
260
+
261
+ function onGripDown(e: PointerEvent, index: number) {
262
+ if (!onreorder || e.button !== 0) return;
263
+ e.preventDefault();
264
+ const grip = e.currentTarget as HTMLElement;
265
+ grip.setPointerCapture?.(e.pointerId);
266
+ const rows = rowEls();
267
+ const startY = e.clientY;
268
+ const top = (rows[index]?.offsetTop ?? 0) - (rows[0]?.offsetTop ?? 0);
269
+ const others = rows.filter((_, i) => i !== index).map((r) => r.offsetHeight);
270
+ dragHeight = rows[index]?.offsetHeight ?? 0;
271
+ dragFrom = index;
272
+ dragTo = index;
273
+ dragDy = 0;
274
+ active = index;
275
+ const move = (ev: PointerEvent) => {
276
+ dragDy = ev.clientY - startY;
277
+ dragTo = slotForTop(top + dragDy, others);
278
+ };
279
+ const end = (ev: PointerEvent) => {
280
+ grip.removeEventListener('pointermove', move);
281
+ grip.removeEventListener('pointerup', end);
282
+ grip.removeEventListener('pointercancel', end);
283
+ const from = dragFrom;
284
+ const to = dragTo;
285
+ dragFrom = -1;
286
+ dragTo = -1;
287
+ dragDy = 0;
288
+ if (ev.type === 'pointerup') reorder(from, to);
289
+ };
290
+ grip.addEventListener('pointermove', move);
291
+ grip.addEventListener('pointerup', end);
292
+ grip.addEventListener('pointercancel', end);
293
+ }
294
+
295
+ function shiftFor(i: number) {
296
+ if (dragFrom < 0) return undefined;
297
+ if (i === dragFrom) return `0 ${dragDy}px`;
298
+ if (dragFrom < i && i <= dragTo) return `0 ${-dragHeight}px`;
299
+ if (dragTo <= i && i < dragFrom) return `0 ${dragHeight}px`;
300
+ return undefined;
301
+ }
302
+
303
+ async function onOpen() {
304
+ typeahead.reset();
305
+ await tick();
306
+ const el = active >= 0 ? root?.ownerDocument.getElementById(optionId(active)) : null;
307
+ el?.scrollIntoView?.({ block: 'nearest' });
308
+ }
309
+
310
+ function onClose() {
311
+ typeahead.reset();
312
+ dragFrom = -1;
313
+ }
314
+
315
+ const panelStyle = $derived(
316
+ triggerWidth > 0
317
+ ? `min-width: max(10rem, ${triggerWidth}px); max-width: min(max(22rem, ${triggerWidth}px), calc(100vw - 2 * var(--sp-3)))`
318
+ : ''
319
+ );
320
+ </script>
321
+
322
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
323
+ <div
324
+ bind:this={root}
325
+ data-tsu="RichSelect"
326
+ class="rich-select box-{box} {klass}"
327
+ class:is-disabled={disabled}
328
+ class:is-dragging={dragFrom >= 0}
329
+ class:is-open={open}
330
+ {style}
331
+ onkeydowncapture={onKeydownCapture}
332
+ >
333
+ <Popover
334
+ bind:open
335
+ bare
336
+ {label}
337
+ {placement}
338
+ {disabled}
339
+ {sound}
340
+ role="group"
341
+ haspopup="listbox"
342
+ triggerClass="rs-trigger"
343
+ panelClass="rs-panel"
344
+ {panelStyle}
345
+ onopen={onOpen}
346
+ onclose={onClose}
347
+ aria-controls={listId}
348
+ aria-activedescendant={open && active >= 0 ? optionId(active) : undefined}
349
+ >
350
+ {#snippet trigger()}
351
+ <span class="rs-value">
352
+ {#if selectedOption !== undefined}
353
+ {@render option({ option: selectedOption, index: selectedIndex, selected: true, active: false, inTrigger: true })}
354
+ {:else if typeof placeholder === 'string'}
355
+ <span class="rs-placeholder">{placeholder}</span>
356
+ {:else}
357
+ {@render placeholder()}
358
+ {/if}
359
+ </span>
360
+ <span class="rs-chevron" aria-hidden="true"><Icon name="chevron-down" /></span>
361
+ {/snippet}
362
+ <div class="rs-list" role="listbox" id={listId} aria-label={label}>
363
+ {#each options as opt, i (getKey(opt))}
364
+ {@const selected = i === selectedIndex}
365
+ <div
366
+ class="rs-row"
367
+ role="presentation"
368
+ data-active={i === active ? '' : undefined}
369
+ data-selected={selected ? '' : undefined}
370
+ data-dragging={i === dragFrom ? '' : undefined}
371
+ style:translate={shiftFor(i)}
372
+ >
373
+ {#if onreorder}
374
+ <span class="rs-grip" aria-hidden="true" onpointerdown={(e) => onGripDown(e, i)}></span>
375
+ {/if}
376
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
377
+ <div
378
+ class="rs-option"
379
+ role="option"
380
+ id={optionId(i)}
381
+ aria-selected={selected}
382
+ tabindex="-1"
383
+ onclick={() => choose(i, true)}
384
+ onmousedown={(e) => e.preventDefault()}
385
+ onpointermove={() => {
386
+ if (dragFrom < 0) active = i;
387
+ }}
388
+ >
389
+ {@render option({ option: opt, index: i, selected, active: i === active, inTrigger: false })}
390
+ </div>
391
+ {#if trailing}
392
+ <div class="rs-trailing">{@render trailing({ option: opt, index: i, close })}</div>
393
+ {/if}
394
+ </div>
395
+ {/each}
396
+ </div>
397
+ {#if footer}
398
+ <div class="rs-footer">{@render footer({ close })}</div>
399
+ {/if}
400
+ </Popover>
401
+ <span class="sr-only" aria-live="polite">{announcement}</span>
402
+ </div>
403
+
404
+ <style>
405
+ .rich-select {
406
+ display: block;
407
+ min-width: 0;
408
+ --rs-height: var(--control-md);
409
+ }
410
+ .rich-select.box-sm {
411
+ --rs-height: var(--control-sm);
412
+ }
413
+ .rich-select.box-lg {
414
+ --rs-height: var(--control-lg);
415
+ }
416
+ .rich-select :global(.pop-trigger.rs-trigger) {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: var(--sp-2);
420
+ box-sizing: border-box;
421
+ width: 100%;
422
+ min-width: 0;
423
+ min-height: var(--rs-height);
424
+ padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-1);
425
+ border: 1px solid var(--border-strong);
426
+ border-radius: var(--r-md);
427
+ background: var(--surface);
428
+ color: var(--text);
429
+ text-align: start;
430
+ cursor: pointer;
431
+ transition:
432
+ border-color var(--dur-fast) var(--ease),
433
+ scale var(--dur-fast) var(--ease-out);
434
+ }
435
+ .rich-select :global(.pop-trigger.rs-trigger:focus-visible) {
436
+ outline: var(--focus-ring);
437
+ outline-offset: var(--focus-ring-offset);
438
+ }
439
+ .rich-select :global(.pop-trigger.rs-trigger:active:not(:disabled)) {
440
+ scale: var(--press-scale-lg);
441
+ transition-duration: var(--dur-press);
442
+ }
443
+ .rich-select :global(.pop-trigger.rs-trigger[aria-expanded='true']) {
444
+ border-color: var(--accent);
445
+ }
446
+ .rich-select :global(.pop-trigger.rs-trigger:disabled) {
447
+ opacity: var(--disabled-opacity);
448
+ cursor: not-allowed;
449
+ }
450
+ @media (hover: hover) and (pointer: fine) {
451
+ .rich-select :global(.pop-trigger.rs-trigger:hover:not(:disabled)) {
452
+ border-color: var(--accent);
453
+ }
454
+ }
455
+ .rs-value {
456
+ flex: 1 1 auto;
457
+ min-width: 0;
458
+ overflow: hidden;
459
+ }
460
+ .rs-placeholder {
461
+ display: block;
462
+ padding-inline: var(--sp-1);
463
+ color: var(--text-muted);
464
+ font-size: var(--control-fs-md);
465
+ line-height: var(--lh-tight);
466
+ overflow: hidden;
467
+ text-overflow: ellipsis;
468
+ white-space: nowrap;
469
+ }
470
+ .rs-chevron {
471
+ display: inline-flex;
472
+ flex: none;
473
+ color: var(--text-muted);
474
+ --icon-size: var(--control-icon-md);
475
+ transition: rotate var(--dur-fast) var(--ease-out);
476
+ }
477
+ .rich-select.is-open .rs-chevron {
478
+ rotate: 180deg;
479
+ }
480
+ .rich-select :global(.pop-panel.rs-panel) {
481
+ display: flex;
482
+ flex-direction: column;
483
+ gap: var(--sp-1);
484
+ box-sizing: border-box;
485
+ max-height: min(70vh, 28rem);
486
+ padding: var(--sp-1);
487
+ }
488
+ .rs-list {
489
+ display: flex;
490
+ flex-direction: column;
491
+ gap: var(--sp-0-5);
492
+ min-height: 0;
493
+ overflow-y: auto;
494
+ overscroll-behavior: contain;
495
+ }
496
+ .rs-row {
497
+ position: relative;
498
+ display: flex;
499
+ align-items: center;
500
+ gap: var(--sp-1);
501
+ min-height: var(--control-sm);
502
+ border-radius: var(--r-sm);
503
+ transition:
504
+ background-color var(--dur-fast) var(--ease),
505
+ translate var(--dur-fast) var(--ease-out);
506
+ }
507
+ .rs-row[data-active] {
508
+ background: var(--bg-elevated-2);
509
+ transition-duration: var(--dur-instant), var(--dur-fast);
510
+ }
511
+ .rs-row[data-selected] {
512
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
513
+ }
514
+ .rs-row[data-selected][data-active] {
515
+ background: color-mix(in srgb, var(--accent) 18%, var(--bg-elevated-2));
516
+ }
517
+ .rs-row[data-selected]::before {
518
+ content: '';
519
+ position: absolute;
520
+ inset-block: var(--sp-1);
521
+ inset-inline-start: 0;
522
+ width: 3px;
523
+ border-radius: var(--r-pill);
524
+ background: var(--accent);
525
+ }
526
+ .rs-row[data-dragging] {
527
+ z-index: 1;
528
+ box-shadow: var(--shadow-md);
529
+ background: var(--bg-elevated);
530
+ transition: none;
531
+ }
532
+ .rs-option {
533
+ flex: 1 1 auto;
534
+ min-width: 0;
535
+ padding: var(--sp-1);
536
+ border-radius: var(--r-sm);
537
+ cursor: pointer;
538
+ outline: none;
539
+ }
540
+ .rs-trailing {
541
+ display: flex;
542
+ flex: none;
543
+ align-items: center;
544
+ gap: var(--sp-1);
545
+ padding-inline-end: var(--sp-1);
546
+ }
547
+ .rs-grip {
548
+ flex: none;
549
+ align-self: stretch;
550
+ width: 24px;
551
+ min-height: 24px;
552
+ margin-inline-start: var(--sp-0-5);
553
+ border-radius: var(--r-sm);
554
+ display: grid;
555
+ place-items: center;
556
+ color: var(--text-muted);
557
+ cursor: grab;
558
+ touch-action: none;
559
+ opacity: 0.6;
560
+ transition: opacity var(--dur-fast) var(--ease);
561
+ }
562
+ .rs-grip::before {
563
+ content: '';
564
+ width: 10px;
565
+ height: 16px;
566
+ background: radial-gradient(circle, currentColor 1.5px, transparent 1.75px) 0 0 / 5px 5.333px;
567
+ }
568
+ @media (hover: hover) and (pointer: fine) {
569
+ .rs-grip:hover {
570
+ opacity: 1;
571
+ }
572
+ }
573
+ .is-dragging .rs-grip {
574
+ cursor: grabbing;
575
+ }
576
+ .rs-footer {
577
+ flex: none;
578
+ display: flex;
579
+ flex-direction: column;
580
+ padding-top: var(--sp-1);
581
+ border-top: 1px solid var(--border);
582
+ }
583
+ </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;
@@ -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/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.76.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",