@heyramzi/ui 2.13.0 → 2.14.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 +1 -0
- package/package.json +1 -1
- package/src/charts/area-chart.svelte +2 -1
- package/src/charts/smoothing.ts +1 -0
- package/src/charts/sparkline.svelte +8 -2
- package/src/work/README.md +3 -3
- package/src/work/kanban-board.svelte +298 -126
- package/src/work/kanban-types.ts +3 -1
package/dist/charts/smoothing.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** The tooltip's name for a drawn point, beside its date. */
|
|
2
2
|
export const ROLLING_LABEL = "7-day average";
|
|
3
3
|
export const weekOf = (start) => `Week of ${start}`;
|
|
4
|
+
export const averageTo = (end) => `${ROLLING_LABEL} to ${end}`;
|
|
4
5
|
const DAY = 864e5;
|
|
5
6
|
const WINDOW = 7;
|
|
6
7
|
// =========================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import ReferenceLine, { type ChartReference } from "./reference-line.svelte";
|
|
16
16
|
import {
|
|
17
17
|
ROLLING_LABEL,
|
|
18
|
+
averageTo,
|
|
18
19
|
smoothSeries,
|
|
19
20
|
weekOf,
|
|
20
21
|
ticksForWidth,
|
|
@@ -99,7 +100,7 @@
|
|
|
99
100
|
/** What a drawn point is: a day, the week it opens, or the end of a 7-day average. */
|
|
100
101
|
function pointLabel(d: TData): string {
|
|
101
102
|
const x = formatX(d[xKey]);
|
|
102
|
-
return mode === "weekly" ? weekOf(x) : x;
|
|
103
|
+
return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
// =========================
|
package/src/charts/smoothing.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type SmoothMode = "raw" | "rolling" | "weekly";
|
|
|
15
15
|
/** The tooltip's name for a drawn point, beside its date. */
|
|
16
16
|
export const ROLLING_LABEL = "7-day average";
|
|
17
17
|
export const weekOf = (start: string) => `Week of ${start}`;
|
|
18
|
+
export const averageTo = (end: string) => `${ROLLING_LABEL} to ${end}`;
|
|
18
19
|
|
|
19
20
|
const DAY = 86_400_000;
|
|
20
21
|
const WINDOW = 7;
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
import { Area, Chart, Highlight, LinearGradient, Points, Spline, Svg, Tooltip } from "layerchart";
|
|
7
7
|
import { curveMonotoneX } from "d3-shape";
|
|
8
8
|
import Reveal from "./reveal.svelte";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
ROLLING_LABEL,
|
|
11
|
+
averageTo,
|
|
12
|
+
smoothSeries,
|
|
13
|
+
weekOf,
|
|
14
|
+
type Smoothing,
|
|
15
|
+
} from "./smoothing.js";
|
|
10
16
|
|
|
11
17
|
// =========================
|
|
12
18
|
// TYPES
|
|
@@ -71,7 +77,7 @@
|
|
|
71
77
|
active = d;
|
|
72
78
|
});
|
|
73
79
|
const x = formatX(d[xKey]);
|
|
74
|
-
return mode === "weekly" ? weekOf(x) : x;
|
|
80
|
+
return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
// =========================
|
package/src/work/README.md
CHANGED
|
@@ -81,15 +81,15 @@ isn't a drop target (an Overdue lane, say).
|
|
|
81
81
|
| `manualTiebreak` | `(a: T, b: T) => number` | Settles two cards with the same manual key, or none. |
|
|
82
82
|
| `getLabel` | `(item: T) => string` | The screen-reader name of a card. Defaults to a keyboard hint. |
|
|
83
83
|
| `onSelect` | `(id: string) => void` | Enter or Space on a focused card. |
|
|
84
|
-
| `columnFooter` | `Snippet<[Column, KanbanColumnContext]>` | Under a column's cards
|
|
84
|
+
| `columnFooter` | `Snippet<[Column, KanbanColumnContext]>` | Under a column's cards and its fold button, e.g. an add button. |
|
|
85
85
|
| `wrapColumn` | `Snippet<[Column, Snippet<[props]>]>` | Wraps a whole column, for a context menu. Render the inner snippet once with the props you are handed. |
|
|
86
|
-
| `limit` | `(columnId: string) => number \| undefined`
|
|
86
|
+
| `limit` | `number \| ((columnId: string) => number \| undefined)` | Cards a column shows before the rest fold, 5 by default; `Infinity` for none. A "Show N more" button opens the fold and "Show less" shuts it, both animated. `moreLabel`, `lessLabel`, `expanded` and `onExpandedChange` go with it. A card moved into a shut column stays in view. |
|
|
87
87
|
| `hidden` | `string[]` | Columns folded into a rail on the right. Fold and unfold animate, and a card dropped on a rail entry brings the column back. `onHiddenChange`, `hiddenLabel`, `showAllLabel` and `hiddenItem` go with it. |
|
|
88
88
|
|
|
89
89
|
`columns` can be your own type, as long as it extends `KanbanColumn`: the
|
|
90
90
|
header, footer and wrap snippets get it back as you passed it, so a column can
|
|
91
91
|
carry a status or a stage for your own markup. The header and footer context is
|
|
92
|
-
`{ count, active, shown, capped, hide, expand }`.
|
|
92
|
+
`{ count, active, shown, capped, hide, expand, collapse }`.
|
|
93
93
|
|
|
94
94
|
`onMove` gives `{ columnId, boardOrder?, beforeId?, afterId? }`. On a manual
|
|
95
95
|
drop `beforeId` and `afterId` name the cards above and below the new slot (null
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts" generics="T, C extends KanbanColumnDef = KanbanColumnDef">
|
|
2
|
-
import type
|
|
2
|
+
import { untrack, type Snippet } from "svelte";
|
|
3
3
|
import { SvelteSet } from "svelte/reactivity";
|
|
4
4
|
import { prefersReducedMotion } from "svelte/motion";
|
|
5
|
+
import { durationMs } from "../lib/motion.js";
|
|
5
6
|
import { cubicOut } from "svelte/easing";
|
|
6
7
|
import { slide } from "svelte/transition";
|
|
7
8
|
import KanbanColumn from "./kanban-column.svelte";
|
|
@@ -10,6 +11,9 @@
|
|
|
10
11
|
import { cn } from "../lib/utils.js";
|
|
11
12
|
import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
|
|
12
13
|
import { CaretDown } from "phosphor-svelte";
|
|
14
|
+
import { Button } from "../shadcn/button/index.js";
|
|
15
|
+
import GroupHead from "../components/work-parts/group-head.svelte";
|
|
16
|
+
import { workLooks } from "../components/work-parts/looks.js";
|
|
13
17
|
import type {
|
|
14
18
|
KanbanCardContext,
|
|
15
19
|
KanbanColumn as KanbanColumnDef,
|
|
@@ -51,12 +55,26 @@
|
|
|
51
55
|
/** The screen-reader name of a card. Defaults to a hint about the keyboard move. */
|
|
52
56
|
getLabel?: (item: T) => string;
|
|
53
57
|
emptyLabel?: string;
|
|
58
|
+
/** Shows a plus in each default column head that adds a card to that column. */
|
|
59
|
+
onAdd?: (columnId: string) => void;
|
|
60
|
+
/** The plus button's accessible name, followed by the column label. */
|
|
61
|
+
addLabel?: string;
|
|
54
62
|
/**
|
|
55
|
-
* Cards a column shows before the rest fold behind
|
|
56
|
-
*
|
|
63
|
+
* Cards a column shows before the rest fold behind "Show N more": a number for every
|
|
64
|
+
* column, or per column (undefined for no cap). `Infinity` turns the fold off.
|
|
65
|
+
*/
|
|
66
|
+
limit?: number | ((columnId: string) => number | undefined);
|
|
67
|
+
/** The fold's open button, given how many cards it holds. */
|
|
68
|
+
moreLabel?: (hidden: number) => string;
|
|
69
|
+
/** The fold's close button. */
|
|
70
|
+
lessLabel?: string;
|
|
71
|
+
/** The header caret's open and close names, followed by the column label. */
|
|
72
|
+
expandColumnLabel?: string;
|
|
73
|
+
collapseColumnLabel?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Column ids the header caret opened. Pass one array to every board in a stack of
|
|
76
|
+
* swimlanes, so the caret opens that column in each. "Show N more" stays per board.
|
|
57
77
|
*/
|
|
58
|
-
limit?: (columnId: string) => number | undefined;
|
|
59
|
-
/** Column ids whose `limit` is lifted. Controlled; leave out to let the board remember. */
|
|
60
78
|
expanded?: string[];
|
|
61
79
|
onExpandedChange?: (ids: string[]) => void;
|
|
62
80
|
/** Column ids folded into the rail on the right. Controlled; leave out to let the board remember. */
|
|
@@ -96,7 +114,13 @@
|
|
|
96
114
|
getLabel,
|
|
97
115
|
onSelect,
|
|
98
116
|
emptyLabel = "Nothing here yet",
|
|
99
|
-
|
|
117
|
+
onAdd,
|
|
118
|
+
addLabel = "Add a card",
|
|
119
|
+
limit = 5,
|
|
120
|
+
moreLabel = (hidden) => `Show ${hidden} more`,
|
|
121
|
+
lessLabel = "Show less",
|
|
122
|
+
expandColumnLabel = "Show every card in",
|
|
123
|
+
collapseColumnLabel = "Fold back",
|
|
100
124
|
expanded,
|
|
101
125
|
onExpandedChange,
|
|
102
126
|
hidden,
|
|
@@ -111,7 +135,10 @@
|
|
|
111
135
|
|
|
112
136
|
// The drop slot takes the held card's height, so the column doesn't jump when it lands.
|
|
113
137
|
let slotHeight = $state(0);
|
|
114
|
-
let slotMotion = $derived({
|
|
138
|
+
let slotMotion = $derived({
|
|
139
|
+
duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
|
|
140
|
+
easing: cubicOut,
|
|
141
|
+
});
|
|
115
142
|
|
|
116
143
|
let internalExpanded = $state<string[]>([]);
|
|
117
144
|
let internalHidden = $state<string[]>([]);
|
|
@@ -152,22 +179,78 @@
|
|
|
152
179
|
return item === undefined ? null : getId(item);
|
|
153
180
|
}
|
|
154
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. */
|
|
155
200
|
function isCapped(columnId: string, total: number): boolean {
|
|
156
|
-
|
|
157
|
-
|
|
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);
|
|
158
220
|
}
|
|
159
221
|
|
|
160
222
|
function canDrop(columnId: string): boolean {
|
|
161
223
|
return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
|
|
162
224
|
}
|
|
163
225
|
|
|
164
|
-
function
|
|
165
|
-
if (expandedIds.includes(columnId)) return;
|
|
166
|
-
const next = [...expandedIds, columnId];
|
|
226
|
+
function setExpanded(next: string[]): void {
|
|
167
227
|
internalExpanded = next;
|
|
168
228
|
onExpandedChange?.(next);
|
|
169
229
|
}
|
|
170
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
|
+
|
|
171
254
|
function setHidden(next: string[]): void {
|
|
172
255
|
internalHidden = next;
|
|
173
256
|
onHiddenChange?.(next);
|
|
@@ -209,7 +292,7 @@
|
|
|
209
292
|
getColumnItems: sortedByColumn,
|
|
210
293
|
getSortKey: () => sortKey,
|
|
211
294
|
boardOrder: (item) => boardOrder?.(item),
|
|
212
|
-
onMove:
|
|
295
|
+
onMove: moveAndPin,
|
|
213
296
|
dropAtTop: (columnId) =>
|
|
214
297
|
dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
|
|
215
298
|
isDroppable: canDrop,
|
|
@@ -242,7 +325,7 @@
|
|
|
242
325
|
const index = atTop ? 0 : column.length;
|
|
243
326
|
const before = column[index - 1];
|
|
244
327
|
const after = column[index];
|
|
245
|
-
void
|
|
328
|
+
void moveAndPin(id, {
|
|
246
329
|
columnId: target.id,
|
|
247
330
|
...(boardOrder
|
|
248
331
|
? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
|
|
@@ -251,7 +334,7 @@
|
|
|
251
334
|
afterId: after === undefined ? null : getId(after),
|
|
252
335
|
});
|
|
253
336
|
} else {
|
|
254
|
-
void
|
|
337
|
+
void moveAndPin(id, { columnId: target.id });
|
|
255
338
|
}
|
|
256
339
|
return;
|
|
257
340
|
}
|
|
@@ -279,33 +362,36 @@
|
|
|
279
362
|
let view = $derived(
|
|
280
363
|
columns.map((column) => {
|
|
281
364
|
const all = sortedByColumn(column.id);
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
all,
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 };
|
|
290
378
|
}),
|
|
291
379
|
);
|
|
292
380
|
|
|
293
381
|
let hiddenColumns = $derived(columns.filter((column) => hiddenSet.has(column.id)));
|
|
294
382
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
capped: boolean,
|
|
300
|
-
active: boolean,
|
|
301
|
-
): KanbanColumnContext {
|
|
383
|
+
type ColumnView = (typeof view)[number];
|
|
384
|
+
|
|
385
|
+
function contextOf(entry: ColumnView, active: boolean): KanbanColumnContext {
|
|
386
|
+
const { column, all, head, capped } = entry;
|
|
302
387
|
return {
|
|
303
388
|
count: all.length,
|
|
304
389
|
active,
|
|
305
|
-
shown:
|
|
390
|
+
shown: capped ? head.length : all.length,
|
|
306
391
|
capped,
|
|
307
392
|
hide: () => hide(column.id),
|
|
308
393
|
expand: () => expand(column.id),
|
|
394
|
+
collapse: () => collapse(column.id),
|
|
309
395
|
};
|
|
310
396
|
}
|
|
311
397
|
</script>
|
|
@@ -313,7 +399,7 @@
|
|
|
313
399
|
<!-- The slot keeps the drag state where it is, or hovering it would bubble to the column and jump it to the end. -->
|
|
314
400
|
{#snippet dropSlot()}
|
|
315
401
|
<div
|
|
316
|
-
class="kanban-drop-slot"
|
|
402
|
+
class={cn("kanban-drop-slot min-h-10 shrink-0", workLooks.hole)}
|
|
317
403
|
aria-hidden="true"
|
|
318
404
|
style:height="{slotHeight}px"
|
|
319
405
|
transition:slide={slotMotion}
|
|
@@ -324,15 +410,54 @@
|
|
|
324
410
|
></div>
|
|
325
411
|
{/snippet}
|
|
326
412
|
|
|
327
|
-
{#snippet
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
)}
|
|
413
|
+
{#snippet card(column: C, item: T, index: number, list: T[])}
|
|
414
|
+
{@const id = getId(item)}
|
|
415
|
+
{@const nextId = idAt(list, index + 1)}
|
|
416
|
+
{@const isSource = dnd.isDragSource(id)}
|
|
417
|
+
<!-- The slot right after the held card is its own spot, so the card's place shows it instead. -->
|
|
418
|
+
{@const atOrigin =
|
|
419
|
+
!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}
|
|
421
|
+
{@render dropSlot()}
|
|
422
|
+
{/if}
|
|
423
|
+
<!-- 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. -->
|
|
424
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
425
|
+
<div
|
|
426
|
+
role="listitem"
|
|
427
|
+
tabindex="0"
|
|
428
|
+
aria-label={getLabel?.(item) ??
|
|
429
|
+
"Card. Press Alt plus Left or Right arrow to move between columns."}
|
|
430
|
+
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
|
|
431
|
+
draggable="true"
|
|
432
|
+
ondragstart={(e) => {
|
|
433
|
+
slotHeight = e.currentTarget.getBoundingClientRect().height;
|
|
434
|
+
liftDragImage(e);
|
|
435
|
+
dnd.onDragStart(e, id);
|
|
436
|
+
}}
|
|
437
|
+
ondragend={() => {
|
|
438
|
+
dnd.resetDrag();
|
|
439
|
+
dragOverHidden = null;
|
|
440
|
+
}}
|
|
441
|
+
ondragover={(e) => dnd.onItemDragOver(e, column.id, id, nextId)}
|
|
442
|
+
onkeydown={(e) => onCardKeyDown(e, id)}
|
|
443
|
+
class={cn(
|
|
444
|
+
"kanban-card cursor-grab outline-none focus-visible:shadow-(--ring-focus) active:cursor-grabbing",
|
|
445
|
+
workLooks.card,
|
|
446
|
+
isSource && (atOrigin ? cn("kanban-origin-slot", workLooks.hole) : "kanban-lifted-out"),
|
|
447
|
+
)}
|
|
448
|
+
>
|
|
449
|
+
{@render renderCard(item, { columnId: column.id, index })}
|
|
450
|
+
</div>
|
|
451
|
+
{/snippet}
|
|
452
|
+
|
|
453
|
+
{#snippet columnShell(entry: ColumnView, extra: Record<string, unknown>)}
|
|
454
|
+
{@const { column, all, head, tail, open, capped } = entry}
|
|
334
455
|
{@const active = dnd.dragOver?.columnId === column.id}
|
|
335
|
-
{@const context = contextOf(
|
|
456
|
+
{@const context = contextOf(entry, active)}
|
|
457
|
+
{@const showTail = tail.length > 0}
|
|
458
|
+
{@const list = [...head, ...tail]}
|
|
459
|
+
{@const columnOpen = expandedIds.includes(column.id)}
|
|
460
|
+
{@const shown = capped ? head : list}
|
|
336
461
|
<KanbanColumn
|
|
337
462
|
{...extra}
|
|
338
463
|
hue={column.hue}
|
|
@@ -348,66 +473,80 @@
|
|
|
348
473
|
{#if columnHeader}
|
|
349
474
|
{@render columnHeader(column, context)}
|
|
350
475
|
{:else}
|
|
351
|
-
<
|
|
352
|
-
|
|
353
|
-
|
|
476
|
+
<GroupHead
|
|
477
|
+
label={column.label}
|
|
478
|
+
hue={column.hue}
|
|
479
|
+
count={all.length}
|
|
480
|
+
onAdd={onAdd ? () => onAdd(column.id) : undefined}
|
|
481
|
+
{addLabel}
|
|
482
|
+
/>
|
|
483
|
+
{/if}
|
|
484
|
+
{#if entry.foldable}
|
|
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"
|
|
354
494
|
>
|
|
355
|
-
|
|
356
|
-
|
|
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>
|
|
357
503
|
{/if}
|
|
358
504
|
{/snippet}
|
|
359
505
|
|
|
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>
|
|
506
|
+
{#each head as item, index (getId(item))}
|
|
507
|
+
{@render card(column, item, index, list)}
|
|
397
508
|
{/each}
|
|
398
509
|
|
|
399
|
-
{#if
|
|
510
|
+
{#if showTail}
|
|
511
|
+
<!-- The tail sits shut in a 0fr row, so opening and closing animate its height. -->
|
|
512
|
+
<div class="kanban-fold" data-open={open} inert={!open}>
|
|
513
|
+
<div class="kanban-fold-inner">
|
|
514
|
+
{#each tail as item, index (getId(item))}
|
|
515
|
+
{@render card(column, item, head.length + index, list)}
|
|
516
|
+
{/each}
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
{/if}
|
|
520
|
+
|
|
521
|
+
{#if dnd.showIndicatorEnd(column.id) && idAt(shown, shown.length - 1) !== dnd.draggedId}
|
|
400
522
|
{@render dropSlot()}
|
|
401
523
|
{/if}
|
|
402
524
|
|
|
525
|
+
{#if tail.length > 0 || open}
|
|
526
|
+
<Button
|
|
527
|
+
variant="ghost"
|
|
528
|
+
size="sm"
|
|
529
|
+
aria-expanded={open}
|
|
530
|
+
onclick={() => (open ? collapse(column.id) : expand(column.id))}
|
|
531
|
+
class="shrink-0 justify-start gap-1.5 px-2 text-xs font-normal text-muted-foreground"
|
|
532
|
+
>
|
|
533
|
+
<span
|
|
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
|
+
>
|
|
540
|
+
<span class="tabular-nums">{open ? lessLabel : moreLabel(tail.length)}</span>
|
|
541
|
+
</Button>
|
|
542
|
+
{/if}
|
|
543
|
+
|
|
403
544
|
{#if columnFooter}
|
|
404
545
|
{@render columnFooter(column, context)}
|
|
405
546
|
{/if}
|
|
406
547
|
|
|
407
|
-
{#if
|
|
408
|
-
<div
|
|
409
|
-
class="rounded-xl border-2 border-dashed border-border py-6 text-center text-xs text-muted-foreground"
|
|
410
|
-
>
|
|
548
|
+
{#if all.length === 0 && !active}
|
|
549
|
+
<div class={cn(workLooks.hole, "py-6 text-center text-xs text-muted-foreground")}>
|
|
411
550
|
{emptyLabel}
|
|
412
551
|
</div>
|
|
413
552
|
{/if}
|
|
@@ -421,36 +560,38 @@
|
|
|
421
560
|
className,
|
|
422
561
|
)}
|
|
423
562
|
>
|
|
424
|
-
{#each view as
|
|
563
|
+
{#each view as entry (entry.column.id)}
|
|
564
|
+
{@const column = entry.column}
|
|
425
565
|
{#if !hiddenSet.has(column.id) || collapsing.has(column.id)}
|
|
426
566
|
{#if wrapColumn}
|
|
427
567
|
{#snippet inner(props: Record<string, unknown>)}
|
|
428
|
-
{@render columnShell(
|
|
568
|
+
{@render columnShell(entry, props)}
|
|
429
569
|
{/snippet}
|
|
430
570
|
{@render wrapColumn(column, inner)}
|
|
431
571
|
{:else}
|
|
432
|
-
{@render columnShell(
|
|
572
|
+
{@render columnShell(entry, {})}
|
|
433
573
|
{/if}
|
|
434
574
|
{/if}
|
|
435
575
|
{/each}
|
|
436
576
|
|
|
437
577
|
{#if hiddenColumns.length > 0}
|
|
438
578
|
<div class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
|
|
439
|
-
<
|
|
440
|
-
|
|
579
|
+
<Button
|
|
580
|
+
variant="ghost"
|
|
581
|
+
size="sm"
|
|
441
582
|
aria-expanded={railOpen}
|
|
442
583
|
onclick={() => (railOpen = !railOpen)}
|
|
443
|
-
class="
|
|
584
|
+
class="justify-start px-2 text-xs font-normal text-muted-foreground"
|
|
444
585
|
>
|
|
445
586
|
<span
|
|
446
587
|
class={cn(
|
|
447
|
-
"size-2.5 transition-transform duration-
|
|
588
|
+
"size-2.5 transition-transform duration-fast ease-smooth-out motion-reduce:transition-none [&>svg]:size-full",
|
|
448
589
|
!railOpen && "-rotate-90",
|
|
449
590
|
)}
|
|
450
591
|
aria-hidden="true"><CaretDown weight="bold" /></span
|
|
451
592
|
>
|
|
452
593
|
{hiddenLabel}
|
|
453
|
-
</
|
|
594
|
+
</Button>
|
|
454
595
|
|
|
455
596
|
{#if railOpen}
|
|
456
597
|
<div class="flex flex-col gap-0.5">
|
|
@@ -462,7 +603,8 @@
|
|
|
462
603
|
role="region"
|
|
463
604
|
aria-label={column.ariaLabel ?? column.label}
|
|
464
605
|
class={cn(
|
|
465
|
-
"kanban-rail-item
|
|
606
|
+
"kanban-rail-item transition-[background-color,outline-color] duration-quick ease-smooth-out",
|
|
607
|
+
workLooks.row,
|
|
466
608
|
leaving.has(column.id) && "kanban-rail-item-leaving",
|
|
467
609
|
)}
|
|
468
610
|
style:background="color-mix(in oklch, {hue}
|
|
@@ -473,10 +615,11 @@
|
|
|
473
615
|
ondragleave={onHiddenDragLeave}
|
|
474
616
|
ondrop={(e) => onHiddenDrop(e, column.id)}
|
|
475
617
|
>
|
|
476
|
-
<
|
|
477
|
-
|
|
618
|
+
<Button
|
|
619
|
+
variant="ghost"
|
|
620
|
+
size="lg"
|
|
478
621
|
onclick={() => show(column.id)}
|
|
479
|
-
class="
|
|
622
|
+
class="w-full justify-start gap-2 text-xs text-muted-foreground"
|
|
480
623
|
>
|
|
481
624
|
{#if hiddenItem}
|
|
482
625
|
{@render hiddenItem(column, { count, active: over })}
|
|
@@ -488,18 +631,19 @@
|
|
|
488
631
|
>
|
|
489
632
|
{/if}
|
|
490
633
|
{/if}
|
|
491
|
-
</
|
|
634
|
+
</Button>
|
|
492
635
|
</div>
|
|
493
636
|
{/each}
|
|
494
637
|
|
|
495
638
|
{#if hiddenColumns.length > 1}
|
|
496
|
-
<
|
|
497
|
-
|
|
639
|
+
<Button
|
|
640
|
+
variant="ghost"
|
|
641
|
+
size="sm"
|
|
498
642
|
onclick={showAll}
|
|
499
|
-
class="mt-1
|
|
643
|
+
class="mt-1 justify-start text-xs font-normal text-muted-foreground"
|
|
500
644
|
>
|
|
501
645
|
{showAllLabel}
|
|
502
|
-
</
|
|
646
|
+
</Button>
|
|
503
647
|
{/if}
|
|
504
648
|
</div>
|
|
505
649
|
{/if}
|
|
@@ -508,38 +652,20 @@
|
|
|
508
652
|
</div>
|
|
509
653
|
|
|
510
654
|
<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
655
|
/* A grid row folds the card out of the column smoothly, so the cards below slide up. */
|
|
526
656
|
.kanban-card {
|
|
527
657
|
display: grid;
|
|
528
658
|
grid-template-columns: minmax(0, 1fr);
|
|
529
659
|
grid-template-rows: 1fr;
|
|
530
660
|
transition:
|
|
531
|
-
grid-template-rows var(--duration-quick
|
|
532
|
-
margin-top var(--duration-quick
|
|
661
|
+
grid-template-rows var(--duration-quick) var(--ease-smooth-out),
|
|
662
|
+
margin-top var(--duration-quick) var(--ease-smooth-out);
|
|
533
663
|
}
|
|
534
664
|
|
|
535
665
|
.kanban-card > :global(*) {
|
|
536
666
|
min-height: 0;
|
|
537
667
|
}
|
|
538
668
|
|
|
539
|
-
.kanban-origin-slot > :global(*) {
|
|
540
|
-
visibility: hidden;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
669
|
/* -0.375rem cancels the column's gap-1.5, so a folded card leaves no gap behind. */
|
|
544
670
|
.kanban-lifted-out {
|
|
545
671
|
grid-template-rows: 0fr;
|
|
@@ -551,14 +677,60 @@
|
|
|
551
677
|
visibility: hidden;
|
|
552
678
|
}
|
|
553
679
|
|
|
680
|
+
/* The fold: height rides grid-template-rows 0fr to 1fr, the fade runs shorter so it leads.
|
|
681
|
+
Shut, -0.375rem cancels the well's gap-1.5, so a closed fold leaves no gap behind. */
|
|
682
|
+
.kanban-fold {
|
|
683
|
+
display: grid;
|
|
684
|
+
grid-template-rows: 0fr;
|
|
685
|
+
margin-top: -0.375rem;
|
|
686
|
+
opacity: 0;
|
|
687
|
+
transition:
|
|
688
|
+
grid-template-rows var(--duration-fast) var(--ease-smooth-out),
|
|
689
|
+
margin-top var(--duration-fast) var(--ease-smooth-out),
|
|
690
|
+
opacity var(--duration-quick) var(--ease-smooth-out);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.kanban-fold[data-open="true"] {
|
|
694
|
+
grid-template-rows: 1fr;
|
|
695
|
+
margin-top: 0;
|
|
696
|
+
opacity: 1;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/* clip, not hidden: the margin lets a lifted card's shadow out while the fold stays shut. */
|
|
700
|
+
.kanban-fold-inner {
|
|
701
|
+
display: flex;
|
|
702
|
+
flex-direction: column;
|
|
703
|
+
gap: 0.375rem;
|
|
704
|
+
min-height: 0;
|
|
705
|
+
overflow: clip;
|
|
706
|
+
overflow-clip-margin: 0.375rem;
|
|
707
|
+
}
|
|
708
|
+
|
|
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
|
+
|
|
554
718
|
.kanban-rail-item-leaving {
|
|
555
719
|
pointer-events: none;
|
|
556
720
|
opacity: 0;
|
|
557
|
-
transition: opacity var(--duration-quick
|
|
721
|
+
transition: opacity var(--duration-quick) var(--ease-smooth-out);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* The held card's place keeps its hole, never its content. */
|
|
725
|
+
.kanban-origin-slot > :global(*) {
|
|
726
|
+
visibility: hidden;
|
|
558
727
|
}
|
|
559
728
|
|
|
560
729
|
@media (prefers-reduced-motion: reduce) {
|
|
561
|
-
.kanban-card
|
|
730
|
+
.kanban-card,
|
|
731
|
+
.kanban-fold,
|
|
732
|
+
.kanban-fold-chevron,
|
|
733
|
+
.kanban-rail-item-leaving {
|
|
562
734
|
transition: none;
|
|
563
735
|
}
|
|
564
736
|
}
|
package/src/work/kanban-types.ts
CHANGED
|
@@ -56,8 +56,10 @@ export type KanbanColumnContext = {
|
|
|
56
56
|
capped: boolean;
|
|
57
57
|
/** Fold the column into the hidden rail. Needs `hidden` or `onHiddenChange` on the board. */
|
|
58
58
|
hide: () => void;
|
|
59
|
-
/**
|
|
59
|
+
/** Open the fold so every card shows. */
|
|
60
60
|
expand: () => void;
|
|
61
|
+
/** Shut the fold back down to `limit`. */
|
|
62
|
+
collapse: () => void;
|
|
61
63
|
};
|
|
62
64
|
|
|
63
65
|
/** The default header renderer when no `columnHeader` snippet is given. */
|