@heyramzi/tasks 0.3.0 → 0.5.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.
- package/README.md +138 -18
- package/package.json +34 -1
- package/src/ambient.d.ts +3 -6
- package/src/demo.svelte +109 -19
- package/src/inbox/inbox-card.svelte +280 -0
- package/src/inbox/inbox-draft.svelte +84 -0
- package/src/inbox/inbox-logic.ts +56 -0
- package/src/inbox/inbox-statuses.ts +48 -0
- package/src/inbox/inbox-trace.svelte +45 -0
- package/src/inbox/inbox-types.ts +108 -0
- package/src/inbox/inbox.svelte +203 -0
- package/src/inbox/index.ts +23 -0
- package/src/index.ts +39 -7
- package/src/kanban-board.svelte +421 -104
- package/src/kanban-column.svelte +1 -1
- package/src/kanban-types.ts +18 -1
- package/src/side-panel.svelte +204 -0
- package/src/status-badge.svelte +37 -18
- package/src/status-dot.svelte +32 -12
- package/src/status-icon.svelte +2 -2
- package/src/status-types.ts +6 -5
- package/src/task-gantt.svelte +208 -36
- package/src/task-list.svelte +206 -101
- package/src/task-types.ts +11 -8
- package/src/time/duration.ts +69 -0
- package/src/time/time-chip.svelte +62 -0
- package/src/time/time-dot.svelte +58 -0
- package/src/time/time-ring.svelte +74 -0
- package/src/time/time-tracker.svelte +564 -0
- package/src/time/time-types.ts +74 -0
- package/src/use-kanban-dnd.svelte.ts +6 -1
- package/src/icons/board-icon.svelte +0 -9
- package/src/icons/chevron-right.svg +0 -1
- package/src/icons/gantt-icon.svelte +0 -11
- package/src/icons/list-icon.svelte +0 -16
- package/src/icons/spinner.svg +0 -1
- package/src/lib/cn.ts +0 -11
- package/src/task-view-switch.svelte +0 -118
- package/src/tone.ts +0 -51
package/src/kanban-board.svelte
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
<script lang="ts" generics="T">
|
|
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
|
+
import { slide } from "svelte/transition";
|
|
3
7
|
import KanbanColumn from "./kanban-column.svelte";
|
|
4
8
|
import { liftDragImage } from "./kanban-drag.js";
|
|
5
9
|
import { boardOrderAt } from "./board-order.js";
|
|
6
|
-
import { cn } from "
|
|
10
|
+
import { cn } from "@heyramzi/utils";
|
|
7
11
|
import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
|
|
12
|
+
import { CaretDown } from "phosphor-svelte";
|
|
8
13
|
import type {
|
|
9
14
|
KanbanCardContext,
|
|
10
15
|
KanbanColumn as KanbanColumnDef,
|
|
@@ -13,8 +18,9 @@
|
|
|
13
18
|
KanbanSortKey,
|
|
14
19
|
} from "./kanban-types.js";
|
|
15
20
|
|
|
16
|
-
type Props<T> = {
|
|
17
|
-
|
|
21
|
+
type Props<T, C extends KanbanColumnDef> = {
|
|
22
|
+
/** Plain column defs, or your own type that extends one. Snippets get it back as you passed it. */
|
|
23
|
+
columns: C[];
|
|
18
24
|
items: T[];
|
|
19
25
|
/** The stable id of a card. Used for keys, drag payloads and move events. */
|
|
20
26
|
getId: (item: T) => string;
|
|
@@ -25,14 +31,44 @@
|
|
|
25
31
|
renderCard: Snippet<[T, KanbanCardContext]>;
|
|
26
32
|
/** Reads a card's manual order key. Required for `sortKey="manual"` ordering. */
|
|
27
33
|
boardOrder?: (item: T) => string | null | undefined;
|
|
34
|
+
/** Breaks a tie in manual order: two cards with the same key, or both without one. */
|
|
35
|
+
manualTiebreak?: (a: T, b: T) => number;
|
|
28
36
|
/** Extra named sorts, e.g. by title or owner. `manual` is always available. */
|
|
29
37
|
sortKeys?: KanbanSortKey<T>[];
|
|
30
|
-
/** The active sort. `"manual"` (default) orders by `boardOrder`. */
|
|
38
|
+
/** The active sort. `"manual"` (default) orders by `boardOrder`, else by the order of `items`. */
|
|
31
39
|
sortKey?: string;
|
|
32
40
|
/** Optional column header. Defaults to the label plus a count. */
|
|
33
|
-
columnHeader?: Snippet<[
|
|
41
|
+
columnHeader?: Snippet<[C, KanbanColumnContext]>;
|
|
42
|
+
/** Rendered under a column's cards, e.g. a "Show all 12" or an add button. */
|
|
43
|
+
columnFooter?: Snippet<[C, KanbanColumnContext]>;
|
|
44
|
+
/**
|
|
45
|
+
* Wraps a whole column, for a context menu or a tooltip around it. Render `column` once and
|
|
46
|
+
* spread the `props` it hands you, so the wrapper's trigger lands on the column.
|
|
47
|
+
*/
|
|
48
|
+
wrapColumn?: Snippet<[C, Snippet<[Record<string, unknown>]>]>;
|
|
49
|
+
/** Enter or Space on a focused card. Leave out when the card holds its own links and buttons. */
|
|
50
|
+
onSelect?: (id: string) => void;
|
|
51
|
+
/** The screen-reader name of a card. Defaults to a hint about the keyboard move. */
|
|
52
|
+
getLabel?: (item: T) => string;
|
|
34
53
|
emptyLabel?: string;
|
|
35
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Cards a column shows before the rest fold behind a footer. Return undefined for no cap.
|
|
56
|
+
* A capped column's empty space drops on top, since the tail is out of sight.
|
|
57
|
+
*/
|
|
58
|
+
limit?: (columnId: string) => number | undefined;
|
|
59
|
+
/** Column ids whose `limit` is lifted. Controlled; leave out to let the board remember. */
|
|
60
|
+
expanded?: string[];
|
|
61
|
+
onExpandedChange?: (ids: string[]) => void;
|
|
62
|
+
/** Column ids folded into the rail on the right. Controlled; leave out to let the board remember. */
|
|
63
|
+
hidden?: string[];
|
|
64
|
+
onHiddenChange?: (ids: string[]) => void;
|
|
65
|
+
/** Heading over the rail of hidden columns. */
|
|
66
|
+
hiddenLabel?: string;
|
|
67
|
+
/** The rail's button that brings every hidden column back. */
|
|
68
|
+
showAllLabel?: string;
|
|
69
|
+
/** Content of one entry in the hidden rail. Defaults to the label and a count. */
|
|
70
|
+
hiddenItem?: Snippet<[C, { count: number; active: boolean }]>;
|
|
71
|
+
/** A capped column is one whose tail is hidden, so its empty space drops on top. Overrides `limit`. */
|
|
36
72
|
dropAtTop?: (columnId: string) => boolean;
|
|
37
73
|
/**
|
|
38
74
|
* Override per-column drop targets. Defaults to each column's `droppable`
|
|
@@ -51,26 +87,54 @@
|
|
|
51
87
|
onMove,
|
|
52
88
|
renderCard,
|
|
53
89
|
boardOrder,
|
|
90
|
+
manualTiebreak,
|
|
54
91
|
sortKeys = [],
|
|
55
92
|
sortKey = "manual",
|
|
56
93
|
columnHeader,
|
|
94
|
+
columnFooter,
|
|
95
|
+
wrapColumn,
|
|
96
|
+
getLabel,
|
|
97
|
+
onSelect,
|
|
57
98
|
emptyLabel = "Nothing here yet",
|
|
99
|
+
limit,
|
|
100
|
+
expanded,
|
|
101
|
+
onExpandedChange,
|
|
102
|
+
hidden,
|
|
103
|
+
onHiddenChange,
|
|
104
|
+
hiddenLabel = "Hidden columns",
|
|
105
|
+
showAllLabel = "Show all",
|
|
106
|
+
hiddenItem,
|
|
58
107
|
dropAtTop,
|
|
59
108
|
isDroppable,
|
|
60
109
|
class: className,
|
|
61
|
-
}: Props<T> = $props();
|
|
110
|
+
}: Props<T, C> = $props();
|
|
62
111
|
|
|
112
|
+
// The drop slot takes the held card's height, so the column doesn't jump when it lands.
|
|
63
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>();
|
|
64
127
|
|
|
65
128
|
function compare(a: T, b: T): number {
|
|
66
129
|
if (sortKey === "manual") {
|
|
67
|
-
|
|
130
|
+
const tie = manualTiebreak?.(a, b) ?? 0;
|
|
131
|
+
if (!boardOrder) return tie;
|
|
68
132
|
const ao = boardOrder(a);
|
|
69
133
|
const bo = boardOrder(b);
|
|
70
|
-
if (ao == null && bo == null) return
|
|
134
|
+
if (ao == null && bo == null) return tie;
|
|
71
135
|
if (ao == null) return 1;
|
|
72
136
|
if (bo == null) return -1;
|
|
73
|
-
return ao < bo ? -1 : ao > bo ? 1 :
|
|
137
|
+
return ao < bo ? -1 : ao > bo ? 1 : tie;
|
|
74
138
|
}
|
|
75
139
|
const key = sortKeys.find((entry) => entry.id === sortKey);
|
|
76
140
|
return key ? key.compare(a, b) : 0;
|
|
@@ -88,6 +152,56 @@
|
|
|
88
152
|
return item === undefined ? null : getId(item);
|
|
89
153
|
}
|
|
90
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
|
+
|
|
91
205
|
const dnd = createKanbanDnd<T>({
|
|
92
206
|
items: () => items,
|
|
93
207
|
getId: (item) => getId(item),
|
|
@@ -96,10 +210,9 @@
|
|
|
96
210
|
getSortKey: () => sortKey,
|
|
97
211
|
boardOrder: (item) => boardOrder?.(item),
|
|
98
212
|
onMove: (id, move) => onMove(id, move),
|
|
99
|
-
dropAtTop: (columnId) =>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
(columns.find((c) => c.id === columnId)?.droppable ?? true),
|
|
213
|
+
dropAtTop: (columnId) =>
|
|
214
|
+
dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
|
|
215
|
+
isDroppable: canDrop,
|
|
103
216
|
});
|
|
104
217
|
|
|
105
218
|
/**
|
|
@@ -108,6 +221,11 @@
|
|
|
108
221
|
* covers keyboard and touch-reader users with the same onMove contract.
|
|
109
222
|
*/
|
|
110
223
|
function onCardKeyDown(e: KeyboardEvent, id: string): void {
|
|
224
|
+
if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
onSelect(id);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
111
229
|
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
|
112
230
|
const item = items.find((entry) => getId(entry) === id);
|
|
113
231
|
if (!item) return;
|
|
@@ -115,22 +233,23 @@
|
|
|
115
233
|
const at = columns.findIndex((c) => c.id === current);
|
|
116
234
|
if (at === -1) return;
|
|
117
235
|
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
236
|
for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
|
|
122
237
|
const target = columns[i];
|
|
123
|
-
if (!canDrop(target.id) || target.id === current) continue;
|
|
238
|
+
if (!canDrop(target.id) || hiddenSet.has(target.id) || target.id === current) continue;
|
|
124
239
|
e.preventDefault();
|
|
125
|
-
if (sortKey === "manual"
|
|
126
|
-
const column = sortedByColumn(target.id).filter(
|
|
127
|
-
|
|
128
|
-
|
|
240
|
+
if (sortKey === "manual") {
|
|
241
|
+
const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
|
|
242
|
+
const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
|
|
243
|
+
const index = atTop ? 0 : column.length;
|
|
244
|
+
const before = column[index - 1];
|
|
245
|
+
const after = column[index];
|
|
129
246
|
void onMove(id, {
|
|
130
247
|
columnId: target.id,
|
|
131
|
-
boardOrder
|
|
132
|
-
boardOrder(entry)
|
|
133
|
-
|
|
248
|
+
...(boardOrder
|
|
249
|
+
? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
|
|
250
|
+
: {}),
|
|
251
|
+
beforeId: before === undefined ? null : getId(before),
|
|
252
|
+
afterId: after === undefined ? null : getId(after),
|
|
134
253
|
});
|
|
135
254
|
} else {
|
|
136
255
|
void onMove(id, { columnId: target.id });
|
|
@@ -139,9 +258,162 @@
|
|
|
139
258
|
}
|
|
140
259
|
}
|
|
141
260
|
|
|
142
|
-
|
|
261
|
+
function onHiddenDragOver(e: DragEvent, columnId: string): void {
|
|
262
|
+
if (!canDrop(columnId)) return;
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
265
|
+
if (dragOverHidden !== columnId) dragOverHidden = columnId;
|
|
266
|
+
dnd.clearDragOver();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function onHiddenDragLeave(e: DragEvent): void {
|
|
270
|
+
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) dragOverHidden = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// A card dropped on a folded column brings the column back and lands in it.
|
|
274
|
+
function onHiddenDrop(e: DragEvent, columnId: string): void {
|
|
275
|
+
dragOverHidden = null;
|
|
276
|
+
dnd.onDrop(e, columnId);
|
|
277
|
+
show(columnId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let view = $derived(
|
|
281
|
+
columns.map((column) => {
|
|
282
|
+
const all = sortedByColumn(column.id);
|
|
283
|
+
const capped = isCapped(column.id, all.length);
|
|
284
|
+
const cap = limit?.(column.id);
|
|
285
|
+
return {
|
|
286
|
+
column,
|
|
287
|
+
all,
|
|
288
|
+
cards: capped && cap !== undefined ? all.slice(0, cap) : all,
|
|
289
|
+
capped,
|
|
290
|
+
};
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
let hiddenColumns = $derived(columns.filter((column) => hiddenSet.has(column.id)));
|
|
295
|
+
|
|
296
|
+
function contextOf(
|
|
297
|
+
column: C,
|
|
298
|
+
all: T[],
|
|
299
|
+
cards: T[],
|
|
300
|
+
capped: boolean,
|
|
301
|
+
active: boolean,
|
|
302
|
+
): KanbanColumnContext {
|
|
303
|
+
return {
|
|
304
|
+
count: all.length,
|
|
305
|
+
active,
|
|
306
|
+
shown: cards.length,
|
|
307
|
+
capped,
|
|
308
|
+
hide: () => hide(column.id),
|
|
309
|
+
expand: () => expand(column.id),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
143
312
|
</script>
|
|
144
313
|
|
|
314
|
+
<!-- The slot keeps the drag state where it is, or hovering it would bubble to the column and jump it to the end. -->
|
|
315
|
+
{#snippet dropSlot()}
|
|
316
|
+
<div
|
|
317
|
+
class="kanban-drop-slot"
|
|
318
|
+
aria-hidden="true"
|
|
319
|
+
style:height="{slotHeight}px"
|
|
320
|
+
transition:slide={slotMotion}
|
|
321
|
+
ondragover={(e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
e.stopPropagation();
|
|
324
|
+
}}
|
|
325
|
+
></div>
|
|
326
|
+
{/snippet}
|
|
327
|
+
|
|
328
|
+
{#snippet columnShell(
|
|
329
|
+
column: C,
|
|
330
|
+
all: T[],
|
|
331
|
+
cards: T[],
|
|
332
|
+
capped: boolean,
|
|
333
|
+
extra: Record<string, unknown>,
|
|
334
|
+
)}
|
|
335
|
+
{@const active = dnd.dragOver?.columnId === column.id}
|
|
336
|
+
{@const context = contextOf(column, all, cards, capped, active)}
|
|
337
|
+
<KanbanColumn
|
|
338
|
+
{...extra}
|
|
339
|
+
hue={column.hue}
|
|
340
|
+
{active}
|
|
341
|
+
collapsing={collapsing.has(column.id)}
|
|
342
|
+
expanding={expanding.has(column.id)}
|
|
343
|
+
ariaLabel={column.ariaLabel ?? column.label}
|
|
344
|
+
ondragover={(e) => dnd.onColumnDragOver(e, column.id)}
|
|
345
|
+
ondragleave={dnd.onDragLeave}
|
|
346
|
+
ondrop={(e) => dnd.onDrop(e, column.id)}
|
|
347
|
+
>
|
|
348
|
+
{#snippet header()}
|
|
349
|
+
{#if columnHeader}
|
|
350
|
+
{@render columnHeader(column, context)}
|
|
351
|
+
{:else}
|
|
352
|
+
<span class="truncate text-sm font-medium text-foreground">{column.label}</span>
|
|
353
|
+
<span
|
|
354
|
+
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"
|
|
355
|
+
>
|
|
356
|
+
{all.length}
|
|
357
|
+
</span>
|
|
358
|
+
{/if}
|
|
359
|
+
{/snippet}
|
|
360
|
+
|
|
361
|
+
{#each cards as item, index (getId(item))}
|
|
362
|
+
{@const id = getId(item)}
|
|
363
|
+
{@const nextId = idAt(cards, index + 1)}
|
|
364
|
+
{@const isSource = dnd.isDragSource(id)}
|
|
365
|
+
<!-- The slot right after the held card is its own spot, so the card's place shows it instead. -->
|
|
366
|
+
{@const atOrigin =
|
|
367
|
+
!dnd.dragOver || (dnd.dragOver.columnId === column.id && dnd.dragOver.overId === nextId)}
|
|
368
|
+
{#if dnd.showIndicatorBefore(column.id, id) && !isSource && idAt(cards, index - 1) !== dnd.draggedId}
|
|
369
|
+
{@render dropSlot()}
|
|
370
|
+
{/if}
|
|
371
|
+
<!-- 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. -->
|
|
372
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
373
|
+
<div
|
|
374
|
+
role="listitem"
|
|
375
|
+
tabindex="0"
|
|
376
|
+
aria-label={getLabel?.(item) ?? "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>
|
|
397
|
+
{/each}
|
|
398
|
+
|
|
399
|
+
{#if dnd.showIndicatorEnd(column.id) && idAt(cards, cards.length - 1) !== dnd.draggedId}
|
|
400
|
+
{@render dropSlot()}
|
|
401
|
+
{/if}
|
|
402
|
+
|
|
403
|
+
{#if columnFooter}
|
|
404
|
+
{@render columnFooter(column, context)}
|
|
405
|
+
{/if}
|
|
406
|
+
|
|
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
|
+
>
|
|
411
|
+
{emptyLabel}
|
|
412
|
+
</div>
|
|
413
|
+
{/if}
|
|
414
|
+
</KanbanColumn>
|
|
415
|
+
{/snippet}
|
|
416
|
+
|
|
145
417
|
<div
|
|
146
418
|
class={cn(
|
|
147
419
|
"kanban-board flex min-h-0 flex-1 items-stretch gap-4 overflow-x-auto pb-2",
|
|
@@ -149,95 +421,140 @@
|
|
|
149
421
|
className,
|
|
150
422
|
)}
|
|
151
423
|
>
|
|
152
|
-
{#each view as { column, cards } (column.id)}
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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)}
|
|
424
|
+
{#each view as { column, all, cards, capped } (column.id)}
|
|
425
|
+
{#if !hiddenSet.has(column.id) || collapsing.has(column.id)}
|
|
426
|
+
{#if wrapColumn}
|
|
427
|
+
{#snippet inner(props: Record<string, unknown>)}
|
|
428
|
+
{@render columnShell(column, all, cards, capped, props)}
|
|
429
|
+
{/snippet}
|
|
430
|
+
{@render wrapColumn(column, inner)}
|
|
431
|
+
{:else}
|
|
432
|
+
{@render columnShell(column, all, cards, capped, {})}
|
|
433
|
+
{/if}
|
|
434
|
+
{/if}
|
|
435
|
+
{/each}
|
|
436
|
+
|
|
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
|
|
201
446
|
class={cn(
|
|
202
|
-
"
|
|
203
|
-
|
|
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",
|
|
204
449
|
)}
|
|
450
|
+
aria-hidden="true"><CaretDown weight="bold" /></span
|
|
205
451
|
>
|
|
206
|
-
|
|
207
|
-
|
|
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}
|
|
452
|
+
{hiddenLabel}
|
|
453
|
+
</button>
|
|
221
454
|
|
|
222
|
-
{#if
|
|
223
|
-
<div
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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} {over ? 10 : 0}%, transparent)"
|
|
469
|
+
style:outline="1px solid color-mix(in oklch, {hue} {over ? 28 : 0}%, transparent)"
|
|
470
|
+
ondragover={(e) => onHiddenDragOver(e, column.id)}
|
|
471
|
+
ondragleave={onHiddenDragLeave}
|
|
472
|
+
ondrop={(e) => onHiddenDrop(e, column.id)}
|
|
473
|
+
>
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
onclick={() => show(column.id)}
|
|
477
|
+
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"
|
|
478
|
+
>
|
|
479
|
+
{#if hiddenItem}
|
|
480
|
+
{@render hiddenItem(column, { count, active: over })}
|
|
481
|
+
{:else}
|
|
482
|
+
<span class="truncate whitespace-nowrap">{column.label}</span>
|
|
483
|
+
{#if count > 0}
|
|
484
|
+
<span class="ms-auto text-xs text-muted-foreground/60 tabular-nums">{count}</span>
|
|
485
|
+
{/if}
|
|
486
|
+
{/if}
|
|
487
|
+
</button>
|
|
488
|
+
</div>
|
|
489
|
+
{/each}
|
|
490
|
+
|
|
491
|
+
{#if hiddenColumns.length > 1}
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onclick={showAll}
|
|
495
|
+
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"
|
|
496
|
+
>
|
|
497
|
+
{showAllLabel}
|
|
498
|
+
</button>
|
|
499
|
+
{/if}
|
|
227
500
|
</div>
|
|
228
501
|
{/if}
|
|
229
|
-
</
|
|
230
|
-
{/
|
|
502
|
+
</div>
|
|
503
|
+
{/if}
|
|
231
504
|
</div>
|
|
232
505
|
|
|
233
506
|
<style>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
507
|
+
/* The drop slot and the held card's own place share a look: a dashed hole the card's size. */
|
|
508
|
+
.kanban-drop-slot,
|
|
509
|
+
.kanban-origin-slot {
|
|
237
510
|
border-radius: var(--radius-lg, 0.5rem);
|
|
238
511
|
background: color-mix(in oklch, var(--color-muted, #e5e7eb) 70%, transparent);
|
|
239
512
|
outline: 1px dashed var(--color-border, #d1d5db);
|
|
240
513
|
outline-offset: -1px;
|
|
241
|
-
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.kanban-drop-slot {
|
|
517
|
+
flex-shrink: 0;
|
|
518
|
+
min-height: 2.5rem;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* A grid row folds the card out of the column smoothly, so the cards below slide up. */
|
|
522
|
+
.kanban-card {
|
|
523
|
+
display: grid;
|
|
524
|
+
grid-template-rows: 1fr;
|
|
525
|
+
transition:
|
|
526
|
+
grid-template-rows var(--duration-quick, 180ms) var(--ease-smooth-out, ease-out),
|
|
527
|
+
margin-top var(--duration-quick, 180ms) var(--ease-smooth-out, ease-out);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.kanban-card > :global(*) {
|
|
531
|
+
min-height: 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.kanban-origin-slot > :global(*) {
|
|
535
|
+
visibility: hidden;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* -0.375rem cancels the column's gap-1.5, so a folded card leaves no gap behind. */
|
|
539
|
+
.kanban-lifted-out {
|
|
540
|
+
grid-template-rows: 0fr;
|
|
541
|
+
margin-top: -0.375rem;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.kanban-lifted-out > :global(*) {
|
|
545
|
+
overflow: hidden;
|
|
546
|
+
visibility: hidden;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.kanban-rail-item-leaving {
|
|
550
|
+
pointer-events: none;
|
|
551
|
+
opacity: 0;
|
|
552
|
+
transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
@media (prefers-reduced-motion: reduce) {
|
|
556
|
+
.kanban-card {
|
|
557
|
+
transition: none;
|
|
558
|
+
}
|
|
242
559
|
}
|
|
243
560
|
</style>
|
package/src/kanban-column.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
-
import { cn } from "
|
|
4
|
+
import { cn } from "@heyramzi/utils";
|
|
5
5
|
|
|
6
6
|
type Props = {
|
|
7
7
|
/** Any CSS color. Only shows while a card is held over the column. */
|
package/src/kanban-types.ts
CHANGED
|
@@ -23,6 +23,13 @@ export type KanbanMove = {
|
|
|
23
23
|
* card in its new slot. Ignore it when you persist order some other way.
|
|
24
24
|
*/
|
|
25
25
|
boardOrder?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The cards that sit above and below the drop slot once the dragged card is
|
|
28
|
+
* set aside. Set on manual drops only, null at an end. For a backend that
|
|
29
|
+
* orders by neighbour instead of by key.
|
|
30
|
+
*/
|
|
31
|
+
beforeId?: string | null;
|
|
32
|
+
afterId?: string | null;
|
|
26
33
|
};
|
|
27
34
|
|
|
28
35
|
/** A named sort the board can apply instead of manual (`boardOrder`) ordering. */
|
|
@@ -38,10 +45,20 @@ export type KanbanCardContext = {
|
|
|
38
45
|
index: number;
|
|
39
46
|
};
|
|
40
47
|
|
|
41
|
-
/** Second argument handed to the column header
|
|
48
|
+
/** Second argument handed to the column header and footer snippets. */
|
|
42
49
|
export type KanbanColumnContext = {
|
|
50
|
+
/** Every card in the column, including the ones a `limit` keeps out of view. */
|
|
43
51
|
count: number;
|
|
52
|
+
/** A card is held over the column. */
|
|
44
53
|
active: boolean;
|
|
54
|
+
/** How many cards render. Fewer than `count` while the column is capped. */
|
|
55
|
+
shown: number;
|
|
56
|
+
/** The column's `limit` is hiding its tail. */
|
|
57
|
+
capped: boolean;
|
|
58
|
+
/** Fold the column into the hidden rail. Needs `hidden` or `onHiddenChange` on the board. */
|
|
59
|
+
hide: () => void;
|
|
60
|
+
/** Lift the `limit` so every card shows. */
|
|
61
|
+
expand: () => void;
|
|
45
62
|
};
|
|
46
63
|
|
|
47
64
|
/** The default header renderer when no `columnHeader` snippet is given. */
|