@heyramzi/tasks 0.3.0 → 0.5.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 (39) hide show
  1. package/README.md +138 -18
  2. package/package.json +34 -1
  3. package/src/ambient.d.ts +3 -6
  4. package/src/demo.svelte +109 -19
  5. package/src/inbox/inbox-card.svelte +280 -0
  6. package/src/inbox/inbox-draft.svelte +84 -0
  7. package/src/inbox/inbox-logic.ts +56 -0
  8. package/src/inbox/inbox-statuses.ts +48 -0
  9. package/src/inbox/inbox-trace.svelte +45 -0
  10. package/src/inbox/inbox-types.ts +108 -0
  11. package/src/inbox/inbox.svelte +203 -0
  12. package/src/inbox/index.ts +23 -0
  13. package/src/index.ts +39 -7
  14. package/src/kanban-board.svelte +421 -104
  15. package/src/kanban-column.svelte +1 -1
  16. package/src/kanban-types.ts +18 -1
  17. package/src/side-panel.svelte +204 -0
  18. package/src/status-badge.svelte +37 -18
  19. package/src/status-dot.svelte +32 -12
  20. package/src/status-icon.svelte +2 -2
  21. package/src/status-types.ts +6 -5
  22. package/src/task-gantt.svelte +208 -36
  23. package/src/task-list.svelte +206 -101
  24. package/src/task-types.ts +11 -8
  25. package/src/time/duration.ts +69 -0
  26. package/src/time/time-chip.svelte +62 -0
  27. package/src/time/time-dot.svelte +58 -0
  28. package/src/time/time-ring.svelte +74 -0
  29. package/src/time/time-tracker.svelte +564 -0
  30. package/src/time/time-types.ts +74 -0
  31. package/src/use-kanban-dnd.svelte.ts +6 -1
  32. package/src/icons/board-icon.svelte +0 -9
  33. package/src/icons/chevron-right.svg +0 -1
  34. package/src/icons/gantt-icon.svelte +0 -11
  35. package/src/icons/list-icon.svelte +0 -16
  36. package/src/icons/spinner.svg +0 -1
  37. package/src/lib/cn.ts +0 -11
  38. package/src/task-view-switch.svelte +0 -118
  39. package/src/tone.ts +0 -51
@@ -1,10 +1,15 @@
1
- <script lang="ts" generics="T">
1
+ <script lang="ts" generics="T, C extends KanbanColumnDef = KanbanColumnDef">
2
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";
3
7
  import KanbanColumn from "./kanban-column.svelte";
4
8
  import { liftDragImage } from "./kanban-drag.js";
5
9
  import { boardOrderAt } from "./board-order.js";
6
- import { cn } from "./lib/cn.js";
10
+ import { cn } from "@heyramzi/utils";
7
11
  import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
12
+ import { CaretDown } from "phosphor-svelte";
8
13
  import type {
9
14
  KanbanCardContext,
10
15
  KanbanColumn as KanbanColumnDef,
@@ -13,8 +18,9 @@
13
18
  KanbanSortKey,
14
19
  } from "./kanban-types.js";
15
20
 
16
- type Props<T> = {
17
- columns: KanbanColumnDef[];
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[];
18
24
  items: T[];
19
25
  /** The stable id of a card. Used for keys, drag payloads and move events. */
20
26
  getId: (item: T) => string;
@@ -25,14 +31,44 @@
25
31
  renderCard: Snippet<[T, KanbanCardContext]>;
26
32
  /** Reads a card's manual order key. Required for `sortKey="manual"` ordering. */
27
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;
28
36
  /** Extra named sorts, e.g. by title or owner. `manual` is always available. */
29
37
  sortKeys?: KanbanSortKey<T>[];
30
- /** The active sort. `"manual"` (default) orders by `boardOrder`. */
38
+ /** The active sort. `"manual"` (default) orders by `boardOrder`, else by the order of `items`. */
31
39
  sortKey?: string;
32
40
  /** Optional column header. Defaults to the label plus a count. */
33
- columnHeader?: Snippet<[KanbanColumnDef, KanbanColumnContext]>;
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;
34
53
  emptyLabel?: string;
35
- /** A capped column is one whose tail is hidden, so its empty space drops on top. */
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`. */
36
72
  dropAtTop?: (columnId: string) => boolean;
37
73
  /**
38
74
  * Override per-column drop targets. Defaults to each column's `droppable`
@@ -51,26 +87,54 @@
51
87
  onMove,
52
88
  renderCard,
53
89
  boardOrder,
90
+ manualTiebreak,
54
91
  sortKeys = [],
55
92
  sortKey = "manual",
56
93
  columnHeader,
94
+ columnFooter,
95
+ wrapColumn,
96
+ getLabel,
97
+ onSelect,
57
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,
58
107
  dropAtTop,
59
108
  isDroppable,
60
109
  class: className,
61
- }: Props<T> = $props();
110
+ }: Props<T, C> = $props();
62
111
 
112
+ // The drop slot takes the held card's height, so the column doesn't jump when it lands.
63
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>();
64
127
 
65
128
  function compare(a: T, b: T): number {
66
129
  if (sortKey === "manual") {
67
- if (!boardOrder) return 0;
130
+ const tie = manualTiebreak?.(a, b) ?? 0;
131
+ if (!boardOrder) return tie;
68
132
  const ao = boardOrder(a);
69
133
  const bo = boardOrder(b);
70
- if (ao == null && bo == null) return 0;
134
+ if (ao == null && bo == null) return tie;
71
135
  if (ao == null) return 1;
72
136
  if (bo == null) return -1;
73
- return ao < bo ? -1 : ao > bo ? 1 : 0;
137
+ return ao < bo ? -1 : ao > bo ? 1 : tie;
74
138
  }
75
139
  const key = sortKeys.find((entry) => entry.id === sortKey);
76
140
  return key ? key.compare(a, b) : 0;
@@ -88,6 +152,56 @@
88
152
  return item === undefined ? null : getId(item);
89
153
  }
90
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
+
91
205
  const dnd = createKanbanDnd<T>({
92
206
  items: () => items,
93
207
  getId: (item) => getId(item),
@@ -96,10 +210,9 @@
96
210
  getSortKey: () => sortKey,
97
211
  boardOrder: (item) => boardOrder?.(item),
98
212
  onMove: (id, move) => onMove(id, move),
99
- dropAtTop: (columnId) => dropAtTop?.(columnId) ?? false,
100
- isDroppable: (columnId) =>
101
- isDroppable?.(columnId) ??
102
- (columns.find((c) => c.id === columnId)?.droppable ?? true),
213
+ dropAtTop: (columnId) =>
214
+ dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
215
+ isDroppable: canDrop,
103
216
  });
104
217
 
105
218
  /**
@@ -108,6 +221,11 @@
108
221
  * covers keyboard and touch-reader users with the same onMove contract.
109
222
  */
110
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
+ }
111
229
  if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
112
230
  const item = items.find((entry) => getId(entry) === id);
113
231
  if (!item) return;
@@ -115,22 +233,23 @@
115
233
  const at = columns.findIndex((c) => c.id === current);
116
234
  if (at === -1) return;
117
235
  const dir = e.key === "ArrowRight" ? 1 : -1;
118
- const canDrop = (columnId: string) =>
119
- isDroppable?.(columnId) ??
120
- (columns.find((c) => c.id === columnId)?.droppable ?? true);
121
236
  for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
122
237
  const target = columns[i];
123
- if (!canDrop(target.id) || target.id === current) continue;
238
+ if (!canDrop(target.id) || hiddenSet.has(target.id) || target.id === current) continue;
124
239
  e.preventDefault();
125
- if (sortKey === "manual" && boardOrder) {
126
- const column = sortedByColumn(target.id).filter(
127
- (entry) => getId(entry) !== id,
128
- );
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];
129
246
  void onMove(id, {
130
247
  columnId: target.id,
131
- boardOrder: boardOrderAt(column, column.length, (entry) =>
132
- boardOrder(entry),
133
- ),
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),
134
253
  });
135
254
  } else {
136
255
  void onMove(id, { columnId: target.id });
@@ -139,9 +258,162 @@
139
258
  }
140
259
  }
141
260
 
142
- let view = $derived(columns.map((column) => ({ column, cards: sortedByColumn(column.id) })));
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
+ }
143
312
  </script>
144
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
+
145
417
  <div
146
418
  class={cn(
147
419
  "kanban-board flex min-h-0 flex-1 items-stretch gap-4 overflow-x-auto pb-2",
@@ -149,95 +421,140 @@
149
421
  className,
150
422
  )}
151
423
  >
152
- {#each view as { column, cards } (column.id)}
153
- {@const active = dnd.dragOver?.columnId === column.id}
154
- <KanbanColumn
155
- hue={column.hue}
156
- {active}
157
- ariaLabel={column.ariaLabel ?? column.label}
158
- ondragover={(e) => dnd.onColumnDragOver(e, column.id)}
159
- ondragleave={dnd.onDragLeave}
160
- ondrop={(e) => dnd.onDrop(e, column.id)}
161
- >
162
- {#snippet header()}
163
- {#if columnHeader}
164
- {@render columnHeader(column, { count: cards.length, active })}
165
- {:else}
166
- <span class="truncate text-sm font-medium text-foreground">{column.label}</span>
167
- <span
168
- 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"
169
- >
170
- {cards.length}
171
- </span>
172
- {/if}
173
- {/snippet}
174
-
175
- {#each cards as item, index (getId(item))}
176
- {@const id = getId(item)}
177
- {#if dnd.showIndicatorBefore(column.id, id) && !dnd.isDragSource(id) && idAt(cards, index - 1) !== dnd.draggedId}
178
- <div
179
- class="kanban-drop-slot"
180
- aria-hidden="true"
181
- style:height="{slotHeight}px"
182
- ondragover={(e) => {
183
- e.preventDefault();
184
- e.stopPropagation();
185
- }}
186
- ></div>
187
- {/if}
188
- <div
189
- role="listitem"
190
- tabindex="0"
191
- aria-label="Card. Press Alt plus Left or Right arrow to move between columns."
192
- draggable="true"
193
- ondragstart={(e) => {
194
- slotHeight = e.currentTarget.getBoundingClientRect().height;
195
- liftDragImage(e);
196
- dnd.onDragStart(e, id);
197
- }}
198
- ondragend={() => dnd.resetDrag()}
199
- ondragover={(e) => dnd.onItemDragOver(e, column.id, id, idAt(cards, index + 1))}
200
- onkeydown={(e) => onCardKeyDown(e, id)}
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
201
446
  class={cn(
202
- "kanban-card cursor-grab active:cursor-grabbing",
203
- dnd.isDragSource(id) && "opacity-40",
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",
204
449
  )}
450
+ aria-hidden="true"><CaretDown weight="bold" /></span
205
451
  >
206
- {@render renderCard(item, { columnId: column.id, index })}
207
- </div>
208
- {/each}
209
-
210
- {#if dnd.showIndicatorEnd(column.id) && idAt(cards, cards.length - 1) !== dnd.draggedId}
211
- <div
212
- class="kanban-drop-slot"
213
- aria-hidden="true"
214
- style:height="{slotHeight}px"
215
- ondragover={(e) => {
216
- e.preventDefault();
217
- e.stopPropagation();
218
- }}
219
- ></div>
220
- {/if}
452
+ {hiddenLabel}
453
+ </button>
221
454
 
222
- {#if cards.length === 0 && !active}
223
- <div
224
- class="rounded-xl border-2 border-dashed border-border py-6 text-center text-xs text-muted-foreground"
225
- >
226
- {emptyLabel}
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}
227
500
  </div>
228
501
  {/if}
229
- </KanbanColumn>
230
- {/each}
502
+ </div>
503
+ {/if}
231
504
  </div>
232
505
 
233
506
  <style>
234
- .kanban-drop-slot {
235
- flex-shrink: 0;
236
- min-height: 2.5rem;
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 {
237
510
  border-radius: var(--radius-lg, 0.5rem);
238
511
  background: color-mix(in oklch, var(--color-muted, #e5e7eb) 70%, transparent);
239
512
  outline: 1px dashed var(--color-border, #d1d5db);
240
513
  outline-offset: -1px;
241
- transition: height var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
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
+ }
242
559
  }
243
560
  </style>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import type { HTMLAttributes } from "svelte/elements";
4
- import { cn } from "./lib/cn.js";
4
+ import { cn } from "@heyramzi/utils";
5
5
 
6
6
  type Props = {
7
7
  /** Any CSS color. Only shows while a card is held over the column. */
@@ -23,6 +23,13 @@ export type KanbanMove = {
23
23
  * card in its new slot. Ignore it when you persist order some other way.
24
24
  */
25
25
  boardOrder?: string;
26
+ /**
27
+ * The cards that sit above and below the drop slot once the dragged card is
28
+ * set aside. Set on manual drops only, null at an end. For a backend that
29
+ * orders by neighbour instead of by key.
30
+ */
31
+ beforeId?: string | null;
32
+ afterId?: string | null;
26
33
  };
27
34
 
28
35
  /** A named sort the board can apply instead of manual (`boardOrder`) ordering. */
@@ -38,10 +45,20 @@ export type KanbanCardContext = {
38
45
  index: number;
39
46
  };
40
47
 
41
- /** Second argument handed to the column header snippet. */
48
+ /** Second argument handed to the column header and footer snippets. */
42
49
  export type KanbanColumnContext = {
50
+ /** Every card in the column, including the ones a `limit` keeps out of view. */
43
51
  count: number;
52
+ /** A card is held over the column. */
44
53
  active: boolean;
54
+ /** How many cards render. Fewer than `count` while the column is capped. */
55
+ shown: number;
56
+ /** The column's `limit` is hiding its tail. */
57
+ capped: boolean;
58
+ /** Fold the column into the hidden rail. Needs `hidden` or `onHiddenChange` on the board. */
59
+ hide: () => void;
60
+ /** Lift the `limit` so every card shows. */
61
+ expand: () => void;
45
62
  };
46
63
 
47
64
  /** The default header renderer when no `columnHeader` snippet is given. */