@dorsk/tsumikit 0.67.1 → 0.68.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 +96 -3
- package/dist/components/atoms/Button.svelte +111 -3
- package/dist/components/atoms/Button.svelte.d.ts +4 -0
- package/dist/components/layouts/NavBar.svelte +184 -30
- package/dist/components/layouts/NavBar.svelte.d.ts +19 -3
- package/dist/components/molecules/GlyphStack.svelte +12 -19
- package/dist/components/molecules/RadialMenu.svelte +579 -0
- package/dist/components/molecules/RadialMenu.svelte.d.ts +42 -0
- package/dist/components/molecules/SortableList.svelte +720 -0
- package/dist/components/molecules/SortableList.svelte.d.ts +116 -0
- package/dist/components/molecules/sortable-list-order.d.ts +68 -0
- package/dist/components/molecules/sortable-list-order.js +133 -0
- package/dist/floating.d.ts +38 -0
- package/dist/floating.js +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/slide-select.d.ts +26 -0
- package/dist/slide-select.js +53 -0
- package/package.json +1 -1
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface SortableListMove {
|
|
3
|
+
key: string;
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SortableListRowContext {
|
|
8
|
+
index: number;
|
|
9
|
+
grabbed: boolean;
|
|
10
|
+
selected: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Accessible strings. `pos` is 1-based. */
|
|
13
|
+
export interface SortableListMessages {
|
|
14
|
+
instructions: string;
|
|
15
|
+
reorder: (name: string) => string;
|
|
16
|
+
moveUp: (name: string) => string;
|
|
17
|
+
moveDown: (name: string) => string;
|
|
18
|
+
grabbed: (name: string, pos: number, total: number) => string;
|
|
19
|
+
moved: (name: string, pos: number, total: number) => string;
|
|
20
|
+
dropped: (name: string, pos: number, total: number) => string;
|
|
21
|
+
cancelled: (name: string, pos: number, total: number) => string;
|
|
22
|
+
}
|
|
23
|
+
export const SORTABLE_LIST_MESSAGES: SortableListMessages = {
|
|
24
|
+
instructions:
|
|
25
|
+
'Press Space or Enter to pick the row up. Use the arrow keys to move it, Home or End to jump to an end, Space or Enter to drop it and Escape to cancel. Alt with an arrow key moves a row one step.',
|
|
26
|
+
reorder: (name) => `Reorder ${name}`,
|
|
27
|
+
moveUp: (name) => `Move ${name} up`,
|
|
28
|
+
moveDown: (name) => `Move ${name} down`,
|
|
29
|
+
grabbed: (name, pos, total) => `Grabbed ${name}, position ${pos} of ${total}.`,
|
|
30
|
+
moved: (name, pos, total) => `${name}, position ${pos} of ${total}.`,
|
|
31
|
+
dropped: (name, pos, total) => `Dropped ${name} at position ${pos} of ${total}.`,
|
|
32
|
+
cancelled: (name, pos, total) => `Cancelled. ${name} is back at position ${pos} of ${total}.`
|
|
33
|
+
};
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<script lang="ts" generics="T">
|
|
37
|
+
/**
|
|
38
|
+
* Reorderable list. The consumer owns the order: `onreorder` reports the new
|
|
39
|
+
* key list, the list shows it until `items` changes or `revertAfter` ms pass.
|
|
40
|
+
* Drag runs on Pointer Events (touch included); the grip is also a keyboard
|
|
41
|
+
* grab button and the arrows move one step.
|
|
42
|
+
*/
|
|
43
|
+
import { tick, type Snippet } from 'svelte';
|
|
44
|
+
import { flip } from 'svelte/animate';
|
|
45
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
46
|
+
import IconButton from './IconButton.svelte';
|
|
47
|
+
import {
|
|
48
|
+
edgeScroll,
|
|
49
|
+
edgeTarget,
|
|
50
|
+
grabbedAction,
|
|
51
|
+
moveItem,
|
|
52
|
+
sameKeys,
|
|
53
|
+
slotForTop,
|
|
54
|
+
stepTarget
|
|
55
|
+
} from './sortable-list-order.js';
|
|
56
|
+
|
|
57
|
+
type Own = {
|
|
58
|
+
items: T[];
|
|
59
|
+
key: (item: T) => string;
|
|
60
|
+
/** Row content; the kit renders the grip, ordinal and arrows around it. */
|
|
61
|
+
row: Snippet<[T, SortableListRowContext]>;
|
|
62
|
+
/** Interactive row extras (go, skip), kept outside the selectable body. */
|
|
63
|
+
trailing?: Snippet<[T, SortableListRowContext]>;
|
|
64
|
+
/** Fires on drop or an arrow press with the full new key order. */
|
|
65
|
+
onreorder?: (keys: string[], move: SortableListMove) => void;
|
|
66
|
+
/** Veto a target index for a key. */
|
|
67
|
+
canMove?: (key: string, to: number) => boolean;
|
|
68
|
+
/** A locked row has no grip and disabled arrows. */
|
|
69
|
+
locked?: (item: T) => boolean;
|
|
70
|
+
/** Accessible row name used in labels and announcements. Defaults to the key. */
|
|
71
|
+
name?: (item: T) => string;
|
|
72
|
+
arrows?: boolean;
|
|
73
|
+
numbered?: boolean;
|
|
74
|
+
selected?: string;
|
|
75
|
+
onselect?: (key: string) => void;
|
|
76
|
+
/** Render the row body as a select button. Defaults to on when `onselect` or `selected` is given. */
|
|
77
|
+
selectable?: boolean;
|
|
78
|
+
/** `row` drags from anywhere on the row; touch then needs a 250 ms long-press. */
|
|
79
|
+
handle?: 'grip' | 'row';
|
|
80
|
+
size?: 'sm' | 'md';
|
|
81
|
+
label: string;
|
|
82
|
+
messages?: Partial<SortableListMessages>;
|
|
83
|
+
as?: 'ol' | 'ul';
|
|
84
|
+
/** Ms the optimistic order is kept when `items` is not updated. */
|
|
85
|
+
revertAfter?: number;
|
|
86
|
+
class?: string;
|
|
87
|
+
style?: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
let {
|
|
91
|
+
items,
|
|
92
|
+
key: keyOf,
|
|
93
|
+
row,
|
|
94
|
+
trailing,
|
|
95
|
+
onreorder,
|
|
96
|
+
canMove,
|
|
97
|
+
locked,
|
|
98
|
+
name,
|
|
99
|
+
arrows = true,
|
|
100
|
+
numbered = false,
|
|
101
|
+
selected = $bindable(),
|
|
102
|
+
onselect,
|
|
103
|
+
selectable,
|
|
104
|
+
handle = 'grip',
|
|
105
|
+
size = 'md',
|
|
106
|
+
label,
|
|
107
|
+
messages,
|
|
108
|
+
as = 'ol',
|
|
109
|
+
revertAfter = 3000,
|
|
110
|
+
class: klass = '',
|
|
111
|
+
style: styleProp,
|
|
112
|
+
...rest
|
|
113
|
+
}: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
|
|
114
|
+
|
|
115
|
+
const LONG_PRESS_MS = 250;
|
|
116
|
+
const uid = `sortable-${Math.random().toString(36).slice(2, 8)}`;
|
|
117
|
+
const msgs = $derived({ ...SORTABLE_LIST_MESSAGES, ...messages });
|
|
118
|
+
const canSelect = $derived(selectable ?? (onselect !== undefined || selected !== undefined));
|
|
119
|
+
|
|
120
|
+
let pending = $state.raw<{ keys: string[]; base: T[] } | null>(null);
|
|
121
|
+
let draft = $state.raw<string[] | null>(null);
|
|
122
|
+
let grabKey = $state<string | null>(null);
|
|
123
|
+
let mode = $state<'keyboard' | 'pointer' | null>(null);
|
|
124
|
+
let dragOffset = $state(0);
|
|
125
|
+
let announcement = $state('');
|
|
126
|
+
let motion = $state(false);
|
|
127
|
+
let listEl = $state<HTMLElement | null>(null);
|
|
128
|
+
|
|
129
|
+
const byKey = $derived(new Map(items.map((item) => [keyOf(item), item])));
|
|
130
|
+
const baseKeys = $derived(items.map(keyOf));
|
|
131
|
+
const pendingKeys = $derived(
|
|
132
|
+
pending && pending.base === items && sameKeys(pending.keys, baseKeys) ? pending.keys : null
|
|
133
|
+
);
|
|
134
|
+
const orderKeys = $derived(
|
|
135
|
+
draft && sameKeys(draft, baseKeys) ? draft : (pendingKeys ?? baseKeys)
|
|
136
|
+
);
|
|
137
|
+
const rows = $derived(orderKeys.map((k) => byKey.get(k) as T));
|
|
138
|
+
const total = $derived(rows.length);
|
|
139
|
+
|
|
140
|
+
let grabFrom = 0;
|
|
141
|
+
let revertTimer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
+
let suppressClick = false;
|
|
143
|
+
|
|
144
|
+
const nameOf = (k: string) => {
|
|
145
|
+
const item = byKey.get(k);
|
|
146
|
+
return item !== undefined && name ? name(item) : k;
|
|
147
|
+
};
|
|
148
|
+
const isLocked = (item: T) => locked?.(item) ?? false;
|
|
149
|
+
const allowedFor = (k: string) => (to: number) => canMove?.(k, to) ?? true;
|
|
150
|
+
|
|
151
|
+
$effect(() => {
|
|
152
|
+
const mq = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
|
|
153
|
+
const update = () => {
|
|
154
|
+
motion = !!mq && !mq.matches && typeof Element.prototype.animate === 'function';
|
|
155
|
+
};
|
|
156
|
+
update();
|
|
157
|
+
mq?.addEventListener('change', update);
|
|
158
|
+
return () => {
|
|
159
|
+
mq?.removeEventListener('change', update);
|
|
160
|
+
clearTimeout(revertTimer);
|
|
161
|
+
endPress();
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
$effect(() => {
|
|
166
|
+
const el = listEl;
|
|
167
|
+
if (!el) return;
|
|
168
|
+
const block = (e: TouchEvent) => {
|
|
169
|
+
if (mode === 'pointer') e.preventDefault();
|
|
170
|
+
};
|
|
171
|
+
el.addEventListener('touchmove', block, { passive: false });
|
|
172
|
+
return () => el.removeEventListener('touchmove', block);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function announce(text: string) {
|
|
176
|
+
announcement = text;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function commit(k: string, from: number, to: number, keys: string[]) {
|
|
180
|
+
pending = { keys, base: items };
|
|
181
|
+
clearTimeout(revertTimer);
|
|
182
|
+
revertTimer = setTimeout(() => (pending = null), revertAfter);
|
|
183
|
+
onreorder?.(keys, { key: k, from, to });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function rowEl(k: string): HTMLElement | undefined {
|
|
187
|
+
for (const el of listEl?.children ?? []) {
|
|
188
|
+
if ((el as HTMLElement).dataset.key === k) return el as HTMLElement;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function refocus(k: string, part: string) {
|
|
194
|
+
await tick();
|
|
195
|
+
const el = rowEl(k);
|
|
196
|
+
const pick = (p: string) =>
|
|
197
|
+
el?.querySelector<HTMLButtonElement>(`[data-part="${p}"]:not(:disabled)`);
|
|
198
|
+
const target =
|
|
199
|
+
pick(part) ?? (part === 'up' ? pick('down') : part === 'down' ? pick('up') : null) ?? pick('grip');
|
|
200
|
+
target?.focus();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function step(k: string, dir: 1 | -1, part: string) {
|
|
204
|
+
if (mode !== null) return;
|
|
205
|
+
const keys = orderKeys;
|
|
206
|
+
const from = keys.indexOf(k);
|
|
207
|
+
const item = byKey.get(k);
|
|
208
|
+
if (from < 0 || item === undefined || isLocked(item)) return;
|
|
209
|
+
const to = stepTarget(from, keys.length, dir, allowedFor(k));
|
|
210
|
+
if (to === undefined) return;
|
|
211
|
+
const next = moveItem(keys, from, to);
|
|
212
|
+
commit(k, from, to, next);
|
|
213
|
+
announce(msgs.moved(nameOf(k), to + 1, next.length));
|
|
214
|
+
refocus(k, part);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function grab(k: string, how: 'keyboard' | 'pointer') {
|
|
218
|
+
draft = [...orderKeys];
|
|
219
|
+
grabFrom = draft.indexOf(k);
|
|
220
|
+
grabKey = k;
|
|
221
|
+
mode = how;
|
|
222
|
+
announce(msgs.grabbed(nameOf(k), grabFrom + 1, draft.length));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function release() {
|
|
226
|
+
draft = null;
|
|
227
|
+
grabKey = null;
|
|
228
|
+
mode = null;
|
|
229
|
+
dragOffset = 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function drop() {
|
|
233
|
+
const k = grabKey;
|
|
234
|
+
const keys = draft;
|
|
235
|
+
if (k === null || keys === null) return release();
|
|
236
|
+
const to = keys.indexOf(k);
|
|
237
|
+
if (sameKeys(keys, baseKeys) && to !== grabFrom) commit(k, grabFrom, to, keys);
|
|
238
|
+
release();
|
|
239
|
+
announce(msgs.dropped(nameOf(k), to + 1, keys.length));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function cancel() {
|
|
243
|
+
const k = grabKey;
|
|
244
|
+
release();
|
|
245
|
+
if (k !== null) announce(msgs.cancelled(nameOf(k), grabFrom + 1, total));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function moveDraft(k: string, to: number | undefined) {
|
|
249
|
+
if (!draft || to === undefined) return;
|
|
250
|
+
const from = draft.indexOf(k);
|
|
251
|
+
if (to === from) return;
|
|
252
|
+
draft = moveItem(draft, from, to);
|
|
253
|
+
announce(msgs.moved(nameOf(k), to + 1, draft.length));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function onGripKeydown(e: KeyboardEvent, k: string) {
|
|
257
|
+
if (mode === 'pointer') return;
|
|
258
|
+
if (grabKey !== k) {
|
|
259
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
grab(k, 'keyboard');
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const action = grabbedAction(e.key);
|
|
266
|
+
if (!action || !draft) return;
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
e.stopPropagation();
|
|
269
|
+
const from = draft.indexOf(k);
|
|
270
|
+
const allowed = allowedFor(k);
|
|
271
|
+
if (action === 'drop') drop();
|
|
272
|
+
else if (action === 'cancel') cancel();
|
|
273
|
+
else if (action === 'up') moveDraft(k, stepTarget(from, draft.length, -1, allowed));
|
|
274
|
+
else if (action === 'down') moveDraft(k, stepTarget(from, draft.length, 1, allowed));
|
|
275
|
+
else moveDraft(k, edgeTarget(from, draft.length, action, allowed));
|
|
276
|
+
refocus(k, 'grip');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function onRowKeydown(e: KeyboardEvent, k: string) {
|
|
280
|
+
if (!e.altKey || mode !== null) return;
|
|
281
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
const part = (document.activeElement as HTMLElement | null)?.dataset.part ?? 'grip';
|
|
284
|
+
step(k, e.key === 'ArrowUp' ? -1 : 1, part);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function onfocusout() {
|
|
288
|
+
if (mode !== 'keyboard') return;
|
|
289
|
+
setTimeout(() => {
|
|
290
|
+
if (mode !== 'keyboard' || grabKey === null) return;
|
|
291
|
+
const grip = rowEl(grabKey)?.querySelector('[data-part="grip"]');
|
|
292
|
+
if (document.activeElement !== grip) cancel();
|
|
293
|
+
}, 0);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type Press = {
|
|
297
|
+
id: number;
|
|
298
|
+
key: string;
|
|
299
|
+
x: number;
|
|
300
|
+
y: number;
|
|
301
|
+
lastY: number;
|
|
302
|
+
started: boolean;
|
|
303
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
304
|
+
grabOffset: number;
|
|
305
|
+
heights: Map<string, number>;
|
|
306
|
+
origin: number;
|
|
307
|
+
scroller: HTMLElement | null;
|
|
308
|
+
frame?: number;
|
|
309
|
+
};
|
|
310
|
+
let press: Press | null = null;
|
|
311
|
+
|
|
312
|
+
function onRowPointerdown(e: PointerEvent, k: string, item: T) {
|
|
313
|
+
if (mode !== null || isLocked(item)) return;
|
|
314
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
315
|
+
const target = e.target as HTMLElement;
|
|
316
|
+
if (target.closest('[data-part="arrows"], [data-part="trailing"]')) return;
|
|
317
|
+
const fromGrip = target.closest('[data-part="grip"]') !== null;
|
|
318
|
+
if (handle === 'grip' && !fromGrip) return;
|
|
319
|
+
endPress();
|
|
320
|
+
press = {
|
|
321
|
+
id: e.pointerId,
|
|
322
|
+
key: k,
|
|
323
|
+
x: e.clientX,
|
|
324
|
+
y: e.clientY,
|
|
325
|
+
lastY: e.clientY,
|
|
326
|
+
started: false,
|
|
327
|
+
grabOffset: 0,
|
|
328
|
+
heights: new Map(),
|
|
329
|
+
origin: 0,
|
|
330
|
+
scroller: null
|
|
331
|
+
};
|
|
332
|
+
if (e.pointerType === 'touch' && !fromGrip) {
|
|
333
|
+
press.timer = setTimeout(() => {
|
|
334
|
+
if (press) {
|
|
335
|
+
press.timer = undefined;
|
|
336
|
+
startDrag();
|
|
337
|
+
}
|
|
338
|
+
}, LONG_PRESS_MS);
|
|
339
|
+
}
|
|
340
|
+
addEventListener('pointermove', onPointermove);
|
|
341
|
+
addEventListener('pointerup', onPointerup);
|
|
342
|
+
addEventListener('pointercancel', onPointercancel);
|
|
343
|
+
addEventListener('keydown', onDragKeydown);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function scrollerOf(el: HTMLElement): HTMLElement | null {
|
|
347
|
+
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
348
|
+
const oy = getComputedStyle(p).overflowY;
|
|
349
|
+
if ((oy === 'auto' || oy === 'scroll') && p.scrollHeight > p.clientHeight) return p;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function startDrag() {
|
|
355
|
+
if (!press || !listEl) return;
|
|
356
|
+
const listTop = listEl.getBoundingClientRect().top;
|
|
357
|
+
let first: number | undefined;
|
|
358
|
+
for (const el of listEl.children) {
|
|
359
|
+
const li = el as HTMLElement;
|
|
360
|
+
const r = li.getBoundingClientRect();
|
|
361
|
+
first ??= r.top;
|
|
362
|
+
press.heights.set(li.dataset.key ?? '', r.height);
|
|
363
|
+
if (li.dataset.key === press.key) press.grabOffset = press.y - r.top;
|
|
364
|
+
}
|
|
365
|
+
press.origin = (first ?? listTop) - listTop;
|
|
366
|
+
press.scroller = scrollerOf(listEl);
|
|
367
|
+
press.started = true;
|
|
368
|
+
grab(press.key, 'pointer');
|
|
369
|
+
autoScroll();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function layout() {
|
|
373
|
+
if (!press || !listEl || !draft) return;
|
|
374
|
+
const k = press.key;
|
|
375
|
+
const y = press.lastY - listEl.getBoundingClientRect().top - press.origin;
|
|
376
|
+
const top = y - press.grabOffset;
|
|
377
|
+
const others = draft.filter((x) => x !== k);
|
|
378
|
+
const slot = slotForTop(top, others.map((x) => press?.heights.get(x) ?? 0));
|
|
379
|
+
if (slot !== draft.indexOf(k) && allowedFor(k)(slot)) moveDraft(k, slot);
|
|
380
|
+
let slotTop = 0;
|
|
381
|
+
for (const x of draft) {
|
|
382
|
+
if (x === k) break;
|
|
383
|
+
slotTop += press.heights.get(x) ?? 0;
|
|
384
|
+
}
|
|
385
|
+
dragOffset = top - slotTop;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function autoScroll() {
|
|
389
|
+
if (!press?.started || typeof requestAnimationFrame !== 'function') return;
|
|
390
|
+
const sc = press.scroller;
|
|
391
|
+
const doc = document.scrollingElement as HTMLElement | null;
|
|
392
|
+
const box = sc ? sc.getBoundingClientRect() : { top: 0, bottom: innerHeight };
|
|
393
|
+
const v = edgeScroll(press.lastY, box.top, box.bottom);
|
|
394
|
+
const el = sc ?? doc;
|
|
395
|
+
if (v !== 0 && el) {
|
|
396
|
+
const before = el.scrollTop;
|
|
397
|
+
el.scrollTop = before + v;
|
|
398
|
+
if (el.scrollTop !== before) layout();
|
|
399
|
+
}
|
|
400
|
+
press.frame = requestAnimationFrame(autoScroll);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function onPointermove(e: PointerEvent) {
|
|
404
|
+
if (!press || e.pointerId !== press.id) return;
|
|
405
|
+
press.lastY = e.clientY;
|
|
406
|
+
if (!press.started) {
|
|
407
|
+
const dist = Math.hypot(e.clientX - press.x, e.clientY - press.y);
|
|
408
|
+
if (press.timer !== undefined) {
|
|
409
|
+
if (dist > 8) endPress();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (dist < 4) return;
|
|
413
|
+
startDrag();
|
|
414
|
+
}
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
layout();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function onPointerup(e: PointerEvent) {
|
|
420
|
+
if (!press || e.pointerId !== press.id) return;
|
|
421
|
+
const started = press.started;
|
|
422
|
+
endPress();
|
|
423
|
+
if (!started) return;
|
|
424
|
+
suppressClick = true;
|
|
425
|
+
setTimeout(() => (suppressClick = false), 0);
|
|
426
|
+
drop();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function onPointercancel(e: PointerEvent) {
|
|
430
|
+
if (!press || e.pointerId !== press.id) return;
|
|
431
|
+
const started = press.started;
|
|
432
|
+
endPress();
|
|
433
|
+
if (started) cancel();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function onDragKeydown(e: KeyboardEvent) {
|
|
437
|
+
if (e.key !== 'Escape' || !press) return;
|
|
438
|
+
e.preventDefault();
|
|
439
|
+
const started = press.started;
|
|
440
|
+
endPress();
|
|
441
|
+
if (started) cancel();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function endPress() {
|
|
445
|
+
if (press) {
|
|
446
|
+
clearTimeout(press.timer);
|
|
447
|
+
if (press.frame !== undefined) cancelAnimationFrame(press.frame);
|
|
448
|
+
}
|
|
449
|
+
press = null;
|
|
450
|
+
if (typeof removeEventListener !== 'function') return;
|
|
451
|
+
removeEventListener('pointermove', onPointermove);
|
|
452
|
+
removeEventListener('pointerup', onPointerup);
|
|
453
|
+
removeEventListener('pointercancel', onPointercancel);
|
|
454
|
+
removeEventListener('keydown', onDragKeydown);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function choose(k: string) {
|
|
458
|
+
if (suppressClick) return;
|
|
459
|
+
selected = k;
|
|
460
|
+
onselect?.(k);
|
|
461
|
+
}
|
|
462
|
+
</script>
|
|
463
|
+
|
|
464
|
+
<div
|
|
465
|
+
{...rest}
|
|
466
|
+
class="sortable sortable-{size} {klass}"
|
|
467
|
+
class:dragging={mode === 'pointer'}
|
|
468
|
+
class:handle-row={handle === 'row'}
|
|
469
|
+
style={styleProp}
|
|
470
|
+
data-tsu="SortableList"
|
|
471
|
+
{onfocusout}
|
|
472
|
+
>
|
|
473
|
+
<svelte:element this={as} bind:this={listEl} class="sl-list" role="list" aria-label={label}>
|
|
474
|
+
{#each rows as item, i (keyOf(item))}
|
|
475
|
+
{@const k = keyOf(item)}
|
|
476
|
+
{@const ctx = { index: i, grabbed: grabKey === k, selected: selected === k }}
|
|
477
|
+
{@const fixed = isLocked(item)}
|
|
478
|
+
{@const lifted = mode === 'pointer' && grabKey === k}
|
|
479
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
480
|
+
<li
|
|
481
|
+
class="sl-row"
|
|
482
|
+
class:locked={fixed}
|
|
483
|
+
class:selected={ctx.selected}
|
|
484
|
+
class:grabbed={ctx.grabbed}
|
|
485
|
+
class:lifted
|
|
486
|
+
data-key={k}
|
|
487
|
+
animate:flip={{ duration: motion && !lifted ? 160 : 0 }}
|
|
488
|
+
onpointerdown={(e) => onRowPointerdown(e, k, item)}
|
|
489
|
+
onkeydown={(e) => onRowKeydown(e, k)}
|
|
490
|
+
oncontextmenu={(e) => {
|
|
491
|
+
if (handle === 'row' && press) e.preventDefault();
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
<div class="sl-lift" style:transform={lifted ? `translateY(${dragOffset}px)` : undefined}>
|
|
495
|
+
{#if fixed}
|
|
496
|
+
<span class="sl-grip-spacer" aria-hidden="true"></span>
|
|
497
|
+
{:else}
|
|
498
|
+
<button
|
|
499
|
+
type="button"
|
|
500
|
+
class="sl-grip"
|
|
501
|
+
data-part="grip"
|
|
502
|
+
aria-label={msgs.reorder(nameOf(k))}
|
|
503
|
+
aria-describedby="{uid}-help"
|
|
504
|
+
aria-pressed={ctx.grabbed}
|
|
505
|
+
onkeydown={(e) => onGripKeydown(e, k)}
|
|
506
|
+
></button>
|
|
507
|
+
{/if}
|
|
508
|
+
{#if numbered}
|
|
509
|
+
<span class="sl-ordinal" aria-hidden="true">{i + 1}</span>
|
|
510
|
+
{/if}
|
|
511
|
+
{#if canSelect}
|
|
512
|
+
<button
|
|
513
|
+
type="button"
|
|
514
|
+
class="sl-body"
|
|
515
|
+
data-part="body"
|
|
516
|
+
aria-current={ctx.selected ? 'true' : undefined}
|
|
517
|
+
onclick={() => choose(k)}
|
|
518
|
+
>
|
|
519
|
+
{@render row(item, ctx)}
|
|
520
|
+
</button>
|
|
521
|
+
{:else}
|
|
522
|
+
<div class="sl-body">{@render row(item, ctx)}</div>
|
|
523
|
+
{/if}
|
|
524
|
+
{#if trailing}
|
|
525
|
+
<span class="sl-trailing" data-part="trailing">{@render trailing(item, ctx)}</span>
|
|
526
|
+
{/if}
|
|
527
|
+
{#if arrows}
|
|
528
|
+
<span class="sl-arrows" data-part="arrows">
|
|
529
|
+
<IconButton
|
|
530
|
+
icon="chevron-up"
|
|
531
|
+
label={msgs.moveUp(nameOf(k))}
|
|
532
|
+
box="xs"
|
|
533
|
+
size={14}
|
|
534
|
+
data-part="up"
|
|
535
|
+
disabled={fixed || stepTarget(i, total, -1, allowedFor(k)) === undefined}
|
|
536
|
+
onclick={() => step(k, -1, 'up')}
|
|
537
|
+
/>
|
|
538
|
+
<IconButton
|
|
539
|
+
icon="chevron-down"
|
|
540
|
+
label={msgs.moveDown(nameOf(k))}
|
|
541
|
+
box="xs"
|
|
542
|
+
size={14}
|
|
543
|
+
data-part="down"
|
|
544
|
+
disabled={fixed || stepTarget(i, total, 1, allowedFor(k)) === undefined}
|
|
545
|
+
onclick={() => step(k, 1, 'down')}
|
|
546
|
+
/>
|
|
547
|
+
</span>
|
|
548
|
+
{/if}
|
|
549
|
+
</div>
|
|
550
|
+
</li>
|
|
551
|
+
{/each}
|
|
552
|
+
</svelte:element>
|
|
553
|
+
<span id="{uid}-help" hidden>{msgs.instructions}</span>
|
|
554
|
+
<div class="sl-live" role="status" aria-live="polite" aria-atomic="true">{announcement}</div>
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
<style>
|
|
558
|
+
.sortable {
|
|
559
|
+
position: relative;
|
|
560
|
+
min-width: 0;
|
|
561
|
+
}
|
|
562
|
+
.sl-list {
|
|
563
|
+
margin: 0;
|
|
564
|
+
padding: 0;
|
|
565
|
+
list-style: none;
|
|
566
|
+
}
|
|
567
|
+
.dragging {
|
|
568
|
+
user-select: none;
|
|
569
|
+
-webkit-user-select: none;
|
|
570
|
+
cursor: grabbing;
|
|
571
|
+
}
|
|
572
|
+
.sl-row {
|
|
573
|
+
position: relative;
|
|
574
|
+
border-bottom: 1px solid var(--border);
|
|
575
|
+
}
|
|
576
|
+
.sl-row:last-child {
|
|
577
|
+
border-bottom: 0;
|
|
578
|
+
}
|
|
579
|
+
.sl-lift {
|
|
580
|
+
display: flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
gap: var(--sp-2);
|
|
583
|
+
min-height: var(--sortable-row-h, 2.25rem);
|
|
584
|
+
padding-inline-end: var(--sp-1);
|
|
585
|
+
border-radius: var(--r-sm);
|
|
586
|
+
background: transparent;
|
|
587
|
+
transition:
|
|
588
|
+
transform 0.16s var(--ease),
|
|
589
|
+
box-shadow 0.16s var(--ease);
|
|
590
|
+
}
|
|
591
|
+
.sortable-sm .sl-lift {
|
|
592
|
+
min-height: var(--sortable-row-h, 1.875rem);
|
|
593
|
+
}
|
|
594
|
+
.sl-row.selected {
|
|
595
|
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
596
|
+
box-shadow: inset 2px 0 0 var(--accent);
|
|
597
|
+
}
|
|
598
|
+
.sl-row.grabbed .sl-lift {
|
|
599
|
+
background: var(--bg-elevated-2);
|
|
600
|
+
outline: 1px solid var(--accent);
|
|
601
|
+
outline-offset: -1px;
|
|
602
|
+
}
|
|
603
|
+
.sl-row.lifted {
|
|
604
|
+
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
|
605
|
+
outline: 1px dashed var(--accent);
|
|
606
|
+
outline-offset: -1px;
|
|
607
|
+
}
|
|
608
|
+
.sl-row.lifted .sl-lift {
|
|
609
|
+
position: relative;
|
|
610
|
+
z-index: 1;
|
|
611
|
+
background: var(--bg-elevated-2);
|
|
612
|
+
box-shadow: var(--shadow-md);
|
|
613
|
+
transition: none;
|
|
614
|
+
}
|
|
615
|
+
.sl-grip,
|
|
616
|
+
.sl-grip-spacer {
|
|
617
|
+
flex: none;
|
|
618
|
+
align-self: stretch;
|
|
619
|
+
width: 1.25rem;
|
|
620
|
+
}
|
|
621
|
+
.sl-grip {
|
|
622
|
+
position: relative;
|
|
623
|
+
padding: 0;
|
|
624
|
+
border: 0;
|
|
625
|
+
border-radius: var(--r-sm);
|
|
626
|
+
color: var(--text-faint);
|
|
627
|
+
background: none;
|
|
628
|
+
cursor: grab;
|
|
629
|
+
touch-action: none;
|
|
630
|
+
}
|
|
631
|
+
.sl-grip::before {
|
|
632
|
+
content: '';
|
|
633
|
+
position: absolute;
|
|
634
|
+
inset: 50% auto auto 50%;
|
|
635
|
+
width: 10px;
|
|
636
|
+
height: 15px;
|
|
637
|
+
translate: -50% -50%;
|
|
638
|
+
background: radial-gradient(circle, currentColor 1px, transparent 1.5px) 0 0 / 5px 5px;
|
|
639
|
+
}
|
|
640
|
+
.sl-grip:hover,
|
|
641
|
+
.sl-grip[aria-pressed='true'] {
|
|
642
|
+
color: var(--accent);
|
|
643
|
+
}
|
|
644
|
+
.sl-grip:focus-visible {
|
|
645
|
+
outline: var(--focus-ring);
|
|
646
|
+
outline-offset: -2px;
|
|
647
|
+
}
|
|
648
|
+
.handle-row .sl-row:not(.locked) {
|
|
649
|
+
cursor: grab;
|
|
650
|
+
-webkit-touch-callout: none;
|
|
651
|
+
}
|
|
652
|
+
.sl-ordinal {
|
|
653
|
+
flex: none;
|
|
654
|
+
min-width: 1.5em;
|
|
655
|
+
color: var(--text-faint);
|
|
656
|
+
font-size: var(--fs-sm);
|
|
657
|
+
font-variant-numeric: tabular-nums;
|
|
658
|
+
text-align: end;
|
|
659
|
+
}
|
|
660
|
+
.sl-body {
|
|
661
|
+
flex: 1 1 auto;
|
|
662
|
+
display: flex;
|
|
663
|
+
align-items: center;
|
|
664
|
+
gap: var(--sp-2);
|
|
665
|
+
min-width: 0;
|
|
666
|
+
align-self: stretch;
|
|
667
|
+
padding: 0;
|
|
668
|
+
border: 0;
|
|
669
|
+
background: none;
|
|
670
|
+
color: inherit;
|
|
671
|
+
font: inherit;
|
|
672
|
+
text-align: start;
|
|
673
|
+
}
|
|
674
|
+
button.sl-body {
|
|
675
|
+
cursor: pointer;
|
|
676
|
+
}
|
|
677
|
+
button.sl-body:focus-visible {
|
|
678
|
+
outline: var(--focus-ring);
|
|
679
|
+
outline-offset: -2px;
|
|
680
|
+
border-radius: var(--r-sm);
|
|
681
|
+
}
|
|
682
|
+
.sl-row.selected button.sl-body {
|
|
683
|
+
color: var(--accent);
|
|
684
|
+
}
|
|
685
|
+
.sl-trailing,
|
|
686
|
+
.sl-arrows {
|
|
687
|
+
flex: none;
|
|
688
|
+
display: inline-flex;
|
|
689
|
+
align-items: center;
|
|
690
|
+
gap: 2px;
|
|
691
|
+
}
|
|
692
|
+
.sl-live {
|
|
693
|
+
position: absolute;
|
|
694
|
+
width: 1px;
|
|
695
|
+
height: 1px;
|
|
696
|
+
margin: -1px;
|
|
697
|
+
padding: 0;
|
|
698
|
+
overflow: hidden;
|
|
699
|
+
clip: rect(0 0 0 0);
|
|
700
|
+
white-space: nowrap;
|
|
701
|
+
border: 0;
|
|
702
|
+
}
|
|
703
|
+
@media (pointer: coarse) {
|
|
704
|
+
.sl-grip,
|
|
705
|
+
.sl-grip-spacer {
|
|
706
|
+
width: var(--touch-target);
|
|
707
|
+
}
|
|
708
|
+
.sl-grip::after {
|
|
709
|
+
content: '';
|
|
710
|
+
position: absolute;
|
|
711
|
+
inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
712
|
+
inset-inline: 0;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
@media (prefers-reduced-motion: reduce) {
|
|
716
|
+
.sl-lift {
|
|
717
|
+
transition: none;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
</style>
|