@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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +285 -2
  3. package/package.json +98 -3
  4. package/src/ambient.d.ts +6 -0
  5. package/src/board-order.ts +137 -0
  6. package/src/dates.ts +31 -0
  7. package/src/define-statuses.ts +32 -0
  8. package/src/demo.svelte +394 -0
  9. package/src/icons/board-icon.svelte +11 -0
  10. package/src/icons/chevron-down.svg +1 -0
  11. package/src/icons/chevron-right.svg +1 -0
  12. package/src/icons/clock.svg +1 -0
  13. package/src/icons/close.svg +1 -0
  14. package/src/icons/gantt-icon.svelte +11 -0
  15. package/src/icons/list-icon.svelte +16 -0
  16. package/src/icons/milestone-flag.svg +1 -0
  17. package/src/icons/play.svg +1 -0
  18. package/src/icons/spinner.svg +1 -0
  19. package/src/inbox/icons/call.svg +1 -0
  20. package/src/inbox/icons/email.svg +1 -0
  21. package/src/inbox/icons/form.svg +1 -0
  22. package/src/inbox/icons/slack.svg +1 -0
  23. package/src/inbox/inbox-card.svelte +279 -0
  24. package/src/inbox/inbox-draft.svelte +84 -0
  25. package/src/inbox/inbox-logic.ts +56 -0
  26. package/src/inbox/inbox-statuses.ts +50 -0
  27. package/src/inbox/inbox-trace.svelte +44 -0
  28. package/src/inbox/inbox-types.ts +108 -0
  29. package/src/inbox/inbox.svelte +203 -0
  30. package/src/inbox/index.ts +23 -0
  31. package/src/index.ts +78 -0
  32. package/src/kanban-board.svelte +560 -0
  33. package/src/kanban-column.svelte +117 -0
  34. package/src/kanban-drag.ts +38 -0
  35. package/src/kanban-types.ts +65 -0
  36. package/src/lib/cn.ts +11 -0
  37. package/src/presets/sales.ts +16 -0
  38. package/src/reorder.ts +20 -0
  39. package/src/status-badge.svelte +57 -0
  40. package/src/status-dot.svelte +228 -0
  41. package/src/status-icon.svelte +30 -0
  42. package/src/status-types.ts +42 -0
  43. package/src/task-gantt.svelte +845 -0
  44. package/src/task-list.svelte +463 -0
  45. package/src/task-types.ts +43 -0
  46. package/src/task-view-switch.svelte +118 -0
  47. package/src/time/duration.ts +69 -0
  48. package/src/time/time-chip.svelte +62 -0
  49. package/src/time/time-dot.svelte +58 -0
  50. package/src/time/time-ring.svelte +74 -0
  51. package/src/time/time-tracker.svelte +567 -0
  52. package/src/time/time-types.ts +74 -0
  53. package/src/tone.ts +54 -0
  54. 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>