@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
package/src/charts/smoothing.ts
CHANGED
|
@@ -10,20 +10,33 @@ export interface Smoothing {
|
|
|
10
10
|
kind?: SeriesKind;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Raw days, a trailing average, or a level read once a week. */
|
|
13
14
|
export type SmoothMode = "raw" | "rolling" | "weekly";
|
|
14
15
|
|
|
16
|
+
export interface Smoothed<TData> {
|
|
17
|
+
points: TData[];
|
|
18
|
+
mode: SmoothMode;
|
|
19
|
+
/** How many days a rolling point averages: 7 up to a quarter, 28 past it. */
|
|
20
|
+
window: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
/** The tooltip's name for a drawn point, beside its date. */
|
|
16
|
-
export const
|
|
24
|
+
export const averageLabel = (days: number) => `${days}-day average`;
|
|
25
|
+
export const ROLLING_LABEL = averageLabel(7);
|
|
17
26
|
export const weekOf = (start: string) => `Week of ${start}`;
|
|
18
|
-
export const averageTo = (end: string) => `${
|
|
27
|
+
export const averageTo = (end: string, days = 7) => `${averageLabel(days)} to ${end}`;
|
|
28
|
+
/** What a point the collector never measured reads as, where a number would claim a 0. */
|
|
29
|
+
export const NO_DATA = "No data";
|
|
19
30
|
|
|
20
31
|
const DAY = 86_400_000;
|
|
21
|
-
const
|
|
32
|
+
const WEEK = 7;
|
|
33
|
+
/** A year at 7 days still drew one-day spikes as humps; 28 days keeps the growth and drops the spikes. */
|
|
34
|
+
const LONG_WINDOW = 28;
|
|
22
35
|
|
|
23
36
|
// =========================
|
|
24
37
|
// FUNCTIONS
|
|
25
38
|
// =========================
|
|
26
|
-
/** About 30 to 60 marks across a chart: raw to a
|
|
39
|
+
/** About 30 to 60 marks across a chart: raw to a fortnight, a daily average to a quarter, weekly past it. */
|
|
27
40
|
export function smoothMode(rangeDays: number): SmoothMode {
|
|
28
41
|
if (rangeDays <= 14) return "raw";
|
|
29
42
|
if (rangeDays <= 120) return "rolling";
|
|
@@ -34,6 +47,13 @@ function timeOf(value: unknown): number {
|
|
|
34
47
|
return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
|
|
35
48
|
}
|
|
36
49
|
|
|
50
|
+
/** Null, undefined or not a number: a day nobody measured. An absent row still counts as 0. */
|
|
51
|
+
function measured(value: unknown): number | null {
|
|
52
|
+
if (value == null) return null;
|
|
53
|
+
const n = Number(value);
|
|
54
|
+
return Number.isFinite(n) ? n : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
/** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
|
|
38
58
|
function sameForm(sample: unknown, time: number): unknown {
|
|
39
59
|
if (sample instanceof Date) return new Date(time);
|
|
@@ -41,66 +61,100 @@ function sameForm(sample: unknown, time: number): unknown {
|
|
|
41
61
|
return String(sample).length === 10 ? iso.slice(0, 10) : iso;
|
|
42
62
|
}
|
|
43
63
|
|
|
64
|
+
const daysBetween = (from: number, to: number) => Math.round((to - from) / DAY);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A trailing mean over `window` days, drawn on the days `keep` picks.
|
|
68
|
+
*
|
|
69
|
+
* A missing value is skipped, never averaged in as 0, and a window with fewer measured days than
|
|
70
|
+
* missing ones draws a gap: one partial day after a 12-day outage is not a week. Days before a
|
|
71
|
+
* series' first measured value are not tracked yet, so they neither draw nor count as missing.
|
|
72
|
+
*/
|
|
73
|
+
function trailingMean<TData extends Record<string, unknown>>(
|
|
74
|
+
data: TData[],
|
|
75
|
+
times: number[],
|
|
76
|
+
yKeys: string[],
|
|
77
|
+
window: number,
|
|
78
|
+
keep: (i: number) => boolean,
|
|
79
|
+
): TData[] {
|
|
80
|
+
const firstSeen = Object.fromEntries(
|
|
81
|
+
yKeys.map((k) => [k, times[data.findIndex((d) => measured(d[k]) !== null)] ?? Infinity]),
|
|
82
|
+
);
|
|
83
|
+
const out: TData[] = [];
|
|
84
|
+
data.forEach((d, i) => {
|
|
85
|
+
if (!keep(i)) return;
|
|
86
|
+
const row: Record<string, unknown> = { ...d };
|
|
87
|
+
for (const k of yKeys) {
|
|
88
|
+
if (times[i] < firstSeen[k]) {
|
|
89
|
+
row[k] = null;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let sum = 0;
|
|
93
|
+
let missing = 0;
|
|
94
|
+
for (let j = i; j >= 0 && times[i] - times[j] < window * DAY; j--) {
|
|
95
|
+
const value = measured(data[j][k]);
|
|
96
|
+
if (value === null) missing += times[j] >= firstSeen[k] ? 1 : 0;
|
|
97
|
+
else sum += value;
|
|
98
|
+
}
|
|
99
|
+
// Divided by the tracked days in the window, so a row absent from a sparse series counts as 0.
|
|
100
|
+
const days = Math.min(window, daysBetween(Math.max(times[0], firstSeen[k]), times[i]) + 1);
|
|
101
|
+
const seen = days - missing;
|
|
102
|
+
row[k] = seen > 0 && seen * 2 >= days ? sum / seen : null;
|
|
103
|
+
}
|
|
104
|
+
out.push(row as TData);
|
|
105
|
+
});
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
/**
|
|
45
|
-
* The line a chart draws
|
|
46
|
-
*
|
|
110
|
+
* The line a chart draws. Up to a quarter, a trailing 7-day mean per day. Past it, a trailing
|
|
111
|
+
* 28-day mean read once a week from the last day, so the axis stays in per-day units at every
|
|
112
|
+
* range; a level reads its last value each week instead. A missing value (null) is a gap in the
|
|
113
|
+
* line, never a 0. Totals stay the caller's raw sums; this only moves the drawn points.
|
|
47
114
|
*/
|
|
48
115
|
export function smoothSeries<TData extends Record<string, unknown>>(
|
|
49
116
|
data: TData[],
|
|
50
117
|
xKey: keyof TData & string,
|
|
51
118
|
yKeys: (keyof TData & string)[],
|
|
52
119
|
{ rangeDays, kind = "count" }: Smoothing,
|
|
53
|
-
):
|
|
120
|
+
): Smoothed<TData> {
|
|
54
121
|
const mode = smoothMode(rangeDays);
|
|
55
|
-
if (mode === "raw" || data.length <
|
|
122
|
+
if (mode === "raw" || data.length < WEEK) return { points: data, mode: "raw", window: 1 };
|
|
56
123
|
const times = data.map((d) => timeOf(d[xKey]));
|
|
124
|
+
const last = times[times.length - 1];
|
|
57
125
|
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const points = data.map((d, i) => {
|
|
64
|
-
for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
|
|
65
|
-
while (times[i] - times[from] >= WINDOW * DAY) {
|
|
66
|
-
for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
|
|
67
|
-
from += 1;
|
|
68
|
-
}
|
|
69
|
-
// Divided by the days in the window, so a missing day counts as zero, not skipped.
|
|
70
|
-
const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
|
|
71
|
-
const row: Record<string, unknown> = { ...d };
|
|
72
|
-
for (const k of yKeys) row[k] = sums[k] / days;
|
|
73
|
-
return row as TData;
|
|
74
|
-
});
|
|
75
|
-
return { points, mode };
|
|
126
|
+
if (kind === "count") {
|
|
127
|
+
const window = mode === "rolling" ? WEEK : LONG_WINDOW;
|
|
128
|
+
const keep =
|
|
129
|
+
mode === "rolling" ? () => true : (i: number) => daysBetween(times[i], last) % WEEK === 0;
|
|
130
|
+
return { points: trailingMean(data, times, yKeys, window, keep), mode: "rolling", window };
|
|
76
131
|
}
|
|
77
132
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
133
|
+
// A level has no daily noise to average, and a mean draws slopes between its steps.
|
|
134
|
+
if (mode === "rolling") return { points: data, mode: "raw", window: 1 };
|
|
135
|
+
|
|
136
|
+
// Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped.
|
|
81
137
|
const buckets = new Map<number, TData[]>();
|
|
82
138
|
data.forEach((d, i) => {
|
|
83
|
-
const index = Math.floor(
|
|
139
|
+
const index = Math.floor(daysBetween(times[i], last) / WEEK);
|
|
84
140
|
buckets.set(index, [...(buckets.get(index) ?? []), d]);
|
|
85
141
|
});
|
|
86
142
|
const oldest = Math.max(...buckets.keys());
|
|
87
|
-
const oldestStart = last - (oldest *
|
|
143
|
+
const oldestStart = last - (oldest * WEEK + WEEK - 1) * DAY;
|
|
88
144
|
if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
|
|
89
145
|
|
|
90
146
|
const points = [...buckets.entries()]
|
|
91
147
|
.toSorted(([a], [b]) => b - a)
|
|
92
148
|
.map(([index, rows]) => {
|
|
93
149
|
const row: Record<string, unknown> = { ...rows[rows.length - 1] };
|
|
94
|
-
row[xKey] = sameForm(data[0][xKey], last - (index *
|
|
150
|
+
row[xKey] = sameForm(data[0][xKey], last - (index * WEEK + WEEK - 1) * DAY);
|
|
95
151
|
for (const k of yKeys) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
? Number(rows[rows.length - 1][k] ?? 0)
|
|
99
|
-
: rows.reduce((sum, r) => sum + Number(r[k] ?? 0), 0);
|
|
152
|
+
const readings = rows.map((r) => measured(r[k])).filter((v) => v !== null);
|
|
153
|
+
row[k] = readings.length > 0 ? readings[readings.length - 1] : null;
|
|
100
154
|
}
|
|
101
155
|
return row as TData;
|
|
102
156
|
});
|
|
103
|
-
return { points, mode };
|
|
157
|
+
return { points, mode, window: WEEK };
|
|
104
158
|
}
|
|
105
159
|
|
|
106
160
|
/** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { curveMonotoneX } from "d3-shape";
|
|
8
8
|
import Reveal from "./reveal.svelte";
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
NO_DATA,
|
|
11
|
+
averageLabel,
|
|
11
12
|
averageTo,
|
|
12
13
|
smoothSeries,
|
|
13
14
|
weekOf,
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
active = d;
|
|
78
79
|
});
|
|
79
80
|
const x = formatX(d[xKey]);
|
|
80
|
-
return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
|
|
81
|
+
return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x, smoothed.window) : x;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
// =========================
|
|
@@ -87,13 +88,14 @@
|
|
|
87
88
|
const smoothed = $derived(
|
|
88
89
|
smoothing
|
|
89
90
|
? smoothSeries(input, xKey, [yKey], smoothing)
|
|
90
|
-
: { points: input, mode: "raw" as const },
|
|
91
|
+
: { points: input, mode: "raw" as const, window: 1 },
|
|
91
92
|
);
|
|
92
93
|
const data = $derived(smoothed.points);
|
|
93
94
|
const mode = $derived(smoothed.mode);
|
|
94
|
-
const valueLabel = $derived(mode === "rolling" ?
|
|
95
|
+
const valueLabel = $derived(mode === "rolling" ? averageLabel(smoothed.window) : String(yKey));
|
|
95
96
|
const curve = $derived(smooth ? curveMonotoneX : undefined);
|
|
96
|
-
|
|
97
|
+
// The newest measured point: a gap at the end has no height for the dot to sit at.
|
|
98
|
+
const last = $derived(data.findLast((d) => d[yKey] != null) ?? null);
|
|
97
99
|
const display = $derived(active ?? last);
|
|
98
100
|
const dot = $derived(endCap && !bare);
|
|
99
101
|
// Bare runs to the edges: the fill reaches the bottom of the box and the line keeps three pixels of head so its stroke is not cut at the series
|
|
@@ -150,7 +152,10 @@
|
|
|
150
152
|
{#snippet children({ data: d })}
|
|
151
153
|
<Tooltip.Header value={trackActive(d as TData)} />
|
|
152
154
|
<Tooltip.List>
|
|
153
|
-
<Tooltip.Item
|
|
155
|
+
<Tooltip.Item
|
|
156
|
+
label={valueLabel}
|
|
157
|
+
value={d[yKey] == null ? NO_DATA : formatValue(Number(d[yKey]))}
|
|
158
|
+
/>
|
|
154
159
|
</Tooltip.List>
|
|
155
160
|
{/snippet}
|
|
156
161
|
</Tooltip.Root>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion tokens for code that needs a number: a Svelte `flip`, `slide` or `fade`, a timer. The
|
|
3
|
+
* CSS in `tokens/craft.css` owns the values; these mirror it for the server render and for a
|
|
4
|
+
* page that never loaded the tokens.
|
|
5
|
+
*/
|
|
6
|
+
export const DURATION_MS = {
|
|
7
|
+
quick: 150,
|
|
8
|
+
fast: 250,
|
|
9
|
+
slow: 300,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export type DurationToken = keyof typeof DURATION_MS;
|
|
13
|
+
|
|
14
|
+
/** A duration token in ms, read off a mounted element; both `ms` and `s` parse. */
|
|
15
|
+
export function tokenMs(el: Element, name: string, fallback: number): number {
|
|
16
|
+
const raw = getComputedStyle(el).getPropertyValue(name).trim();
|
|
17
|
+
const n = Number.parseFloat(raw);
|
|
18
|
+
if (Number.isNaN(n)) return fallback;
|
|
19
|
+
return raw.endsWith("ms") ? n : n * 1000;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `--duration-<token>` in ms off the root, so a token edit retimes the JS motion too. */
|
|
23
|
+
export function durationMs(token: DurationToken): number {
|
|
24
|
+
if (typeof document === "undefined") return DURATION_MS[token];
|
|
25
|
+
return tokenMs(document.documentElement, `--duration-${token}`, DURATION_MS[token]);
|
|
26
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { untrack } from "svelte";
|
|
2
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
3
|
+
import { prefersReducedMotion } from "svelte/motion";
|
|
4
|
+
import { cubicOut } from "svelte/easing";
|
|
5
|
+
import { durationMs } from "../lib/motion.js";
|
|
6
|
+
import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
|
|
7
|
+
import { createKanbanRailState } from "./kanban-rail-state.svelte.js";
|
|
8
|
+
import { createCardKeyDown } from "./kanban-keyboard.js";
|
|
9
|
+
import type {
|
|
10
|
+
KanbanColumn as KanbanColumnDef,
|
|
11
|
+
KanbanColumnContext,
|
|
12
|
+
KanbanMove,
|
|
13
|
+
KanbanSortKey,
|
|
14
|
+
} from "./kanban-types.js";
|
|
15
|
+
|
|
16
|
+
/** The board props the state reads, with the board's defaults already applied. */
|
|
17
|
+
export type KanbanBoardInput<T, C extends KanbanColumnDef> = {
|
|
18
|
+
columns: C[];
|
|
19
|
+
items: T[];
|
|
20
|
+
getId: (item: T) => string;
|
|
21
|
+
getColumn: (item: T) => string;
|
|
22
|
+
onMove: (id: string, move: KanbanMove) => void | Promise<void>;
|
|
23
|
+
boardOrder?: (item: T) => string | null | undefined;
|
|
24
|
+
manualTiebreak?: (a: T, b: T) => number;
|
|
25
|
+
sortKeys: KanbanSortKey<T>[];
|
|
26
|
+
sortKey: string;
|
|
27
|
+
onSelect?: (id: string) => void;
|
|
28
|
+
limit: number | ((columnId: string) => number | undefined);
|
|
29
|
+
expanded?: string[];
|
|
30
|
+
onExpandedChange?: (ids: string[]) => void;
|
|
31
|
+
hidden?: string[];
|
|
32
|
+
onHiddenChange?: (ids: string[]) => void;
|
|
33
|
+
dropAtTop?: (columnId: string) => boolean;
|
|
34
|
+
isDroppable?: (columnId: string) => boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One column as the board renders it: every card, the head over the fold and the tail in it. */
|
|
38
|
+
export type KanbanColumnView<T, C extends KanbanColumnDef> = {
|
|
39
|
+
column: C;
|
|
40
|
+
all: T[];
|
|
41
|
+
head: T[];
|
|
42
|
+
tail: T[];
|
|
43
|
+
foldable: boolean;
|
|
44
|
+
open: boolean;
|
|
45
|
+
capped: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A board's state: sort, the per-column fold and cap, and the drag wiring. The hidden rail
|
|
50
|
+
* lives in `rail`, the keyboard move in `onCardKeyDown`.
|
|
51
|
+
* Call it during component init, since it registers an effect. `input` is read on every use.
|
|
52
|
+
*/
|
|
53
|
+
export function createKanbanBoardState<T, C extends KanbanColumnDef>(
|
|
54
|
+
input: () => KanbanBoardInput<T, C>,
|
|
55
|
+
) {
|
|
56
|
+
// The drop slot takes the held card's height, so the column doesn't jump when it lands.
|
|
57
|
+
let slotHeight = $state(0);
|
|
58
|
+
let slotMotion = $derived({
|
|
59
|
+
duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
|
|
60
|
+
easing: cubicOut,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
let internalExpanded = $state<string[]>([]);
|
|
64
|
+
let expandedIds = $derived(input().expanded ?? internalExpanded);
|
|
65
|
+
|
|
66
|
+
function compare(a: T, b: T): number {
|
|
67
|
+
const { sortKey, manualTiebreak, boardOrder, sortKeys } = input();
|
|
68
|
+
if (sortKey === "manual") {
|
|
69
|
+
const tie = manualTiebreak?.(a, b) ?? 0;
|
|
70
|
+
if (!boardOrder) return tie;
|
|
71
|
+
const ao = boardOrder(a);
|
|
72
|
+
const bo = boardOrder(b);
|
|
73
|
+
if (ao == null && bo == null) return tie;
|
|
74
|
+
if (ao == null) return 1;
|
|
75
|
+
if (bo == null) return -1;
|
|
76
|
+
return ao < bo ? -1 : ao > bo ? 1 : tie;
|
|
77
|
+
}
|
|
78
|
+
const key = sortKeys.find((entry) => entry.id === sortKey);
|
|
79
|
+
return key ? key.compare(a, b) : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sortedByColumn(columnId: string): T[] {
|
|
83
|
+
const { items, getColumn } = input();
|
|
84
|
+
return items
|
|
85
|
+
.filter((item) => getColumn(item) === columnId)
|
|
86
|
+
.slice()
|
|
87
|
+
.sort(compare);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function idAt(list: T[], index: number): string | null {
|
|
91
|
+
const item = list[index];
|
|
92
|
+
return item === undefined ? null : input().getId(item);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function capOf(columnId: string): number | undefined {
|
|
96
|
+
const { limit } = input();
|
|
97
|
+
return typeof limit === "function" ? limit(columnId) : limit;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The column holds more cards than its cap, so it gets a fold. */
|
|
101
|
+
function isFoldable(columnId: string, total: number): boolean {
|
|
102
|
+
const cap = capOf(columnId);
|
|
103
|
+
return cap !== undefined && total > cap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Open from this board's "Show N more", on top of the header caret's `expanded`.
|
|
107
|
+
const laneOpen = new SvelteSet<string>();
|
|
108
|
+
|
|
109
|
+
function isOpen(columnId: string): boolean {
|
|
110
|
+
return expandedIds.includes(columnId) || laneOpen.has(columnId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The fold is shut, so the tail is out of sight. */
|
|
114
|
+
function isCapped(columnId: string, total: number): boolean {
|
|
115
|
+
return isFoldable(columnId, total) && !isOpen(columnId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A card moved into a folded column stays in view, wherever its sort puts it.
|
|
119
|
+
const pinned = new SvelteSet<string>();
|
|
120
|
+
|
|
121
|
+
// The caret folding a column shuts this board's own fold of it too, so every lane lands on the cap.
|
|
122
|
+
let previousExpanded: string[] = [];
|
|
123
|
+
$effect(() => {
|
|
124
|
+
const now = expandedIds;
|
|
125
|
+
untrack(() => {
|
|
126
|
+
for (const id of previousExpanded) if (!now.includes(id)) shut(id);
|
|
127
|
+
});
|
|
128
|
+
previousExpanded = [...now];
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function moveAndPin(id: string, move: KanbanMove): void | Promise<void> {
|
|
132
|
+
pinned.add(id);
|
|
133
|
+
return input().onMove(id, move);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function canDrop(columnId: string): boolean {
|
|
137
|
+
const { isDroppable, columns } = input();
|
|
138
|
+
return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setExpanded(next: string[]): void {
|
|
142
|
+
internalExpanded = next;
|
|
143
|
+
input().onExpandedChange?.(next);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Shutting drops the column's pins, so the fold closes to exactly its cap.
|
|
147
|
+
function shut(columnId: string): void {
|
|
148
|
+
laneOpen.delete(columnId);
|
|
149
|
+
for (const item of sortedByColumn(columnId)) pinned.delete(input().getId(item));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** "Show N more": opens this board's cell only. */
|
|
153
|
+
function expand(columnId: string): void {
|
|
154
|
+
laneOpen.add(columnId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** "Show less": shuts this board's cell, and lifts the caret's open if it was the caret's. */
|
|
158
|
+
function collapse(columnId: string): void {
|
|
159
|
+
shut(columnId);
|
|
160
|
+
if (expandedIds.includes(columnId)) setExpanded(expandedIds.filter((id) => id !== columnId));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The header caret: the whole column, in every board that shares `expanded`. */
|
|
164
|
+
function toggleColumn(columnId: string): void {
|
|
165
|
+
if (expandedIds.includes(columnId)) collapse(columnId);
|
|
166
|
+
else setExpanded([...expandedIds, columnId]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const dnd = createKanbanDnd<T>({
|
|
170
|
+
items: () => input().items,
|
|
171
|
+
getId: (item) => input().getId(item),
|
|
172
|
+
getColumn: (item) => input().getColumn(item),
|
|
173
|
+
getColumnItems: sortedByColumn,
|
|
174
|
+
getSortKey: () => input().sortKey,
|
|
175
|
+
boardOrder: (item) => input().boardOrder?.(item),
|
|
176
|
+
onMove: moveAndPin,
|
|
177
|
+
dropAtTop: (columnId) =>
|
|
178
|
+
input().dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
|
|
179
|
+
isDroppable: canDrop,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const rail = createKanbanRailState({
|
|
183
|
+
hidden: () => input().hidden,
|
|
184
|
+
onHiddenChange: () => input().onHiddenChange,
|
|
185
|
+
canDrop,
|
|
186
|
+
dnd,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const onCardKeyDown = createCardKeyDown<T, C>({
|
|
190
|
+
input,
|
|
191
|
+
canDrop,
|
|
192
|
+
isHidden: (columnId) => rail.hiddenSet.has(columnId),
|
|
193
|
+
sortedByColumn,
|
|
194
|
+
isCapped,
|
|
195
|
+
moveAndPin,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
let view = $derived(
|
|
199
|
+
input().columns.map((column): KanbanColumnView<T, C> => {
|
|
200
|
+
const all = sortedByColumn(column.id);
|
|
201
|
+
const cap = capOf(column.id);
|
|
202
|
+
const foldable = isFoldable(column.id, all.length);
|
|
203
|
+
const open = foldable && isOpen(column.id);
|
|
204
|
+
if (!foldable || cap === undefined) {
|
|
205
|
+
return { column, all, head: all, tail: [] as T[], foldable, open, capped: false };
|
|
206
|
+
}
|
|
207
|
+
// Head: the first `cap` cards plus any pinned ones, in sort order. The fold holds the rest.
|
|
208
|
+
const head: T[] = [];
|
|
209
|
+
const tail: T[] = [];
|
|
210
|
+
all.forEach((item, index) => {
|
|
211
|
+
(index < cap || pinned.has(input().getId(item)) ? head : tail).push(item);
|
|
212
|
+
});
|
|
213
|
+
return { column, all, head, tail, foldable, open, capped: !open && tail.length > 0 };
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
let hiddenColumns = $derived(input().columns.filter((column) => rail.hiddenSet.has(column.id)));
|
|
218
|
+
|
|
219
|
+
function contextOf(entry: KanbanColumnView<T, C>, active: boolean): KanbanColumnContext {
|
|
220
|
+
const { column, all, head, capped } = entry;
|
|
221
|
+
return {
|
|
222
|
+
count: all.length,
|
|
223
|
+
active,
|
|
224
|
+
shown: capped ? head.length : all.length,
|
|
225
|
+
capped,
|
|
226
|
+
hide: () => rail.hide(column.id),
|
|
227
|
+
expand: () => expand(column.id),
|
|
228
|
+
collapse: () => collapse(column.id),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
get slotHeight() {
|
|
234
|
+
return slotHeight;
|
|
235
|
+
},
|
|
236
|
+
set slotHeight(value: number) {
|
|
237
|
+
slotHeight = value;
|
|
238
|
+
},
|
|
239
|
+
get slotMotion() {
|
|
240
|
+
return slotMotion;
|
|
241
|
+
},
|
|
242
|
+
get expandedIds() {
|
|
243
|
+
return expandedIds;
|
|
244
|
+
},
|
|
245
|
+
get view() {
|
|
246
|
+
return view;
|
|
247
|
+
},
|
|
248
|
+
get hiddenColumns() {
|
|
249
|
+
return hiddenColumns;
|
|
250
|
+
},
|
|
251
|
+
dnd,
|
|
252
|
+
rail,
|
|
253
|
+
sortedByColumn,
|
|
254
|
+
idAt,
|
|
255
|
+
expand,
|
|
256
|
+
collapse,
|
|
257
|
+
toggleColumn,
|
|
258
|
+
onCardKeyDown,
|
|
259
|
+
contextOf,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export type KanbanBoardState<T, C extends KanbanColumnDef> = ReturnType<
|
|
264
|
+
typeof createKanbanBoardState<T, C>
|
|
265
|
+
>;
|