@heyramzi/ui 2.14.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 -38
- 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/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
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"kanban-column flex flex-col gap-2",
|
|
50
50
|
collapsing && "kanban-column-collapsing",
|
|
51
51
|
expanding && "kanban-column-expanding",
|
|
52
|
-
|
|
52
|
+
"min-h-0",
|
|
53
53
|
className,
|
|
54
54
|
)}
|
|
55
55
|
>
|
|
@@ -73,39 +73,61 @@
|
|
|
73
73
|
</div>
|
|
74
74
|
|
|
75
75
|
<style>
|
|
76
|
+
/* Columns split the board's width equally, never under 280px (the board scrolls instead)
|
|
77
|
+
nor over 420px (a 2-line card title stays near 55 characters a line). */
|
|
76
78
|
.kanban-column {
|
|
79
|
+
flex: 1 1 0;
|
|
80
|
+
min-width: var(--kanban-column-min, 280px);
|
|
81
|
+
max-width: var(--kanban-column-max, 420px);
|
|
77
82
|
transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
/* A board under 40rem wide (a phone, a side panel) shows one column and a peek of the next, as 82vw did on a phone. */
|
|
86
|
+
@container kanban-board (width < 40rem) {
|
|
87
|
+
.kanban-column {
|
|
88
|
+
flex: 0 0 88cqw;
|
|
89
|
+
min-width: 0;
|
|
90
|
+
max-width: 88cqw;
|
|
91
|
+
scroll-snap-align: start;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
.kanban-column-collapsing,
|
|
81
96
|
.kanban-column-expanding {
|
|
82
97
|
will-change: opacity;
|
|
98
|
+
overflow: hidden;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
.kanban-column-collapsing {
|
|
86
|
-
flex: 0 0 0px;
|
|
87
|
-
width: 0;
|
|
88
|
-
opacity: 0;
|
|
89
|
-
overflow: hidden;
|
|
90
102
|
pointer-events: none;
|
|
103
|
+
animation: column-fold var(--duration-slow) var(--ease-smooth-out) forwards;
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
.kanban-column-expanding {
|
|
94
|
-
|
|
95
|
-
width: 280px;
|
|
96
|
-
animation: column-expand var(--duration-slow, 300ms) var(--ease-smooth-out, ease-out) both;
|
|
107
|
+
animation: column-unfold var(--duration-slow) var(--ease-smooth-out) both;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
/* One end only: the other is the column's resting width, whatever the container gave it.
|
|
111
|
+
The negative margin takes the board's gap-4 with it, so no gap is left behind. */
|
|
112
|
+
@keyframes column-fold {
|
|
113
|
+
to {
|
|
101
114
|
opacity: 0;
|
|
102
|
-
flex: 0
|
|
103
|
-
|
|
115
|
+
flex-grow: 0;
|
|
116
|
+
flex-basis: 0;
|
|
117
|
+
min-width: 0;
|
|
118
|
+
max-width: 0;
|
|
119
|
+
margin-inline-end: -1rem;
|
|
104
120
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@keyframes column-unfold {
|
|
124
|
+
from {
|
|
125
|
+
opacity: 0;
|
|
126
|
+
flex-grow: 0;
|
|
127
|
+
flex-basis: 0;
|
|
128
|
+
min-width: 0;
|
|
129
|
+
max-width: 0;
|
|
130
|
+
margin-inline-end: -1rem;
|
|
109
131
|
}
|
|
110
132
|
}
|
|
111
133
|
|
|
@@ -113,5 +135,14 @@
|
|
|
113
135
|
.kanban-column-expanding {
|
|
114
136
|
animation: none;
|
|
115
137
|
}
|
|
138
|
+
|
|
139
|
+
.kanban-column-collapsing {
|
|
140
|
+
animation: none;
|
|
141
|
+
opacity: 0;
|
|
142
|
+
flex: 0 0 0;
|
|
143
|
+
min-width: 0;
|
|
144
|
+
max-width: 0;
|
|
145
|
+
margin-inline-end: -1rem;
|
|
146
|
+
}
|
|
116
147
|
}
|
|
117
148
|
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { CaretDown } from "phosphor-svelte";
|
|
3
|
+
import { cn } from "../lib/utils.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Flips the caret to point up. */
|
|
7
|
+
open: boolean;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { open, class: className }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<span
|
|
15
|
+
class={cn("kanban-fold-chevron [&>svg]:size-full", open && "kanban-fold-chevron-open", className)}
|
|
16
|
+
aria-hidden="true"><CaretDown weight="bold" /></span
|
|
17
|
+
>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.kanban-fold-chevron {
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
transition: transform var(--duration-fast) var(--ease-smooth-out);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.kanban-fold-chevron-open {
|
|
26
|
+
transform: scaleY(-1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media (prefers-reduced-motion: reduce) {
|
|
30
|
+
.kanban-fold-chevron {
|
|
31
|
+
transition: none;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
</style>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts" generics="C extends KanbanColumnDef">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { CaretDown } from "phosphor-svelte";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
import { Button } from "../shadcn/button/index.js";
|
|
6
|
+
import { workLooks } from "../components/work-parts/looks.js";
|
|
7
|
+
import type { KanbanRailState } from "./kanban-rail-state.svelte.js";
|
|
8
|
+
import type { KanbanColumn as KanbanColumnDef } from "./kanban-types.js";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The columns folded into the rail, in board order. */
|
|
12
|
+
columns: C[];
|
|
13
|
+
rail: KanbanRailState;
|
|
14
|
+
/** How many cards a column holds. */
|
|
15
|
+
countOf: (columnId: string) => number;
|
|
16
|
+
hiddenLabel: string;
|
|
17
|
+
showAllLabel: string;
|
|
18
|
+
hiddenItem?: Snippet<[C, { count: number; active: boolean }]>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let { columns, rail, countOf, hiddenLabel, showAllLabel, hiddenItem }: Props = $props();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<div class="kanban-rail flex w-40 shrink-0 flex-col gap-1">
|
|
25
|
+
<Button
|
|
26
|
+
variant="ghost"
|
|
27
|
+
size="sm"
|
|
28
|
+
aria-expanded={rail.railOpen}
|
|
29
|
+
onclick={() => (rail.railOpen = !rail.railOpen)}
|
|
30
|
+
class="justify-start px-2 text-xs font-normal text-muted-foreground"
|
|
31
|
+
>
|
|
32
|
+
<span
|
|
33
|
+
class={cn(
|
|
34
|
+
"size-2.5 transition-transform duration-fast ease-smooth-out motion-reduce:transition-none [&>svg]:size-full",
|
|
35
|
+
!rail.railOpen && "-rotate-90",
|
|
36
|
+
)}
|
|
37
|
+
aria-hidden="true"><CaretDown weight="bold" /></span
|
|
38
|
+
>
|
|
39
|
+
{hiddenLabel}
|
|
40
|
+
</Button>
|
|
41
|
+
|
|
42
|
+
{#if rail.railOpen}
|
|
43
|
+
<div class="flex flex-col gap-0.5">
|
|
44
|
+
{#each columns as column (column.id)}
|
|
45
|
+
{@const count = countOf(column.id)}
|
|
46
|
+
{@const over = rail.dragOverHidden === column.id}
|
|
47
|
+
{@const hue = column.hue ?? "var(--color-primary)"}
|
|
48
|
+
<div
|
|
49
|
+
role="region"
|
|
50
|
+
aria-label={column.ariaLabel ?? column.label}
|
|
51
|
+
class={cn(
|
|
52
|
+
"kanban-rail-item transition-[background-color,outline-color] duration-quick ease-smooth-out",
|
|
53
|
+
workLooks.row,
|
|
54
|
+
rail.leaving.has(column.id) && "kanban-rail-item-leaving",
|
|
55
|
+
)}
|
|
56
|
+
style:background="color-mix(in oklch, {hue}
|
|
57
|
+
{over ? 10 : 0}%, transparent)"
|
|
58
|
+
style:outline="1px solid color-mix(in oklch, {hue}
|
|
59
|
+
{over ? 28 : 0}%, transparent)"
|
|
60
|
+
ondragover={(e) => rail.onHiddenDragOver(e, column.id)}
|
|
61
|
+
ondragleave={rail.onHiddenDragLeave}
|
|
62
|
+
ondrop={(e) => rail.onHiddenDrop(e, column.id)}
|
|
63
|
+
>
|
|
64
|
+
<Button
|
|
65
|
+
variant="ghost"
|
|
66
|
+
size="lg"
|
|
67
|
+
onclick={() => rail.show(column.id)}
|
|
68
|
+
class="w-full justify-start gap-2 text-xs text-muted-foreground"
|
|
69
|
+
>
|
|
70
|
+
{#if hiddenItem}
|
|
71
|
+
{@render hiddenItem(column, { count, active: over })}
|
|
72
|
+
{:else}
|
|
73
|
+
<span class="truncate whitespace-nowrap">{column.label}</span>
|
|
74
|
+
{#if count > 0}
|
|
75
|
+
<span class="ms-auto text-xs text-muted-foreground/60 tabular-nums"
|
|
76
|
+
>{count.toLocaleString()}</span
|
|
77
|
+
>
|
|
78
|
+
{/if}
|
|
79
|
+
{/if}
|
|
80
|
+
</Button>
|
|
81
|
+
</div>
|
|
82
|
+
{/each}
|
|
83
|
+
|
|
84
|
+
{#if columns.length > 1}
|
|
85
|
+
<Button
|
|
86
|
+
variant="ghost"
|
|
87
|
+
size="sm"
|
|
88
|
+
onclick={rail.showAll}
|
|
89
|
+
class="mt-1 justify-start text-xs font-normal text-muted-foreground"
|
|
90
|
+
>
|
|
91
|
+
{showAllLabel}
|
|
92
|
+
</Button>
|
|
93
|
+
{/if}
|
|
94
|
+
</div>
|
|
95
|
+
{/if}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<style>
|
|
99
|
+
.kanban-rail-item-leaving {
|
|
100
|
+
pointer-events: none;
|
|
101
|
+
opacity: 0;
|
|
102
|
+
transition: opacity var(--duration-quick) var(--ease-smooth-out);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@media (prefers-reduced-motion: reduce) {
|
|
106
|
+
.kanban-rail-item-leaving {
|
|
107
|
+
transition: none;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { boardOrderAt } from "./board-order.js";
|
|
2
|
+
import type { KanbanBoardInput } from "./kanban-board-state.svelte.js";
|
|
3
|
+
import type { KanbanColumn as KanbanColumnDef, KanbanMove } from "./kanban-types.js";
|
|
4
|
+
|
|
5
|
+
export type KanbanKeyboardConfig<T, C extends KanbanColumnDef> = {
|
|
6
|
+
input: () => KanbanBoardInput<T, C>;
|
|
7
|
+
canDrop: (columnId: string) => boolean;
|
|
8
|
+
isHidden: (columnId: string) => boolean;
|
|
9
|
+
sortedByColumn: (columnId: string) => T[];
|
|
10
|
+
isCapped: (columnId: string, total: number) => boolean;
|
|
11
|
+
moveAndPin: (id: string, move: KanbanMove) => void | Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** The card's keydown handler: Enter or Space selects, Alt + arrow moves. */
|
|
15
|
+
export function createCardKeyDown<T, C extends KanbanColumnDef>(
|
|
16
|
+
config: KanbanKeyboardConfig<T, C>,
|
|
17
|
+
) {
|
|
18
|
+
const { input, canDrop, isHidden, sortedByColumn, isCapped, moveAndPin } = config;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Alt + ArrowLeft/Right moves the focused card to the next droppable column, so
|
|
22
|
+
* keyboard and screen-reader users get drag's onMove contract.
|
|
23
|
+
*/
|
|
24
|
+
return function onCardKeyDown(e: KeyboardEvent, id: string): void {
|
|
25
|
+
const { onSelect, items, getId, getColumn, columns, sortKey, dropAtTop, boardOrder } = input();
|
|
26
|
+
if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
onSelect(id);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
|
32
|
+
const item = items.find((entry) => getId(entry) === id);
|
|
33
|
+
if (!item) return;
|
|
34
|
+
const current = getColumn(item);
|
|
35
|
+
const at = columns.findIndex((c) => c.id === current);
|
|
36
|
+
if (at === -1) return;
|
|
37
|
+
const dir = e.key === "ArrowRight" ? 1 : -1;
|
|
38
|
+
for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
|
|
39
|
+
const target = columns[i];
|
|
40
|
+
if (!canDrop(target.id) || isHidden(target.id) || target.id === current) continue;
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
if (sortKey === "manual") {
|
|
43
|
+
const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
|
|
44
|
+
const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
|
|
45
|
+
const index = atTop ? 0 : column.length;
|
|
46
|
+
const before = column[index - 1];
|
|
47
|
+
const after = column[index];
|
|
48
|
+
void moveAndPin(id, {
|
|
49
|
+
columnId: target.id,
|
|
50
|
+
...(boardOrder
|
|
51
|
+
? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) }
|
|
52
|
+
: {}),
|
|
53
|
+
beforeId: before === undefined ? null : getId(before),
|
|
54
|
+
afterId: after === undefined ? null : getId(after),
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
void moveAndPin(id, { columnId: target.id });
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
2
|
+
|
|
3
|
+
export type KanbanRailConfig = {
|
|
4
|
+
/** The board's controlled `hidden`, or undefined to let the rail remember. */
|
|
5
|
+
hidden: () => string[] | undefined;
|
|
6
|
+
onHiddenChange: () => ((ids: string[]) => void) | undefined;
|
|
7
|
+
canDrop: (columnId: string) => boolean;
|
|
8
|
+
dnd: { clearDragOver: () => void; onDrop: (e: DragEvent, columnId: string) => void };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** The rail of hidden columns: which are folded, the fold and unfold timing, and drops onto it. */
|
|
12
|
+
export function createKanbanRailState(config: KanbanRailConfig) {
|
|
13
|
+
const { canDrop, dnd } = config;
|
|
14
|
+
let internalHidden = $state<string[]>([]);
|
|
15
|
+
let hiddenIds = $derived(config.hidden() ?? internalHidden);
|
|
16
|
+
let hiddenSet = $derived(new Set(hiddenIds));
|
|
17
|
+
|
|
18
|
+
let railOpen = $state(true);
|
|
19
|
+
let dragOverHidden = $state<string | null>(null);
|
|
20
|
+
const collapsing = new SvelteSet<string>();
|
|
21
|
+
const expanding = new SvelteSet<string>();
|
|
22
|
+
const leaving = new SvelteSet<string>();
|
|
23
|
+
|
|
24
|
+
function setHidden(next: string[]): void {
|
|
25
|
+
internalHidden = next;
|
|
26
|
+
config.onHiddenChange()?.(next);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fold: the column fades out first, then leaves the row and joins the rail.
|
|
30
|
+
function hide(columnId: string): void {
|
|
31
|
+
if (hiddenSet.has(columnId) || collapsing.has(columnId)) return;
|
|
32
|
+
collapsing.add(columnId);
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
setHidden([...hiddenIds, columnId]);
|
|
35
|
+
collapsing.delete(columnId);
|
|
36
|
+
}, 300);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Unfold: the rail entry leaves, then the column grows back in.
|
|
40
|
+
function show(columnId: string): void {
|
|
41
|
+
leaving.add(columnId);
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
leaving.delete(columnId);
|
|
44
|
+
expanding.add(columnId);
|
|
45
|
+
setHidden(hiddenIds.filter((id) => id !== columnId));
|
|
46
|
+
setTimeout(() => expanding.delete(columnId), 350);
|
|
47
|
+
}, 200);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function showAll(): void {
|
|
51
|
+
for (const id of hiddenIds) leaving.add(id);
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
leaving.clear();
|
|
54
|
+
setHidden([]);
|
|
55
|
+
}, 200);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onHiddenDragOver(e: DragEvent, columnId: string): void {
|
|
59
|
+
if (!canDrop(columnId)) return;
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
62
|
+
if (dragOverHidden !== columnId) dragOverHidden = columnId;
|
|
63
|
+
dnd.clearDragOver();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onHiddenDragLeave(e: DragEvent): void {
|
|
67
|
+
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) dragOverHidden = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A card dropped on a folded column brings the column back and lands in it.
|
|
71
|
+
function onHiddenDrop(e: DragEvent, columnId: string): void {
|
|
72
|
+
dragOverHidden = null;
|
|
73
|
+
dnd.onDrop(e, columnId);
|
|
74
|
+
show(columnId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get hiddenSet() {
|
|
79
|
+
return hiddenSet;
|
|
80
|
+
},
|
|
81
|
+
get railOpen() {
|
|
82
|
+
return railOpen;
|
|
83
|
+
},
|
|
84
|
+
set railOpen(value: boolean) {
|
|
85
|
+
railOpen = value;
|
|
86
|
+
},
|
|
87
|
+
get dragOverHidden() {
|
|
88
|
+
return dragOverHidden;
|
|
89
|
+
},
|
|
90
|
+
set dragOverHidden(value: string | null) {
|
|
91
|
+
dragOverHidden = value;
|
|
92
|
+
},
|
|
93
|
+
collapsing,
|
|
94
|
+
expanding,
|
|
95
|
+
leaving,
|
|
96
|
+
hide,
|
|
97
|
+
show,
|
|
98
|
+
showAll,
|
|
99
|
+
onHiddenDragOver,
|
|
100
|
+
onHiddenDragLeave,
|
|
101
|
+
onHiddenDrop,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type KanbanRailState = ReturnType<typeof createKanbanRailState>;
|