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