@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.
- package/README.md +5 -5
- package/dist/components/Alert.svelte +44 -8
- package/dist/components/Alert.svelte.d.ts +8 -0
- package/dist/components/Banner.svelte +41 -6
- package/dist/components/Banner.svelte.d.ts +8 -0
- package/dist/components/ColorField.svelte +221 -0
- package/dist/components/ColorField.svelte.d.ts +38 -0
- package/dist/components/CopyButton.svelte +8 -2
- package/dist/components/CopyField.svelte +4 -4
- package/dist/components/DiffView.svelte +152 -0
- package/dist/components/DiffView.svelte.d.ts +27 -0
- package/dist/components/FormMessage.svelte +11 -5
- package/dist/components/Logo.svelte +1 -1
- package/dist/components/SortableList.svelte +520 -0
- package/dist/components/SortableList.svelte.d.ts +57 -0
- package/dist/components/Tabs.svelte +9 -5
- package/dist/components/Toaster.svelte +2 -2
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -1
- package/dist/internal/reorder.d.ts +50 -0
- package/dist/internal/reorder.js +58 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
71
|
-
|
|
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.
|
|
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.
|
|
125
|
+
export const VERSION = '0.44.0';
|