@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.
@@ -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}
@@ -49,7 +49,7 @@
49
49
  "kanban-column flex flex-col gap-2",
50
50
  collapsing && "kanban-column-collapsing",
51
51
  expanding && "kanban-column-expanding",
52
- !collapsing && !expanding && "w-[280px] shrink-0 min-h-0 max-sm:w-[82vw] max-sm:snap-start",
52
+ "min-h-0",
53
53
  className,
54
54
  )}
55
55
  >
@@ -73,39 +73,61 @@
73
73
  </div>
74
74
 
75
75
  <style>
76
+ /* Columns split the board's width equally, never under 280px (the board scrolls instead)
77
+ nor over 420px (a 2-line card title stays near 55 characters a line). */
76
78
  .kanban-column {
79
+ flex: 1 1 0;
80
+ min-width: var(--kanban-column-min, 280px);
81
+ max-width: var(--kanban-column-max, 420px);
77
82
  transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
78
83
  }
79
84
 
85
+ /* A board under 40rem wide (a phone, a side panel) shows one column and a peek of the next, as 82vw did on a phone. */
86
+ @container kanban-board (width < 40rem) {
87
+ .kanban-column {
88
+ flex: 0 0 88cqw;
89
+ min-width: 0;
90
+ max-width: 88cqw;
91
+ scroll-snap-align: start;
92
+ }
93
+ }
94
+
80
95
  .kanban-column-collapsing,
81
96
  .kanban-column-expanding {
82
97
  will-change: opacity;
98
+ overflow: hidden;
83
99
  }
84
100
 
85
101
  .kanban-column-collapsing {
86
- flex: 0 0 0px;
87
- width: 0;
88
- opacity: 0;
89
- overflow: hidden;
90
102
  pointer-events: none;
103
+ animation: column-fold var(--duration-slow) var(--ease-smooth-out) forwards;
91
104
  }
92
105
 
93
106
  .kanban-column-expanding {
94
- flex: 0 0 280px;
95
- width: 280px;
96
- animation: column-expand var(--duration-slow, 300ms) var(--ease-smooth-out, ease-out) both;
107
+ animation: column-unfold var(--duration-slow) var(--ease-smooth-out) both;
97
108
  }
98
109
 
99
- @keyframes column-expand {
100
- from {
110
+ /* One end only: the other is the column's resting width, whatever the container gave it.
111
+ The negative margin takes the board's gap-4 with it, so no gap is left behind. */
112
+ @keyframes column-fold {
113
+ to {
101
114
  opacity: 0;
102
- flex: 0 0 0px;
103
- width: 0;
115
+ flex-grow: 0;
116
+ flex-basis: 0;
117
+ min-width: 0;
118
+ max-width: 0;
119
+ margin-inline-end: -1rem;
104
120
  }
105
- to {
106
- opacity: 1;
107
- flex: 0 0 280px;
108
- width: 280px;
121
+ }
122
+
123
+ @keyframes column-unfold {
124
+ from {
125
+ opacity: 0;
126
+ flex-grow: 0;
127
+ flex-basis: 0;
128
+ min-width: 0;
129
+ max-width: 0;
130
+ margin-inline-end: -1rem;
109
131
  }
110
132
  }
111
133
 
@@ -113,5 +135,14 @@
113
135
  .kanban-column-expanding {
114
136
  animation: none;
115
137
  }
138
+
139
+ .kanban-column-collapsing {
140
+ animation: none;
141
+ opacity: 0;
142
+ flex: 0 0 0;
143
+ min-width: 0;
144
+ max-width: 0;
145
+ margin-inline-end: -1rem;
146
+ }
116
147
  }
117
148
  </style>
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ import { CaretDown } from "phosphor-svelte";
3
+ import { cn } from "../lib/utils.js";
4
+
5
+ interface Props {
6
+ /** Flips the caret to point up. */
7
+ open: boolean;
8
+ class?: string;
9
+ }
10
+
11
+ let { open, class: className }: Props = $props();
12
+ </script>
13
+
14
+ <span
15
+ class={cn("kanban-fold-chevron [&>svg]:size-full", open && "kanban-fold-chevron-open", className)}
16
+ aria-hidden="true"><CaretDown weight="bold" /></span
17
+ >
18
+
19
+ <style>
20
+ .kanban-fold-chevron {
21
+ display: inline-flex;
22
+ transition: transform var(--duration-fast) var(--ease-smooth-out);
23
+ }
24
+
25
+ .kanban-fold-chevron-open {
26
+ transform: scaleY(-1);
27
+ }
28
+
29
+ @media (prefers-reduced-motion: reduce) {
30
+ .kanban-fold-chevron {
31
+ transition: none;
32
+ }
33
+ }
34
+ </style>
@@ -0,0 +1,110 @@
1
+ <script lang="ts" generics="C extends KanbanColumnDef">
2
+ import type { Snippet } from "svelte";
3
+ import { CaretDown } from "phosphor-svelte";
4
+ import { cn } from "../lib/utils.js";
5
+ import { Button } from "../shadcn/button/index.js";
6
+ import { workLooks } from "../components/work-parts/looks.js";
7
+ import type { KanbanRailState } from "./kanban-rail-state.svelte.js";
8
+ import type { KanbanColumn as KanbanColumnDef } from "./kanban-types.js";
9
+
10
+ interface Props {
11
+ /** The columns folded into the rail, in board order. */
12
+ columns: C[];
13
+ rail: KanbanRailState;
14
+ /** How many cards a column holds. */
15
+ countOf: (columnId: string) => number;
16
+ hiddenLabel: string;
17
+ showAllLabel: string;
18
+ hiddenItem?: Snippet<[C, { count: number; active: boolean }]>;
19
+ }
20
+
21
+ let { columns, rail, countOf, hiddenLabel, showAllLabel, hiddenItem }: Props = $props();
22
+ </script>
23
+
24
+ <div class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
25
+ <Button
26
+ variant="ghost"
27
+ size="sm"
28
+ aria-expanded={rail.railOpen}
29
+ onclick={() => (rail.railOpen = !rail.railOpen)}
30
+ class="justify-start px-2 text-xs font-normal text-muted-foreground"
31
+ >
32
+ <span
33
+ class={cn(
34
+ "size-2.5 transition-transform duration-fast ease-smooth-out motion-reduce:transition-none [&>svg]:size-full",
35
+ !rail.railOpen && "-rotate-90",
36
+ )}
37
+ aria-hidden="true"><CaretDown weight="bold" /></span
38
+ >
39
+ {hiddenLabel}
40
+ </Button>
41
+
42
+ {#if rail.railOpen}
43
+ <div class="flex flex-col gap-0.5">
44
+ {#each columns as column (column.id)}
45
+ {@const count = countOf(column.id)}
46
+ {@const over = rail.dragOverHidden === column.id}
47
+ {@const hue = column.hue ?? "var(--color-primary)"}
48
+ <div
49
+ role="region"
50
+ aria-label={column.ariaLabel ?? column.label}
51
+ class={cn(
52
+ "kanban-rail-item transition-[background-color,outline-color] duration-quick ease-smooth-out",
53
+ workLooks.row,
54
+ rail.leaving.has(column.id) && "kanban-rail-item-leaving",
55
+ )}
56
+ style:background="color-mix(in oklch, {hue}
57
+ {over ? 10 : 0}%, transparent)"
58
+ style:outline="1px solid color-mix(in oklch, {hue}
59
+ {over ? 28 : 0}%, transparent)"
60
+ ondragover={(e) => rail.onHiddenDragOver(e, column.id)}
61
+ ondragleave={rail.onHiddenDragLeave}
62
+ ondrop={(e) => rail.onHiddenDrop(e, column.id)}
63
+ >
64
+ <Button
65
+ variant="ghost"
66
+ size="lg"
67
+ onclick={() => rail.show(column.id)}
68
+ class="w-full justify-start gap-2 text-xs text-muted-foreground"
69
+ >
70
+ {#if hiddenItem}
71
+ {@render hiddenItem(column, { count, active: over })}
72
+ {:else}
73
+ <span class="truncate whitespace-nowrap">{column.label}</span>
74
+ {#if count > 0}
75
+ <span class="ms-auto text-xs text-muted-foreground/60 tabular-nums"
76
+ >{count.toLocaleString()}</span
77
+ >
78
+ {/if}
79
+ {/if}
80
+ </Button>
81
+ </div>
82
+ {/each}
83
+
84
+ {#if columns.length > 1}
85
+ <Button
86
+ variant="ghost"
87
+ size="sm"
88
+ onclick={rail.showAll}
89
+ class="mt-1 justify-start text-xs font-normal text-muted-foreground"
90
+ >
91
+ {showAllLabel}
92
+ </Button>
93
+ {/if}
94
+ </div>
95
+ {/if}
96
+ </div>
97
+
98
+ <style>
99
+ .kanban-rail-item-leaving {
100
+ pointer-events: none;
101
+ opacity: 0;
102
+ transition: opacity var(--duration-quick) var(--ease-smooth-out);
103
+ }
104
+
105
+ @media (prefers-reduced-motion: reduce) {
106
+ .kanban-rail-item-leaving {
107
+ transition: none;
108
+ }
109
+ }
110
+ </style>
@@ -0,0 +1,62 @@
1
+ import { boardOrderAt } from "./board-order.js";
2
+ import type { KanbanBoardInput } from "./kanban-board-state.svelte.js";
3
+ import type { KanbanColumn as KanbanColumnDef, KanbanMove } from "./kanban-types.js";
4
+
5
+ export type KanbanKeyboardConfig<T, C extends KanbanColumnDef> = {
6
+ input: () => KanbanBoardInput<T, C>;
7
+ canDrop: (columnId: string) => boolean;
8
+ isHidden: (columnId: string) => boolean;
9
+ sortedByColumn: (columnId: string) => T[];
10
+ isCapped: (columnId: string, total: number) => boolean;
11
+ moveAndPin: (id: string, move: KanbanMove) => void | Promise<void>;
12
+ };
13
+
14
+ /** The card's keydown handler: Enter or Space selects, Alt + arrow moves. */
15
+ export function createCardKeyDown<T, C extends KanbanColumnDef>(
16
+ config: KanbanKeyboardConfig<T, C>,
17
+ ) {
18
+ const { input, canDrop, isHidden, sortedByColumn, isCapped, moveAndPin } = config;
19
+
20
+ /**
21
+ * Alt + ArrowLeft/Right moves the focused card to the next droppable column, so
22
+ * keyboard and screen-reader users get drag's onMove contract.
23
+ */
24
+ return function onCardKeyDown(e: KeyboardEvent, id: string): void {
25
+ const { onSelect, items, getId, getColumn, columns, sortKey, dropAtTop, boardOrder } = input();
26
+ if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
27
+ e.preventDefault();
28
+ onSelect(id);
29
+ return;
30
+ }
31
+ if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
32
+ const item = items.find((entry) => getId(entry) === id);
33
+ if (!item) return;
34
+ const current = getColumn(item);
35
+ const at = columns.findIndex((c) => c.id === current);
36
+ if (at === -1) return;
37
+ const dir = e.key === "ArrowRight" ? 1 : -1;
38
+ for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
39
+ const target = columns[i];
40
+ if (!canDrop(target.id) || isHidden(target.id) || target.id === current) continue;
41
+ e.preventDefault();
42
+ if (sortKey === "manual") {
43
+ const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
44
+ const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
45
+ const index = atTop ? 0 : column.length;
46
+ const before = column[index - 1];
47
+ const after = column[index];
48
+ void moveAndPin(id, {
49
+ columnId: target.id,
50
+ ...(boardOrder
51
+ ? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
52
+ : {}),
53
+ beforeId: before === undefined ? null : getId(before),
54
+ afterId: after === undefined ? null : getId(after),
55
+ });
56
+ } else {
57
+ void moveAndPin(id, { columnId: target.id });
58
+ }
59
+ return;
60
+ }
61
+ };
62
+ }
@@ -0,0 +1,105 @@
1
+ import { SvelteSet } from "svelte/reactivity";
2
+
3
+ export type KanbanRailConfig = {
4
+ /** The board's controlled `hidden`, or undefined to let the rail remember. */
5
+ hidden: () => string[] | undefined;
6
+ onHiddenChange: () => ((ids: string[]) => void) | undefined;
7
+ canDrop: (columnId: string) => boolean;
8
+ dnd: { clearDragOver: () => void; onDrop: (e: DragEvent, columnId: string) => void };
9
+ };
10
+
11
+ /** The rail of hidden columns: which are folded, the fold and unfold timing, and drops onto it. */
12
+ export function createKanbanRailState(config: KanbanRailConfig) {
13
+ const { canDrop, dnd } = config;
14
+ let internalHidden = $state<string[]>([]);
15
+ let hiddenIds = $derived(config.hidden() ?? internalHidden);
16
+ let hiddenSet = $derived(new Set(hiddenIds));
17
+
18
+ let railOpen = $state(true);
19
+ let dragOverHidden = $state<string | null>(null);
20
+ const collapsing = new SvelteSet<string>();
21
+ const expanding = new SvelteSet<string>();
22
+ const leaving = new SvelteSet<string>();
23
+
24
+ function setHidden(next: string[]): void {
25
+ internalHidden = next;
26
+ config.onHiddenChange()?.(next);
27
+ }
28
+
29
+ // Fold: the column fades out first, then leaves the row and joins the rail.
30
+ function hide(columnId: string): void {
31
+ if (hiddenSet.has(columnId) || collapsing.has(columnId)) return;
32
+ collapsing.add(columnId);
33
+ setTimeout(() => {
34
+ setHidden([...hiddenIds, columnId]);
35
+ collapsing.delete(columnId);
36
+ }, 300);
37
+ }
38
+
39
+ // Unfold: the rail entry leaves, then the column grows back in.
40
+ function show(columnId: string): void {
41
+ leaving.add(columnId);
42
+ setTimeout(() => {
43
+ leaving.delete(columnId);
44
+ expanding.add(columnId);
45
+ setHidden(hiddenIds.filter((id) => id !== columnId));
46
+ setTimeout(() => expanding.delete(columnId), 350);
47
+ }, 200);
48
+ }
49
+
50
+ function showAll(): void {
51
+ for (const id of hiddenIds) leaving.add(id);
52
+ setTimeout(() => {
53
+ leaving.clear();
54
+ setHidden([]);
55
+ }, 200);
56
+ }
57
+
58
+ function onHiddenDragOver(e: DragEvent, columnId: string): void {
59
+ if (!canDrop(columnId)) return;
60
+ e.preventDefault();
61
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
62
+ if (dragOverHidden !== columnId) dragOverHidden = columnId;
63
+ dnd.clearDragOver();
64
+ }
65
+
66
+ function onHiddenDragLeave(e: DragEvent): void {
67
+ if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) dragOverHidden = null;
68
+ }
69
+
70
+ // A card dropped on a folded column brings the column back and lands in it.
71
+ function onHiddenDrop(e: DragEvent, columnId: string): void {
72
+ dragOverHidden = null;
73
+ dnd.onDrop(e, columnId);
74
+ show(columnId);
75
+ }
76
+
77
+ return {
78
+ get hiddenSet() {
79
+ return hiddenSet;
80
+ },
81
+ get railOpen() {
82
+ return railOpen;
83
+ },
84
+ set railOpen(value: boolean) {
85
+ railOpen = value;
86
+ },
87
+ get dragOverHidden() {
88
+ return dragOverHidden;
89
+ },
90
+ set dragOverHidden(value: string | null) {
91
+ dragOverHidden = value;
92
+ },
93
+ collapsing,
94
+ expanding,
95
+ leaving,
96
+ hide,
97
+ show,
98
+ showAll,
99
+ onHiddenDragOver,
100
+ onHiddenDragLeave,
101
+ onHiddenDrop,
102
+ };
103
+ }
104
+
105
+ export type KanbanRailState = ReturnType<typeof createKanbanRailState>;
@@ -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. */