@heyramzi/tasks 0.0.0-stage → 0.3.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,299 @@
1
+ <script lang="ts">
2
+ import KanbanBoard from "./kanban-board.svelte";
3
+ import TaskList from "./task-list.svelte";
4
+ import TaskGantt from "./task-gantt.svelte";
5
+ import TaskViewSwitch from "./task-view-switch.svelte";
6
+ import StatusBadge from "./status-badge.svelte";
7
+ import StatusDot from "./status-dot.svelte";
8
+ import BoardIcon from "./icons/board-icon.svelte";
9
+ import ListIcon from "./icons/list-icon.svelte";
10
+ import GanttIcon from "./icons/gantt-icon.svelte";
11
+ import { defineStatuses } from "./define-statuses.js";
12
+ import { addDays, dayNumber, todayIso } from "./dates.js";
13
+ import { salesStatuses } from "./presets/sales.js";
14
+ import type { KanbanMove } from "./kanban-types.js";
15
+ import type { GanttChange } from "./task-types.js";
16
+
17
+ type Priority = "low" | "medium" | "high";
18
+
19
+ type Task = {
20
+ id: string;
21
+ status: string;
22
+ title: string;
23
+ assignee: string;
24
+ priority: Priority;
25
+ boardOrder: string;
26
+ start: string;
27
+ end: string;
28
+ dependsOn?: string[];
29
+ };
30
+
31
+ const statuses = defineStatuses({
32
+ todo: { label: "To do", tone: "neutral", variant: "open" },
33
+ doing: { label: "In progress", tone: "info", variant: "progress", progress: 0.5 },
34
+ review: { label: "Review", tone: "warning", variant: "progress", progress: 0.75 },
35
+ done: { label: "Done", tone: "success", variant: "completed" },
36
+ });
37
+
38
+ const columns = [
39
+ { id: "todo", label: "To do", hue: "var(--color-muted-foreground)" },
40
+ { id: "doing", label: "In progress", hue: "var(--color-info)" },
41
+ { id: "review", label: "Review", hue: "var(--color-warning)" },
42
+ { id: "done", label: "Done", hue: "var(--color-success)" },
43
+ ];
44
+
45
+ const progressByStatus: Record<string, number> = { todo: 0, doing: 0.45, review: 0.8, done: 1 };
46
+
47
+ // Dates hang off today, so the timeline always opens on live work.
48
+ const today = todayIso();
49
+ const on = (days: number) => addDays(today, days);
50
+
51
+ let tasks = $state<Task[]>([
52
+ { id: "t1", status: "done", title: "Set up analytics", assignee: "Bo", priority: "low", boardOrder: "a0", start: on(-12), end: on(-8) },
53
+ { id: "t2", status: "done", title: "Interview five customers", assignee: "Cleo", priority: "medium", boardOrder: "a1", start: on(-10), end: on(-4) },
54
+ { id: "t3", status: "review", title: "Write the pricing page", assignee: "Ada", priority: "high", boardOrder: "a0", start: on(-5), end: on(1), dependsOn: ["t2"] },
55
+ { id: "t4", status: "doing", title: "Build the onboarding flow", assignee: "Dana", priority: "high", boardOrder: "a0", start: on(-3), end: on(5), dependsOn: ["t1"] },
56
+ { id: "t5", status: "doing", title: "Draft the launch email", assignee: "Bo", priority: "medium", boardOrder: "a1", start: on(0), end: on(3), dependsOn: ["t3"] },
57
+ { id: "t6", status: "todo", title: "Record the demo video", assignee: "Cleo", priority: "medium", boardOrder: "a0", start: on(4), end: on(8), dependsOn: ["t4"] },
58
+ { id: "t7", status: "todo", title: "Pick a shipping date", assignee: "Ada", priority: "low", boardOrder: "a1", start: on(6), end: on(6) },
59
+ { id: "t8", status: "todo", title: "Launch on Product Hunt", assignee: "Dana", priority: "high", boardOrder: "a2", start: on(10), end: on(11), dependsOn: ["t5", "t6"] },
60
+ ]);
61
+
62
+ const views = [
63
+ { id: "board", label: "Board", icon: BoardIcon },
64
+ { id: "list", label: "List", icon: ListIcon },
65
+ { id: "gantt", label: "Gantt", icon: GanttIcon },
66
+ ];
67
+
68
+ let view = $state("board");
69
+ let sortKey = $state("manual");
70
+ let scale = $state<"days" | "weeks">("days");
71
+
72
+ const sortKeys = [
73
+ { id: "title", label: "Title", compare: (a: Task, b: Task) => a.title.localeCompare(b.title) },
74
+ { id: "assignee", label: "Assignee", compare: (a: Task, b: Task) => a.assignee.localeCompare(b.assignee) },
75
+ { id: "due", label: "Due date", compare: (a: Task, b: Task) => a.end.localeCompare(b.end) },
76
+ ];
77
+
78
+ const priorityClass: Record<Priority, string> = {
79
+ low: "bg-muted text-muted-foreground",
80
+ medium: "bg-warning/15 text-warning-700 dark:text-warning-400",
81
+ high: "bg-destructive/15 text-destructive",
82
+ };
83
+
84
+ const people: Record<string, string> = {
85
+ Ada: "oklch(0.72 0.12 280)",
86
+ Bo: "oklch(0.74 0.12 160)",
87
+ Cleo: "oklch(0.76 0.12 60)",
88
+ Dana: "oklch(0.72 0.13 20)",
89
+ };
90
+
91
+ const dueFormat = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
92
+
93
+ function due(task: Task): { label: string; late: boolean } {
94
+ const late = task.status !== "done" && dayNumber(task.end) < dayNumber(today);
95
+ return { label: dueFormat.format(new Date(`${task.end}T00:00:00Z`)), late };
96
+ }
97
+
98
+ function onMove(id: string, move: KanbanMove) {
99
+ tasks = tasks.map((task) =>
100
+ task.id === id
101
+ ? { ...task, status: move.columnId, boardOrder: move.boardOrder ?? task.boardOrder }
102
+ : task,
103
+ );
104
+ }
105
+
106
+ function onChange(id: string, change: GanttChange) {
107
+ tasks = tasks.map((task) => (task.id === id ? { ...task, ...change } : task));
108
+ }
109
+
110
+ const listGrid = "grid grid-cols-[minmax(0,1fr)_7rem_5.5rem_4.5rem] items-center gap-3 max-sm:grid-cols-[minmax(0,1fr)_4.5rem]";
111
+ </script>
112
+
113
+ {#snippet avatar(name: string)}
114
+ <span
115
+ class="grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white"
116
+ style:background={people[name] ?? "var(--color-muted-foreground)"}
117
+ aria-hidden="true"
118
+ >
119
+ {name[0]}
120
+ </span>
121
+ {/snippet}
122
+
123
+ <div class="flex flex-col gap-6 rounded-xl border border-border bg-background p-4">
124
+ <div class="flex h-[600px] flex-col gap-3">
125
+ <div class="flex flex-wrap items-center gap-2">
126
+ <TaskViewSwitch {views} bind:value={view} label="Task view" />
127
+ <div class="ml-auto flex items-center gap-2">
128
+ {#if view === "gantt"}
129
+ <TaskViewSwitch
130
+ views={[
131
+ { id: "days", label: "Days" },
132
+ { id: "weeks", label: "Weeks" },
133
+ ]}
134
+ bind:value={scale}
135
+ label="Timeline scale"
136
+ />
137
+ {:else}
138
+ <label for="tasks-sort" class="text-xs font-medium text-muted-foreground">Sort</label>
139
+ <select
140
+ id="tasks-sort"
141
+ bind:value={sortKey}
142
+ class="h-8 rounded-md border border-border bg-background px-2 text-xs text-foreground"
143
+ >
144
+ <option value="manual">Manual</option>
145
+ {#each sortKeys as key (key.id)}
146
+ <option value={key.id}>{key.label}</option>
147
+ {/each}
148
+ </select>
149
+ {/if}
150
+ </div>
151
+ </div>
152
+
153
+ {#key view}
154
+ <div class="tasks-view flex min-h-0 flex-1 flex-col">
155
+ {#if view === "board"}
156
+ <KanbanBoard
157
+ {columns}
158
+ items={tasks}
159
+ getId={(task) => task.id}
160
+ getColumn={(task) => task.status}
161
+ {sortKey}
162
+ {sortKeys}
163
+ boardOrder={(task) => task.boardOrder}
164
+ {onMove}
165
+ >
166
+ {#snippet renderCard(task)}
167
+ {@const d = due(task)}
168
+ <article class="rounded-xl border border-border bg-card p-2.5 text-card-foreground shadow-sm">
169
+ <p class="text-sm leading-snug">{task.title}</p>
170
+ <div class="mt-2.5 flex items-center gap-2 text-xs">
171
+ {@render avatar(task.assignee)}
172
+ <span class={d.late ? "font-medium text-destructive" : "text-muted-foreground"}>{d.label}</span>
173
+ <span class="ml-auto rounded-full px-1.5 py-0.5 font-medium capitalize {priorityClass[task.priority]}">
174
+ {task.priority}
175
+ </span>
176
+ </div>
177
+ </article>
178
+ {/snippet}
179
+ </KanbanBoard>
180
+ {:else if view === "list"}
181
+ <TaskList
182
+ groups={columns}
183
+ items={tasks}
184
+ getId={(task) => task.id}
185
+ getGroup={(task) => task.status}
186
+ {sortKey}
187
+ {sortKeys}
188
+ boardOrder={(task) => task.boardOrder}
189
+ {onMove}
190
+ >
191
+ {#snippet head()}
192
+ <div class="{listGrid} w-full px-3.5">
193
+ <span class="pl-6">Task</span>
194
+ <span class="max-sm:hidden">Assignee</span>
195
+ <span class="max-sm:hidden">Priority</span>
196
+ <span class="text-right">Due</span>
197
+ </div>
198
+ {/snippet}
199
+ {#snippet renderRow(task)}
200
+ {@const d = due(task)}
201
+ <div class="{listGrid} h-10 px-2 text-sm">
202
+ <span class="flex min-w-0 items-center gap-2.5">
203
+ {#key task.status}
204
+ <StatusDot statuses={statuses} status={task.status} />
205
+ {/key}
206
+ <span class="truncate" class:text-muted-foreground={task.status === "done"} class:line-through={task.status === "done"}>
207
+ {task.title}
208
+ </span>
209
+ </span>
210
+ <span class="flex min-w-0 items-center gap-2 text-muted-foreground max-sm:hidden">
211
+ {@render avatar(task.assignee)}
212
+ <span class="truncate">{task.assignee}</span>
213
+ </span>
214
+ <span class="max-sm:hidden">
215
+ <span class="rounded-full px-1.5 py-0.5 text-xs font-medium capitalize {priorityClass[task.priority]}">
216
+ {task.priority}
217
+ </span>
218
+ </span>
219
+ <span class="text-right text-xs tabular-nums {d.late ? 'font-medium text-destructive' : 'text-muted-foreground'}">
220
+ {d.label}
221
+ </span>
222
+ </div>
223
+ {/snippet}
224
+ </TaskList>
225
+ {:else}
226
+ <TaskGantt
227
+ items={tasks}
228
+ getId={(task) => task.id}
229
+ getTitle={(task) => task.title}
230
+ getStart={(task) => task.start}
231
+ getEnd={(task) => task.end}
232
+ getProgress={(task) => progressByStatus[task.status]}
233
+ getDependsOn={(task) => task.dependsOn}
234
+ groups={columns}
235
+ getGroup={(task) => task.status}
236
+ {today}
237
+ dayWidth={scale === "days" ? 32 : 14}
238
+ labelWidth={220}
239
+ {onChange}
240
+ >
241
+ {#snippet renderLabel(task)}
242
+ <StatusDot statuses={statuses} status={task.status} />
243
+ <span class="min-w-0 flex-1 truncate">{task.title}</span>
244
+ {@render avatar(task.assignee)}
245
+ {/snippet}
246
+ </TaskGantt>
247
+ {/if}
248
+ </div>
249
+ {/key}
250
+
251
+ <p class="text-xs text-muted-foreground/70">
252
+ {#if view === "board"}
253
+ Drag a card between columns, or focus one and press Alt plus Left or Right.
254
+ {:else if view === "list"}
255
+ Drag a row between groups, or focus one and press Alt plus Up or Down.
256
+ {:else}
257
+ Drag a bar to move it, or its edges to resize. Arrow keys nudge a focused bar a day.
258
+ {/if}
259
+ </p>
260
+ </div>
261
+
262
+ <section class="flex flex-col gap-2 border-t border-border pt-4">
263
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Status badges</h3>
264
+ <div class="flex flex-wrap items-center gap-2">
265
+ {#each salesStatuses.ids as id (id)}
266
+ <StatusBadge statuses={salesStatuses} status={id} />
267
+ {/each}
268
+ </div>
269
+ <ul class="mt-1 flex flex-col gap-1.5">
270
+ {#each salesStatuses.ids.slice(0, 3) as id (id)}
271
+ <li class="flex items-center gap-3 text-sm">
272
+ <StatusDot statuses={salesStatuses} status={id} />
273
+ <span class="text-foreground">{salesStatuses.label(id)}</span>
274
+ </li>
275
+ {/each}
276
+ </ul>
277
+ </section>
278
+ </div>
279
+
280
+ <style>
281
+ /* Switching views: the new one rises in with a touch of blur, the old one is gone at once. */
282
+ .tasks-view {
283
+ animation: tasks-view-in var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
284
+ }
285
+
286
+ @keyframes tasks-view-in {
287
+ from {
288
+ opacity: 0;
289
+ transform: translateY(var(--distance-base, 8px));
290
+ filter: blur(var(--blur-small, 2px));
291
+ }
292
+ }
293
+
294
+ @media (prefers-reduced-motion: reduce) {
295
+ .tasks-view {
296
+ animation: none;
297
+ }
298
+ }
299
+ </style>
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ let { size = 16, class: className }: { size?: number; class?: string } = $props();
3
+ </script>
4
+
5
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
6
+ <rect x="2" y="2.5" width="3.5" height="11" rx="1.25" stroke="currentColor" stroke-width="1.5"></rect>
7
+ <rect x="7" y="2.5" width="3.5" height="7" rx="1.25" stroke="currentColor" stroke-width="1.5"></rect>
8
+ <rect x="12" y="2.5" width="2" height="9" rx="1" stroke="currentColor" stroke-width="1.5"></rect>
9
+ </svg>
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
@@ -0,0 +1,11 @@
1
+ <script lang="ts">
2
+ let { size = 16, class: className }: { size?: number; class?: string } = $props();
3
+ </script>
4
+
5
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
6
+ <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
7
+ <line x1="2" y1="4" x2="8" y2="4"></line>
8
+ <line x1="5" y1="8" x2="12" y2="8"></line>
9
+ <line x1="9" y1="12" x2="14" y2="12"></line>
10
+ </g>
11
+ </svg>
@@ -0,0 +1,16 @@
1
+ <script lang="ts">
2
+ let { size = 16, class: className }: { size?: number; class?: string } = $props();
3
+ </script>
4
+
5
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
6
+ <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
7
+ <line x1="6" y1="4" x2="14" y2="4"></line>
8
+ <line x1="6" y1="8" x2="14" y2="8"></line>
9
+ <line x1="6" y1="12" x2="14" y2="12"></line>
10
+ </g>
11
+ <g fill="currentColor">
12
+ <circle cx="2.75" cy="4" r="1"></circle>
13
+ <circle cx="2.75" cy="8" r="1"></circle>
14
+ <circle cx="2.75" cy="12" r="1"></circle>
15
+ </g>
16
+ </svg>
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export { default as KanbanBoard } from "./kanban-board.svelte";
2
+ export { default as KanbanColumn } from "./kanban-column.svelte";
3
+ export { liftDragImage } from "./kanban-drag.js";
4
+ export { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
5
+ export { boardOrderAt, keyBetween } from "./board-order.js";
6
+ export { reorderByIds } from "./reorder.js";
7
+ export type { KanbanDndConfig, KanbanDragOver } from "./use-kanban-dnd.svelte.js";
8
+ export type {
9
+ KanbanCardContext,
10
+ KanbanCardSnippet,
11
+ KanbanColumn as KanbanColumnDef,
12
+ KanbanColumnContext,
13
+ KanbanMove,
14
+ KanbanSortKey,
15
+ } from "./kanban-types.js";
16
+ export { default as StatusBadge } from "./status-badge.svelte";
17
+ export { default as StatusDot } from "./status-dot.svelte";
18
+ export { default as StatusIcon } from "./status-icon.svelte";
19
+ export { defineStatuses } from "./define-statuses.js";
20
+ export { TONE_CLASS, TONE_COLOR, TONE_ICON, TONE_TEXT, insetWellClass } from "./tone.js";
21
+ export type {
22
+ StatusDef,
23
+ StatusDotVariant,
24
+ StatusId,
25
+ StatusMap,
26
+ Statuses,
27
+ Tone,
28
+ } from "./status-types.js";
29
+ export { default as TaskList } from "./task-list.svelte";
30
+ export { default as TaskGantt } from "./task-gantt.svelte";
31
+ export { default as TaskViewSwitch } from "./task-view-switch.svelte";
32
+ export { default as BoardIcon } from "./icons/board-icon.svelte";
33
+ export { default as ListIcon } from "./icons/list-icon.svelte";
34
+ export { default as GanttIcon } from "./icons/gantt-icon.svelte";
35
+ export { addDays, dayNumber, isoFromDay, todayIso } from "./dates.js";
36
+ export type {
37
+ GanttChange,
38
+ TaskListGroupContext,
39
+ TaskListRowContext,
40
+ TaskView,
41
+ } from "./task-types.js";
@@ -0,0 +1,243 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from "svelte";
3
+ import KanbanColumn from "./kanban-column.svelte";
4
+ import { liftDragImage } from "./kanban-drag.js";
5
+ import { boardOrderAt } from "./board-order.js";
6
+ import { cn } from "./lib/cn.js";
7
+ import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
8
+ import type {
9
+ KanbanCardContext,
10
+ KanbanColumn as KanbanColumnDef,
11
+ KanbanColumnContext,
12
+ KanbanMove,
13
+ KanbanSortKey,
14
+ } from "./kanban-types.js";
15
+
16
+ type Props<T> = {
17
+ columns: KanbanColumnDef[];
18
+ items: T[];
19
+ /** The stable id of a card. Used for keys, drag payloads and move events. */
20
+ getId: (item: T) => string;
21
+ /** The column id a card belongs to. */
22
+ getColumn: (item: T) => string;
23
+ /** Called on drop with what to persist. Awaited, so the caller can save. */
24
+ onMove: (id: string, move: KanbanMove) => void | Promise<void>;
25
+ renderCard: Snippet<[T, KanbanCardContext]>;
26
+ /** Reads a card's manual order key. Required for `sortKey="manual"` ordering. */
27
+ boardOrder?: (item: T) => string | null | undefined;
28
+ /** Extra named sorts, e.g. by title or owner. `manual` is always available. */
29
+ sortKeys?: KanbanSortKey<T>[];
30
+ /** The active sort. `"manual"` (default) orders by `boardOrder`. */
31
+ sortKey?: string;
32
+ /** Optional column header. Defaults to the label plus a count. */
33
+ columnHeader?: Snippet<[KanbanColumnDef, KanbanColumnContext]>;
34
+ emptyLabel?: string;
35
+ /** A capped column is one whose tail is hidden, so its empty space drops on top. */
36
+ dropAtTop?: (columnId: string) => boolean;
37
+ /**
38
+ * Override per-column drop targets. Defaults to each column's `droppable`
39
+ * flag (true when unset). A column it returns false for never calls
40
+ * preventDefault, so it is not a drop target.
41
+ */
42
+ isDroppable?: (columnId: string) => boolean;
43
+ class?: string;
44
+ };
45
+
46
+ let {
47
+ columns,
48
+ items,
49
+ getId,
50
+ getColumn,
51
+ onMove,
52
+ renderCard,
53
+ boardOrder,
54
+ sortKeys = [],
55
+ sortKey = "manual",
56
+ columnHeader,
57
+ emptyLabel = "Nothing here yet",
58
+ dropAtTop,
59
+ isDroppable,
60
+ class: className,
61
+ }: Props<T> = $props();
62
+
63
+ let slotHeight = $state(0);
64
+
65
+ function compare(a: T, b: T): number {
66
+ if (sortKey === "manual") {
67
+ if (!boardOrder) return 0;
68
+ const ao = boardOrder(a);
69
+ const bo = boardOrder(b);
70
+ if (ao == null && bo == null) return 0;
71
+ if (ao == null) return 1;
72
+ if (bo == null) return -1;
73
+ return ao < bo ? -1 : ao > bo ? 1 : 0;
74
+ }
75
+ const key = sortKeys.find((entry) => entry.id === sortKey);
76
+ return key ? key.compare(a, b) : 0;
77
+ }
78
+
79
+ function sortedByColumn(columnId: string): T[] {
80
+ return items
81
+ .filter((item) => getColumn(item) === columnId)
82
+ .slice()
83
+ .sort(compare);
84
+ }
85
+
86
+ function idAt(list: T[], index: number): string | null {
87
+ const item = list[index];
88
+ return item === undefined ? null : getId(item);
89
+ }
90
+
91
+ const dnd = createKanbanDnd<T>({
92
+ items: () => items,
93
+ getId: (item) => getId(item),
94
+ getColumn: (item) => getColumn(item),
95
+ getColumnItems: sortedByColumn,
96
+ getSortKey: () => sortKey,
97
+ boardOrder: (item) => boardOrder?.(item),
98
+ onMove: (id, move) => onMove(id, move),
99
+ dropAtTop: (columnId) => dropAtTop?.(columnId) ?? false,
100
+ isDroppable: (columnId) =>
101
+ isDroppable?.(columnId) ??
102
+ (columns.find((c) => c.id === columnId)?.droppable ?? true),
103
+ });
104
+
105
+ /**
106
+ * Keyboard move: Alt + ArrowLeft/Right drops the focused card in the
107
+ * adjacent droppable column. Native drag stays the primary path; this
108
+ * covers keyboard and touch-reader users with the same onMove contract.
109
+ */
110
+ function onCardKeyDown(e: KeyboardEvent, id: string): void {
111
+ if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
112
+ const item = items.find((entry) => getId(entry) === id);
113
+ if (!item) return;
114
+ const current = getColumn(item);
115
+ const at = columns.findIndex((c) => c.id === current);
116
+ if (at === -1) return;
117
+ const dir = e.key === "ArrowRight" ? 1 : -1;
118
+ const canDrop = (columnId: string) =>
119
+ isDroppable?.(columnId) ??
120
+ (columns.find((c) => c.id === columnId)?.droppable ?? true);
121
+ for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
122
+ const target = columns[i];
123
+ if (!canDrop(target.id) || target.id === current) continue;
124
+ e.preventDefault();
125
+ if (sortKey === "manual" && boardOrder) {
126
+ const column = sortedByColumn(target.id).filter(
127
+ (entry) => getId(entry) !== id,
128
+ );
129
+ void onMove(id, {
130
+ columnId: target.id,
131
+ boardOrder: boardOrderAt(column, column.length, (entry) =>
132
+ boardOrder(entry),
133
+ ),
134
+ });
135
+ } else {
136
+ void onMove(id, { columnId: target.id });
137
+ }
138
+ return;
139
+ }
140
+ }
141
+
142
+ let view = $derived(columns.map((column) => ({ column, cards: sortedByColumn(column.id) })));
143
+ </script>
144
+
145
+ <div
146
+ class={cn(
147
+ "kanban-board flex min-h-0 flex-1 items-stretch gap-4 overflow-x-auto pb-2",
148
+ "max-sm:-mx-4 max-sm:snap-x max-sm:snap-proximity max-sm:scroll-px-4 max-sm:px-4",
149
+ className,
150
+ )}
151
+ >
152
+ {#each view as { column, cards } (column.id)}
153
+ {@const active = dnd.dragOver?.columnId === column.id}
154
+ <KanbanColumn
155
+ hue={column.hue}
156
+ {active}
157
+ ariaLabel={column.ariaLabel ?? column.label}
158
+ ondragover={(e) => dnd.onColumnDragOver(e, column.id)}
159
+ ondragleave={dnd.onDragLeave}
160
+ ondrop={(e) => dnd.onDrop(e, column.id)}
161
+ >
162
+ {#snippet header()}
163
+ {#if columnHeader}
164
+ {@render columnHeader(column, { count: cards.length, active })}
165
+ {:else}
166
+ <span class="truncate text-sm font-medium text-foreground">{column.label}</span>
167
+ <span
168
+ class="grid h-5 min-w-5 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums"
169
+ >
170
+ {cards.length}
171
+ </span>
172
+ {/if}
173
+ {/snippet}
174
+
175
+ {#each cards as item, index (getId(item))}
176
+ {@const id = getId(item)}
177
+ {#if dnd.showIndicatorBefore(column.id, id) && !dnd.isDragSource(id) && idAt(cards, index - 1) !== dnd.draggedId}
178
+ <div
179
+ class="kanban-drop-slot"
180
+ aria-hidden="true"
181
+ style:height="{slotHeight}px"
182
+ ondragover={(e) => {
183
+ e.preventDefault();
184
+ e.stopPropagation();
185
+ }}
186
+ ></div>
187
+ {/if}
188
+ <div
189
+ role="listitem"
190
+ tabindex="0"
191
+ aria-label="Card. Press Alt plus Left or Right arrow to move between columns."
192
+ draggable="true"
193
+ ondragstart={(e) => {
194
+ slotHeight = e.currentTarget.getBoundingClientRect().height;
195
+ liftDragImage(e);
196
+ dnd.onDragStart(e, id);
197
+ }}
198
+ ondragend={() => dnd.resetDrag()}
199
+ ondragover={(e) => dnd.onItemDragOver(e, column.id, id, idAt(cards, index + 1))}
200
+ onkeydown={(e) => onCardKeyDown(e, id)}
201
+ class={cn(
202
+ "kanban-card cursor-grab active:cursor-grabbing",
203
+ dnd.isDragSource(id) && "opacity-40",
204
+ )}
205
+ >
206
+ {@render renderCard(item, { columnId: column.id, index })}
207
+ </div>
208
+ {/each}
209
+
210
+ {#if dnd.showIndicatorEnd(column.id) && idAt(cards, cards.length - 1) !== dnd.draggedId}
211
+ <div
212
+ class="kanban-drop-slot"
213
+ aria-hidden="true"
214
+ style:height="{slotHeight}px"
215
+ ondragover={(e) => {
216
+ e.preventDefault();
217
+ e.stopPropagation();
218
+ }}
219
+ ></div>
220
+ {/if}
221
+
222
+ {#if cards.length === 0 && !active}
223
+ <div
224
+ class="rounded-xl border-2 border-dashed border-border py-6 text-center text-xs text-muted-foreground"
225
+ >
226
+ {emptyLabel}
227
+ </div>
228
+ {/if}
229
+ </KanbanColumn>
230
+ {/each}
231
+ </div>
232
+
233
+ <style>
234
+ .kanban-drop-slot {
235
+ flex-shrink: 0;
236
+ min-height: 2.5rem;
237
+ border-radius: var(--radius-lg, 0.5rem);
238
+ background: color-mix(in oklch, var(--color-muted, #e5e7eb) 70%, transparent);
239
+ outline: 1px dashed var(--color-border, #d1d5db);
240
+ outline-offset: -1px;
241
+ transition: height var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
242
+ }
243
+ </style>