@heyramzi/ui 2.14.0 → 2.15.1

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,18 +1,17 @@
1
1
  <script lang="ts" generics="T, C extends KanbanColumnDef = KanbanColumnDef">
2
- import { untrack, type Snippet } from "svelte";
3
- import { SvelteSet } from "svelte/reactivity";
4
- import { prefersReducedMotion } from "svelte/motion";
5
- import { durationMs } from "../lib/motion.js";
6
- import { cubicOut } from "svelte/easing";
2
+ import type { Snippet } from "svelte";
7
3
  import { slide } from "svelte/transition";
8
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";
9
8
  import { liftDragImage } from "./kanban-drag.js";
10
- import { boardOrderAt } from "./board-order.js";
11
9
  import { cn } from "../lib/utils.js";
12
- import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
13
- import { CaretDown } from "phosphor-svelte";
10
+ import {
11
+ createKanbanBoardState,
12
+ type KanbanColumnView,
13
+ } from "./kanban-board-state.svelte.js";
14
14
  import { Button } from "../shadcn/button/index.js";
15
- import GroupHead from "../components/work-parts/group-head.svelte";
16
15
  import { workLooks } from "../components/work-parts/looks.js";
17
16
  import type {
18
17
  KanbanCardContext,
@@ -133,267 +132,29 @@
133
132
  class: className,
134
133
  }: Props<T, C> = $props();
135
134
 
136
- // The drop slot takes the held card's height, so the column doesn't jump when it lands.
137
- let slotHeight = $state(0);
138
- let slotMotion = $derived({
139
- duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
140
- easing: cubicOut,
141
- });
142
-
143
- let internalExpanded = $state<string[]>([]);
144
- let internalHidden = $state<string[]>([]);
145
- let expandedIds = $derived(expanded ?? internalExpanded);
146
- let hiddenIds = $derived(hidden ?? internalHidden);
147
- let hiddenSet = $derived(new Set(hiddenIds));
148
-
149
- let railOpen = $state(true);
150
- let dragOverHidden = $state<string | null>(null);
151
- const collapsing = new SvelteSet<string>();
152
- const expanding = new SvelteSet<string>();
153
- const leaving = new SvelteSet<string>();
154
-
155
- function compare(a: T, b: T): number {
156
- if (sortKey === "manual") {
157
- const tie = manualTiebreak?.(a, b) ?? 0;
158
- if (!boardOrder) return tie;
159
- const ao = boardOrder(a);
160
- const bo = boardOrder(b);
161
- if (ao == null && bo == null) return tie;
162
- if (ao == null) return 1;
163
- if (bo == null) return -1;
164
- return ao < bo ? -1 : ao > bo ? 1 : tie;
165
- }
166
- const key = sortKeys.find((entry) => entry.id === sortKey);
167
- return key ? key.compare(a, b) : 0;
168
- }
169
-
170
- function sortedByColumn(columnId: string): T[] {
171
- return items
172
- .filter((item) => getColumn(item) === columnId)
173
- .slice()
174
- .sort(compare);
175
- }
176
-
177
- function idAt(list: T[], index: number): string | null {
178
- const item = list[index];
179
- return item === undefined ? null : getId(item);
180
- }
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. */
200
- function isCapped(columnId: string, total: number): boolean {
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);
220
- }
221
-
222
- function canDrop(columnId: string): boolean {
223
- return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
224
- }
225
-
226
- function setExpanded(next: string[]): void {
227
- internalExpanded = next;
228
- onExpandedChange?.(next);
229
- }
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
-
254
- function setHidden(next: string[]): void {
255
- internalHidden = next;
256
- onHiddenChange?.(next);
257
- }
258
-
259
- // Fold: the column fades out first, then leaves the row and joins the rail.
260
- function hide(columnId: string): void {
261
- if (hiddenSet.has(columnId) || collapsing.has(columnId)) return;
262
- collapsing.add(columnId);
263
- setTimeout(() => {
264
- setHidden([...hiddenIds, columnId]);
265
- collapsing.delete(columnId);
266
- }, 300);
267
- }
268
-
269
- // Unfold: the rail entry leaves, then the column grows back in.
270
- function show(columnId: string): void {
271
- leaving.add(columnId);
272
- setTimeout(() => {
273
- leaving.delete(columnId);
274
- expanding.add(columnId);
275
- setHidden(hiddenIds.filter((id) => id !== columnId));
276
- setTimeout(() => expanding.delete(columnId), 350);
277
- }, 200);
278
- }
279
-
280
- function showAll(): void {
281
- for (const id of hiddenIds) leaving.add(id);
282
- setTimeout(() => {
283
- leaving.clear();
284
- setHidden([]);
285
- }, 200);
286
- }
287
-
288
- const dnd = createKanbanDnd<T>({
289
- items: () => items,
290
- getId: (item) => getId(item),
291
- getColumn: (item) => getColumn(item),
292
- getColumnItems: sortedByColumn,
293
- getSortKey: () => sortKey,
294
- boardOrder: (item) => boardOrder?.(item),
295
- onMove: moveAndPin,
296
- dropAtTop: (columnId) =>
297
- dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
298
- isDroppable: canDrop,
299
- });
300
-
301
- /**
302
- * Alt + ArrowLeft/Right moves the focused card to the next droppable column, so
303
- * keyboard and screen-reader users get drag's onMove contract.
304
- */
305
- function onCardKeyDown(e: KeyboardEvent, id: string): void {
306
- if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
307
- e.preventDefault();
308
- onSelect(id);
309
- return;
310
- }
311
- if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
312
- const item = items.find((entry) => getId(entry) === id);
313
- if (!item) return;
314
- const current = getColumn(item);
315
- const at = columns.findIndex((c) => c.id === current);
316
- if (at === -1) return;
317
- const dir = e.key === "ArrowRight" ? 1 : -1;
318
- for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
319
- const target = columns[i];
320
- if (!canDrop(target.id) || hiddenSet.has(target.id) || target.id === current) continue;
321
- e.preventDefault();
322
- if (sortKey === "manual") {
323
- const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
324
- const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
325
- const index = atTop ? 0 : column.length;
326
- const before = column[index - 1];
327
- const after = column[index];
328
- void moveAndPin(id, {
329
- columnId: target.id,
330
- ...(boardOrder
331
- ? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
332
- : {}),
333
- beforeId: before === undefined ? null : getId(before),
334
- afterId: after === undefined ? null : getId(after),
335
- });
336
- } else {
337
- void moveAndPin(id, { columnId: target.id });
338
- }
339
- return;
340
- }
341
- }
342
-
343
- function onHiddenDragOver(e: DragEvent, columnId: string): void {
344
- if (!canDrop(columnId)) return;
345
- e.preventDefault();
346
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
347
- if (dragOverHidden !== columnId) dragOverHidden = columnId;
348
- dnd.clearDragOver();
349
- }
350
-
351
- function onHiddenDragLeave(e: DragEvent): void {
352
- if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) dragOverHidden = null;
353
- }
354
-
355
- // A card dropped on a folded column brings the column back and lands in it.
356
- function onHiddenDrop(e: DragEvent, columnId: string): void {
357
- dragOverHidden = null;
358
- dnd.onDrop(e, columnId);
359
- show(columnId);
360
- }
361
-
362
- let view = $derived(
363
- columns.map((column) => {
364
- const all = sortedByColumn(column.id);
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 };
378
- }),
379
- );
380
-
381
- let hiddenColumns = $derived(columns.filter((column) => hiddenSet.has(column.id)));
382
-
383
- type ColumnView = (typeof view)[number];
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;
384
156
 
385
- function contextOf(entry: ColumnView, active: boolean): KanbanColumnContext {
386
- const { column, all, head, capped } = entry;
387
- return {
388
- count: all.length,
389
- active,
390
- shown: capped ? head.length : all.length,
391
- capped,
392
- hide: () => hide(column.id),
393
- expand: () => expand(column.id),
394
- collapse: () => collapse(column.id),
395
- };
396
- }
157
+ type ColumnView = KanbanColumnView<T, C>;
397
158
  </script>
398
159
 
399
160
  <!-- The slot keeps the drag state where it is, or hovering it would bubble to the column and jump it to the end. -->
@@ -401,8 +162,8 @@
401
162
  <div
402
163
  class={cn("kanban-drop-slot min-h-10 shrink-0", workLooks.hole)}
403
164
  aria-hidden="true"
404
- style:height="{slotHeight}px"
405
- transition:slide={slotMotion}
165
+ style:height="{board.slotHeight}px"
166
+ transition:slide={board.slotMotion}
406
167
  ondragover={(e) => {
407
168
  e.preventDefault();
408
169
  e.stopPropagation();
@@ -412,12 +173,12 @@
412
173
 
413
174
  {#snippet card(column: C, item: T, index: number, list: T[])}
414
175
  {@const id = getId(item)}
415
- {@const nextId = idAt(list, index + 1)}
176
+ {@const nextId = board.idAt(list, index + 1)}
416
177
  {@const isSource = dnd.isDragSource(id)}
417
178
  <!-- The slot right after the held card is its own spot, so the card's place shows it instead. -->
418
179
  {@const atOrigin =
419
180
  !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}
181
+ {#if dnd.showIndicatorBefore(column.id, id) && !isSource && board.idAt(list, index - 1) !== dnd.draggedId}
421
182
  {@render dropSlot()}
422
183
  {/if}
423
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. -->
@@ -430,16 +191,16 @@
430
191
  aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
431
192
  draggable="true"
432
193
  ondragstart={(e) => {
433
- slotHeight = e.currentTarget.getBoundingClientRect().height;
194
+ board.slotHeight = e.currentTarget.getBoundingClientRect().height;
434
195
  liftDragImage(e);
435
196
  dnd.onDragStart(e, id);
436
197
  }}
437
198
  ondragend={() => {
438
199
  dnd.resetDrag();
439
- dragOverHidden = null;
200
+ rail.dragOverHidden = null;
440
201
  }}
441
202
  ondragover={(e) => dnd.onItemDragOver(e, column.id, id, nextId)}
442
- onkeydown={(e) => onCardKeyDown(e, id)}
203
+ onkeydown={(e) => board.onCardKeyDown(e, id)}
443
204
  class={cn(
444
205
  "kanban-card cursor-grab outline-none focus-visible:shadow-(--ring-focus) active:cursor-grabbing",
445
206
  workLooks.card,
@@ -453,54 +214,34 @@
453
214
  {#snippet columnShell(entry: ColumnView, extra: Record<string, unknown>)}
454
215
  {@const { column, all, head, tail, open, capped } = entry}
455
216
  {@const active = dnd.dragOver?.columnId === column.id}
456
- {@const context = contextOf(entry, active)}
217
+ {@const context = board.contextOf(entry, active)}
457
218
  {@const showTail = tail.length > 0}
458
219
  {@const list = [...head, ...tail]}
459
- {@const columnOpen = expandedIds.includes(column.id)}
460
220
  {@const shown = capped ? head : list}
461
221
  <KanbanColumn
462
222
  {...extra}
463
223
  hue={column.hue}
464
224
  {active}
465
- collapsing={collapsing.has(column.id)}
466
- expanding={expanding.has(column.id)}
225
+ collapsing={rail.collapsing.has(column.id)}
226
+ expanding={rail.expanding.has(column.id)}
467
227
  ariaLabel={column.ariaLabel ?? column.label}
468
228
  ondragover={(e) => dnd.onColumnDragOver(e, column.id)}
469
229
  ondragleave={dnd.onDragLeave}
470
230
  ondrop={(e) => dnd.onDrop(e, column.id)}
471
231
  >
472
232
  {#snippet header()}
473
- {#if columnHeader}
474
- {@render columnHeader(column, context)}
475
- {:else}
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"
494
- >
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>
503
- {/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
+ />
504
245
  {/snippet}
505
246
 
506
247
  {#each head as item, index (getId(item))}
@@ -518,7 +259,7 @@
518
259
  </div>
519
260
  {/if}
520
261
 
521
- {#if dnd.showIndicatorEnd(column.id) && idAt(shown, shown.length - 1) !== dnd.draggedId}
262
+ {#if dnd.showIndicatorEnd(column.id) && board.idAt(shown, shown.length - 1) !== dnd.draggedId}
522
263
  {@render dropSlot()}
523
264
  {/if}
524
265
 
@@ -527,16 +268,10 @@
527
268
  variant="ghost"
528
269
  size="sm"
529
270
  aria-expanded={open}
530
- onclick={() => (open ? collapse(column.id) : expand(column.id))}
271
+ onclick={() => (open ? board.collapse(column.id) : board.expand(column.id))}
531
272
  class="shrink-0 justify-start gap-1.5 px-2 text-xs font-normal text-muted-foreground"
532
273
  >
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
- >
274
+ <KanbanFoldChevron {open} class="size-2.5" />
540
275
  <span class="tabular-nums">{open ? lessLabel : moreLabel(tail.length)}</span>
541
276
  </Button>
542
277
  {/if}
@@ -555,14 +290,14 @@
555
290
 
556
291
  <div
557
292
  class={cn(
558
- "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",
559
294
  "max-sm:-mx-4 max-sm:snap-x max-sm:snap-proximity max-sm:scroll-px-4 max-sm:px-4",
560
295
  className,
561
296
  )}
562
297
  >
563
- {#each view as entry (entry.column.id)}
298
+ {#each board.view as entry (entry.column.id)}
564
299
  {@const column = entry.column}
565
- {#if !hiddenSet.has(column.id) || collapsing.has(column.id)}
300
+ {#if !rail.hiddenSet.has(column.id) || rail.collapsing.has(column.id)}
566
301
  {#if wrapColumn}
567
302
  {#snippet inner(props: Record<string, unknown>)}
568
303
  {@render columnShell(entry, props)}
@@ -574,80 +309,15 @@
574
309
  {/if}
575
310
  {/each}
576
311
 
577
- {#if hiddenColumns.length > 0}
578
- <div class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
579
- <Button
580
- variant="ghost"
581
- size="sm"
582
- aria-expanded={railOpen}
583
- onclick={() => (railOpen = !railOpen)}
584
- class="justify-start px-2 text-xs font-normal text-muted-foreground"
585
- >
586
- <span
587
- class={cn(
588
- "size-2.5 transition-transform duration-fast ease-smooth-out motion-reduce:transition-none [&>svg]:size-full",
589
- !railOpen && "-rotate-90",
590
- )}
591
- aria-hidden="true"><CaretDown weight="bold" /></span
592
- >
593
- {hiddenLabel}
594
- </Button>
595
-
596
- {#if railOpen}
597
- <div class="flex flex-col gap-0.5">
598
- {#each hiddenColumns as column (column.id)}
599
- {@const count = sortedByColumn(column.id).length}
600
- {@const over = dragOverHidden === column.id}
601
- {@const hue = column.hue ?? "var(--color-primary)"}
602
- <div
603
- role="region"
604
- aria-label={column.ariaLabel ?? column.label}
605
- class={cn(
606
- "kanban-rail-item transition-[background-color,outline-color] duration-quick ease-smooth-out",
607
- workLooks.row,
608
- leaving.has(column.id) && "kanban-rail-item-leaving",
609
- )}
610
- style:background="color-mix(in oklch, {hue}
611
- {over ? 10 : 0}%, transparent)"
612
- style:outline="1px solid color-mix(in oklch, {hue}
613
- {over ? 28 : 0}%, transparent)"
614
- ondragover={(e) => onHiddenDragOver(e, column.id)}
615
- ondragleave={onHiddenDragLeave}
616
- ondrop={(e) => onHiddenDrop(e, column.id)}
617
- >
618
- <Button
619
- variant="ghost"
620
- size="lg"
621
- onclick={() => show(column.id)}
622
- class="w-full justify-start gap-2 text-xs text-muted-foreground"
623
- >
624
- {#if hiddenItem}
625
- {@render hiddenItem(column, { count, active: over })}
626
- {:else}
627
- <span class="truncate whitespace-nowrap">{column.label}</span>
628
- {#if count > 0}
629
- <span class="ms-auto text-xs text-muted-foreground/60 tabular-nums"
630
- >{count.toLocaleString()}</span
631
- >
632
- {/if}
633
- {/if}
634
- </Button>
635
- </div>
636
- {/each}
637
-
638
- {#if hiddenColumns.length > 1}
639
- <Button
640
- variant="ghost"
641
- size="sm"
642
- onclick={showAll}
643
- class="mt-1 justify-start text-xs font-normal text-muted-foreground"
644
- >
645
- {showAllLabel}
646
- </Button>
647
- {/if}
648
- </div>
649
- {/if}
650
- </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
+ />
651
321
  {/if}
652
322
  </div>
653
323
 
@@ -706,21 +376,6 @@
706
376
  overflow-clip-margin: 0.375rem;
707
377
  }
708
378
 
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
-
718
- .kanban-rail-item-leaving {
719
- pointer-events: none;
720
- opacity: 0;
721
- transition: opacity var(--duration-quick) var(--ease-smooth-out);
722
- }
723
-
724
379
  /* The held card's place keeps its hole, never its content. */
725
380
  .kanban-origin-slot > :global(*) {
726
381
  visibility: hidden;
@@ -728,9 +383,7 @@
728
383
 
729
384
  @media (prefers-reduced-motion: reduce) {
730
385
  .kanban-card,
731
- .kanban-fold,
732
- .kanban-fold-chevron,
733
- .kanban-rail-item-leaving {
386
+ .kanban-fold {
734
387
  transition: none;
735
388
  }
736
389
  }
@@ -0,0 +1,65 @@
1
+ <script lang="ts" generics="C extends KanbanColumnDef">
2
+ import type { Snippet } from "svelte";
3
+ import { Button } from "../shadcn/button/index.js";
4
+ import GroupHead from "../components/work-parts/group-head.svelte";
5
+ import KanbanFoldChevron from "./kanban-fold-chevron.svelte";
6
+ import type {
7
+ KanbanColumn as KanbanColumnDef,
8
+ KanbanColumnContext,
9
+ } from "./kanban-types.js";
10
+
11
+ interface Props {
12
+ column: C;
13
+ context: KanbanColumnContext;
14
+ /** The board's `columnHeader`. Left out, the head is GroupHead. */
15
+ columnHeader?: Snippet<[C, KanbanColumnContext]>;
16
+ onAdd?: (columnId: string) => void;
17
+ addLabel: string;
18
+ /** The column holds more cards than its cap, so the caret shows. */
19
+ foldable: boolean;
20
+ /** The caret has the column open in every lane. */
21
+ columnOpen: boolean;
22
+ onToggle: () => void;
23
+ expandColumnLabel: string;
24
+ collapseColumnLabel: string;
25
+ }
26
+
27
+ let {
28
+ column,
29
+ context,
30
+ columnHeader,
31
+ onAdd,
32
+ addLabel,
33
+ foldable,
34
+ columnOpen,
35
+ onToggle,
36
+ expandColumnLabel,
37
+ collapseColumnLabel,
38
+ }: Props = $props();
39
+ </script>
40
+
41
+ {#if columnHeader}
42
+ {@render columnHeader(column, context)}
43
+ {:else}
44
+ <GroupHead
45
+ label={column.label}
46
+ hue={column.hue}
47
+ count={context.count}
48
+ onAdd={onAdd ? () => onAdd(column.id) : undefined}
49
+ {addLabel}
50
+ />
51
+ {/if}
52
+ {#if foldable}
53
+ <!-- Opens the column in every lane; "Show N more" opens one. Hiding into the rail stays the header's own button. -->
54
+ <Button
55
+ variant="ghost"
56
+ size="sm"
57
+ icon
58
+ aria-expanded={columnOpen}
59
+ aria-label="{columnOpen ? collapseColumnLabel : expandColumnLabel} {column.label}"
60
+ onclick={onToggle}
61
+ class="-me-1 ms-auto size-6 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity duration-(--duration-quick) hover:text-foreground focus-visible:opacity-100 [.kanban-column:hover_&]:opacity-100 [@media(hover:none)]:opacity-100"
62
+ >
63
+ <KanbanFoldChevron open={columnOpen} class="size-3" />
64
+ </Button>
65
+ {/if}