@lyeve-labs/ui-kit 0.42.2 → 0.44.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.
@@ -0,0 +1,520 @@
1
+ <script lang="ts" module>
2
+ /** One row a person can move. */
3
+ export interface SortableItem {
4
+ /**
5
+ * Stable, and unique in the list. The rows are keyed by it, so a repeated
6
+ * id throws in development, and an id that changes between renders
7
+ * remounts its row and loses the focus inside it.
8
+ */
9
+ id: string;
10
+ /** What the row is called. Its handle reads "Move <label>", and each move is announced by it. */
11
+ label: string;
12
+ }
13
+
14
+ /** A move that landed. */
15
+ export interface SortableMove<T extends SortableItem> {
16
+ item: T;
17
+ from: number;
18
+ to: number;
19
+ /** The list in its new order, which `items` holds as well. */
20
+ items: T[];
21
+ }
22
+ </script>
23
+
24
+ <script lang="ts" generics="T extends SortableItem">
25
+ /**
26
+ * A list a person puts in order, by dragging a row's handle, from the
27
+ * handle's menu, or by pressing Alt with an arrow key.
28
+ *
29
+ * A mouse lifts a row once it moves a few pixels with the button down. A
30
+ * finger lifts it after resting a quarter of a second on the handle, so a
31
+ * swipe that starts there still scrolls the page. A lifted row follows the
32
+ * pointer, a line marks where it will land, the box that scrolls the list
33
+ * scrolls when the pointer nears its edge, and Escape or a second finger
34
+ * puts the row back. A click or tap on the handle opens a menu that moves
35
+ * the row up or down, which is the way in for a pointer that cannot drag
36
+ * and for a screen reader on a phone. Alt+ArrowUp and Alt+ArrowDown move the
37
+ * row that holds the focus, unless the focus is in a text field, where
38
+ * Option and an arrow already move the caret on macOS, or a control in the
39
+ * row has already taken the key.
40
+ *
41
+ * Every move, and every move that cannot happen, is said in a polite live
42
+ * region in the row's own words. `canMove` refuses a move by returning the
43
+ * sentence to say instead, and the line turns to the danger tone while the
44
+ * pointer is over a refused place. A gesture follows its row by id, so a
45
+ * caller that changes `items` mid-drag still moves the row that was lifted,
46
+ * and removing that row ends the drag.
47
+ */
48
+ import { GripVertical } from '@lucide/svelte';
49
+ import { tick, type Snippet } from 'svelte';
50
+ import * as motion from '../motion.js';
51
+ import { cn } from '../utils/cn.js';
52
+ import {
53
+ DRAG_SLOP_PX,
54
+ EDGE_SCROLL_PX,
55
+ HOLD_SLOP_PX,
56
+ TOUCH_HOLD_MS,
57
+ attempt,
58
+ landing,
59
+ type MoveCheck,
60
+ } from '../internal/reorder.js';
61
+ import Button from './Button.svelte';
62
+ import Dropdown from './Dropdown.svelte';
63
+
64
+ interface Props {
65
+ /** The rows, in order. Bind it and a move writes the new order back. */
66
+ items: T[];
67
+ /** Names the list for a screen reader. */
68
+ label: string;
69
+ /** Allows a move with true, or refuses it with the sentence to announce instead. */
70
+ canMove?: (item: T, from: number, to: number) => MoveCheck;
71
+ /** Called once a move has landed, when `items` already holds the new order. */
72
+ onmove?: (move: SortableMove<T>) => void;
73
+ /** Draws a row's content beside its handle. */
74
+ row: Snippet<[T, number]>;
75
+ class?: string;
76
+ }
77
+
78
+ let { items = $bindable(), label, canMove, onmove, row, class: klass = '' }: Props = $props();
79
+
80
+ const uid = $props.id();
81
+
82
+ /** A press on a handle that has not lifted its row yet. */
83
+ interface Press {
84
+ pointerId: number;
85
+ /** The row's id. A gesture never holds an index, because `items` can change under it. */
86
+ id: string;
87
+ x: number;
88
+ y: number;
89
+ handle: HTMLElement;
90
+ timer?: ReturnType<typeof setTimeout>;
91
+ }
92
+
93
+ /** A lifted row, following the pointer. */
94
+ interface Drag {
95
+ pointerId: number;
96
+ id: string;
97
+ /** Where the pointer holds the row, measured from the row's top. */
98
+ grab: number;
99
+ /** How far the row has traveled from its place. */
100
+ dy: number;
101
+ /** The pointer's latest height, which the edge scroll reads between moves. */
102
+ y: number;
103
+ /** Where the row would land, and whether the check refuses that place. */
104
+ to: number;
105
+ refused: boolean;
106
+ }
107
+
108
+ let press: Press | null = null;
109
+ let drag = $state<Drag | null>(null);
110
+ /**
111
+ * Whether the handle's latest press lifted its row, so the click that ends
112
+ * that press is a drop and opens no menu. The next press clears it, not a
113
+ * timer, because a phone can send a tap's click well after the release. A
114
+ * click from Enter or Space has no press behind it (detail 0) and always
115
+ * opens the menu.
116
+ */
117
+ let dragged = false;
118
+ /** The row easing back to its place after a drop that moved nothing. */
119
+ let settling = $state<string | null>(null);
120
+ let spoken = $state({ n: 0, text: '' });
121
+ let list: HTMLUListElement | undefined = $state();
122
+ let scroller: Element | null = null;
123
+ let frame = 0;
124
+
125
+ /**
126
+ * Each announcement gets a node of its own, so a screen reader speaks a
127
+ * sentence again when it repeats, such as a second press at the top.
128
+ */
129
+ function say(text: string) {
130
+ if (text) spoken = { n: spoken.n + 1, text };
131
+ }
132
+
133
+ function rowsNow(): HTMLElement[] {
134
+ return list ? (Array.from(list.children) as HTMLElement[]) : [];
135
+ }
136
+
137
+ /** Where the row with this id is now, or -1 once the caller has removed it. */
138
+ function at(id: string): number {
139
+ return items.findIndex((item) => item.id === id);
140
+ }
141
+
142
+ /** Lands a move, or says why it cannot. True when the order changed. */
143
+ function land(from: number, to: number, announce = say): boolean {
144
+ const result = attempt(items, from, to, canMove);
145
+ announce(result.text);
146
+ if (!result.moved) return false;
147
+ const item = items[from];
148
+ items = result.items;
149
+ onmove?.({ item, from, to: result.to, items: result.items });
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Lands a move and keeps the focus where it was. The keyed rows move as
155
+ * nodes, and a browser drops the focus of a node it takes out of the
156
+ * document, even to put it straight back.
157
+ */
158
+ async function move(from: number, to: number) {
159
+ const focused = document.activeElement;
160
+ let text = '';
161
+ if (!land(from, to, (t) => (text = t))) return say(text);
162
+ await tick();
163
+ if (focused instanceof HTMLElement && document.activeElement !== focused) focused.focus();
164
+ // Firefox reports the restored focus as a new one, and Orca drops a polite
165
+ // sentence still waiting when focus arrives. The browser builds its
166
+ // accessibility tree after a frame's callbacks run, so the second frame is
167
+ // the first that reaches it after the focus.
168
+ requestAnimationFrame(() => requestAnimationFrame(() => say(text)));
169
+ }
170
+
171
+ function typing(target: EventTarget | null): boolean {
172
+ return (
173
+ target instanceof HTMLElement &&
174
+ (target.isContentEditable || target.matches('input, textarea, select, [role="textbox"]'))
175
+ );
176
+ }
177
+
178
+ /** Whether the key came from this row's own handle, and not from a list inside the row. */
179
+ function fromHandle(e: KeyboardEvent): boolean {
180
+ return (
181
+ e.target instanceof HTMLElement &&
182
+ e.target.hasAttribute('data-handle') &&
183
+ e.target.closest('li') === e.currentTarget
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Moves the row on Alt and an arrow, and says whether it took the key. The
189
+ * caller reads the answer rather than defaultPrevented, which an event that
190
+ * cannot be canceled never sets.
191
+ */
192
+ function keyMove(e: KeyboardEvent, index: number): boolean {
193
+ if (e.defaultPrevented || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false;
194
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false;
195
+ if (typing(e.target)) return false;
196
+ e.preventDefault();
197
+ if (!drag) void move(index, index + (e.key === 'ArrowUp' ? -1 : 1));
198
+ return true;
199
+ }
200
+
201
+ /**
202
+ * The handle's menu, built once per row. It reads the row's place only when
203
+ * it opens, so a move elsewhere in a long list rebuilds no menu.
204
+ */
205
+ function menu(id: string) {
206
+ const step = (by: number) => {
207
+ const from = at(id);
208
+ if (from >= 0) void move(from, from + by);
209
+ };
210
+ return [
211
+ {
212
+ label: 'Move up',
213
+ get disabled() {
214
+ return at(id) === 0;
215
+ },
216
+ onclick: () => step(-1),
217
+ },
218
+ {
219
+ label: 'Move down',
220
+ get disabled() {
221
+ return at(id) === items.length - 1;
222
+ },
223
+ onclick: () => step(1),
224
+ },
225
+ ];
226
+ }
227
+
228
+ function pressHandle(e: PointerEvent, id: string) {
229
+ if (e.button !== 0 || press || drag) return;
230
+ dragged = false;
231
+ const handle = e.currentTarget as HTMLElement;
232
+ const held: Press = { pointerId: e.pointerId, id, x: e.clientX, y: e.clientY, handle };
233
+ press = held;
234
+ if (e.pointerType === 'touch') held.timer = setTimeout(() => lift(held), TOUCH_HOLD_MS);
235
+ else handle.setPointerCapture?.(e.pointerId);
236
+ }
237
+
238
+ function lift(p: Press) {
239
+ quit();
240
+ const from = at(p.id);
241
+ const lifted = rowsNow()[from];
242
+ if (!lifted || !list || !p.handle.isConnected) return;
243
+ p.handle.setPointerCapture?.(p.pointerId);
244
+ settling = null;
245
+ dragged = true;
246
+ drag = {
247
+ pointerId: p.pointerId,
248
+ id: p.id,
249
+ grab: p.y - lifted.getBoundingClientRect().top,
250
+ dy: 0,
251
+ y: p.y,
252
+ to: from,
253
+ refused: false,
254
+ };
255
+ scroller = scrollParent(list);
256
+ frame = requestAnimationFrame(edgeScroll);
257
+ }
258
+
259
+ function moveHandle(e: PointerEvent) {
260
+ if (press?.pointerId === e.pointerId) {
261
+ const wandered = Math.hypot(e.clientX - press.x, e.clientY - press.y);
262
+ // A finger that travels before the hold is up is a swipe, and the page
263
+ // scrolls under it.
264
+ if (e.pointerType === 'touch') {
265
+ if (wandered > HOLD_SLOP_PX) quit();
266
+ } else if (wandered > DRAG_SLOP_PX) {
267
+ lift(press);
268
+ follow(e.clientY);
269
+ }
270
+ return;
271
+ }
272
+ if (!drag || drag.pointerId !== e.pointerId) return;
273
+ drag.y = e.clientY;
274
+ follow(e.clientY);
275
+ }
276
+
277
+ /** Moves the lifted row to the pointer and works out where it would land. */
278
+ function follow(y: number) {
279
+ const d = drag;
280
+ const from = d ? at(d.id) : -1;
281
+ const rows = rowsNow();
282
+ const lifted = rows[from];
283
+ if (!d || !list || !lifted) return;
284
+ // Where the row sits without its travel. Reading it on every move keeps
285
+ // the row under the pointer while the list scrolls beneath it.
286
+ const box = lifted.getBoundingClientRect();
287
+ const top = box.top - d.dy;
288
+ const bounds = list.getBoundingClientRect();
289
+ d.dy = Math.min(bounds.bottom - top - box.height, Math.max(bounds.top - top, y - d.grab - top));
290
+ let gap = rows.length;
291
+ for (let i = 0; i < rows.length; i++) {
292
+ if (i === from) continue;
293
+ const r = rows[i].getBoundingClientRect();
294
+ if (y < r.top + r.height / 2) {
295
+ gap = i;
296
+ break;
297
+ }
298
+ }
299
+ const to = landing(from, gap);
300
+ if (to === d.to) return;
301
+ d.to = to;
302
+ d.refused = to !== from && (canMove?.(items[from], from, to) ?? true) !== true;
303
+ }
304
+
305
+ function releaseHandle(e: PointerEvent) {
306
+ if (press?.pointerId === e.pointerId) return quit();
307
+ if (!drag || drag.pointerId !== e.pointerId) return;
308
+ // The caller may have changed the rows since the pointer last moved.
309
+ follow(e.clientY);
310
+ const { id, to } = drag;
311
+ const from = at(id);
312
+ stop();
313
+ if (from < 0) return;
314
+ // A move that lands animates from where the row was let go, because the
315
+ // rows are measured before its travel is cleared. A drop that moves
316
+ // nothing eases the row back to its place instead.
317
+ if (to === from || !land(from, to)) settling = id;
318
+ }
319
+
320
+ /** Puts a lifted row back where it was, and forgets a press. */
321
+ function cancel() {
322
+ quit();
323
+ if (!drag) return;
324
+ const { id } = drag;
325
+ stop();
326
+ if (at(id) >= 0) settling = id;
327
+ }
328
+
329
+ function cancelPointer(e: PointerEvent) {
330
+ if (press?.pointerId === e.pointerId || drag?.pointerId === e.pointerId) cancel();
331
+ }
332
+
333
+ function quit() {
334
+ if (press) clearTimeout(press.timer);
335
+ press = null;
336
+ }
337
+
338
+ function stop() {
339
+ drag = null;
340
+ scroller = null;
341
+ cancelAnimationFrame(frame);
342
+ }
343
+
344
+ /** The nearest box that scrolls the list, or the page. */
345
+ function scrollParent(node: HTMLElement): Element {
346
+ for (let el = node.parentElement; el; el = el.parentElement) {
347
+ const { overflowY } = getComputedStyle(el);
348
+ if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
349
+ return el;
350
+ }
351
+ }
352
+ return document.scrollingElement ?? document.documentElement;
353
+ }
354
+
355
+ /** Scrolls toward the edge the pointer is near, faster the nearer it is. */
356
+ function edgeScroll() {
357
+ const d = drag;
358
+ if (!d || !scroller) return;
359
+ const page = scroller === document.scrollingElement;
360
+ const box = page ? { top: 0, bottom: window.innerHeight } : scroller.getBoundingClientRect();
361
+ const past =
362
+ d.y < box.top + EDGE_SCROLL_PX
363
+ ? d.y - box.top - EDGE_SCROLL_PX
364
+ : d.y > box.bottom - EDGE_SCROLL_PX
365
+ ? d.y - box.bottom + EDGE_SCROLL_PX
366
+ : 0;
367
+ if (past !== 0) {
368
+ scroller.scrollTop += Math.round(
369
+ Math.max(-EDGE_SCROLL_PX, Math.min(EDGE_SCROLL_PX, past)) / 3,
370
+ );
371
+ follow(d.y);
372
+ }
373
+ frame = requestAnimationFrame(edgeScroll);
374
+ }
375
+
376
+ $effect(() => {
377
+ // The window hears how a gesture ends even once the handle that began it
378
+ // has left the document, as when the caller removes the row mid-drag.
379
+ window.addEventListener('pointerup', releaseHandle, true);
380
+ window.addEventListener('pointercancel', cancelPointer, true);
381
+ window.addEventListener('lostpointercapture', cancelPointer, true);
382
+ return () => {
383
+ window.removeEventListener('pointerup', releaseHandle, true);
384
+ window.removeEventListener('pointercancel', cancelPointer, true);
385
+ window.removeEventListener('lostpointercapture', cancelPointer, true);
386
+ };
387
+ });
388
+
389
+ $effect(() => {
390
+ if (!drag) return;
391
+ const escape = (e: KeyboardEvent) => {
392
+ if (e.key !== 'Escape') return;
393
+ // Stopped here so a dialog around the list stays open.
394
+ e.preventDefault();
395
+ e.stopPropagation();
396
+ cancel();
397
+ };
398
+ const second = (e: PointerEvent) => {
399
+ if (drag && e.pointerId !== drag.pointerId) cancel();
400
+ };
401
+ window.addEventListener('keydown', escape, true);
402
+ window.addEventListener('pointerdown', second, true);
403
+ return () => {
404
+ window.removeEventListener('keydown', escape, true);
405
+ window.removeEventListener('pointerdown', second, true);
406
+ };
407
+ });
408
+
409
+ $effect(() => {
410
+ const node = list;
411
+ if (!node) return;
412
+ // Svelte listens to touchmove passively, and only a listener that is not
413
+ // passive can keep a lifted row's finger from scrolling the page.
414
+ const hold = (e: TouchEvent) => {
415
+ if (drag) e.preventDefault();
416
+ };
417
+ node.addEventListener('touchmove', hold, { passive: false });
418
+ return () => {
419
+ node.removeEventListener('touchmove', hold);
420
+ quit();
421
+ cancelAnimationFrame(frame);
422
+ };
423
+ });
424
+
425
+ /** The row that draws the landing line, and on which of its edges. */
426
+ const line = $derived.by(() => {
427
+ const from = drag ? at(drag.id) : -1;
428
+ if (!drag || from < 0 || drag.to === from) return null;
429
+ return { index: drag.to, edge: drag.to > from ? 'after' : 'before', refused: drag.refused };
430
+ });
431
+
432
+ // Tailwind reads class names from source, so each edge spells its line out.
433
+ const LINE = {
434
+ before: {
435
+ shape: 'before:absolute before:inset-x-0 before:-top-px before:h-0.5 before:rounded-full',
436
+ ok: 'before:bg-brand',
437
+ refused: 'before:bg-danger',
438
+ },
439
+ after: {
440
+ shape: 'after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:rounded-full',
441
+ ok: 'after:bg-brand',
442
+ refused: 'after:bg-danger',
443
+ },
444
+ } as const;
445
+
446
+ function rowClass(index: number, id: string): string {
447
+ const mark = line?.index === index ? LINE[line.edge as 'before' | 'after'] : null;
448
+ return cn(
449
+ 'relative flex items-center gap-1 rounded-lg',
450
+ drag?.id === id && 'z-dropdown bg-surface shadow-lg',
451
+ settling === id && 'transition-[translate] duration-base ease-move',
452
+ mark && `${mark.shape} ${line?.refused ? mark.refused : mark.ok}`,
453
+ );
454
+ }
455
+
456
+ function gripClass(id: string): string {
457
+ if (drag?.id !== id) return 'cursor-grab [-webkit-touch-callout:none]';
458
+ return `[-webkit-touch-callout:none] ${drag.refused ? 'cursor-not-allowed' : 'cursor-grabbing'}`;
459
+ }
460
+ </script>
461
+
462
+ <div class={cn('relative', klass)}>
463
+ <ul bind:this={list} aria-label={label} class="isolate flex flex-col">
464
+ {#each items as item, index (item.id)}
465
+ <!-- The row hears Alt and an arrow from whatever holds the focus inside
466
+ it, the handle or a control the row draws, so the keys work without
467
+ a tab stop of their own. The handle's keys are taken on the way
468
+ down, before its menu opens on an arrow, and the rest on the way
469
+ up, after a control in the row has had its chance to take them. -->
470
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
471
+ <li
472
+ animate:motion.reorder
473
+ class={rowClass(index, item.id)}
474
+ style:translate={drag?.id === item.id ? `0 ${drag.dy}px` : undefined}
475
+ onkeydowncapture={(e) => {
476
+ if (fromHandle(e) && keyMove(e, index)) e.stopPropagation();
477
+ }}
478
+ onkeydown={(e) => {
479
+ if (!fromHandle(e)) keyMove(e, index);
480
+ }}
481
+ >
482
+ <Dropdown items={menu(item.id)} class="shrink-0">
483
+ {#snippet trigger({ open, toggle })}
484
+ <Button
485
+ variant="ghost"
486
+ size="sm"
487
+ data-handle
488
+ aria-label="Move {item.label}"
489
+ aria-describedby="{uid}-keys"
490
+ aria-haspopup="menu"
491
+ aria-expanded={open}
492
+ hint={false}
493
+ class={gripClass(item.id)}
494
+ onclick={(e: MouseEvent) => {
495
+ if (e.detail === 0 || !dragged) toggle();
496
+ }}
497
+ onpointerdown={(e: PointerEvent) => pressHandle(e, item.id)}
498
+ onpointermove={moveHandle}
499
+ oncontextmenu={(e: MouseEvent) => {
500
+ if (press || drag) e.preventDefault();
501
+ }}
502
+ >
503
+ <GripVertical size={16} />
504
+ </Button>
505
+ {/snippet}
506
+ </Dropdown>
507
+ <div class="min-w-0 flex-1">{@render row(item, index)}</div>
508
+ </li>
509
+ {/each}
510
+ </ul>
511
+ <p id="{uid}-keys" class="sr-only">
512
+ Press Alt with the up or down arrow to move a row, or Enter for a menu.
513
+ </p>
514
+ <!-- A div with no role, because Orca speaks nothing inserted into a status
515
+ role, and in Firefox drops a paragraph's text that follows the list's
516
+ own events too closely. -->
517
+ <div aria-live="polite" class="sr-only">
518
+ {#key spoken.n}<span>{spoken.text}</span>{/key}
519
+ </div>
520
+ </div>
@@ -0,0 +1,57 @@
1
+ /** One row a person can move. */
2
+ export interface SortableItem {
3
+ /**
4
+ * Stable, and unique in the list. The rows are keyed by it, so a repeated
5
+ * id throws in development, and an id that changes between renders
6
+ * remounts its row and loses the focus inside it.
7
+ */
8
+ id: string;
9
+ /** What the row is called. Its handle reads "Move <label>", and each move is announced by it. */
10
+ label: string;
11
+ }
12
+ /** A move that landed. */
13
+ export interface SortableMove<T extends SortableItem> {
14
+ item: T;
15
+ from: number;
16
+ to: number;
17
+ /** The list in its new order, which `items` holds as well. */
18
+ items: T[];
19
+ }
20
+ import { type Snippet } from 'svelte';
21
+ import { type MoveCheck } from '../internal/reorder.js';
22
+ declare function $$render<T extends SortableItem>(): {
23
+ props: {
24
+ /** The rows, in order. Bind it and a move writes the new order back. */
25
+ items: T[];
26
+ /** Names the list for a screen reader. */
27
+ label: string;
28
+ /** Allows a move with true, or refuses it with the sentence to announce instead. */
29
+ canMove?: (item: T, from: number, to: number) => MoveCheck;
30
+ /** Called once a move has landed, when `items` already holds the new order. */
31
+ onmove?: (move: SortableMove<T>) => void;
32
+ /** Draws a row's content beside its handle. */
33
+ row: Snippet<[T, number]>;
34
+ class?: string;
35
+ };
36
+ exports: {};
37
+ bindings: "items";
38
+ slots: {};
39
+ events: {};
40
+ };
41
+ declare class __sveltets_Render<T extends SortableItem> {
42
+ props(): ReturnType<typeof $$render<T>>['props'];
43
+ events(): ReturnType<typeof $$render<T>>['events'];
44
+ slots(): ReturnType<typeof $$render<T>>['slots'];
45
+ bindings(): "items";
46
+ exports(): {};
47
+ }
48
+ interface $$IsomorphicComponent {
49
+ new <T extends SortableItem>(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']>> & {
50
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
51
+ } & ReturnType<__sveltets_Render<T>['exports']>;
52
+ <T extends SortableItem>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
53
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
54
+ }
55
+ declare const SortableList: $$IsomorphicComponent;
56
+ type SortableList<T extends SortableItem> = InstanceType<typeof SortableList<T>>;
57
+ export default SortableList;
@@ -57,8 +57,10 @@
57
57
  /*
58
58
  * The tab for the active id is brought into view when the id changes, so a
59
59
  * page that opens on its fifth tab does not open on a strip that hides it.
60
- * `nearest` on both axes moves the strip and nothing else. A test DOM has
61
- * no layout and no scrollIntoView, and neither is an error here.
60
+ * Only the strip's own offset moves. scrollIntoView moves every scroll box
61
+ * above the tab, the page among them, so a strip below the fold pulled the
62
+ * page down to it as soon as it mounted. A test DOM lays nothing out, so
63
+ * every box is empty and nothing moves.
62
64
  */
63
65
  $effect(() => {
64
66
  const el = scroller;
@@ -67,9 +69,11 @@
67
69
  const tab = [...el.querySelectorAll<HTMLElement>('[role="tab"]')].find(
68
70
  (t) => t.dataset.tab === id,
69
71
  );
70
- if (tab && typeof tab.scrollIntoView === 'function') {
71
- tab.scrollIntoView({ block: 'nearest', inline: 'nearest' });
72
- }
72
+ if (!tab) return;
73
+ const box = el.getBoundingClientRect();
74
+ const at = tab.getBoundingClientRect();
75
+ if (at.left < box.left) el.scrollLeft -= box.left - at.left;
76
+ else if (at.right > box.right) el.scrollLeft += at.right - box.right;
73
77
  });
74
78
 
75
79
  /*
@@ -17,11 +17,11 @@
17
17
  app and empty most of the time. Marking each toast `role="status"` instead
18
18
  announced nothing: assistive technology watches an existing region for
19
19
  changes, and a region that arrives already holding its text is not a
20
- change. -->
20
+ change. It carries `aria-live` and no `role="status"`: browsers expose a
21
+ status role as a status bar, and Orca speaks nothing inserted into one. -->
21
22
  <div
22
23
  data-print="hide"
23
24
  class="pointer-events-none fixed bottom-4 end-4 z-toast flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
24
- role="status"
25
25
  aria-live="polite"
26
26
  aria-atomic="false"
27
27
  >
package/dist/index.d.ts CHANGED
@@ -19,8 +19,12 @@ export { default as AccordionItem } from './components/AccordionItem.svelte';
19
19
  export { default as Collapsible } from './components/Collapsible.svelte';
20
20
  export { default as Table } from './components/Table.svelte';
21
21
  export { default as DescriptionList } from './components/DescriptionList.svelte';
22
+ export { default as DiffView } from './components/DiffView.svelte';
23
+ export type { DiffChange } from './components/DiffView.svelte';
22
24
  export { default as Toolbar } from './components/Toolbar.svelte';
23
25
  export { default as TreeView } from './components/TreeView.svelte';
26
+ export { default as SortableList } from './components/SortableList.svelte';
27
+ export type { SortableItem, SortableMove } from './components/SortableList.svelte';
24
28
  export { default as SplitPane } from './components/SplitPane.svelte';
25
29
  export type { CellFit } from './components/Table.svelte';
26
30
  export type { SidebarState } from './components/AppShell.svelte';
@@ -35,6 +39,8 @@ export { default as Textarea } from './components/Textarea.svelte';
35
39
  export { default as NumberInput } from './components/NumberInput.svelte';
36
40
  export { default as SearchInput } from './components/SearchInput.svelte';
37
41
  export { default as FileInput } from './components/FileInput.svelte';
42
+ export { default as ColorField } from './components/ColorField.svelte';
43
+ export type { ColorSwatch } from './components/ColorField.svelte';
38
44
  export { default as Label } from './components/Label.svelte';
39
45
  export { default as Field } from './components/Field.svelte';
40
46
  export { default as FormMessage } from './components/FormMessage.svelte';
@@ -107,4 +113,4 @@ export { cn, type ClassValue } from './utils/cn.js';
107
113
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
108
114
  export * as motion from './motion.js';
109
115
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
110
- export declare const VERSION = "0.42.2";
116
+ export declare const VERSION = "0.44.0";
package/dist/index.js CHANGED
@@ -20,8 +20,10 @@ export { default as AccordionItem } from './components/AccordionItem.svelte';
20
20
  export { default as Collapsible } from './components/Collapsible.svelte';
21
21
  export { default as Table } from './components/Table.svelte';
22
22
  export { default as DescriptionList } from './components/DescriptionList.svelte';
23
+ export { default as DiffView } from './components/DiffView.svelte';
23
24
  export { default as Toolbar } from './components/Toolbar.svelte';
24
25
  export { default as TreeView } from './components/TreeView.svelte';
26
+ export { default as SortableList } from './components/SortableList.svelte';
25
27
  export { default as SplitPane } from './components/SplitPane.svelte';
26
28
  // Stated once in an app's root layout, the format turns every PageShell's
27
29
  // title into the browser tab's. An app that never states one gets no title
@@ -36,6 +38,7 @@ export { default as Textarea } from './components/Textarea.svelte';
36
38
  export { default as NumberInput } from './components/NumberInput.svelte';
37
39
  export { default as SearchInput } from './components/SearchInput.svelte';
38
40
  export { default as FileInput } from './components/FileInput.svelte';
41
+ export { default as ColorField } from './components/ColorField.svelte';
39
42
  export { default as Label } from './components/Label.svelte';
40
43
  // The field furniture as a component, for a consumer composing a control the
41
44
  // kit does not ship. internal/field.ts stays private, and a page that could
@@ -119,4 +122,4 @@ export * as motion from './motion.js';
119
122
  // ── Version ────────────────────────────────────────────────────────────────
120
123
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
121
124
  // this line. The build and the test suite fail when the two disagree.
122
- export const VERSION = '0.42.2';
125
+ export const VERSION = '0.44.0';