@heyramzi/ui 2.12.0 → 2.14.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.
@@ -1,7 +1,8 @@
1
1
  <script lang="ts" generics="T, C extends KanbanColumnDef = KanbanColumnDef">
2
- import type { Snippet } from "svelte";
2
+ import { untrack, type Snippet } from "svelte";
3
3
  import { SvelteSet } from "svelte/reactivity";
4
4
  import { prefersReducedMotion } from "svelte/motion";
5
+ import { durationMs } from "../lib/motion.js";
5
6
  import { cubicOut } from "svelte/easing";
6
7
  import { slide } from "svelte/transition";
7
8
  import KanbanColumn from "./kanban-column.svelte";
@@ -10,6 +11,9 @@
10
11
  import { cn } from "../lib/utils.js";
11
12
  import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
12
13
  import { CaretDown } from "phosphor-svelte";
14
+ import { Button } from "../shadcn/button/index.js";
15
+ import GroupHead from "../components/work-parts/group-head.svelte";
16
+ import { workLooks } from "../components/work-parts/looks.js";
13
17
  import type {
14
18
  KanbanCardContext,
15
19
  KanbanColumn as KanbanColumnDef,
@@ -51,12 +55,26 @@
51
55
  /** The screen-reader name of a card. Defaults to a hint about the keyboard move. */
52
56
  getLabel?: (item: T) => string;
53
57
  emptyLabel?: string;
58
+ /** Shows a plus in each default column head that adds a card to that column. */
59
+ onAdd?: (columnId: string) => void;
60
+ /** The plus button's accessible name, followed by the column label. */
61
+ addLabel?: string;
54
62
  /**
55
- * Cards a column shows before the rest fold behind a footer, or undefined for no cap; a
56
- * capped column drops on top, since its tail is out of sight.
63
+ * Cards a column shows before the rest fold behind "Show N more": a number for every
64
+ * column, or per column (undefined for no cap). `Infinity` turns the fold off.
65
+ */
66
+ limit?: number | ((columnId: string) => number | undefined);
67
+ /** The fold's open button, given how many cards it holds. */
68
+ moreLabel?: (hidden: number) => string;
69
+ /** The fold's close button. */
70
+ lessLabel?: string;
71
+ /** The header caret's open and close names, followed by the column label. */
72
+ expandColumnLabel?: string;
73
+ collapseColumnLabel?: string;
74
+ /**
75
+ * Column ids the header caret opened. Pass one array to every board in a stack of
76
+ * swimlanes, so the caret opens that column in each. "Show N more" stays per board.
57
77
  */
58
- limit?: (columnId: string) => number | undefined;
59
- /** Column ids whose `limit` is lifted. Controlled; leave out to let the board remember. */
60
78
  expanded?: string[];
61
79
  onExpandedChange?: (ids: string[]) => void;
62
80
  /** Column ids folded into the rail on the right. Controlled; leave out to let the board remember. */
@@ -96,7 +114,13 @@
96
114
  getLabel,
97
115
  onSelect,
98
116
  emptyLabel = "Nothing here yet",
99
- limit,
117
+ onAdd,
118
+ addLabel = "Add a card",
119
+ limit = 5,
120
+ moreLabel = (hidden) => `Show ${hidden} more`,
121
+ lessLabel = "Show less",
122
+ expandColumnLabel = "Show every card in",
123
+ collapseColumnLabel = "Fold back",
100
124
  expanded,
101
125
  onExpandedChange,
102
126
  hidden,
@@ -111,7 +135,10 @@
111
135
 
112
136
  // The drop slot takes the held card's height, so the column doesn't jump when it lands.
113
137
  let slotHeight = $state(0);
114
- let slotMotion = $derived({ duration: prefersReducedMotion.current ? 0 : 180, easing: cubicOut });
138
+ let slotMotion = $derived({
139
+ duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
140
+ easing: cubicOut,
141
+ });
115
142
 
116
143
  let internalExpanded = $state<string[]>([]);
117
144
  let internalHidden = $state<string[]>([]);
@@ -152,22 +179,78 @@
152
179
  return item === undefined ? null : getId(item);
153
180
  }
154
181
 
182
+ function capOf(columnId: string): number | undefined {
183
+ return typeof limit === "function" ? limit(columnId) : limit;
184
+ }
185
+
186
+ /** The column holds more cards than its cap, so it gets a fold. */
187
+ function isFoldable(columnId: string, total: number): boolean {
188
+ const cap = capOf(columnId);
189
+ return cap !== undefined && total > cap;
190
+ }
191
+
192
+ // Open from this board's "Show N more", on top of the header caret's `expanded`.
193
+ const laneOpen = new SvelteSet<string>();
194
+
195
+ function isOpen(columnId: string): boolean {
196
+ return expandedIds.includes(columnId) || laneOpen.has(columnId);
197
+ }
198
+
199
+ /** The fold is shut, so the tail is out of sight. */
155
200
  function isCapped(columnId: string, total: number): boolean {
156
- const cap = limit?.(columnId);
157
- return cap !== undefined && !expandedIds.includes(columnId) && total > cap;
201
+ return isFoldable(columnId, total) && !isOpen(columnId);
202
+ }
203
+
204
+ // A card moved into a folded column stays in view, wherever its sort puts it.
205
+ const pinned = new SvelteSet<string>();
206
+
207
+ // The caret folding a column shuts this board's own fold of it too, so every lane lands on the cap.
208
+ let previousExpanded: string[] = [];
209
+ $effect(() => {
210
+ const now = expandedIds;
211
+ untrack(() => {
212
+ for (const id of previousExpanded) if (!now.includes(id)) shut(id);
213
+ });
214
+ previousExpanded = [...now];
215
+ });
216
+
217
+ function moveAndPin(id: string, move: KanbanMove): void | Promise<void> {
218
+ pinned.add(id);
219
+ return onMove(id, move);
158
220
  }
159
221
 
160
222
  function canDrop(columnId: string): boolean {
161
223
  return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
162
224
  }
163
225
 
164
- function expand(columnId: string): void {
165
- if (expandedIds.includes(columnId)) return;
166
- const next = [...expandedIds, columnId];
226
+ function setExpanded(next: string[]): void {
167
227
  internalExpanded = next;
168
228
  onExpandedChange?.(next);
169
229
  }
170
230
 
231
+ // Shutting drops the column's pins, so the fold closes to exactly its cap.
232
+ function shut(columnId: string): void {
233
+ laneOpen.delete(columnId);
234
+ for (const item of sortedByColumn(columnId)) pinned.delete(getId(item));
235
+ }
236
+
237
+ /** "Show N more": opens this board's cell only. */
238
+ function expand(columnId: string): void {
239
+ laneOpen.add(columnId);
240
+ }
241
+
242
+ /** "Show less": shuts this board's cell, and lifts the caret's open if it was the caret's. */
243
+ function collapse(columnId: string): void {
244
+ shut(columnId);
245
+ if (expandedIds.includes(columnId)) setExpanded(expandedIds.filter((id) => id !== columnId));
246
+ }
247
+
248
+ /** The header caret: the whole column, in every board that shares `expanded`. */
249
+ function toggleColumn(columnId: string): void {
250
+ if (expandedIds.includes(columnId)) collapse(columnId);
251
+ else setExpanded([...expandedIds, columnId]);
252
+ }
253
+
171
254
  function setHidden(next: string[]): void {
172
255
  internalHidden = next;
173
256
  onHiddenChange?.(next);
@@ -209,7 +292,7 @@
209
292
  getColumnItems: sortedByColumn,
210
293
  getSortKey: () => sortKey,
211
294
  boardOrder: (item) => boardOrder?.(item),
212
- onMove: (id, move) => onMove(id, move),
295
+ onMove: moveAndPin,
213
296
  dropAtTop: (columnId) =>
214
297
  dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
215
298
  isDroppable: canDrop,
@@ -242,7 +325,7 @@
242
325
  const index = atTop ? 0 : column.length;
243
326
  const before = column[index - 1];
244
327
  const after = column[index];
245
- void onMove(id, {
328
+ void moveAndPin(id, {
246
329
  columnId: target.id,
247
330
  ...(boardOrder
248
331
  ? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
@@ -251,7 +334,7 @@
251
334
  afterId: after === undefined ? null : getId(after),
252
335
  });
253
336
  } else {
254
- void onMove(id, { columnId: target.id });
337
+ void moveAndPin(id, { columnId: target.id });
255
338
  }
256
339
  return;
257
340
  }
@@ -279,33 +362,36 @@
279
362
  let view = $derived(
280
363
  columns.map((column) => {
281
364
  const all = sortedByColumn(column.id);
282
- const capped = isCapped(column.id, all.length);
283
- const cap = limit?.(column.id);
284
- return {
285
- column,
286
- all,
287
- cards: capped && cap !== undefined ? all.slice(0, cap) : all,
288
- capped,
289
- };
365
+ const cap = capOf(column.id);
366
+ const foldable = isFoldable(column.id, all.length);
367
+ const open = foldable && isOpen(column.id);
368
+ if (!foldable || cap === undefined) {
369
+ return { column, all, head: all, tail: [] as T[], foldable, open, capped: false };
370
+ }
371
+ // Head: the first `cap` cards plus any pinned ones, in sort order. The fold holds the rest.
372
+ const head: T[] = [];
373
+ const tail: T[] = [];
374
+ all.forEach((item, index) => {
375
+ (index < cap || pinned.has(getId(item)) ? head : tail).push(item);
376
+ });
377
+ return { column, all, head, tail, foldable, open, capped: !open && tail.length > 0 };
290
378
  }),
291
379
  );
292
380
 
293
381
  let hiddenColumns = $derived(columns.filter((column) => hiddenSet.has(column.id)));
294
382
 
295
- function contextOf(
296
- column: C,
297
- all: T[],
298
- cards: T[],
299
- capped: boolean,
300
- active: boolean,
301
- ): KanbanColumnContext {
383
+ type ColumnView = (typeof view)[number];
384
+
385
+ function contextOf(entry: ColumnView, active: boolean): KanbanColumnContext {
386
+ const { column, all, head, capped } = entry;
302
387
  return {
303
388
  count: all.length,
304
389
  active,
305
- shown: cards.length,
390
+ shown: capped ? head.length : all.length,
306
391
  capped,
307
392
  hide: () => hide(column.id),
308
393
  expand: () => expand(column.id),
394
+ collapse: () => collapse(column.id),
309
395
  };
310
396
  }
311
397
  </script>
@@ -313,7 +399,7 @@
313
399
  <!-- The slot keeps the drag state where it is, or hovering it would bubble to the column and jump it to the end. -->
314
400
  {#snippet dropSlot()}
315
401
  <div
316
- class="kanban-drop-slot"
402
+ class={cn("kanban-drop-slot min-h-10 shrink-0", workLooks.hole)}
317
403
  aria-hidden="true"
318
404
  style:height="{slotHeight}px"
319
405
  transition:slide={slotMotion}
@@ -324,15 +410,54 @@
324
410
  ></div>
325
411
  {/snippet}
326
412
 
327
- {#snippet columnShell(
328
- column: C,
329
- all: T[],
330
- cards: T[],
331
- capped: boolean,
332
- extra: Record<string, unknown>,
333
- )}
413
+ {#snippet card(column: C, item: T, index: number, list: T[])}
414
+ {@const id = getId(item)}
415
+ {@const nextId = idAt(list, index + 1)}
416
+ {@const isSource = dnd.isDragSource(id)}
417
+ <!-- The slot right after the held card is its own spot, so the card's place shows it instead. -->
418
+ {@const atOrigin =
419
+ !dnd.dragOver || (dnd.dragOver.columnId === column.id && dnd.dragOver.overId === nextId)}
420
+ {#if dnd.showIndicatorBefore(column.id, id) && !isSource && idAt(list, index - 1) !== dnd.draggedId}
421
+ {@render dropSlot()}
422
+ {/if}
423
+ <!-- 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. -->
424
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
425
+ <div
426
+ role="listitem"
427
+ tabindex="0"
428
+ aria-label={getLabel?.(item) ??
429
+ "Card. Press Alt plus Left or Right arrow to move between columns."}
430
+ aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
431
+ draggable="true"
432
+ ondragstart={(e) => {
433
+ slotHeight = e.currentTarget.getBoundingClientRect().height;
434
+ liftDragImage(e);
435
+ dnd.onDragStart(e, id);
436
+ }}
437
+ ondragend={() => {
438
+ dnd.resetDrag();
439
+ dragOverHidden = null;
440
+ }}
441
+ ondragover={(e) => dnd.onItemDragOver(e, column.id, id, nextId)}
442
+ onkeydown={(e) => onCardKeyDown(e, id)}
443
+ class={cn(
444
+ "kanban-card cursor-grab outline-none focus-visible:shadow-(--ring-focus) active:cursor-grabbing",
445
+ workLooks.card,
446
+ isSource && (atOrigin ? cn("kanban-origin-slot", workLooks.hole) : "kanban-lifted-out"),
447
+ )}
448
+ >
449
+ {@render renderCard(item, { columnId: column.id, index })}
450
+ </div>
451
+ {/snippet}
452
+
453
+ {#snippet columnShell(entry: ColumnView, extra: Record<string, unknown>)}
454
+ {@const { column, all, head, tail, open, capped } = entry}
334
455
  {@const active = dnd.dragOver?.columnId === column.id}
335
- {@const context = contextOf(column, all, cards, capped, active)}
456
+ {@const context = contextOf(entry, active)}
457
+ {@const showTail = tail.length > 0}
458
+ {@const list = [...head, ...tail]}
459
+ {@const columnOpen = expandedIds.includes(column.id)}
460
+ {@const shown = capped ? head : list}
336
461
  <KanbanColumn
337
462
  {...extra}
338
463
  hue={column.hue}
@@ -348,66 +473,80 @@
348
473
  {#if columnHeader}
349
474
  {@render columnHeader(column, context)}
350
475
  {:else}
351
- <span class="truncate text-sm font-medium text-foreground">{column.label}</span>
352
- <span
353
- 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"
476
+ <GroupHead
477
+ label={column.label}
478
+ hue={column.hue}
479
+ count={all.length}
480
+ onAdd={onAdd ? () => onAdd(column.id) : undefined}
481
+ {addLabel}
482
+ />
483
+ {/if}
484
+ {#if entry.foldable}
485
+ <!-- Opens the column in every lane; "Show N more" opens one. Hiding into the rail stays the header's own button. -->
486
+ <Button
487
+ variant="ghost"
488
+ size="sm"
489
+ icon
490
+ aria-expanded={columnOpen}
491
+ aria-label="{columnOpen ? collapseColumnLabel : expandColumnLabel} {column.label}"
492
+ onclick={() => toggleColumn(column.id)}
493
+ class="-me-1 ms-auto size-6 shrink-0 text-muted-foreground/60 hover:text-foreground"
354
494
  >
355
- {all.length}
356
- </span>
495
+ <span
496
+ class={cn(
497
+ "kanban-fold-chevron size-3 [&>svg]:size-full",
498
+ columnOpen && "kanban-fold-chevron-open",
499
+ )}
500
+ aria-hidden="true"><CaretDown weight="bold" /></span
501
+ >
502
+ </Button>
357
503
  {/if}
358
504
  {/snippet}
359
505
 
360
- {#each cards as item, index (getId(item))}
361
- {@const id = getId(item)}
362
- {@const nextId = idAt(cards, index + 1)}
363
- {@const isSource = dnd.isDragSource(id)}
364
- <!-- The slot right after the held card is its own spot, so the card's place shows it instead. -->
365
- {@const atOrigin =
366
- !dnd.dragOver || (dnd.dragOver.columnId === column.id && dnd.dragOver.overId === nextId)}
367
- {#if dnd.showIndicatorBefore(column.id, id) && !isSource && idAt(cards, index - 1) !== dnd.draggedId}
368
- {@render dropSlot()}
369
- {/if}
370
- <!-- 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. -->
371
- <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
372
- <div
373
- role="listitem"
374
- tabindex="0"
375
- aria-label={getLabel?.(item) ??
376
- "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>
506
+ {#each head as item, index (getId(item))}
507
+ {@render card(column, item, index, list)}
397
508
  {/each}
398
509
 
399
- {#if dnd.showIndicatorEnd(column.id) && idAt(cards, cards.length - 1) !== dnd.draggedId}
510
+ {#if showTail}
511
+ <!-- The tail sits shut in a 0fr row, so opening and closing animate its height. -->
512
+ <div class="kanban-fold" data-open={open} inert={!open}>
513
+ <div class="kanban-fold-inner">
514
+ {#each tail as item, index (getId(item))}
515
+ {@render card(column, item, head.length + index, list)}
516
+ {/each}
517
+ </div>
518
+ </div>
519
+ {/if}
520
+
521
+ {#if dnd.showIndicatorEnd(column.id) && idAt(shown, shown.length - 1) !== dnd.draggedId}
400
522
  {@render dropSlot()}
401
523
  {/if}
402
524
 
525
+ {#if tail.length > 0 || open}
526
+ <Button
527
+ variant="ghost"
528
+ size="sm"
529
+ aria-expanded={open}
530
+ onclick={() => (open ? collapse(column.id) : expand(column.id))}
531
+ class="shrink-0 justify-start gap-1.5 px-2 text-xs font-normal text-muted-foreground"
532
+ >
533
+ <span
534
+ class={cn(
535
+ "kanban-fold-chevron size-2.5 [&>svg]:size-full",
536
+ open && "kanban-fold-chevron-open",
537
+ )}
538
+ aria-hidden="true"><CaretDown weight="bold" /></span
539
+ >
540
+ <span class="tabular-nums">{open ? lessLabel : moreLabel(tail.length)}</span>
541
+ </Button>
542
+ {/if}
543
+
403
544
  {#if columnFooter}
404
545
  {@render columnFooter(column, context)}
405
546
  {/if}
406
547
 
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
- >
548
+ {#if all.length === 0 && !active}
549
+ <div class={cn(workLooks.hole, "py-6 text-center text-xs text-muted-foreground")}>
411
550
  {emptyLabel}
412
551
  </div>
413
552
  {/if}
@@ -421,36 +560,38 @@
421
560
  className,
422
561
  )}
423
562
  >
424
- {#each view as { column, all, cards, capped } (column.id)}
563
+ {#each view as entry (entry.column.id)}
564
+ {@const column = entry.column}
425
565
  {#if !hiddenSet.has(column.id) || collapsing.has(column.id)}
426
566
  {#if wrapColumn}
427
567
  {#snippet inner(props: Record<string, unknown>)}
428
- {@render columnShell(column, all, cards, capped, props)}
568
+ {@render columnShell(entry, props)}
429
569
  {/snippet}
430
570
  {@render wrapColumn(column, inner)}
431
571
  {:else}
432
- {@render columnShell(column, all, cards, capped, {})}
572
+ {@render columnShell(entry, {})}
433
573
  {/if}
434
574
  {/if}
435
575
  {/each}
436
576
 
437
577
  {#if hiddenColumns.length > 0}
438
578
  <div class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
439
- <button
440
- type="button"
579
+ <Button
580
+ variant="ghost"
581
+ size="sm"
441
582
  aria-expanded={railOpen}
442
583
  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"
584
+ class="justify-start px-2 text-xs font-normal text-muted-foreground"
444
585
  >
445
586
  <span
446
587
  class={cn(
447
- "size-2.5 transition-transform duration-[var(--duration-fast,160ms)] ease-[var(--ease-smooth-out,ease-out)] [&>svg]:size-full",
588
+ "size-2.5 transition-transform duration-fast ease-smooth-out motion-reduce:transition-none [&>svg]:size-full",
448
589
  !railOpen && "-rotate-90",
449
590
  )}
450
591
  aria-hidden="true"><CaretDown weight="bold" /></span
451
592
  >
452
593
  {hiddenLabel}
453
- </button>
594
+ </Button>
454
595
 
455
596
  {#if railOpen}
456
597
  <div class="flex flex-col gap-0.5">
@@ -462,7 +603,8 @@
462
603
  role="region"
463
604
  aria-label={column.ariaLabel ?? column.label}
464
605
  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)]",
606
+ "kanban-rail-item transition-[background-color,outline-color] duration-quick ease-smooth-out",
607
+ workLooks.row,
466
608
  leaving.has(column.id) && "kanban-rail-item-leaving",
467
609
  )}
468
610
  style:background="color-mix(in oklch, {hue}
@@ -473,10 +615,11 @@
473
615
  ondragleave={onHiddenDragLeave}
474
616
  ondrop={(e) => onHiddenDrop(e, column.id)}
475
617
  >
476
- <button
477
- type="button"
618
+ <Button
619
+ variant="ghost"
620
+ size="lg"
478
621
  onclick={() => show(column.id)}
479
- 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"
622
+ class="w-full justify-start gap-2 text-xs text-muted-foreground"
480
623
  >
481
624
  {#if hiddenItem}
482
625
  {@render hiddenItem(column, { count, active: over })}
@@ -488,18 +631,19 @@
488
631
  >
489
632
  {/if}
490
633
  {/if}
491
- </button>
634
+ </Button>
492
635
  </div>
493
636
  {/each}
494
637
 
495
638
  {#if hiddenColumns.length > 1}
496
- <button
497
- type="button"
639
+ <Button
640
+ variant="ghost"
641
+ size="sm"
498
642
  onclick={showAll}
499
- 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"
643
+ class="mt-1 justify-start text-xs font-normal text-muted-foreground"
500
644
  >
501
645
  {showAllLabel}
502
- </button>
646
+ </Button>
503
647
  {/if}
504
648
  </div>
505
649
  {/if}
@@ -508,38 +652,20 @@
508
652
  </div>
509
653
 
510
654
  <style>
511
- /* The drop slot and the held card's own place share a look: a dashed hole the card's size. */
512
- .kanban-drop-slot,
513
- .kanban-origin-slot {
514
- border-radius: var(--radius-lg, 0.5rem);
515
- background: color-mix(in oklch, var(--color-muted, #e5e7eb) 70%, transparent);
516
- outline: 1px dashed var(--color-border, #d1d5db);
517
- outline-offset: -1px;
518
- }
519
-
520
- .kanban-drop-slot {
521
- flex-shrink: 0;
522
- min-height: 2.5rem;
523
- }
524
-
525
655
  /* A grid row folds the card out of the column smoothly, so the cards below slide up. */
526
656
  .kanban-card {
527
657
  display: grid;
528
658
  grid-template-columns: minmax(0, 1fr);
529
659
  grid-template-rows: 1fr;
530
660
  transition:
531
- grid-template-rows var(--duration-quick, 180ms) var(--ease-smooth-out, ease-out),
532
- margin-top var(--duration-quick, 180ms) var(--ease-smooth-out, ease-out);
661
+ grid-template-rows var(--duration-quick) var(--ease-smooth-out),
662
+ margin-top var(--duration-quick) var(--ease-smooth-out);
533
663
  }
534
664
 
535
665
  .kanban-card > :global(*) {
536
666
  min-height: 0;
537
667
  }
538
668
 
539
- .kanban-origin-slot > :global(*) {
540
- visibility: hidden;
541
- }
542
-
543
669
  /* -0.375rem cancels the column's gap-1.5, so a folded card leaves no gap behind. */
544
670
  .kanban-lifted-out {
545
671
  grid-template-rows: 0fr;
@@ -551,14 +677,60 @@
551
677
  visibility: hidden;
552
678
  }
553
679
 
680
+ /* The fold: height rides grid-template-rows 0fr to 1fr, the fade runs shorter so it leads.
681
+ Shut, -0.375rem cancels the well's gap-1.5, so a closed fold leaves no gap behind. */
682
+ .kanban-fold {
683
+ display: grid;
684
+ grid-template-rows: 0fr;
685
+ margin-top: -0.375rem;
686
+ opacity: 0;
687
+ transition:
688
+ grid-template-rows var(--duration-fast) var(--ease-smooth-out),
689
+ margin-top var(--duration-fast) var(--ease-smooth-out),
690
+ opacity var(--duration-quick) var(--ease-smooth-out);
691
+ }
692
+
693
+ .kanban-fold[data-open="true"] {
694
+ grid-template-rows: 1fr;
695
+ margin-top: 0;
696
+ opacity: 1;
697
+ }
698
+
699
+ /* clip, not hidden: the margin lets a lifted card's shadow out while the fold stays shut. */
700
+ .kanban-fold-inner {
701
+ display: flex;
702
+ flex-direction: column;
703
+ gap: 0.375rem;
704
+ min-height: 0;
705
+ overflow: clip;
706
+ overflow-clip-margin: 0.375rem;
707
+ }
708
+
709
+ .kanban-fold-chevron {
710
+ display: inline-flex;
711
+ transition: transform var(--duration-fast) var(--ease-smooth-out);
712
+ }
713
+
714
+ .kanban-fold-chevron-open {
715
+ transform: scaleY(-1);
716
+ }
717
+
554
718
  .kanban-rail-item-leaving {
555
719
  pointer-events: none;
556
720
  opacity: 0;
557
- transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
721
+ transition: opacity var(--duration-quick) var(--ease-smooth-out);
722
+ }
723
+
724
+ /* The held card's place keeps its hole, never its content. */
725
+ .kanban-origin-slot > :global(*) {
726
+ visibility: hidden;
558
727
  }
559
728
 
560
729
  @media (prefers-reduced-motion: reduce) {
561
- .kanban-card {
730
+ .kanban-card,
731
+ .kanban-fold,
732
+ .kanban-fold-chevron,
733
+ .kanban-rail-item-leaving {
562
734
  transition: none;
563
735
  }
564
736
  }
@@ -56,8 +56,10 @@ export type KanbanColumnContext = {
56
56
  capped: boolean;
57
57
  /** Fold the column into the hidden rail. Needs `hidden` or `onHiddenChange` on the board. */
58
58
  hide: () => void;
59
- /** Lift the `limit` so every card shows. */
59
+ /** Open the fold so every card shows. */
60
60
  expand: () => void;
61
+ /** Shut the fold back down to `limit`. */
62
+ collapse: () => void;
61
63
  };
62
64
 
63
65
  /** The default header renderer when no `columnHeader` snippet is given. */