@heyramzi/tasks 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +285 -2
- package/package.json +98 -3
- package/src/ambient.d.ts +6 -0
- package/src/board-order.ts +137 -0
- package/src/dates.ts +31 -0
- package/src/define-statuses.ts +32 -0
- package/src/demo.svelte +394 -0
- package/src/icons/board-icon.svelte +11 -0
- package/src/icons/chevron-down.svg +1 -0
- package/src/icons/chevron-right.svg +1 -0
- package/src/icons/clock.svg +1 -0
- package/src/icons/close.svg +1 -0
- package/src/icons/gantt-icon.svelte +11 -0
- package/src/icons/list-icon.svelte +16 -0
- package/src/icons/milestone-flag.svg +1 -0
- package/src/icons/play.svg +1 -0
- package/src/icons/spinner.svg +1 -0
- package/src/inbox/icons/call.svg +1 -0
- package/src/inbox/icons/email.svg +1 -0
- package/src/inbox/icons/form.svg +1 -0
- package/src/inbox/icons/slack.svg +1 -0
- package/src/inbox/inbox-card.svelte +279 -0
- package/src/inbox/inbox-draft.svelte +84 -0
- package/src/inbox/inbox-logic.ts +56 -0
- package/src/inbox/inbox-statuses.ts +50 -0
- package/src/inbox/inbox-trace.svelte +44 -0
- package/src/inbox/inbox-types.ts +108 -0
- package/src/inbox/inbox.svelte +203 -0
- package/src/inbox/index.ts +23 -0
- package/src/index.ts +78 -0
- package/src/kanban-board.svelte +560 -0
- package/src/kanban-column.svelte +117 -0
- package/src/kanban-drag.ts +38 -0
- package/src/kanban-types.ts +65 -0
- package/src/lib/cn.ts +11 -0
- package/src/presets/sales.ts +16 -0
- package/src/reorder.ts +20 -0
- package/src/status-badge.svelte +57 -0
- package/src/status-dot.svelte +228 -0
- package/src/status-icon.svelte +30 -0
- package/src/status-types.ts +42 -0
- package/src/task-gantt.svelte +845 -0
- package/src/task-list.svelte +463 -0
- package/src/task-types.ts +43 -0
- package/src/task-view-switch.svelte +118 -0
- package/src/time/duration.ts +69 -0
- package/src/time/time-chip.svelte +62 -0
- package/src/time/time-dot.svelte +58 -0
- package/src/time/time-ring.svelte +74 -0
- package/src/time/time-tracker.svelte +567 -0
- package/src/time/time-types.ts +74 -0
- package/src/tone.ts +54 -0
- package/src/use-kanban-dnd.svelte.ts +173 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
<script lang="ts" generics="T, C extends KanbanColumnDef = KanbanColumnDef">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
4
|
+
import { prefersReducedMotion } from "svelte/motion";
|
|
5
|
+
import { cubicOut } from "svelte/easing";
|
|
6
|
+
import { slide } from "svelte/transition";
|
|
7
|
+
import KanbanColumn from "./kanban-column.svelte";
|
|
8
|
+
import { liftDragImage } from "./kanban-drag.js";
|
|
9
|
+
import { boardOrderAt } from "./board-order.js";
|
|
10
|
+
import { cn } from "./lib/cn.js";
|
|
11
|
+
import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
|
|
12
|
+
import chevronDownIcon from "./icons/chevron-down.svg?raw";
|
|
13
|
+
import type {
|
|
14
|
+
KanbanCardContext,
|
|
15
|
+
KanbanColumn as KanbanColumnDef,
|
|
16
|
+
KanbanColumnContext,
|
|
17
|
+
KanbanMove,
|
|
18
|
+
KanbanSortKey,
|
|
19
|
+
} from "./kanban-types.js";
|
|
20
|
+
|
|
21
|
+
type Props<T, C extends KanbanColumnDef> = {
|
|
22
|
+
/** Plain column defs, or your own type that extends one. Snippets get it back as you passed it. */
|
|
23
|
+
columns: C[];
|
|
24
|
+
items: T[];
|
|
25
|
+
/** The stable id of a card. Used for keys, drag payloads and move events. */
|
|
26
|
+
getId: (item: T) => string;
|
|
27
|
+
/** The column id a card belongs to. */
|
|
28
|
+
getColumn: (item: T) => string;
|
|
29
|
+
/** Called on drop with what to persist. Awaited, so the caller can save. */
|
|
30
|
+
onMove: (id: string, move: KanbanMove) => void | Promise<void>;
|
|
31
|
+
renderCard: Snippet<[T, KanbanCardContext]>;
|
|
32
|
+
/** Reads a card's manual order key. Required for `sortKey="manual"` ordering. */
|
|
33
|
+
boardOrder?: (item: T) => string | null | undefined;
|
|
34
|
+
/** Breaks a tie in manual order: two cards with the same key, or both without one. */
|
|
35
|
+
manualTiebreak?: (a: T, b: T) => number;
|
|
36
|
+
/** Extra named sorts, e.g. by title or owner. `manual` is always available. */
|
|
37
|
+
sortKeys?: KanbanSortKey<T>[];
|
|
38
|
+
/** The active sort. `"manual"` (default) orders by `boardOrder`, else by the order of `items`. */
|
|
39
|
+
sortKey?: string;
|
|
40
|
+
/** Optional column header. Defaults to the label plus a count. */
|
|
41
|
+
columnHeader?: Snippet<[C, KanbanColumnContext]>;
|
|
42
|
+
/** Rendered under a column's cards, e.g. a "Show all 12" or an add button. */
|
|
43
|
+
columnFooter?: Snippet<[C, KanbanColumnContext]>;
|
|
44
|
+
/**
|
|
45
|
+
* Wraps a whole column, for a context menu or a tooltip around it. Render `column` once and
|
|
46
|
+
* spread the `props` it hands you, so the wrapper's trigger lands on the column.
|
|
47
|
+
*/
|
|
48
|
+
wrapColumn?: Snippet<[C, Snippet<[Record<string, unknown>]>]>;
|
|
49
|
+
/** Enter or Space on a focused card. Leave out when the card holds its own links and buttons. */
|
|
50
|
+
onSelect?: (id: string) => void;
|
|
51
|
+
/** The screen-reader name of a card. Defaults to a hint about the keyboard move. */
|
|
52
|
+
getLabel?: (item: T) => string;
|
|
53
|
+
emptyLabel?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Cards a column shows before the rest fold behind a footer. Return undefined for no cap.
|
|
56
|
+
* A capped column's empty space drops on top, since the tail is out of sight.
|
|
57
|
+
*/
|
|
58
|
+
limit?: (columnId: string) => number | undefined;
|
|
59
|
+
/** Column ids whose `limit` is lifted. Controlled; leave out to let the board remember. */
|
|
60
|
+
expanded?: string[];
|
|
61
|
+
onExpandedChange?: (ids: string[]) => void;
|
|
62
|
+
/** Column ids folded into the rail on the right. Controlled; leave out to let the board remember. */
|
|
63
|
+
hidden?: string[];
|
|
64
|
+
onHiddenChange?: (ids: string[]) => void;
|
|
65
|
+
/** Heading over the rail of hidden columns. */
|
|
66
|
+
hiddenLabel?: string;
|
|
67
|
+
/** The rail's button that brings every hidden column back. */
|
|
68
|
+
showAllLabel?: string;
|
|
69
|
+
/** Content of one entry in the hidden rail. Defaults to the label and a count. */
|
|
70
|
+
hiddenItem?: Snippet<[C, { count: number; active: boolean }]>;
|
|
71
|
+
/** A capped column is one whose tail is hidden, so its empty space drops on top. Overrides `limit`. */
|
|
72
|
+
dropAtTop?: (columnId: string) => boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Override per-column drop targets. Defaults to each column's `droppable`
|
|
75
|
+
* flag (true when unset). A column it returns false for never calls
|
|
76
|
+
* preventDefault, so it is not a drop target.
|
|
77
|
+
*/
|
|
78
|
+
isDroppable?: (columnId: string) => boolean;
|
|
79
|
+
class?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let {
|
|
83
|
+
columns,
|
|
84
|
+
items,
|
|
85
|
+
getId,
|
|
86
|
+
getColumn,
|
|
87
|
+
onMove,
|
|
88
|
+
renderCard,
|
|
89
|
+
boardOrder,
|
|
90
|
+
manualTiebreak,
|
|
91
|
+
sortKeys = [],
|
|
92
|
+
sortKey = "manual",
|
|
93
|
+
columnHeader,
|
|
94
|
+
columnFooter,
|
|
95
|
+
wrapColumn,
|
|
96
|
+
getLabel,
|
|
97
|
+
onSelect,
|
|
98
|
+
emptyLabel = "Nothing here yet",
|
|
99
|
+
limit,
|
|
100
|
+
expanded,
|
|
101
|
+
onExpandedChange,
|
|
102
|
+
hidden,
|
|
103
|
+
onHiddenChange,
|
|
104
|
+
hiddenLabel = "Hidden columns",
|
|
105
|
+
showAllLabel = "Show all",
|
|
106
|
+
hiddenItem,
|
|
107
|
+
dropAtTop,
|
|
108
|
+
isDroppable,
|
|
109
|
+
class: className,
|
|
110
|
+
}: Props<T, C> = $props();
|
|
111
|
+
|
|
112
|
+
// The drop slot takes the held card's height, so the column doesn't jump when it lands.
|
|
113
|
+
let slotHeight = $state(0);
|
|
114
|
+
let slotMotion = $derived({ duration: prefersReducedMotion.current ? 0 : 180, easing: cubicOut });
|
|
115
|
+
|
|
116
|
+
let internalExpanded = $state<string[]>([]);
|
|
117
|
+
let internalHidden = $state<string[]>([]);
|
|
118
|
+
let expandedIds = $derived(expanded ?? internalExpanded);
|
|
119
|
+
let hiddenIds = $derived(hidden ?? internalHidden);
|
|
120
|
+
let hiddenSet = $derived(new Set(hiddenIds));
|
|
121
|
+
|
|
122
|
+
let railOpen = $state(true);
|
|
123
|
+
let dragOverHidden = $state<string | null>(null);
|
|
124
|
+
const collapsing = new SvelteSet<string>();
|
|
125
|
+
const expanding = new SvelteSet<string>();
|
|
126
|
+
const leaving = new SvelteSet<string>();
|
|
127
|
+
|
|
128
|
+
function compare(a: T, b: T): number {
|
|
129
|
+
if (sortKey === "manual") {
|
|
130
|
+
const tie = manualTiebreak?.(a, b) ?? 0;
|
|
131
|
+
if (!boardOrder) return tie;
|
|
132
|
+
const ao = boardOrder(a);
|
|
133
|
+
const bo = boardOrder(b);
|
|
134
|
+
if (ao == null && bo == null) return tie;
|
|
135
|
+
if (ao == null) return 1;
|
|
136
|
+
if (bo == null) return -1;
|
|
137
|
+
return ao < bo ? -1 : ao > bo ? 1 : tie;
|
|
138
|
+
}
|
|
139
|
+
const key = sortKeys.find((entry) => entry.id === sortKey);
|
|
140
|
+
return key ? key.compare(a, b) : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sortedByColumn(columnId: string): T[] {
|
|
144
|
+
return items
|
|
145
|
+
.filter((item) => getColumn(item) === columnId)
|
|
146
|
+
.slice()
|
|
147
|
+
.sort(compare);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function idAt(list: T[], index: number): string | null {
|
|
151
|
+
const item = list[index];
|
|
152
|
+
return item === undefined ? null : getId(item);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isCapped(columnId: string, total: number): boolean {
|
|
156
|
+
const cap = limit?.(columnId);
|
|
157
|
+
return cap !== undefined && !expandedIds.includes(columnId) && total > cap;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function canDrop(columnId: string): boolean {
|
|
161
|
+
return isDroppable?.(columnId) ?? (columns.find((c) => c.id === columnId)?.droppable ?? true);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function expand(columnId: string): void {
|
|
165
|
+
if (expandedIds.includes(columnId)) return;
|
|
166
|
+
const next = [...expandedIds, columnId];
|
|
167
|
+
internalExpanded = next;
|
|
168
|
+
onExpandedChange?.(next);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function setHidden(next: string[]): void {
|
|
172
|
+
internalHidden = next;
|
|
173
|
+
onHiddenChange?.(next);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Fold: the column fades out first, then leaves the row and joins the rail.
|
|
177
|
+
function hide(columnId: string): void {
|
|
178
|
+
if (hiddenSet.has(columnId) || collapsing.has(columnId)) return;
|
|
179
|
+
collapsing.add(columnId);
|
|
180
|
+
setTimeout(() => {
|
|
181
|
+
setHidden([...hiddenIds, columnId]);
|
|
182
|
+
collapsing.delete(columnId);
|
|
183
|
+
}, 300);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Unfold: the rail entry leaves, then the column grows back in.
|
|
187
|
+
function show(columnId: string): void {
|
|
188
|
+
leaving.add(columnId);
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
leaving.delete(columnId);
|
|
191
|
+
expanding.add(columnId);
|
|
192
|
+
setHidden(hiddenIds.filter((id) => id !== columnId));
|
|
193
|
+
setTimeout(() => expanding.delete(columnId), 350);
|
|
194
|
+
}, 200);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function showAll(): void {
|
|
198
|
+
for (const id of hiddenIds) leaving.add(id);
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
leaving.clear();
|
|
201
|
+
setHidden([]);
|
|
202
|
+
}, 200);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const dnd = createKanbanDnd<T>({
|
|
206
|
+
items: () => items,
|
|
207
|
+
getId: (item) => getId(item),
|
|
208
|
+
getColumn: (item) => getColumn(item),
|
|
209
|
+
getColumnItems: sortedByColumn,
|
|
210
|
+
getSortKey: () => sortKey,
|
|
211
|
+
boardOrder: (item) => boardOrder?.(item),
|
|
212
|
+
onMove: (id, move) => onMove(id, move),
|
|
213
|
+
dropAtTop: (columnId) =>
|
|
214
|
+
dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
|
|
215
|
+
isDroppable: canDrop,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Keyboard move: Alt + ArrowLeft/Right drops the focused card in the
|
|
220
|
+
* adjacent droppable column. Native drag stays the primary path; this
|
|
221
|
+
* covers keyboard and touch-reader users with the same onMove contract.
|
|
222
|
+
*/
|
|
223
|
+
function onCardKeyDown(e: KeyboardEvent, id: string): void {
|
|
224
|
+
if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
onSelect(id);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
|
230
|
+
const item = items.find((entry) => getId(entry) === id);
|
|
231
|
+
if (!item) return;
|
|
232
|
+
const current = getColumn(item);
|
|
233
|
+
const at = columns.findIndex((c) => c.id === current);
|
|
234
|
+
if (at === -1) return;
|
|
235
|
+
const dir = e.key === "ArrowRight" ? 1 : -1;
|
|
236
|
+
for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
|
|
237
|
+
const target = columns[i];
|
|
238
|
+
if (!canDrop(target.id) || hiddenSet.has(target.id) || target.id === current) continue;
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
if (sortKey === "manual") {
|
|
241
|
+
const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
|
|
242
|
+
const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
|
|
243
|
+
const index = atTop ? 0 : column.length;
|
|
244
|
+
const before = column[index - 1];
|
|
245
|
+
const after = column[index];
|
|
246
|
+
void onMove(id, {
|
|
247
|
+
columnId: target.id,
|
|
248
|
+
...(boardOrder
|
|
249
|
+
? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
|
|
250
|
+
: {}),
|
|
251
|
+
beforeId: before === undefined ? null : getId(before),
|
|
252
|
+
afterId: after === undefined ? null : getId(after),
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
void onMove(id, { columnId: target.id });
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function onHiddenDragOver(e: DragEvent, columnId: string): void {
|
|
262
|
+
if (!canDrop(columnId)) return;
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
265
|
+
if (dragOverHidden !== columnId) dragOverHidden = columnId;
|
|
266
|
+
dnd.clearDragOver();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function onHiddenDragLeave(e: DragEvent): void {
|
|
270
|
+
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) dragOverHidden = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// A card dropped on a folded column brings the column back and lands in it.
|
|
274
|
+
function onHiddenDrop(e: DragEvent, columnId: string): void {
|
|
275
|
+
dragOverHidden = null;
|
|
276
|
+
dnd.onDrop(e, columnId);
|
|
277
|
+
show(columnId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let view = $derived(
|
|
281
|
+
columns.map((column) => {
|
|
282
|
+
const all = sortedByColumn(column.id);
|
|
283
|
+
const capped = isCapped(column.id, all.length);
|
|
284
|
+
const cap = limit?.(column.id);
|
|
285
|
+
return {
|
|
286
|
+
column,
|
|
287
|
+
all,
|
|
288
|
+
cards: capped && cap !== undefined ? all.slice(0, cap) : all,
|
|
289
|
+
capped,
|
|
290
|
+
};
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
let hiddenColumns = $derived(columns.filter((column) => hiddenSet.has(column.id)));
|
|
295
|
+
|
|
296
|
+
function contextOf(
|
|
297
|
+
column: C,
|
|
298
|
+
all: T[],
|
|
299
|
+
cards: T[],
|
|
300
|
+
capped: boolean,
|
|
301
|
+
active: boolean,
|
|
302
|
+
): KanbanColumnContext {
|
|
303
|
+
return {
|
|
304
|
+
count: all.length,
|
|
305
|
+
active,
|
|
306
|
+
shown: cards.length,
|
|
307
|
+
capped,
|
|
308
|
+
hide: () => hide(column.id),
|
|
309
|
+
expand: () => expand(column.id),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
</script>
|
|
313
|
+
|
|
314
|
+
<!-- The slot keeps the drag state where it is, or hovering it would bubble to the column and jump it to the end. -->
|
|
315
|
+
{#snippet dropSlot()}
|
|
316
|
+
<div
|
|
317
|
+
class="kanban-drop-slot"
|
|
318
|
+
aria-hidden="true"
|
|
319
|
+
style:height="{slotHeight}px"
|
|
320
|
+
transition:slide={slotMotion}
|
|
321
|
+
ondragover={(e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
e.stopPropagation();
|
|
324
|
+
}}
|
|
325
|
+
></div>
|
|
326
|
+
{/snippet}
|
|
327
|
+
|
|
328
|
+
{#snippet columnShell(
|
|
329
|
+
column: C,
|
|
330
|
+
all: T[],
|
|
331
|
+
cards: T[],
|
|
332
|
+
capped: boolean,
|
|
333
|
+
extra: Record<string, unknown>,
|
|
334
|
+
)}
|
|
335
|
+
{@const active = dnd.dragOver?.columnId === column.id}
|
|
336
|
+
{@const context = contextOf(column, all, cards, capped, active)}
|
|
337
|
+
<KanbanColumn
|
|
338
|
+
{...extra}
|
|
339
|
+
hue={column.hue}
|
|
340
|
+
{active}
|
|
341
|
+
collapsing={collapsing.has(column.id)}
|
|
342
|
+
expanding={expanding.has(column.id)}
|
|
343
|
+
ariaLabel={column.ariaLabel ?? column.label}
|
|
344
|
+
ondragover={(e) => dnd.onColumnDragOver(e, column.id)}
|
|
345
|
+
ondragleave={dnd.onDragLeave}
|
|
346
|
+
ondrop={(e) => dnd.onDrop(e, column.id)}
|
|
347
|
+
>
|
|
348
|
+
{#snippet header()}
|
|
349
|
+
{#if columnHeader}
|
|
350
|
+
{@render columnHeader(column, context)}
|
|
351
|
+
{:else}
|
|
352
|
+
<span class="truncate text-sm font-medium text-foreground">{column.label}</span>
|
|
353
|
+
<span
|
|
354
|
+
class="grid h-5 min-w-5 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums"
|
|
355
|
+
>
|
|
356
|
+
{all.length}
|
|
357
|
+
</span>
|
|
358
|
+
{/if}
|
|
359
|
+
{/snippet}
|
|
360
|
+
|
|
361
|
+
{#each cards as item, index (getId(item))}
|
|
362
|
+
{@const id = getId(item)}
|
|
363
|
+
{@const nextId = idAt(cards, index + 1)}
|
|
364
|
+
{@const isSource = dnd.isDragSource(id)}
|
|
365
|
+
<!-- The slot right after the held card is its own spot, so the card's place shows it instead. -->
|
|
366
|
+
{@const atOrigin =
|
|
367
|
+
!dnd.dragOver || (dnd.dragOver.columnId === column.id && dnd.dragOver.overId === nextId)}
|
|
368
|
+
{#if dnd.showIndicatorBefore(column.id, id) && !isSource && idAt(cards, index - 1) !== dnd.draggedId}
|
|
369
|
+
{@render dropSlot()}
|
|
370
|
+
{/if}
|
|
371
|
+
<!-- WHY: a card is a list item that takes focus for the Alt+arrow move; making it a button would hide the list from screen readers. -->
|
|
372
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
373
|
+
<div
|
|
374
|
+
role="listitem"
|
|
375
|
+
tabindex="0"
|
|
376
|
+
aria-label={getLabel?.(item) ?? "Card. Press Alt plus Left or Right arrow to move between columns."}
|
|
377
|
+
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
|
|
378
|
+
draggable="true"
|
|
379
|
+
ondragstart={(e) => {
|
|
380
|
+
slotHeight = e.currentTarget.getBoundingClientRect().height;
|
|
381
|
+
liftDragImage(e);
|
|
382
|
+
dnd.onDragStart(e, id);
|
|
383
|
+
}}
|
|
384
|
+
ondragend={() => {
|
|
385
|
+
dnd.resetDrag();
|
|
386
|
+
dragOverHidden = null;
|
|
387
|
+
}}
|
|
388
|
+
ondragover={(e) => dnd.onItemDragOver(e, column.id, id, nextId)}
|
|
389
|
+
onkeydown={(e) => onCardKeyDown(e, id)}
|
|
390
|
+
class={cn(
|
|
391
|
+
"kanban-card cursor-grab rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring/60 active:cursor-grabbing",
|
|
392
|
+
isSource && (atOrigin ? "kanban-origin-slot" : "kanban-lifted-out"),
|
|
393
|
+
)}
|
|
394
|
+
>
|
|
395
|
+
{@render renderCard(item, { columnId: column.id, index })}
|
|
396
|
+
</div>
|
|
397
|
+
{/each}
|
|
398
|
+
|
|
399
|
+
{#if dnd.showIndicatorEnd(column.id) && idAt(cards, cards.length - 1) !== dnd.draggedId}
|
|
400
|
+
{@render dropSlot()}
|
|
401
|
+
{/if}
|
|
402
|
+
|
|
403
|
+
{#if columnFooter}
|
|
404
|
+
{@render columnFooter(column, context)}
|
|
405
|
+
{/if}
|
|
406
|
+
|
|
407
|
+
{#if cards.length === 0 && !active}
|
|
408
|
+
<div
|
|
409
|
+
class="rounded-xl border-2 border-dashed border-border py-6 text-center text-xs text-muted-foreground"
|
|
410
|
+
>
|
|
411
|
+
{emptyLabel}
|
|
412
|
+
</div>
|
|
413
|
+
{/if}
|
|
414
|
+
</KanbanColumn>
|
|
415
|
+
{/snippet}
|
|
416
|
+
|
|
417
|
+
<div
|
|
418
|
+
class={cn(
|
|
419
|
+
"kanban-board flex min-h-0 flex-1 items-stretch gap-4 overflow-x-auto pb-2",
|
|
420
|
+
"max-sm:-mx-4 max-sm:snap-x max-sm:snap-proximity max-sm:scroll-px-4 max-sm:px-4",
|
|
421
|
+
className,
|
|
422
|
+
)}
|
|
423
|
+
>
|
|
424
|
+
{#each view as { column, all, cards, capped } (column.id)}
|
|
425
|
+
{#if !hiddenSet.has(column.id) || collapsing.has(column.id)}
|
|
426
|
+
{#if wrapColumn}
|
|
427
|
+
{#snippet inner(props: Record<string, unknown>)}
|
|
428
|
+
{@render columnShell(column, all, cards, capped, props)}
|
|
429
|
+
{/snippet}
|
|
430
|
+
{@render wrapColumn(column, inner)}
|
|
431
|
+
{:else}
|
|
432
|
+
{@render columnShell(column, all, cards, capped, {})}
|
|
433
|
+
{/if}
|
|
434
|
+
{/if}
|
|
435
|
+
{/each}
|
|
436
|
+
|
|
437
|
+
{#if hiddenColumns.length > 0}
|
|
438
|
+
<div class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
aria-expanded={railOpen}
|
|
442
|
+
onclick={() => (railOpen = !railOpen)}
|
|
443
|
+
class="flex h-8 items-center gap-1.5 rounded-md px-2 text-start text-xs text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,120ms)] hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
444
|
+
>
|
|
445
|
+
<span
|
|
446
|
+
class={cn(
|
|
447
|
+
"size-2.5 transition-transform duration-[var(--duration-fast,160ms)] ease-[var(--ease-smooth-out,ease-out)] [&>svg]:size-full",
|
|
448
|
+
!railOpen && "-rotate-90",
|
|
449
|
+
)}
|
|
450
|
+
aria-hidden="true">{@html chevronDownIcon}</span
|
|
451
|
+
>
|
|
452
|
+
{hiddenLabel}
|
|
453
|
+
</button>
|
|
454
|
+
|
|
455
|
+
{#if railOpen}
|
|
456
|
+
<div class="flex flex-col gap-0.5">
|
|
457
|
+
{#each hiddenColumns as column (column.id)}
|
|
458
|
+
{@const count = sortedByColumn(column.id).length}
|
|
459
|
+
{@const over = dragOverHidden === column.id}
|
|
460
|
+
{@const hue = column.hue ?? "var(--color-primary)"}
|
|
461
|
+
<div
|
|
462
|
+
role="region"
|
|
463
|
+
aria-label={column.ariaLabel ?? column.label}
|
|
464
|
+
class={cn(
|
|
465
|
+
"kanban-rail-item rounded-md transition-[background-color,outline-color] duration-[var(--duration-quick,120ms)] ease-[var(--ease-smooth-out,ease-out)]",
|
|
466
|
+
leaving.has(column.id) && "kanban-rail-item-leaving",
|
|
467
|
+
)}
|
|
468
|
+
style:background="color-mix(in oklch, {hue} {over ? 10 : 0}%, transparent)"
|
|
469
|
+
style:outline="1px solid color-mix(in oklch, {hue} {over ? 28 : 0}%, transparent)"
|
|
470
|
+
ondragover={(e) => onHiddenDragOver(e, column.id)}
|
|
471
|
+
ondragleave={onHiddenDragLeave}
|
|
472
|
+
ondrop={(e) => onHiddenDrop(e, column.id)}
|
|
473
|
+
>
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
onclick={() => show(column.id)}
|
|
477
|
+
class="flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-start text-xs font-medium text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,120ms)] hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
478
|
+
>
|
|
479
|
+
{#if hiddenItem}
|
|
480
|
+
{@render hiddenItem(column, { count, active: over })}
|
|
481
|
+
{:else}
|
|
482
|
+
<span class="truncate whitespace-nowrap">{column.label}</span>
|
|
483
|
+
{#if count > 0}
|
|
484
|
+
<span class="ms-auto text-xs text-muted-foreground/60 tabular-nums">{count}</span>
|
|
485
|
+
{/if}
|
|
486
|
+
{/if}
|
|
487
|
+
</button>
|
|
488
|
+
</div>
|
|
489
|
+
{/each}
|
|
490
|
+
|
|
491
|
+
{#if hiddenColumns.length > 1}
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onclick={showAll}
|
|
495
|
+
class="mt-1 flex h-8 items-center rounded-md px-2.5 text-start text-xs text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,120ms)] hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
496
|
+
>
|
|
497
|
+
{showAllLabel}
|
|
498
|
+
</button>
|
|
499
|
+
{/if}
|
|
500
|
+
</div>
|
|
501
|
+
{/if}
|
|
502
|
+
</div>
|
|
503
|
+
{/if}
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<style>
|
|
507
|
+
/* The drop slot and the held card's own place share a look: a dashed hole the card's size. */
|
|
508
|
+
.kanban-drop-slot,
|
|
509
|
+
.kanban-origin-slot {
|
|
510
|
+
border-radius: var(--radius-lg, 0.5rem);
|
|
511
|
+
background: color-mix(in oklch, var(--color-muted, #e5e7eb) 70%, transparent);
|
|
512
|
+
outline: 1px dashed var(--color-border, #d1d5db);
|
|
513
|
+
outline-offset: -1px;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.kanban-drop-slot {
|
|
517
|
+
flex-shrink: 0;
|
|
518
|
+
min-height: 2.5rem;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* A grid row folds the card out of the column smoothly, so the cards below slide up. */
|
|
522
|
+
.kanban-card {
|
|
523
|
+
display: grid;
|
|
524
|
+
grid-template-rows: 1fr;
|
|
525
|
+
transition:
|
|
526
|
+
grid-template-rows var(--duration-quick, 180ms) var(--ease-smooth-out, ease-out),
|
|
527
|
+
margin-top var(--duration-quick, 180ms) var(--ease-smooth-out, ease-out);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.kanban-card > :global(*) {
|
|
531
|
+
min-height: 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.kanban-origin-slot > :global(*) {
|
|
535
|
+
visibility: hidden;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* -0.375rem cancels the column's gap-1.5, so a folded card leaves no gap behind. */
|
|
539
|
+
.kanban-lifted-out {
|
|
540
|
+
grid-template-rows: 0fr;
|
|
541
|
+
margin-top: -0.375rem;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.kanban-lifted-out > :global(*) {
|
|
545
|
+
overflow: hidden;
|
|
546
|
+
visibility: hidden;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.kanban-rail-item-leaving {
|
|
550
|
+
pointer-events: none;
|
|
551
|
+
opacity: 0;
|
|
552
|
+
transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
@media (prefers-reduced-motion: reduce) {
|
|
556
|
+
.kanban-card {
|
|
557
|
+
transition: none;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
</style>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
import { cn } from "./lib/cn.js";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
/** Any CSS color. Only shows while a card is held over the column. */
|
|
8
|
+
hue?: string;
|
|
9
|
+
active?: boolean;
|
|
10
|
+
collapsing?: boolean;
|
|
11
|
+
expanding?: boolean;
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
header: Snippet;
|
|
14
|
+
children: Snippet;
|
|
15
|
+
ondragover?: (e: DragEvent) => void;
|
|
16
|
+
ondragleave?: (e: DragEvent) => void;
|
|
17
|
+
ondrop?: (e: DragEvent) => void;
|
|
18
|
+
class?: string;
|
|
19
|
+
bodyClass?: string;
|
|
20
|
+
} & HTMLAttributes<HTMLDivElement>;
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
hue,
|
|
24
|
+
active = false,
|
|
25
|
+
collapsing = false,
|
|
26
|
+
expanding = false,
|
|
27
|
+
ariaLabel,
|
|
28
|
+
header,
|
|
29
|
+
children,
|
|
30
|
+
ondragover,
|
|
31
|
+
ondragleave,
|
|
32
|
+
ondrop,
|
|
33
|
+
class: className,
|
|
34
|
+
bodyClass,
|
|
35
|
+
...restProps
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
// A column rests neutral. Its hue shows only while a card is held over it.
|
|
39
|
+
let style = $derived(
|
|
40
|
+
hue && active
|
|
41
|
+
? `background: color-mix(in oklch, ${hue} 8%, var(--color-muted, transparent)); outline: 1px solid color-mix(in oklch, ${hue} 30%, transparent);`
|
|
42
|
+
: undefined,
|
|
43
|
+
);
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
{...restProps}
|
|
48
|
+
class={cn(
|
|
49
|
+
"kanban-column flex flex-col gap-2",
|
|
50
|
+
collapsing && "kanban-column-collapsing",
|
|
51
|
+
expanding && "kanban-column-expanding",
|
|
52
|
+
!collapsing && !expanding && "w-[280px] shrink-0 min-h-0 max-sm:w-[82vw] max-sm:snap-start",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<div
|
|
57
|
+
role="region"
|
|
58
|
+
aria-label={ariaLabel}
|
|
59
|
+
class={cn(
|
|
60
|
+
"flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto overflow-x-hidden rounded-xl bg-muted/50 p-1.5 outline-offset-[-1px] transition-[background-color,outline-color] duration-[var(--duration-quick,120ms)] ease-[var(--ease-smooth-out,ease-out)] dark:bg-muted/30",
|
|
61
|
+
bodyClass,
|
|
62
|
+
)}
|
|
63
|
+
{style}
|
|
64
|
+
{ondragover}
|
|
65
|
+
{ondragleave}
|
|
66
|
+
{ondrop}
|
|
67
|
+
>
|
|
68
|
+
<div class="flex h-8 min-w-0 shrink-0 items-center gap-2 px-1.5">
|
|
69
|
+
{@render header()}
|
|
70
|
+
</div>
|
|
71
|
+
{@render children()}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.kanban-column {
|
|
77
|
+
transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.kanban-column-collapsing,
|
|
81
|
+
.kanban-column-expanding {
|
|
82
|
+
will-change: opacity;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.kanban-column-collapsing {
|
|
86
|
+
flex: 0 0 0px;
|
|
87
|
+
width: 0;
|
|
88
|
+
opacity: 0;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
pointer-events: none;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.kanban-column-expanding {
|
|
94
|
+
flex: 0 0 280px;
|
|
95
|
+
width: 280px;
|
|
96
|
+
animation: column-expand var(--duration-slow, 320ms) var(--ease-smooth-out, ease-out) both;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@keyframes column-expand {
|
|
100
|
+
from {
|
|
101
|
+
opacity: 0;
|
|
102
|
+
flex: 0 0 0px;
|
|
103
|
+
width: 0;
|
|
104
|
+
}
|
|
105
|
+
to {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
flex: 0 0 280px;
|
|
108
|
+
width: 280px;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@media (prefers-reduced-motion: reduce) {
|
|
113
|
+
.kanban-column-expanding {
|
|
114
|
+
animation: none;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
</style>
|