@classic-homes/theme-svelte 1.31.1 → 1.33.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/dist/lib/components/FilterPanel.svelte +4 -2
- package/dist/lib/components/FilterPanel.svelte.d.ts +3 -1
- package/dist/lib/components/KanbanBoard.svelte +519 -0
- package/dist/lib/components/KanbanBoard.svelte.d.ts +93 -0
- package/dist/lib/components/MultiSelect.svelte +23 -3
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +2 -0
- package/dist/lib/utils/range-select.d.ts +22 -0
- package/dist/lib/utils/range-select.js +40 -0
- package/package.json +1 -1
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* Opens and closes with a grid-rows height transition (no measured heights).
|
|
10
10
|
* While closed the content is `inert`, so the hidden controls are neither
|
|
11
11
|
* tabbable nor exposed to screen readers. Controls are laid out on a
|
|
12
|
-
* responsive grid
|
|
12
|
+
* responsive grid, top-aligned so a control with help text under its input
|
|
13
|
+
* doesn't push its label and input out of line with the rest of the row;
|
|
14
|
+
* "Clear all" follows them when any filter is active.
|
|
13
15
|
*
|
|
14
16
|
* Usually rendered by FilterToolbar; use it directly only for a custom strip.
|
|
15
17
|
*/
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
>
|
|
53
55
|
<div class="overflow-hidden" inert={!open}>
|
|
54
56
|
<div class="mt-2 rounded-lg border border-border bg-muted/50 p-4">
|
|
55
|
-
<div class="grid items-
|
|
57
|
+
<div class="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
56
58
|
{@render children()}
|
|
57
59
|
</div>
|
|
58
60
|
{#if onClear && activeCount > 0}
|
|
@@ -5,7 +5,9 @@ import type { Snippet } from 'svelte';
|
|
|
5
5
|
* Opens and closes with a grid-rows height transition (no measured heights).
|
|
6
6
|
* While closed the content is `inert`, so the hidden controls are neither
|
|
7
7
|
* tabbable nor exposed to screen readers. Controls are laid out on a
|
|
8
|
-
* responsive grid
|
|
8
|
+
* responsive grid, top-aligned so a control with help text under its input
|
|
9
|
+
* doesn't push its label and input out of line with the rest of the row;
|
|
10
|
+
* "Clear all" follows them when any filter is active.
|
|
9
11
|
*
|
|
10
12
|
* Usually rendered by FilterToolbar; use it directly only for a custom strip.
|
|
11
13
|
*/
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/** One column of the board */
|
|
3
|
+
export interface KanbanColumn {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
/** Muted line under the title */
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A card dropped somewhere new: `index` is its position in `to`, other cards counted without it */
|
|
11
|
+
export interface KanbanMove<T> {
|
|
12
|
+
item: T;
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
index: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Attributes to spread on the `<button>` that acts as a card's drag handle */
|
|
19
|
+
export interface KanbanHandleProps {
|
|
20
|
+
type: 'button';
|
|
21
|
+
'aria-label': string;
|
|
22
|
+
'aria-roledescription': string;
|
|
23
|
+
'aria-describedby': string;
|
|
24
|
+
'aria-pressed': boolean;
|
|
25
|
+
'aria-disabled': boolean | undefined;
|
|
26
|
+
'data-kanban-handle': string;
|
|
27
|
+
onpointerdown: (event: PointerEvent) => void;
|
|
28
|
+
onkeydown: (event: KeyboardEvent) => void;
|
|
29
|
+
onblur: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface KanbanCardContext<T> {
|
|
33
|
+
item: T;
|
|
34
|
+
column: KanbanColumn;
|
|
35
|
+
/** Spread on a `<button>` inside the card: `<button {...handle}>` */
|
|
36
|
+
handle: KanbanHandleProps;
|
|
37
|
+
/** The card is being dragged or is picked up by the keyboard */
|
|
38
|
+
dragging: boolean;
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<script lang="ts" generics="T">
|
|
43
|
+
import { tick, type Snippet } from 'svelte';
|
|
44
|
+
import { cn } from '../utils.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* KanbanBoard - cards in columns, moved between columns by drag or keyboard.
|
|
48
|
+
*
|
|
49
|
+
* Each card renders its own drag handle by spreading `handle` on a button,
|
|
50
|
+
* so the rest of the card stays clickable. Pointer: press and drag the
|
|
51
|
+
* handle; the column under the pointer is the target. Keyboard: focus the
|
|
52
|
+
* handle, Space or Enter to pick the card up, Left/Right to change column
|
|
53
|
+
* (Up/Down, Home/End within it when `sortable`), Space/Enter to drop, Escape
|
|
54
|
+
* to put it back. Every move is announced.
|
|
55
|
+
*
|
|
56
|
+
* The board never moves cards itself: it calls `onMove` once per drop and
|
|
57
|
+
* renders whatever `items` / `getColumn` say next — a parent that ignores
|
|
58
|
+
* the move snaps the card back.
|
|
59
|
+
*/
|
|
60
|
+
interface Props {
|
|
61
|
+
columns: KanbanColumn[];
|
|
62
|
+
items: T[];
|
|
63
|
+
/** Stable key for each card */
|
|
64
|
+
getKey: (item: T) => string | number;
|
|
65
|
+
/** Id of the column the card is in; cards matching no column are not shown */
|
|
66
|
+
getColumn: (item: T) => string;
|
|
67
|
+
/** A card was dropped in a new place (once per drop) */
|
|
68
|
+
onMove: (move: KanbanMove<T>) => void;
|
|
69
|
+
/** Renders one card; spread `handle` on its drag-handle button */
|
|
70
|
+
card: Snippet<[KanbanCardContext<T>]>;
|
|
71
|
+
/** Replaces the default header (title, count, description) */
|
|
72
|
+
columnHeader?: Snippet<[{ column: KanbanColumn; items: T[] }]>;
|
|
73
|
+
/** Shown in a column with no cards */
|
|
74
|
+
columnEmpty?: Snippet<[{ column: KanbanColumn }]>;
|
|
75
|
+
/** Whether a card may go to a column; refused columns dim and are skipped */
|
|
76
|
+
canDrop?: (item: T, to: string) => boolean;
|
|
77
|
+
/** Cards can be reordered within a column; otherwise a move lands at the end */
|
|
78
|
+
sortable?: boolean;
|
|
79
|
+
/** Accessible name of the board, e.g. "Designer assignments" */
|
|
80
|
+
label: string;
|
|
81
|
+
/** Name used in announcements and handle labels */
|
|
82
|
+
getItemLabel?: (item: T) => string;
|
|
83
|
+
/** Turns moving off; handles stay rendered but do nothing */
|
|
84
|
+
disabled?: boolean;
|
|
85
|
+
class?: string;
|
|
86
|
+
columnClass?: string;
|
|
87
|
+
cardClass?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let {
|
|
91
|
+
columns,
|
|
92
|
+
items,
|
|
93
|
+
getKey,
|
|
94
|
+
getColumn,
|
|
95
|
+
onMove,
|
|
96
|
+
card: cardSnippet,
|
|
97
|
+
columnHeader,
|
|
98
|
+
columnEmpty,
|
|
99
|
+
canDrop,
|
|
100
|
+
sortable = false,
|
|
101
|
+
label,
|
|
102
|
+
getItemLabel = () => 'Card',
|
|
103
|
+
disabled = false,
|
|
104
|
+
class: className,
|
|
105
|
+
columnClass,
|
|
106
|
+
cardClass,
|
|
107
|
+
}: Props = $props();
|
|
108
|
+
|
|
109
|
+
const uid = $props.id();
|
|
110
|
+
const instructionsId = `kanban-${uid}-help`;
|
|
111
|
+
|
|
112
|
+
let boardEl: HTMLDivElement | undefined = $state();
|
|
113
|
+
let scrollerEl: HTMLDivElement | undefined = $state();
|
|
114
|
+
let announcement = $state('');
|
|
115
|
+
|
|
116
|
+
interface Drag {
|
|
117
|
+
key: string;
|
|
118
|
+
item: T;
|
|
119
|
+
mode: 'pointer' | 'keyboard';
|
|
120
|
+
from: string;
|
|
121
|
+
fromIndex: number;
|
|
122
|
+
to: string;
|
|
123
|
+
index: number;
|
|
124
|
+
}
|
|
125
|
+
let drag = $state<Drag | null>(null);
|
|
126
|
+
|
|
127
|
+
// Pointer bookkeeping: drag starts after a few pixels, so a click is a click
|
|
128
|
+
let pressed: { key: string; item: T; x: number; y: number } | null = null;
|
|
129
|
+
let grabOffset = { x: 0, y: 0 };
|
|
130
|
+
let ghost = $state<{ x: number; y: number; width: number } | null>(null);
|
|
131
|
+
// Set while a keyboard move re-renders the card, so its blur doesn't cancel
|
|
132
|
+
let moving = false;
|
|
133
|
+
|
|
134
|
+
const DRAG_THRESHOLD = 4;
|
|
135
|
+
const EDGE = 48;
|
|
136
|
+
|
|
137
|
+
const keyOf = (item: T) => String(getKey(item));
|
|
138
|
+
|
|
139
|
+
const byColumn = $derived.by(() => {
|
|
140
|
+
const map = new Map<string, T[]>(columns.map((c) => [c.id, []]));
|
|
141
|
+
for (const item of items) map.get(getColumn(item))?.push(item);
|
|
142
|
+
return map;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const columnTitle = (id: string) => columns.find((c) => c.id === id)?.title ?? id;
|
|
146
|
+
const allowed = (item: T, to: string, from: string) =>
|
|
147
|
+
to === from || !canDrop || canDrop(item, to);
|
|
148
|
+
|
|
149
|
+
type Entry = { kind: 'card'; item: T; key: string } | { kind: 'placeholder'; key: string };
|
|
150
|
+
|
|
151
|
+
/** A column's cards, with the dragged card (keyboard) or a placeholder (pointer) at its preview spot */
|
|
152
|
+
function entries(columnId: string): Entry[] {
|
|
153
|
+
const cards = byColumn.get(columnId) ?? [];
|
|
154
|
+
const list: Entry[] = cards
|
|
155
|
+
.filter((item) => !drag || keyOf(item) !== drag.key)
|
|
156
|
+
.map((item) => ({ kind: 'card', item, key: keyOf(item) }));
|
|
157
|
+
if (drag && drag.to === columnId) {
|
|
158
|
+
const at = Math.min(drag.index, list.length);
|
|
159
|
+
list.splice(
|
|
160
|
+
at,
|
|
161
|
+
0,
|
|
162
|
+
drag.mode === 'keyboard'
|
|
163
|
+
? { kind: 'card', item: drag.item, key: drag.key }
|
|
164
|
+
: { kind: 'placeholder', key: `placeholder-${drag.key}` }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return list;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Cards in `to` other than the dragged one: the last index a card can land at */
|
|
171
|
+
function othersIn(to: string): number {
|
|
172
|
+
return (byColumn.get(to) ?? []).filter((item) => !drag || keyOf(item) !== drag.key).length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Where a card lands in `to` when the user didn't pick a spot: back in place, else the end */
|
|
176
|
+
function naturalIndex(to: string): number {
|
|
177
|
+
return drag && to === drag.from ? drag.fromIndex : othersIn(to);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function announce(message: string) {
|
|
181
|
+
announcement = '';
|
|
182
|
+
queueMicrotask(() => (announcement = message));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function positionText(to: string, index: number) {
|
|
186
|
+
return sortable
|
|
187
|
+
? `${columnTitle(to)}, position ${index + 1} of ${othersIn(to) + 1}`
|
|
188
|
+
: columnTitle(to);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function begin(item: T, mode: Drag['mode']) {
|
|
192
|
+
const from = getColumn(item);
|
|
193
|
+
const key = keyOf(item);
|
|
194
|
+
const fromIndex = (byColumn.get(from) ?? []).findIndex((i) => keyOf(i) === key);
|
|
195
|
+
drag = { key, item, mode, from, fromIndex, to: from, index: fromIndex };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Ends the drag; `commit` reports a real move to the parent */
|
|
199
|
+
function finish(commit: boolean) {
|
|
200
|
+
const d = drag;
|
|
201
|
+
drag = null;
|
|
202
|
+
ghost = null;
|
|
203
|
+
pressed = null;
|
|
204
|
+
stopPointer();
|
|
205
|
+
if (!d || !commit) return false;
|
|
206
|
+
const moved = d.to !== d.from || (sortable && d.index !== d.fromIndex);
|
|
207
|
+
if (moved) onMove({ item: d.item, from: d.from, to: d.to, index: d.index });
|
|
208
|
+
return moved;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function focusHandle(key: string) {
|
|
212
|
+
await tick();
|
|
213
|
+
boardEl?.querySelector<HTMLElement>(`[data-kanban-handle="${CSS.escape(key)}"]`)?.focus();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function keyboardMove(to: string, index: number) {
|
|
217
|
+
if (!drag) return;
|
|
218
|
+
moving = true;
|
|
219
|
+
drag.to = to;
|
|
220
|
+
drag.index = index;
|
|
221
|
+
announce(`${getItemLabel(drag.item)} moved to ${positionText(to, index)}.`);
|
|
222
|
+
await focusHandle(drag.key);
|
|
223
|
+
moving = false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The next column in `step` direction this card may go to */
|
|
227
|
+
function nextColumn(step: 1 | -1): string | null {
|
|
228
|
+
if (!drag) return null;
|
|
229
|
+
const at = columns.findIndex((c) => c.id === drag!.to);
|
|
230
|
+
for (let i = at + step; i >= 0 && i < columns.length; i += step) {
|
|
231
|
+
if (allowed(drag.item, columns[i].id, drag.from)) return columns[i].id;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Pointer: the column and slot under the pointer
|
|
237
|
+
function locate(x: number, y: number): { to: string; index: number } | null {
|
|
238
|
+
if (!drag || !boardEl) return null;
|
|
239
|
+
const hit = document.elementFromPoint(x, y)?.closest<HTMLElement>('[data-kanban-column]');
|
|
240
|
+
if (!hit || !boardEl.contains(hit)) return null;
|
|
241
|
+
const to = hit.dataset.kanbanColumn!;
|
|
242
|
+
if (!allowed(drag.item, to, drag.from)) return null;
|
|
243
|
+
if (!sortable) return { to, index: naturalIndex(to) };
|
|
244
|
+
const cards = Array.from(
|
|
245
|
+
hit.querySelectorAll<HTMLElement>('[data-kanban-card]:not([data-kanban-placeholder])')
|
|
246
|
+
);
|
|
247
|
+
const index = cards.filter((el) => {
|
|
248
|
+
const rect = el.getBoundingClientRect();
|
|
249
|
+
return rect.top + rect.height / 2 < y;
|
|
250
|
+
}).length;
|
|
251
|
+
return { to, index };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function autoScroll(x: number, y: number) {
|
|
255
|
+
if (scrollerEl) {
|
|
256
|
+
const rect = scrollerEl.getBoundingClientRect();
|
|
257
|
+
if (x < rect.left + EDGE) scrollerEl.scrollLeft -= 16;
|
|
258
|
+
else if (x > rect.right - EDGE) scrollerEl.scrollLeft += 16;
|
|
259
|
+
}
|
|
260
|
+
const list = document
|
|
261
|
+
.elementFromPoint(x, y)
|
|
262
|
+
?.closest<HTMLElement>('[data-kanban-column]')
|
|
263
|
+
?.querySelector<HTMLElement>('[data-kanban-list]');
|
|
264
|
+
if (list) {
|
|
265
|
+
const rect = list.getBoundingClientRect();
|
|
266
|
+
if (y < rect.top + EDGE) list.scrollTop -= 16;
|
|
267
|
+
else if (y > rect.bottom - EDGE) list.scrollTop += 16;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleProps(item: T, key: string): KanbanHandleProps {
|
|
272
|
+
const name = getItemLabel(item);
|
|
273
|
+
const grabbed = drag?.key === key && drag.mode === 'keyboard';
|
|
274
|
+
return {
|
|
275
|
+
type: 'button',
|
|
276
|
+
'aria-label': `Move ${name}`,
|
|
277
|
+
'aria-roledescription': 'draggable card handle',
|
|
278
|
+
'aria-describedby': instructionsId,
|
|
279
|
+
'aria-pressed': grabbed,
|
|
280
|
+
'aria-disabled': disabled || undefined,
|
|
281
|
+
'data-kanban-handle': key,
|
|
282
|
+
onpointerdown(event) {
|
|
283
|
+
if (disabled || event.button !== 0 || drag) return;
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
pressed = { key, item, x: event.clientX, y: event.clientY };
|
|
286
|
+
const rect = (event.currentTarget as HTMLElement)
|
|
287
|
+
.closest<HTMLElement>('[data-kanban-card]')
|
|
288
|
+
?.getBoundingClientRect();
|
|
289
|
+
grabOffset = rect
|
|
290
|
+
? { x: event.clientX - rect.left, y: event.clientY - rect.top }
|
|
291
|
+
: { x: 0, y: 0 };
|
|
292
|
+
ghost = rect ? { x: rect.left, y: rect.top, width: rect.width } : null;
|
|
293
|
+
// Window listeners, not pointer capture: the card leaves its column while dragged
|
|
294
|
+
window.addEventListener('pointermove', onWindowMove);
|
|
295
|
+
window.addEventListener('pointerup', onWindowUp);
|
|
296
|
+
window.addEventListener('pointercancel', onWindowCancel);
|
|
297
|
+
window.addEventListener('keydown', onWindowKey);
|
|
298
|
+
},
|
|
299
|
+
onkeydown(event) {
|
|
300
|
+
if (disabled) return;
|
|
301
|
+
const picked = drag?.key === key && drag.mode === 'keyboard';
|
|
302
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
if (picked) {
|
|
305
|
+
const to = drag!.to;
|
|
306
|
+
const index = drag!.index;
|
|
307
|
+
finish(true);
|
|
308
|
+
announce(`${name} dropped in ${positionText(to, index)}.`);
|
|
309
|
+
void focusHandle(key);
|
|
310
|
+
} else if (!drag) {
|
|
311
|
+
begin(item, 'keyboard');
|
|
312
|
+
announce(
|
|
313
|
+
`${name} picked up in ${columnTitle(drag!.from)}. Use the arrow keys to move it, Space to drop, Escape to cancel.`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (!picked || !drag) return;
|
|
319
|
+
if (event.key === 'Escape') {
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
event.stopPropagation();
|
|
322
|
+
const from = drag.from;
|
|
323
|
+
moving = true;
|
|
324
|
+
finish(false);
|
|
325
|
+
announce(`Move cancelled. ${name} is back in ${columnTitle(from)}.`);
|
|
326
|
+
void focusHandle(key).then(() => (moving = false));
|
|
327
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
const to = nextColumn(event.key === 'ArrowLeft' ? -1 : 1);
|
|
330
|
+
if (to)
|
|
331
|
+
void keyboardMove(to, sortable ? Math.min(drag.index, othersIn(to)) : naturalIndex(to));
|
|
332
|
+
} else if (sortable && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
const index = drag.index + (event.key === 'ArrowUp' ? -1 : 1);
|
|
335
|
+
if (index >= 0 && index <= othersIn(drag.to)) void keyboardMove(drag.to, index);
|
|
336
|
+
} else if (sortable && (event.key === 'Home' || event.key === 'End')) {
|
|
337
|
+
event.preventDefault();
|
|
338
|
+
void keyboardMove(drag.to, event.key === 'Home' ? 0 : othersIn(drag.to));
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
onblur() {
|
|
342
|
+
// Focus leaving a picked-up card (other than our own re-render) cancels the move
|
|
343
|
+
if (moving || drag?.key !== key || drag.mode !== 'keyboard') return;
|
|
344
|
+
const from = drag.from;
|
|
345
|
+
finish(false);
|
|
346
|
+
announce(`Move cancelled. ${name} is back in ${columnTitle(from)}.`);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function onWindowMove(event: PointerEvent) {
|
|
352
|
+
if (!pressed) return;
|
|
353
|
+
if (!drag) {
|
|
354
|
+
const moved = Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y);
|
|
355
|
+
if (moved < DRAG_THRESHOLD) return;
|
|
356
|
+
begin(pressed.item, 'pointer');
|
|
357
|
+
}
|
|
358
|
+
if (!drag || drag.mode !== 'pointer') return;
|
|
359
|
+
if (ghost) {
|
|
360
|
+
ghost.x = event.clientX - grabOffset.x;
|
|
361
|
+
ghost.y = event.clientY - grabOffset.y;
|
|
362
|
+
}
|
|
363
|
+
autoScroll(event.clientX, event.clientY);
|
|
364
|
+
const spot = locate(event.clientX, event.clientY);
|
|
365
|
+
drag.to = spot?.to ?? drag.from;
|
|
366
|
+
drag.index = spot?.index ?? drag.fromIndex;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function onWindowUp() {
|
|
370
|
+
if (drag?.mode === 'pointer') {
|
|
371
|
+
const d = drag;
|
|
372
|
+
// The click that ends a drag shouldn't open the card under the pointer
|
|
373
|
+
window.addEventListener('click', swallowClick, { capture: true, once: true });
|
|
374
|
+
setTimeout(() => window.removeEventListener('click', swallowClick, { capture: true }), 0);
|
|
375
|
+
if (finish(true)) announce(`${getItemLabel(d.item)} dropped in ${columnTitle(d.to)}.`);
|
|
376
|
+
} else {
|
|
377
|
+
finish(false);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function onWindowCancel() {
|
|
382
|
+
finish(false);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function onWindowKey(event: KeyboardEvent) {
|
|
386
|
+
if (event.key !== 'Escape' || drag?.mode !== 'pointer') return;
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
const d = drag;
|
|
389
|
+
finish(false);
|
|
390
|
+
announce(`Move cancelled. ${getItemLabel(d.item)} is back in ${columnTitle(d.from)}.`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function swallowClick(event: MouseEvent) {
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
event.stopPropagation();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function stopPointer() {
|
|
399
|
+
window.removeEventListener('pointermove', onWindowMove);
|
|
400
|
+
window.removeEventListener('pointerup', onWindowUp);
|
|
401
|
+
window.removeEventListener('pointercancel', onWindowCancel);
|
|
402
|
+
window.removeEventListener('keydown', onWindowKey);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
$effect(() => stopPointer);
|
|
406
|
+
|
|
407
|
+
const noop = () => {};
|
|
408
|
+
/** The floating copy's handle: same look, no behaviour */
|
|
409
|
+
const inertHandle = (item: T): KanbanHandleProps => ({
|
|
410
|
+
...handleProps(item, ''),
|
|
411
|
+
'data-kanban-handle': '',
|
|
412
|
+
onpointerdown: noop,
|
|
413
|
+
onkeydown: noop,
|
|
414
|
+
onblur: noop,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const draggedColumn = $derived(
|
|
418
|
+
drag ? (columns.find((c) => c.id === drag!.to) ?? columns[0]) : null
|
|
419
|
+
);
|
|
420
|
+
</script>
|
|
421
|
+
|
|
422
|
+
<div bind:this={boardEl} role="group" aria-label={label} class={cn('relative', className)}>
|
|
423
|
+
<div bind:this={scrollerEl} class="overflow-x-auto pb-2">
|
|
424
|
+
<div class="flex min-w-max gap-3">
|
|
425
|
+
{#each columns as column (column.id)}
|
|
426
|
+
{@const cards = byColumn.get(column.id) ?? []}
|
|
427
|
+
{@const list = entries(column.id)}
|
|
428
|
+
{@const refused = !!drag && !allowed(drag.item, column.id, drag.from)}
|
|
429
|
+
{@const target = !!drag && drag.to === column.id && drag.to !== drag.from}
|
|
430
|
+
<section
|
|
431
|
+
data-kanban-column={column.id}
|
|
432
|
+
aria-label="{column.title}, {cards.length} {cards.length === 1 ? 'card' : 'cards'}"
|
|
433
|
+
data-drop-target={target || undefined}
|
|
434
|
+
data-drop-refused={refused || undefined}
|
|
435
|
+
class={cn(
|
|
436
|
+
'flex w-72 shrink-0 flex-col rounded-lg border border-border bg-muted/30 transition-[opacity,box-shadow] duration-150',
|
|
437
|
+
target && 'ring-2 ring-primary/40',
|
|
438
|
+
refused && 'opacity-50',
|
|
439
|
+
columnClass
|
|
440
|
+
)}
|
|
441
|
+
>
|
|
442
|
+
{#if columnHeader}
|
|
443
|
+
{@render columnHeader({ column, items: cards })}
|
|
444
|
+
{:else}
|
|
445
|
+
<header class="border-b border-border px-3 py-2">
|
|
446
|
+
<div class="flex items-baseline gap-2">
|
|
447
|
+
<h3 class="min-w-0 flex-1 truncate text-sm font-semibold" title={column.title}>
|
|
448
|
+
{column.title}
|
|
449
|
+
</h3>
|
|
450
|
+
<span class="text-sm tabular-nums text-muted-foreground">{cards.length}</span>
|
|
451
|
+
</div>
|
|
452
|
+
{#if column.description}
|
|
453
|
+
<p class="mt-0.5 text-xs text-muted-foreground">{column.description}</p>
|
|
454
|
+
{/if}
|
|
455
|
+
</header>
|
|
456
|
+
{/if}
|
|
457
|
+
<ul data-kanban-list class="flex min-h-24 flex-1 flex-col gap-2 overflow-y-auto p-2">
|
|
458
|
+
{#each list as entry (entry.key)}
|
|
459
|
+
{#if entry.kind === 'placeholder'}
|
|
460
|
+
<li
|
|
461
|
+
data-kanban-card
|
|
462
|
+
data-kanban-placeholder
|
|
463
|
+
aria-hidden="true"
|
|
464
|
+
class="min-h-16 rounded-md border-2 border-dashed border-primary/40 bg-primary/5"
|
|
465
|
+
></li>
|
|
466
|
+
{:else}
|
|
467
|
+
{@const dragging = drag?.key === entry.key}
|
|
468
|
+
<li
|
|
469
|
+
data-kanban-card={entry.key}
|
|
470
|
+
data-dragging={dragging || undefined}
|
|
471
|
+
class={cn(
|
|
472
|
+
'rounded-md',
|
|
473
|
+
dragging && drag?.mode === 'keyboard' && 'ring-2 ring-primary',
|
|
474
|
+
cardClass
|
|
475
|
+
)}
|
|
476
|
+
>
|
|
477
|
+
{@render cardSnippet({
|
|
478
|
+
item: entry.item,
|
|
479
|
+
column,
|
|
480
|
+
handle: handleProps(entry.item, entry.key),
|
|
481
|
+
dragging,
|
|
482
|
+
})}
|
|
483
|
+
</li>
|
|
484
|
+
{/if}
|
|
485
|
+
{/each}
|
|
486
|
+
{#if list.length === 0 && columnEmpty}
|
|
487
|
+
<li class="list-none">{@render columnEmpty({ column })}</li>
|
|
488
|
+
{/if}
|
|
489
|
+
</ul>
|
|
490
|
+
</section>
|
|
491
|
+
{/each}
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
{#if drag?.mode === 'pointer' && ghost && draggedColumn}
|
|
496
|
+
<div
|
|
497
|
+
aria-hidden="true"
|
|
498
|
+
class="pointer-events-none fixed left-0 top-0 z-50 rotate-1 opacity-95 shadow-lg"
|
|
499
|
+
style:width="{ghost.width}px"
|
|
500
|
+
style:transform="translate({ghost.x}px, {ghost.y}px)"
|
|
501
|
+
>
|
|
502
|
+
<div class={cn('rounded-md', cardClass)}>
|
|
503
|
+
{@render cardSnippet({
|
|
504
|
+
item: drag.item,
|
|
505
|
+
column: draggedColumn,
|
|
506
|
+
handle: inertHandle(drag.item),
|
|
507
|
+
dragging: true,
|
|
508
|
+
})}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
{/if}
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
<p id={instructionsId} class="sr-only">
|
|
515
|
+
Press Space or Enter to pick up a card, the left and right arrow keys to change column{sortable
|
|
516
|
+
? ', up and down to change its place in the column'
|
|
517
|
+
: ''}, Space or Enter to drop, Escape to cancel.
|
|
518
|
+
</p>
|
|
519
|
+
<div class="sr-only" role="status" aria-live="assertive" aria-atomic="true">{announcement}</div>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** One column of the board */
|
|
2
|
+
export interface KanbanColumn {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
/** Muted line under the title */
|
|
6
|
+
description?: string;
|
|
7
|
+
}
|
|
8
|
+
/** A card dropped somewhere new: `index` is its position in `to`, other cards counted without it */
|
|
9
|
+
export interface KanbanMove<T> {
|
|
10
|
+
item: T;
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
index: number;
|
|
14
|
+
}
|
|
15
|
+
/** Attributes to spread on the `<button>` that acts as a card's drag handle */
|
|
16
|
+
export interface KanbanHandleProps {
|
|
17
|
+
type: 'button';
|
|
18
|
+
'aria-label': string;
|
|
19
|
+
'aria-roledescription': string;
|
|
20
|
+
'aria-describedby': string;
|
|
21
|
+
'aria-pressed': boolean;
|
|
22
|
+
'aria-disabled': boolean | undefined;
|
|
23
|
+
'data-kanban-handle': string;
|
|
24
|
+
onpointerdown: (event: PointerEvent) => void;
|
|
25
|
+
onkeydown: (event: KeyboardEvent) => void;
|
|
26
|
+
onblur: () => void;
|
|
27
|
+
}
|
|
28
|
+
export interface KanbanCardContext<T> {
|
|
29
|
+
item: T;
|
|
30
|
+
column: KanbanColumn;
|
|
31
|
+
/** Spread on a `<button>` inside the card: `<button {...handle}>` */
|
|
32
|
+
handle: KanbanHandleProps;
|
|
33
|
+
/** The card is being dragged or is picked up by the keyboard */
|
|
34
|
+
dragging: boolean;
|
|
35
|
+
}
|
|
36
|
+
import { type Snippet } from 'svelte';
|
|
37
|
+
declare function $$render<T>(): {
|
|
38
|
+
props: {
|
|
39
|
+
columns: KanbanColumn[];
|
|
40
|
+
items: T[];
|
|
41
|
+
/** Stable key for each card */
|
|
42
|
+
getKey: (item: T) => string | number;
|
|
43
|
+
/** Id of the column the card is in; cards matching no column are not shown */
|
|
44
|
+
getColumn: (item: T) => string;
|
|
45
|
+
/** A card was dropped in a new place (once per drop) */
|
|
46
|
+
onMove: (move: KanbanMove<T>) => void;
|
|
47
|
+
/** Renders one card; spread `handle` on its drag-handle button */
|
|
48
|
+
card: Snippet<[KanbanCardContext<T>]>;
|
|
49
|
+
/** Replaces the default header (title, count, description) */
|
|
50
|
+
columnHeader?: Snippet<[{
|
|
51
|
+
column: KanbanColumn;
|
|
52
|
+
items: T[];
|
|
53
|
+
}]>;
|
|
54
|
+
/** Shown in a column with no cards */
|
|
55
|
+
columnEmpty?: Snippet<[{
|
|
56
|
+
column: KanbanColumn;
|
|
57
|
+
}]>;
|
|
58
|
+
/** Whether a card may go to a column; refused columns dim and are skipped */
|
|
59
|
+
canDrop?: (item: T, to: string) => boolean;
|
|
60
|
+
/** Cards can be reordered within a column; otherwise a move lands at the end */
|
|
61
|
+
sortable?: boolean;
|
|
62
|
+
/** Accessible name of the board, e.g. "Designer assignments" */
|
|
63
|
+
label: string;
|
|
64
|
+
/** Name used in announcements and handle labels */
|
|
65
|
+
getItemLabel?: (item: T) => string;
|
|
66
|
+
/** Turns moving off; handles stay rendered but do nothing */
|
|
67
|
+
disabled?: boolean;
|
|
68
|
+
class?: string;
|
|
69
|
+
columnClass?: string;
|
|
70
|
+
cardClass?: string;
|
|
71
|
+
};
|
|
72
|
+
exports: {};
|
|
73
|
+
bindings: "";
|
|
74
|
+
slots: {};
|
|
75
|
+
events: {};
|
|
76
|
+
};
|
|
77
|
+
declare class __sveltets_Render<T> {
|
|
78
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
79
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
80
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
81
|
+
bindings(): "";
|
|
82
|
+
exports(): {};
|
|
83
|
+
}
|
|
84
|
+
interface $$IsomorphicComponent {
|
|
85
|
+
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']>> & {
|
|
86
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
87
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
88
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
89
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
90
|
+
}
|
|
91
|
+
declare const KanbanBoard: $$IsomorphicComponent;
|
|
92
|
+
type KanbanBoard<T> = InstanceType<typeof KanbanBoard<T>>;
|
|
93
|
+
export default KanbanBoard;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Combobox as ComboboxPrimitive } from 'bits-ui';
|
|
3
3
|
import { cn } from '../utils.js';
|
|
4
4
|
import { computeDescribedBy } from '../utils/form.js';
|
|
5
|
+
import { rangeSelect } from '../utils/range-select.js';
|
|
5
6
|
import Spinner from './Spinner.svelte';
|
|
6
7
|
import Label from './Label.svelte';
|
|
7
8
|
import LiveRegion from './LiveRegion.svelte';
|
|
@@ -110,11 +111,26 @@
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
// Shift-click (or Shift+Enter) selects or clears the whole range from the
|
|
115
|
+
// option toggled before. Shift is read when the pick starts, since the
|
|
116
|
+
// combobox reports only the new values.
|
|
117
|
+
let anchor: string | null = null;
|
|
118
|
+
let shiftPick = false;
|
|
119
|
+
|
|
113
120
|
function handleValueChange(newValues: string[] | undefined) {
|
|
114
|
-
if (newValues
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
if (newValues === undefined) return;
|
|
122
|
+
const added = newValues.filter((v) => !value.includes(v));
|
|
123
|
+
const removed = value.filter((v) => !newValues.includes(v));
|
|
124
|
+
const toggled = added.length + removed.length === 1 ? (added[0] ?? removed[0]) : null;
|
|
125
|
+
let next = newValues;
|
|
126
|
+
if (shiftPick && toggled !== null && anchor !== null && anchor !== toggled) {
|
|
127
|
+
next =
|
|
128
|
+
rangeSelect(value, filteredOptions, anchor, toggled, added.length === 1, max) ?? newValues;
|
|
117
129
|
}
|
|
130
|
+
shiftPick = false;
|
|
131
|
+
if (toggled !== null) anchor = toggled;
|
|
132
|
+
value = next;
|
|
133
|
+
onValueChange?.(next);
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
function removeValue(valueToRemove: string) {
|
|
@@ -224,6 +240,9 @@
|
|
|
224
240
|
oninput={handleSearchInput}
|
|
225
241
|
onfocus={() => (open = true)}
|
|
226
242
|
onclick={() => (open = true)}
|
|
243
|
+
onkeydown={(e: KeyboardEvent) => {
|
|
244
|
+
if (e.key === 'Enter') shiftPick = e.shiftKey;
|
|
245
|
+
}}
|
|
227
246
|
class={cn(
|
|
228
247
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
229
248
|
error && 'border-destructive focus:ring-destructive'
|
|
@@ -260,6 +279,7 @@
|
|
|
260
279
|
value={option.value}
|
|
261
280
|
label={option.label}
|
|
262
281
|
disabled={option.disabled || isDisabledByMax}
|
|
282
|
+
onpointerdown={(e: PointerEvent) => (shiftPick = e.shiftKey)}
|
|
263
283
|
class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
|
|
264
284
|
>
|
|
265
285
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export { default as SegmentedControl, type SegmentedControlOption, } from './com
|
|
|
68
68
|
export { default as ViewToggle, type ViewToggleOption, defaultViewToggleOptions, } from './components/ViewToggle.svelte';
|
|
69
69
|
export { default as Sheet } from './components/Sheet.svelte';
|
|
70
70
|
export { default as SortableList, type SortableHandleProps, type SortableItemContext, } from './components/SortableList.svelte';
|
|
71
|
+
export { default as KanbanBoard, type KanbanCardContext, type KanbanColumn, type KanbanHandleProps, type KanbanMove, } from './components/KanbanBoard.svelte';
|
|
71
72
|
export { Studio, StudioBar, StudioOutline, StudioCanvas, StudioCanvasItem, StudioInspector, StudioInspectorSection, StudioPage, SaveStatus, IssuesButton, InsertPalette, getStudioContext, type StudioOutlineEntry, type StudioInspectorTab, type SaveState, type StudioIssue, type InsertPaletteItem, type InsertPaletteGroup, type StudioContext, type StudioLayout, } from './components/studio/index.js';
|
|
72
73
|
export { AspectRatio, Image, Video, Audio, MediaEmbed, MediaGrid, MediaCarousel, Lightbox, } from './components/media/index.js';
|
|
73
74
|
export type { MediaGridColumns } from './components/media/MediaGrid.svelte';
|
package/dist/lib/index.js
CHANGED
|
@@ -83,6 +83,8 @@ export { default as ViewToggle, defaultViewToggleOptions, } from './components/V
|
|
|
83
83
|
export { default as Sheet } from './components/Sheet.svelte';
|
|
84
84
|
// Reorderable list
|
|
85
85
|
export { default as SortableList, } from './components/SortableList.svelte';
|
|
86
|
+
// Kanban board: cards moved between columns by drag or keyboard
|
|
87
|
+
export { default as KanbanBoard, } from './components/KanbanBoard.svelte';
|
|
86
88
|
// Studio - full-screen editor frame (outline, canvas, inspector)
|
|
87
89
|
export { Studio, StudioBar, StudioOutline, StudioCanvas, StudioCanvasItem, StudioInspector, StudioInspectorSection, StudioPage, SaveStatus, IssuesButton, InsertPalette, getStudioContext, } from './components/studio/index.js';
|
|
88
90
|
// Media Components
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shift-click range selection for multi-select lists.
|
|
3
|
+
*/
|
|
4
|
+
export interface RangeSelectOption {
|
|
5
|
+
value: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Selects (or clears) every option from `anchor` to `target`, inclusive, in
|
|
10
|
+
* the order `options` lists them (what the user sees). Disabled options are
|
|
11
|
+
* skipped. When selecting, `max` caps the result, filling from the anchor
|
|
12
|
+
* towards the target. Returns null when either end isn't in `options`, so
|
|
13
|
+
* the caller falls back to a plain toggle.
|
|
14
|
+
*
|
|
15
|
+
* @param current - The selection before the click
|
|
16
|
+
* @param options - The options as listed (already filtered by any search)
|
|
17
|
+
* @param anchor - The option toggled before this one
|
|
18
|
+
* @param target - The option just clicked
|
|
19
|
+
* @param select - Whether the click selected the target (else it cleared it)
|
|
20
|
+
* @param max - Maximum number of selections, if any
|
|
21
|
+
*/
|
|
22
|
+
export declare function rangeSelect(current: readonly string[], options: readonly RangeSelectOption[], anchor: string, target: string, select: boolean, max?: number): string[] | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shift-click range selection for multi-select lists.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Selects (or clears) every option from `anchor` to `target`, inclusive, in
|
|
6
|
+
* the order `options` lists them (what the user sees). Disabled options are
|
|
7
|
+
* skipped. When selecting, `max` caps the result, filling from the anchor
|
|
8
|
+
* towards the target. Returns null when either end isn't in `options`, so
|
|
9
|
+
* the caller falls back to a plain toggle.
|
|
10
|
+
*
|
|
11
|
+
* @param current - The selection before the click
|
|
12
|
+
* @param options - The options as listed (already filtered by any search)
|
|
13
|
+
* @param anchor - The option toggled before this one
|
|
14
|
+
* @param target - The option just clicked
|
|
15
|
+
* @param select - Whether the click selected the target (else it cleared it)
|
|
16
|
+
* @param max - Maximum number of selections, if any
|
|
17
|
+
*/
|
|
18
|
+
export function rangeSelect(current, options, anchor, target, select, max) {
|
|
19
|
+
const from = options.findIndex((o) => o.value === anchor);
|
|
20
|
+
const to = options.findIndex((o) => o.value === target);
|
|
21
|
+
if (from < 0 || to < 0)
|
|
22
|
+
return null;
|
|
23
|
+
const step = from <= to ? 1 : -1;
|
|
24
|
+
const range = [];
|
|
25
|
+
for (let i = from; i !== to + step; i += step) {
|
|
26
|
+
if (!options[i].disabled)
|
|
27
|
+
range.push(options[i].value);
|
|
28
|
+
}
|
|
29
|
+
if (!select)
|
|
30
|
+
return current.filter((v) => !range.includes(v));
|
|
31
|
+
const next = [...current];
|
|
32
|
+
for (const v of range) {
|
|
33
|
+
if (next.includes(v))
|
|
34
|
+
continue;
|
|
35
|
+
if (max !== undefined && next.length >= max)
|
|
36
|
+
break;
|
|
37
|
+
next.push(v);
|
|
38
|
+
}
|
|
39
|
+
return next;
|
|
40
|
+
}
|