@heyramzi/ui 2.13.0 → 2.15.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,15 +1,18 @@
1
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
3
  import { slide } from "svelte/transition";
7
4
  import KanbanColumn from "./kanban-column.svelte";
5
+ import KanbanColumnHead from "./kanban-column-head.svelte";
6
+ import KanbanFoldChevron from "./kanban-fold-chevron.svelte";
7
+ import KanbanHiddenRail from "./kanban-hidden-rail.svelte";
8
8
  import { liftDragImage } from "./kanban-drag.js";
9
- import { boardOrderAt } from "./board-order.js";
10
9
  import { cn } from "../lib/utils.js";
11
- import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
12
- import { CaretDown } from "phosphor-svelte";
10
+ import {
11
+ createKanbanBoardState,
12
+ type KanbanColumnView,
13
+ } from "./kanban-board-state.svelte.js";
14
+ import { Button } from "../shadcn/button/index.js";
15
+ import { workLooks } from "../components/work-parts/looks.js";
13
16
  import type {
14
17
  KanbanCardContext,
15
18
  KanbanColumn as KanbanColumnDef,
@@ -51,12 +54,26 @@
51
54
  /** The screen-reader name of a card. Defaults to a hint about the keyboard move. */
52
55
  getLabel?: (item: T) => string;
53
56
  emptyLabel?: string;
57
+ /** Shows a plus in each default column head that adds a card to that column. */
58
+ onAdd?: (columnId: string) => void;
59
+ /** The plus button's accessible name, followed by the column label. */
60
+ addLabel?: string;
54
61
  /**
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.
62
+ * Cards a column shows before the rest fold behind "Show N more": a number for every
63
+ * column, or per column (undefined for no cap). `Infinity` turns the fold off.
64
+ */
65
+ limit?: number | ((columnId: string) => number | undefined);
66
+ /** The fold's open button, given how many cards it holds. */
67
+ moreLabel?: (hidden: number) => string;
68
+ /** The fold's close button. */
69
+ lessLabel?: string;
70
+ /** The header caret's open and close names, followed by the column label. */
71
+ expandColumnLabel?: string;
72
+ collapseColumnLabel?: string;
73
+ /**
74
+ * Column ids the header caret opened. Pass one array to every board in a stack of
75
+ * swimlanes, so the caret opens that column in each. "Show N more" stays per board.
57
76
  */
58
- limit?: (columnId: string) => number | undefined;
59
- /** Column ids whose `limit` is lifted. Controlled; leave out to let the board remember. */
60
77
  expanded?: string[];
61
78
  onExpandedChange?: (ids: string[]) => void;
62
79
  /** Column ids folded into the rail on the right. Controlled; leave out to let the board remember. */
@@ -96,7 +113,13 @@
96
113
  getLabel,
97
114
  onSelect,
98
115
  emptyLabel = "Nothing here yet",
99
- limit,
116
+ onAdd,
117
+ addLabel = "Add a card",
118
+ limit = 5,
119
+ moreLabel = (hidden) => `Show ${hidden} more`,
120
+ lessLabel = "Show less",
121
+ expandColumnLabel = "Show every card in",
122
+ collapseColumnLabel = "Fold back",
100
123
  expanded,
101
124
  onExpandedChange,
102
125
  hidden,
@@ -109,214 +132,38 @@
109
132
  class: className,
110
133
  }: Props<T, C> = $props();
111
134
 
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
- * Alt + ArrowLeft/Right moves the focused card to the next droppable column, so
220
- * keyboard and screen-reader users get drag's onMove contract.
221
- */
222
- function onCardKeyDown(e: KeyboardEvent, id: string): void {
223
- if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
224
- e.preventDefault();
225
- onSelect(id);
226
- return;
227
- }
228
- if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
229
- const item = items.find((entry) => getId(entry) === id);
230
- if (!item) return;
231
- const current = getColumn(item);
232
- const at = columns.findIndex((c) => c.id === current);
233
- if (at === -1) return;
234
- const dir = e.key === "ArrowRight" ? 1 : -1;
235
- for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
236
- const target = columns[i];
237
- if (!canDrop(target.id) || hiddenSet.has(target.id) || target.id === current) continue;
238
- e.preventDefault();
239
- if (sortKey === "manual") {
240
- const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
241
- const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
242
- const index = atTop ? 0 : column.length;
243
- const before = column[index - 1];
244
- const after = column[index];
245
- void onMove(id, {
246
- columnId: target.id,
247
- ...(boardOrder
248
- ? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
249
- : {}),
250
- beforeId: before === undefined ? null : getId(before),
251
- afterId: after === undefined ? null : getId(after),
252
- });
253
- } else {
254
- void onMove(id, { columnId: target.id });
255
- }
256
- return;
257
- }
258
- }
259
-
260
- function onHiddenDragOver(e: DragEvent, columnId: string): void {
261
- if (!canDrop(columnId)) return;
262
- e.preventDefault();
263
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
264
- if (dragOverHidden !== columnId) dragOverHidden = columnId;
265
- dnd.clearDragOver();
266
- }
267
-
268
- function onHiddenDragLeave(e: DragEvent): void {
269
- if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) dragOverHidden = null;
270
- }
271
-
272
- // A card dropped on a folded column brings the column back and lands in it.
273
- function onHiddenDrop(e: DragEvent, columnId: string): void {
274
- dragOverHidden = null;
275
- dnd.onDrop(e, columnId);
276
- show(columnId);
277
- }
278
-
279
- let view = $derived(
280
- columns.map((column) => {
281
- 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
- };
290
- }),
291
- );
292
-
293
- let hiddenColumns = $derived(columns.filter((column) => hiddenSet.has(column.id)));
135
+ const board = createKanbanBoardState<T, C>(() => ({
136
+ columns,
137
+ items,
138
+ getId,
139
+ getColumn,
140
+ onMove,
141
+ boardOrder,
142
+ manualTiebreak,
143
+ sortKeys,
144
+ sortKey,
145
+ onSelect,
146
+ limit,
147
+ expanded,
148
+ onExpandedChange,
149
+ hidden,
150
+ onHiddenChange,
151
+ dropAtTop,
152
+ isDroppable,
153
+ }));
154
+ const dnd = board.dnd;
155
+ const rail = board.rail;
294
156
 
295
- function contextOf(
296
- column: C,
297
- all: T[],
298
- cards: T[],
299
- capped: boolean,
300
- active: boolean,
301
- ): KanbanColumnContext {
302
- return {
303
- count: all.length,
304
- active,
305
- shown: cards.length,
306
- capped,
307
- hide: () => hide(column.id),
308
- expand: () => expand(column.id),
309
- };
310
- }
157
+ type ColumnView = KanbanColumnView<T, C>;
311
158
  </script>
312
159
 
313
160
  <!-- The slot keeps the drag state where it is, or hovering it would bubble to the column and jump it to the end. -->
314
161
  {#snippet dropSlot()}
315
162
  <div
316
- class="kanban-drop-slot"
163
+ class={cn("kanban-drop-slot min-h-10 shrink-0", workLooks.hole)}
317
164
  aria-hidden="true"
318
- style:height="{slotHeight}px"
319
- transition:slide={slotMotion}
165
+ style:height="{board.slotHeight}px"
166
+ transition:slide={board.slotMotion}
320
167
  ondragover={(e) => {
321
168
  e.preventDefault();
322
169
  e.stopPropagation();
@@ -324,90 +171,117 @@
324
171
  ></div>
325
172
  {/snippet}
326
173
 
327
- {#snippet columnShell(
328
- column: C,
329
- all: T[],
330
- cards: T[],
331
- capped: boolean,
332
- extra: Record<string, unknown>,
333
- )}
174
+ {#snippet card(column: C, item: T, index: number, list: T[])}
175
+ {@const id = getId(item)}
176
+ {@const nextId = board.idAt(list, index + 1)}
177
+ {@const isSource = dnd.isDragSource(id)}
178
+ <!-- The slot right after the held card is its own spot, so the card's place shows it instead. -->
179
+ {@const atOrigin =
180
+ !dnd.dragOver || (dnd.dragOver.columnId === column.id && dnd.dragOver.overId === nextId)}
181
+ {#if dnd.showIndicatorBefore(column.id, id) && !isSource && board.idAt(list, index - 1) !== dnd.draggedId}
182
+ {@render dropSlot()}
183
+ {/if}
184
+ <!-- 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. -->
185
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
186
+ <div
187
+ role="listitem"
188
+ tabindex="0"
189
+ aria-label={getLabel?.(item) ??
190
+ "Card. Press Alt plus Left or Right arrow to move between columns."}
191
+ aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
192
+ draggable="true"
193
+ ondragstart={(e) => {
194
+ board.slotHeight = e.currentTarget.getBoundingClientRect().height;
195
+ liftDragImage(e);
196
+ dnd.onDragStart(e, id);
197
+ }}
198
+ ondragend={() => {
199
+ dnd.resetDrag();
200
+ rail.dragOverHidden = null;
201
+ }}
202
+ ondragover={(e) => dnd.onItemDragOver(e, column.id, id, nextId)}
203
+ onkeydown={(e) => board.onCardKeyDown(e, id)}
204
+ class={cn(
205
+ "kanban-card cursor-grab outline-none focus-visible:shadow-(--ring-focus) active:cursor-grabbing",
206
+ workLooks.card,
207
+ isSource && (atOrigin ? cn("kanban-origin-slot", workLooks.hole) : "kanban-lifted-out"),
208
+ )}
209
+ >
210
+ {@render renderCard(item, { columnId: column.id, index })}
211
+ </div>
212
+ {/snippet}
213
+
214
+ {#snippet columnShell(entry: ColumnView, extra: Record<string, unknown>)}
215
+ {@const { column, all, head, tail, open, capped } = entry}
334
216
  {@const active = dnd.dragOver?.columnId === column.id}
335
- {@const context = contextOf(column, all, cards, capped, active)}
217
+ {@const context = board.contextOf(entry, active)}
218
+ {@const showTail = tail.length > 0}
219
+ {@const list = [...head, ...tail]}
220
+ {@const shown = capped ? head : list}
336
221
  <KanbanColumn
337
222
  {...extra}
338
223
  hue={column.hue}
339
224
  {active}
340
- collapsing={collapsing.has(column.id)}
341
- expanding={expanding.has(column.id)}
225
+ collapsing={rail.collapsing.has(column.id)}
226
+ expanding={rail.expanding.has(column.id)}
342
227
  ariaLabel={column.ariaLabel ?? column.label}
343
228
  ondragover={(e) => dnd.onColumnDragOver(e, column.id)}
344
229
  ondragleave={dnd.onDragLeave}
345
230
  ondrop={(e) => dnd.onDrop(e, column.id)}
346
231
  >
347
232
  {#snippet header()}
348
- {#if columnHeader}
349
- {@render columnHeader(column, context)}
350
- {: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"
354
- >
355
- {all.length}
356
- </span>
357
- {/if}
233
+ <KanbanColumnHead
234
+ {column}
235
+ {context}
236
+ {columnHeader}
237
+ {onAdd}
238
+ {addLabel}
239
+ foldable={entry.foldable}
240
+ columnOpen={board.expandedIds.includes(column.id)}
241
+ onToggle={() => board.toggleColumn(column.id)}
242
+ {expandColumnLabel}
243
+ {collapseColumnLabel}
244
+ />
358
245
  {/snippet}
359
246
 
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>
247
+ {#each head as item, index (getId(item))}
248
+ {@render card(column, item, index, list)}
397
249
  {/each}
398
250
 
399
- {#if dnd.showIndicatorEnd(column.id) && idAt(cards, cards.length - 1) !== dnd.draggedId}
251
+ {#if showTail}
252
+ <!-- The tail sits shut in a 0fr row, so opening and closing animate its height. -->
253
+ <div class="kanban-fold" data-open={open} inert={!open}>
254
+ <div class="kanban-fold-inner">
255
+ {#each tail as item, index (getId(item))}
256
+ {@render card(column, item, head.length + index, list)}
257
+ {/each}
258
+ </div>
259
+ </div>
260
+ {/if}
261
+
262
+ {#if dnd.showIndicatorEnd(column.id) && board.idAt(shown, shown.length - 1) !== dnd.draggedId}
400
263
  {@render dropSlot()}
401
264
  {/if}
402
265
 
266
+ {#if tail.length > 0 || open}
267
+ <Button
268
+ variant="ghost"
269
+ size="sm"
270
+ aria-expanded={open}
271
+ onclick={() => (open ? board.collapse(column.id) : board.expand(column.id))}
272
+ class="shrink-0 justify-start gap-1.5 px-2 text-xs font-normal text-muted-foreground"
273
+ >
274
+ <KanbanFoldChevron {open} class="size-2.5" />
275
+ <span class="tabular-nums">{open ? lessLabel : moreLabel(tail.length)}</span>
276
+ </Button>
277
+ {/if}
278
+
403
279
  {#if columnFooter}
404
280
  {@render columnFooter(column, context)}
405
281
  {/if}
406
282
 
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
- >
283
+ {#if all.length === 0 && !active}
284
+ <div class={cn(workLooks.hole, "py-6 text-center text-xs text-muted-foreground")}>
411
285
  {emptyLabel}
412
286
  </div>
413
287
  {/if}
@@ -416,130 +290,52 @@
416
290
 
417
291
  <div
418
292
  class={cn(
419
- "kanban-board flex min-h-0 flex-1 items-stretch gap-4 overflow-x-auto pb-2",
293
+ "kanban-board @container/kanban-board flex min-h-0 flex-1 items-stretch gap-4 overflow-x-auto pb-2",
420
294
  "max-sm:-mx-4 max-sm:snap-x max-sm:snap-proximity max-sm:scroll-px-4 max-sm:px-4",
421
295
  className,
422
296
  )}
423
297
  >
424
- {#each view as { column, all, cards, capped } (column.id)}
425
- {#if !hiddenSet.has(column.id) || collapsing.has(column.id)}
298
+ {#each board.view as entry (entry.column.id)}
299
+ {@const column = entry.column}
300
+ {#if !rail.hiddenSet.has(column.id) || rail.collapsing.has(column.id)}
426
301
  {#if wrapColumn}
427
302
  {#snippet inner(props: Record<string, unknown>)}
428
- {@render columnShell(column, all, cards, capped, props)}
303
+ {@render columnShell(entry, props)}
429
304
  {/snippet}
430
305
  {@render wrapColumn(column, inner)}
431
306
  {:else}
432
- {@render columnShell(column, all, cards, capped, {})}
307
+ {@render columnShell(entry, {})}
433
308
  {/if}
434
309
  {/if}
435
310
  {/each}
436
311
 
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"><CaretDown weight="bold" /></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}
469
- {over ? 10 : 0}%, transparent)"
470
- style:outline="1px solid color-mix(in oklch, {hue}
471
- {over ? 28 : 0}%, transparent)"
472
- ondragover={(e) => onHiddenDragOver(e, column.id)}
473
- ondragleave={onHiddenDragLeave}
474
- ondrop={(e) => onHiddenDrop(e, column.id)}
475
- >
476
- <button
477
- type="button"
478
- 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"
480
- >
481
- {#if hiddenItem}
482
- {@render hiddenItem(column, { count, active: over })}
483
- {:else}
484
- <span class="truncate whitespace-nowrap">{column.label}</span>
485
- {#if count > 0}
486
- <span class="ms-auto text-xs text-muted-foreground/60 tabular-nums"
487
- >{count.toLocaleString()}</span
488
- >
489
- {/if}
490
- {/if}
491
- </button>
492
- </div>
493
- {/each}
494
-
495
- {#if hiddenColumns.length > 1}
496
- <button
497
- type="button"
498
- 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"
500
- >
501
- {showAllLabel}
502
- </button>
503
- {/if}
504
- </div>
505
- {/if}
506
- </div>
312
+ {#if board.hiddenColumns.length > 0}
313
+ <KanbanHiddenRail
314
+ columns={board.hiddenColumns}
315
+ {rail}
316
+ countOf={(columnId) => board.sortedByColumn(columnId).length}
317
+ {hiddenLabel}
318
+ {showAllLabel}
319
+ {hiddenItem}
320
+ />
507
321
  {/if}
508
322
  </div>
509
323
 
510
324
  <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
325
  /* A grid row folds the card out of the column smoothly, so the cards below slide up. */
526
326
  .kanban-card {
527
327
  display: grid;
528
328
  grid-template-columns: minmax(0, 1fr);
529
329
  grid-template-rows: 1fr;
530
330
  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);
331
+ grid-template-rows var(--duration-quick) var(--ease-smooth-out),
332
+ margin-top var(--duration-quick) var(--ease-smooth-out);
533
333
  }
534
334
 
535
335
  .kanban-card > :global(*) {
536
336
  min-height: 0;
537
337
  }
538
338
 
539
- .kanban-origin-slot > :global(*) {
540
- visibility: hidden;
541
- }
542
-
543
339
  /* -0.375rem cancels the column's gap-1.5, so a folded card leaves no gap behind. */
544
340
  .kanban-lifted-out {
545
341
  grid-template-rows: 0fr;
@@ -551,14 +347,43 @@
551
347
  visibility: hidden;
552
348
  }
553
349
 
554
- .kanban-rail-item-leaving {
555
- pointer-events: none;
350
+ /* The fold: height rides grid-template-rows 0fr to 1fr, the fade runs shorter so it leads.
351
+ Shut, -0.375rem cancels the well's gap-1.5, so a closed fold leaves no gap behind. */
352
+ .kanban-fold {
353
+ display: grid;
354
+ grid-template-rows: 0fr;
355
+ margin-top: -0.375rem;
556
356
  opacity: 0;
557
- transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
357
+ transition:
358
+ grid-template-rows var(--duration-fast) var(--ease-smooth-out),
359
+ margin-top var(--duration-fast) var(--ease-smooth-out),
360
+ opacity var(--duration-quick) var(--ease-smooth-out);
361
+ }
362
+
363
+ .kanban-fold[data-open="true"] {
364
+ grid-template-rows: 1fr;
365
+ margin-top: 0;
366
+ opacity: 1;
367
+ }
368
+
369
+ /* clip, not hidden: the margin lets a lifted card's shadow out while the fold stays shut. */
370
+ .kanban-fold-inner {
371
+ display: flex;
372
+ flex-direction: column;
373
+ gap: 0.375rem;
374
+ min-height: 0;
375
+ overflow: clip;
376
+ overflow-clip-margin: 0.375rem;
377
+ }
378
+
379
+ /* The held card's place keeps its hole, never its content. */
380
+ .kanban-origin-slot > :global(*) {
381
+ visibility: hidden;
558
382
  }
559
383
 
560
384
  @media (prefers-reduced-motion: reduce) {
561
- .kanban-card {
385
+ .kanban-card,
386
+ .kanban-fold {
562
387
  transition: none;
563
388
  }
564
389
  }