@classic-homes/theme-svelte 1.32.0 → 1.34.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/KanbanBoard.svelte +519 -0
- package/dist/lib/components/KanbanBoard.svelte.d.ts +93 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +3 -2
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +3 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +2 -0
- package/dist/lib/utils/truncate-title.d.ts +12 -0
- package/dist/lib/utils/truncate-title.js +30 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { Snippet } from 'svelte';
|
|
14
14
|
import type { NavItem } from '../../../types/layout.js';
|
|
15
15
|
import { cn } from '../../../utils.js';
|
|
16
|
+
import { truncateTitle } from '../../../utils/truncate-title.js';
|
|
16
17
|
import { sidebarStore } from '../../../stores/sidebar.svelte.js';
|
|
17
18
|
import Icon, { type IconName } from '../../Icon.svelte';
|
|
18
19
|
import SidebarNavItem from './SidebarNavItem.svelte';
|
|
@@ -195,13 +196,13 @@
|
|
|
195
196
|
highlight: { before: string; match: string; after: string } | null
|
|
196
197
|
)}
|
|
197
198
|
{#if highlight}
|
|
198
|
-
<span class="flex-1 min-w-0 truncate">
|
|
199
|
+
<span class="flex-1 min-w-0 truncate" use:truncateTitle={name}>
|
|
199
200
|
{highlight.before}<mark class="bg-yellow-200 dark:bg-yellow-900 text-inherit rounded px-0.5"
|
|
200
201
|
>{highlight.match}</mark
|
|
201
202
|
>{highlight.after}
|
|
202
203
|
</span>
|
|
203
204
|
{:else}
|
|
204
|
-
<span class="flex-1 min-w-0 truncate">{name}</span>
|
|
205
|
+
<span class="flex-1 min-w-0 truncate" use:truncateTitle={name}>{name}</span>
|
|
205
206
|
{/if}
|
|
206
207
|
{/snippet}
|
|
207
208
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { Snippet } from 'svelte';
|
|
10
10
|
import type { NavSection, NavItem } from '../../../types/layout.js';
|
|
11
11
|
import { cn } from '../../../utils.js';
|
|
12
|
+
import { truncateTitle } from '../../../utils/truncate-title.js';
|
|
12
13
|
import { sidebarStore } from '../../../stores/sidebar.svelte.js';
|
|
13
14
|
import SidebarNavItem from './SidebarNavItem.svelte';
|
|
14
15
|
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
aria-expanded={isExpanded}
|
|
94
95
|
aria-controls={`sidebar-section-${section.id}`}
|
|
95
96
|
>
|
|
96
|
-
<span class="truncate">{section.title}</span>
|
|
97
|
+
<span class="truncate" use:truncateTitle={section.title}>{section.title}</span>
|
|
97
98
|
<svg
|
|
98
99
|
class={cn(
|
|
99
100
|
'h-4 w-4 shrink-0',
|
|
@@ -119,6 +120,7 @@
|
|
|
119
120
|
'mb-2 px-4 text-xs font-semibold uppercase tracking-wider overflow-hidden truncate',
|
|
120
121
|
isLight ? 'text-muted-foreground' : 'text-sidebar-foreground/60'
|
|
121
122
|
)}
|
|
123
|
+
use:truncateTitle={section.title}
|
|
122
124
|
>
|
|
123
125
|
{section.title}
|
|
124
126
|
</h2>
|
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,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-text tooltip for `truncate`d labels.
|
|
3
|
+
*/
|
|
4
|
+
import type { Action } from 'svelte/action';
|
|
5
|
+
/**
|
|
6
|
+
* Sets the element's native `title` to `text` while its content is cut off
|
|
7
|
+
* by an ellipsis, and removes it when the text fits. Measured on hover rather
|
|
8
|
+
* than observed, so it costs nothing until someone points at it and stays
|
|
9
|
+
* right as the container resizes. (The accessible name is the text itself,
|
|
10
|
+
* so screen readers already get the full label.)
|
|
11
|
+
*/
|
|
12
|
+
export declare const truncateTitle: Action<HTMLElement, string>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-text tooltip for `truncate`d labels.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Sets the element's native `title` to `text` while its content is cut off
|
|
6
|
+
* by an ellipsis, and removes it when the text fits. Measured on hover rather
|
|
7
|
+
* than observed, so it costs nothing until someone points at it and stays
|
|
8
|
+
* right as the container resizes. (The accessible name is the text itself,
|
|
9
|
+
* so screen readers already get the full label.)
|
|
10
|
+
*/
|
|
11
|
+
export const truncateTitle = (node, text) => {
|
|
12
|
+
let current = text;
|
|
13
|
+
function sync() {
|
|
14
|
+
if (node.scrollWidth > node.clientWidth)
|
|
15
|
+
node.title = current;
|
|
16
|
+
else
|
|
17
|
+
node.removeAttribute('title');
|
|
18
|
+
}
|
|
19
|
+
node.addEventListener('pointerenter', sync);
|
|
20
|
+
return {
|
|
21
|
+
update(next) {
|
|
22
|
+
current = next;
|
|
23
|
+
if (node.hasAttribute('title'))
|
|
24
|
+
sync();
|
|
25
|
+
},
|
|
26
|
+
destroy() {
|
|
27
|
+
node.removeEventListener('pointerenter', sync);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
};
|