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