@kinetixui/ui 0.11.0 → 0.12.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/index.d.ts +344 -1
- package/dist/index.js +1705 -2
- package/package.json +5 -2
- package/src/components/color-picker.tsx +352 -0
- package/src/components/data-grid.tsx +304 -0
- package/src/components/diff-viewer.tsx +155 -0
- package/src/components/json-viewer.tsx +168 -0
- package/src/components/kanban-board.tsx +246 -0
- package/src/components/markdown-editor.tsx +288 -0
- package/src/components/message-bubble.tsx +83 -0
- package/src/components/multi-select.tsx +134 -0
- package/src/components/notification-center.tsx +119 -0
- package/src/components/tour.tsx +156 -0
- package/src/components/tree-view.tsx +273 -0
- package/src/components/virtual-list.tsx +92 -0
- package/src/index.ts +20 -0
- package/src/stories/ColorPicker.stories.tsx +28 -0
- package/src/stories/DataGrid.stories.tsx +54 -0
- package/src/stories/DiffViewer.stories.tsx +26 -0
- package/src/stories/JsonViewer.stories.tsx +31 -0
- package/src/stories/KanbanBoard.stories.tsx +44 -0
- package/src/stories/MarkdownEditor.stories.tsx +25 -0
- package/src/stories/MessageBubble.stories.tsx +32 -0
- package/src/stories/MultiSelect.stories.tsx +35 -0
- package/src/stories/NotificationCenter.stories.tsx +41 -0
- package/src/stories/Tour.stories.tsx +70 -0
- package/src/stories/TreeView.stories.tsx +38 -0
- package/src/stories/VirtualList.stories.tsx +36 -0
- package/tailwind.config.ts +2 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
export interface DiffViewerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
7
|
+
oldText: string;
|
|
8
|
+
newText: string;
|
|
9
|
+
/** "unified" (one interleaved column, like `git diff`) or "split" (two columns, side by side). Default "unified". */
|
|
10
|
+
mode?: "unified" | "split";
|
|
11
|
+
/** column headers, `mode="split"` only */
|
|
12
|
+
oldLabel?: string;
|
|
13
|
+
newLabel?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type DiffOp =
|
|
17
|
+
| { type: "equal"; oldLine: number; newLine: number; text: string }
|
|
18
|
+
| { type: "remove"; oldLine: number; text: string }
|
|
19
|
+
| { type: "add"; newLine: number; text: string };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Line-based LCS diff (the same longest-common-subsequence backtrack
|
|
23
|
+
* `git diff`'s line mode is built on), hand-rolled rather than pulled from
|
|
24
|
+
* a package: it needs to behave identically across all four platforms, and
|
|
25
|
+
* a ~30-line DP table is easier to keep in lockstep across React/Compose/
|
|
26
|
+
* SwiftUI/Flutter than four bindings to (or ports of) someone else's diff
|
|
27
|
+
* library. O(n·m) time and space — fine for a config file or a token
|
|
28
|
+
* snapshot, the sizes this dev-tool surface actually sees; not meant for
|
|
29
|
+
* multi-thousand-line files.
|
|
30
|
+
*/
|
|
31
|
+
function computeLineDiff(oldText: string, newText: string): DiffOp[] {
|
|
32
|
+
const oldLines = oldText.split("\n");
|
|
33
|
+
const newLines = newText.split("\n");
|
|
34
|
+
const n = oldLines.length;
|
|
35
|
+
const m = newLines.length;
|
|
36
|
+
|
|
37
|
+
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
38
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
39
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
40
|
+
dp[i]![j] = oldLines[i] === newLines[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ops: DiffOp[] = [];
|
|
45
|
+
let i = 0;
|
|
46
|
+
let j = 0;
|
|
47
|
+
while (i < n && j < m) {
|
|
48
|
+
if (oldLines[i] === newLines[j]) {
|
|
49
|
+
ops.push({ type: "equal", oldLine: i + 1, newLine: j + 1, text: oldLines[i]! });
|
|
50
|
+
i++;
|
|
51
|
+
j++;
|
|
52
|
+
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
|
|
53
|
+
ops.push({ type: "remove", oldLine: i + 1, text: oldLines[i]! });
|
|
54
|
+
i++;
|
|
55
|
+
} else {
|
|
56
|
+
ops.push({ type: "add", newLine: j + 1, text: newLines[j]! });
|
|
57
|
+
j++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
while (i < n) {
|
|
61
|
+
ops.push({ type: "remove", oldLine: i + 1, text: oldLines[i]! });
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
while (j < m) {
|
|
65
|
+
ops.push({ type: "add", newLine: j + 1, text: newLines[j]! });
|
|
66
|
+
j++;
|
|
67
|
+
}
|
|
68
|
+
return ops;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* DiffViewer — a side-by-side / inline text diff with line markers and
|
|
73
|
+
* gutter line numbers (token diffs, config changes — the site itself could
|
|
74
|
+
* use this for a "what changed between versions" view). `split` mode
|
|
75
|
+
* doesn't pair adjacent remove/add runs onto the same row the way GitHub's
|
|
76
|
+
* split view does — each op renders in its own column, blank on the other
|
|
77
|
+
* side — a deliberate simplification over that extra alignment heuristic.
|
|
78
|
+
* Gap-fill addition (not in the original Figma source).
|
|
79
|
+
*/
|
|
80
|
+
const DiffViewer = React.forwardRef<HTMLDivElement, DiffViewerProps>(
|
|
81
|
+
({ oldText, newText, mode = "unified", oldLabel = "Before", newLabel = "After", className, ...props }, ref) => {
|
|
82
|
+
const ops = React.useMemo(() => computeLineDiff(oldText, newText), [oldText, newText]);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div ref={ref} className={cn("overflow-auto rounded-md border font-mono text-xs", className)} {...props}>
|
|
86
|
+
{mode === "split" ? (
|
|
87
|
+
<SplitDiff ops={ops} oldLabel={oldLabel} newLabel={newLabel} />
|
|
88
|
+
) : (
|
|
89
|
+
<UnifiedDiff ops={ops} />
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
DiffViewer.displayName = "DiffViewer";
|
|
96
|
+
|
|
97
|
+
function UnifiedDiff({ ops }: { ops: DiffOp[] }) {
|
|
98
|
+
return (
|
|
99
|
+
<div role="table">
|
|
100
|
+
{ops.map((op, i) => (
|
|
101
|
+
<div
|
|
102
|
+
key={i}
|
|
103
|
+
role="row"
|
|
104
|
+
className={cn("flex", op.type === "add" && "bg-success/10", op.type === "remove" && "bg-destructive/10")}
|
|
105
|
+
>
|
|
106
|
+
<span className="w-9 shrink-0 select-none border-r px-1.5 text-right text-muted-foreground">
|
|
107
|
+
{op.type !== "add" ? op.oldLine : ""}
|
|
108
|
+
</span>
|
|
109
|
+
<span className="w-9 shrink-0 select-none border-r px-1.5 text-right text-muted-foreground">
|
|
110
|
+
{op.type !== "remove" ? op.newLine : ""}
|
|
111
|
+
</span>
|
|
112
|
+
<span
|
|
113
|
+
className={cn(
|
|
114
|
+
"w-4 shrink-0 select-none text-center",
|
|
115
|
+
op.type === "add" && "text-success",
|
|
116
|
+
op.type === "remove" && "text-destructive",
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{op.type === "add" ? "+" : op.type === "remove" ? "−" : ""}
|
|
120
|
+
</span>
|
|
121
|
+
<span className="min-w-0 flex-1 whitespace-pre px-1">{op.text}</span>
|
|
122
|
+
</div>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function SplitDiff({ ops, oldLabel, newLabel }: { ops: DiffOp[]; oldLabel: string; newLabel: string }) {
|
|
129
|
+
return (
|
|
130
|
+
<div role="table">
|
|
131
|
+
<div className="flex border-b bg-muted/40 text-muted-foreground">
|
|
132
|
+
<div className="flex-1 px-2 py-1">{oldLabel}</div>
|
|
133
|
+
<div className="flex-1 border-l px-2 py-1">{newLabel}</div>
|
|
134
|
+
</div>
|
|
135
|
+
{ops.map((op, i) => (
|
|
136
|
+
<div key={i} role="row" className="flex">
|
|
137
|
+
<div className={cn("flex flex-1 items-start", op.type === "remove" && "bg-destructive/10")}>
|
|
138
|
+
<span className="w-9 shrink-0 select-none px-1.5 text-right text-muted-foreground">
|
|
139
|
+
{op.type !== "add" ? op.oldLine : ""}
|
|
140
|
+
</span>
|
|
141
|
+
<span className="min-w-0 flex-1 whitespace-pre px-1">{op.type !== "add" ? op.text : ""}</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div className={cn("flex flex-1 items-start border-l", op.type === "add" && "bg-success/10")}>
|
|
144
|
+
<span className="w-9 shrink-0 select-none px-1.5 text-right text-muted-foreground">
|
|
145
|
+
{op.type !== "remove" ? op.newLine : ""}
|
|
146
|
+
</span>
|
|
147
|
+
<span className="min-w-0 flex-1 whitespace-pre px-1">{op.type !== "remove" ? op.text : ""}</span>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { DiffViewer };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check, ChevronRight, Copy } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface JsonViewerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
8
|
+
data: unknown;
|
|
9
|
+
/** tree depth (root = 0) to auto-expand; deeper nodes start collapsed. Default 1. */
|
|
10
|
+
expandDepth?: number;
|
|
11
|
+
hideCopy?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* JsonViewer — a collapsible, syntax-colored tree for arbitrary JSON data
|
|
16
|
+
* (API responses, registry payloads, token diffs — a dev-tool surface the
|
|
17
|
+
* site itself needs for `/docs/kinetixui-json` and friends). Distinct from
|
|
18
|
+
* `TreeView`: this renders a *data structure* (object/array/primitive),
|
|
19
|
+
* not a caller-composed hierarchy of `TreeItem`s, so expand state is
|
|
20
|
+
* per-node uncontrolled rather than a lifted `expanded` prop — there's no
|
|
21
|
+
* stable "value" to key a node by beyond its position in the data, and
|
|
22
|
+
* nothing external needs to drive which nodes are open. Value colors reuse
|
|
23
|
+
* the existing semantic tokens (`--success`/`--info`/`--warning`) rather
|
|
24
|
+
* than introducing a separate syntax-highlighting palette. Gap-fill
|
|
25
|
+
* addition (not in the original Figma source).
|
|
26
|
+
*/
|
|
27
|
+
const JsonViewer = React.forwardRef<HTMLDivElement, JsonViewerProps>(
|
|
28
|
+
({ data, expandDepth = 1, hideCopy, className, ...props }, ref) => {
|
|
29
|
+
const [copied, setCopied] = React.useState(false);
|
|
30
|
+
|
|
31
|
+
const copy = async () => {
|
|
32
|
+
try {
|
|
33
|
+
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
|
34
|
+
setCopied(true);
|
|
35
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
36
|
+
} catch {
|
|
37
|
+
// clipboard unavailable — no-op
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={ref}
|
|
44
|
+
className={cn("relative overflow-auto rounded-md border bg-muted/30 p-3 font-mono text-xs", className)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
{!hideCopy && (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={copy}
|
|
51
|
+
aria-label="Copy JSON"
|
|
52
|
+
className="absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
53
|
+
>
|
|
54
|
+
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
57
|
+
<div role="tree">
|
|
58
|
+
<JsonNode value={data} depth={0} expandDepth={expandDepth} isLast />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
JsonViewer.displayName = "JsonViewer";
|
|
65
|
+
|
|
66
|
+
function JsonNode({
|
|
67
|
+
name,
|
|
68
|
+
value,
|
|
69
|
+
depth,
|
|
70
|
+
expandDepth,
|
|
71
|
+
isLast,
|
|
72
|
+
}: {
|
|
73
|
+
name?: string;
|
|
74
|
+
value: unknown;
|
|
75
|
+
depth: number;
|
|
76
|
+
expandDepth: number;
|
|
77
|
+
isLast: boolean;
|
|
78
|
+
}) {
|
|
79
|
+
const isContainer = value !== null && typeof value === "object";
|
|
80
|
+
const [expanded, setExpanded] = React.useState(depth < expandDepth);
|
|
81
|
+
|
|
82
|
+
if (!isContainer) {
|
|
83
|
+
return (
|
|
84
|
+
<div role="treeitem" style={{ paddingLeft: depth * 16 }}>
|
|
85
|
+
{name !== undefined && <span className="text-muted-foreground">{JSON.stringify(name)}: </span>}
|
|
86
|
+
<JsonPrimitive value={value} />
|
|
87
|
+
{!isLast && <span className="text-muted-foreground">,</span>}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isArray = Array.isArray(value);
|
|
93
|
+
const entries: [string, unknown][] = isArray
|
|
94
|
+
? (value as unknown[]).map((v, i) => [String(i), v])
|
|
95
|
+
: Object.entries(value as Record<string, unknown>);
|
|
96
|
+
const openBracket = isArray ? "[" : "{";
|
|
97
|
+
const closeBracket = isArray ? "]" : "}";
|
|
98
|
+
|
|
99
|
+
if (entries.length === 0) {
|
|
100
|
+
return (
|
|
101
|
+
<div role="treeitem" style={{ paddingLeft: depth * 16 + 20 }}>
|
|
102
|
+
{name !== undefined && <span className="text-muted-foreground">{JSON.stringify(name)}: </span>}
|
|
103
|
+
<span>
|
|
104
|
+
{openBracket}
|
|
105
|
+
{closeBracket}
|
|
106
|
+
</span>
|
|
107
|
+
{!isLast && <span className="text-muted-foreground">,</span>}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div role="treeitem" aria-expanded={expanded}>
|
|
114
|
+
<div style={{ paddingLeft: depth * 16 }} className="flex items-start gap-1">
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
onClick={() => setExpanded((e) => !e)}
|
|
118
|
+
aria-label={expanded ? "Collapse" : "Expand"}
|
|
119
|
+
className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground"
|
|
120
|
+
>
|
|
121
|
+
<ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} />
|
|
122
|
+
</button>
|
|
123
|
+
<span>
|
|
124
|
+
{name !== undefined && <span className="text-muted-foreground">{JSON.stringify(name)}: </span>}
|
|
125
|
+
<span>{openBracket}</span>
|
|
126
|
+
{!expanded && (
|
|
127
|
+
<span className="text-muted-foreground">
|
|
128
|
+
{" "}
|
|
129
|
+
{entries.length} {isArray ? "item" : "key"}
|
|
130
|
+
{entries.length === 1 ? "" : "s"}{" "}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
{!expanded && <span>{closeBracket}</span>}
|
|
134
|
+
{!expanded && !isLast && <span className="text-muted-foreground">,</span>}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
{expanded && (
|
|
138
|
+
<>
|
|
139
|
+
{entries.map(([key, val], i) => (
|
|
140
|
+
<JsonNode
|
|
141
|
+
key={key}
|
|
142
|
+
name={isArray ? undefined : key}
|
|
143
|
+
value={val}
|
|
144
|
+
depth={depth + 1}
|
|
145
|
+
expandDepth={expandDepth}
|
|
146
|
+
isLast={i === entries.length - 1}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
<div style={{ paddingLeft: depth * 16 }}>
|
|
150
|
+
<span>{closeBracket}</span>
|
|
151
|
+
{!isLast && <span className="text-muted-foreground">,</span>}
|
|
152
|
+
</div>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function JsonPrimitive({ value }: { value: unknown }) {
|
|
160
|
+
if (value === null) return <span className="italic text-muted-foreground">null</span>;
|
|
161
|
+
if (value === undefined) return <span className="italic text-muted-foreground">undefined</span>;
|
|
162
|
+
if (typeof value === "string") return <span className="text-success">{JSON.stringify(value)}</span>;
|
|
163
|
+
if (typeof value === "number") return <span className="text-info">{value}</span>;
|
|
164
|
+
if (typeof value === "boolean") return <span className="text-warning">{String(value)}</span>;
|
|
165
|
+
return <span>{String(value)}</span>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export { JsonViewer };
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
DndContext,
|
|
6
|
+
DragOverlay,
|
|
7
|
+
KeyboardSensor,
|
|
8
|
+
PointerSensor,
|
|
9
|
+
closestCenter,
|
|
10
|
+
getFirstCollision,
|
|
11
|
+
pointerWithin,
|
|
12
|
+
rectIntersection,
|
|
13
|
+
useDroppable,
|
|
14
|
+
useSensor,
|
|
15
|
+
useSensors,
|
|
16
|
+
type CollisionDetection,
|
|
17
|
+
type DragEndEvent,
|
|
18
|
+
type DragOverEvent,
|
|
19
|
+
type DragStartEvent,
|
|
20
|
+
} from "@dnd-kit/core";
|
|
21
|
+
import {
|
|
22
|
+
SortableContext,
|
|
23
|
+
arrayMove,
|
|
24
|
+
sortableKeyboardCoordinates,
|
|
25
|
+
useSortable,
|
|
26
|
+
verticalListSortingStrategy,
|
|
27
|
+
} from "@dnd-kit/sortable";
|
|
28
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
29
|
+
import { cn } from "../lib/utils";
|
|
30
|
+
|
|
31
|
+
export interface KanbanCard {
|
|
32
|
+
id: string;
|
|
33
|
+
content: React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface KanbanColumn {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
cards: KanbanCard[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KanbanBoardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
43
|
+
columns: KanbanColumn[];
|
|
44
|
+
onColumnsChange: (columns: KanbanColumn[]) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* KanbanBoard — draggable cards across columns, with keyboard DnD. No DnD
|
|
49
|
+
* primitive existed in this package at all, and hand-rolling accessible
|
|
50
|
+
* drag-and-drop (pointer + touch + keyboard, collision detection, live
|
|
51
|
+
* screen-reader announcements) from scratch would both take far longer
|
|
52
|
+
* than this component's own logic and likely be worse than a battle-tested
|
|
53
|
+
* library — unlike `DiffViewer`'s hand-rolled LCS diff, there's no
|
|
54
|
+
* cross-platform-identical-algorithm argument here, since each native
|
|
55
|
+
* platform reaches for its own idiomatic drag primitive anyway (Compose's
|
|
56
|
+
* drag gestures, SwiftUI's `.draggable`/`.dropDestination`, Flutter's
|
|
57
|
+
* `Draggable`/`DragTarget`). `@dnd-kit` is the dependency: no runtime CSS,
|
|
58
|
+
* accessible by default, and this is exactly its documented "multiple
|
|
59
|
+
* containers" sortable pattern — `onDragOver` re-parents a card into the
|
|
60
|
+
* hovered column live (the standard approach; a little re-render churn
|
|
61
|
+
* mid-drag is expected/upstream-documented), `onDragEnd` commits the
|
|
62
|
+
* final within-column reorder. Column order itself isn't draggable — the
|
|
63
|
+
* `COMPONENT-ADDITIONS.md` entry only asked for card dragging.
|
|
64
|
+
*
|
|
65
|
+
* `collisionDetectionStrategy` below is dnd-kit's own documented fix for a
|
|
66
|
+
* specific multi-container gotcha: each column is both a `useDroppable`
|
|
67
|
+
* (so an empty column, or the space below its last card, is still a valid
|
|
68
|
+
* drop target) and wraps a `SortableContext` of its cards. A column's rect
|
|
69
|
+
* is a strict superset of its cards' rects, so plain `closestCorners` (or
|
|
70
|
+
* `closestCenter`/`rectIntersection` alone) almost always reports the
|
|
71
|
+
* column itself as the collision, never a specific card underneath — over
|
|
72
|
+
* gets stuck on the column id and item-level reordering never fires. The
|
|
73
|
+
* fix (lifted from dnd-kit's own multiple-containers example): try
|
|
74
|
+
* `pointerWithin` first, fall back to `rectIntersection`, and if the
|
|
75
|
+
* winning id is a column, re-run `closestCenter` scoped to just that
|
|
76
|
+
* column's cards to find the specific one under the pointer.
|
|
77
|
+
*/
|
|
78
|
+
const KanbanBoard = React.forwardRef<HTMLDivElement, KanbanBoardProps>(
|
|
79
|
+
({ columns, onColumnsChange, className, ...props }, ref) => {
|
|
80
|
+
const sensors = useSensors(
|
|
81
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
82
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
83
|
+
);
|
|
84
|
+
const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null);
|
|
85
|
+
const lastOverId = React.useRef<string | null>(null);
|
|
86
|
+
|
|
87
|
+
const findColumn = React.useCallback(
|
|
88
|
+
(id: string) => columns.find((col) => col.id === id || col.cards.some((c) => c.id === id)),
|
|
89
|
+
[columns],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const collisionDetectionStrategy: CollisionDetection = React.useCallback(
|
|
93
|
+
(args) => {
|
|
94
|
+
const pointerIntersections = pointerWithin(args);
|
|
95
|
+
const intersections = pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
|
|
96
|
+
let overId = getFirstCollision(intersections, "id");
|
|
97
|
+
|
|
98
|
+
if (overId != null) {
|
|
99
|
+
const overColumn = columns.find((col) => col.id === overId);
|
|
100
|
+
if (overColumn && overColumn.cards.length > 0) {
|
|
101
|
+
const cardIds = new Set(overColumn.cards.map((c) => c.id));
|
|
102
|
+
const cardCollision = getFirstCollision(
|
|
103
|
+
closestCenter({
|
|
104
|
+
...args,
|
|
105
|
+
droppableContainers: args.droppableContainers.filter((c) => cardIds.has(String(c.id))),
|
|
106
|
+
}),
|
|
107
|
+
"id",
|
|
108
|
+
);
|
|
109
|
+
if (cardCollision != null) overId = cardCollision;
|
|
110
|
+
}
|
|
111
|
+
lastOverId.current = String(overId);
|
|
112
|
+
return [{ id: overId }];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return lastOverId.current ? [{ id: lastOverId.current }] : [];
|
|
116
|
+
},
|
|
117
|
+
[columns],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
function handleDragStart(event: DragStartEvent) {
|
|
121
|
+
lastOverId.current = String(event.active.id);
|
|
122
|
+
const column = findColumn(String(event.active.id));
|
|
123
|
+
const card = column?.cards.find((c) => c.id === event.active.id);
|
|
124
|
+
setActiveCard(card ?? null);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleDragOver(event: DragOverEvent) {
|
|
128
|
+
const { active, over } = event;
|
|
129
|
+
if (!over) return;
|
|
130
|
+
const activeId = String(active.id);
|
|
131
|
+
const overId = String(over.id);
|
|
132
|
+
if (activeId === overId) return;
|
|
133
|
+
|
|
134
|
+
const activeColumn = findColumn(activeId);
|
|
135
|
+
const overColumn = findColumn(overId);
|
|
136
|
+
if (!activeColumn || !overColumn || activeColumn.id === overColumn.id) return;
|
|
137
|
+
|
|
138
|
+
onColumnsChange(
|
|
139
|
+
columns.map((col) => {
|
|
140
|
+
if (col.id === activeColumn.id) {
|
|
141
|
+
return { ...col, cards: col.cards.filter((c) => c.id !== activeId) };
|
|
142
|
+
}
|
|
143
|
+
if (col.id === overColumn.id) {
|
|
144
|
+
const movedCard = activeColumn.cards.find((c) => c.id === activeId);
|
|
145
|
+
if (!movedCard) return col;
|
|
146
|
+
const overIndex = col.cards.findIndex((c) => c.id === overId);
|
|
147
|
+
const insertAt = overIndex >= 0 ? overIndex : col.cards.length;
|
|
148
|
+
const nextCards = [...col.cards];
|
|
149
|
+
nextCards.splice(insertAt, 0, movedCard);
|
|
150
|
+
return { ...col, cards: nextCards };
|
|
151
|
+
}
|
|
152
|
+
return col;
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
158
|
+
setActiveCard(null);
|
|
159
|
+
lastOverId.current = null;
|
|
160
|
+
const { active, over } = event;
|
|
161
|
+
if (!over) return;
|
|
162
|
+
const activeId = String(active.id);
|
|
163
|
+
const overId = String(over.id);
|
|
164
|
+
const activeColumn = findColumn(activeId);
|
|
165
|
+
// overId may be the column itself (dropped in empty space / an empty column)
|
|
166
|
+
const overColumn = columns.find((col) => col.id === overId) ?? findColumn(overId);
|
|
167
|
+
if (!activeColumn || !overColumn || activeColumn.id !== overColumn.id) return;
|
|
168
|
+
|
|
169
|
+
const activeIndex = activeColumn.cards.findIndex((c) => c.id === activeId);
|
|
170
|
+
const rawOverIndex = overColumn.cards.findIndex((c) => c.id === overId);
|
|
171
|
+
const overIndex = rawOverIndex >= 0 ? rawOverIndex : overColumn.cards.length - 1;
|
|
172
|
+
if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) return;
|
|
173
|
+
|
|
174
|
+
onColumnsChange(
|
|
175
|
+
columns.map((col) =>
|
|
176
|
+
col.id === activeColumn.id ? { ...col, cards: arrayMove(col.cards, activeIndex, overIndex) } : col,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<DndContext
|
|
183
|
+
sensors={sensors}
|
|
184
|
+
collisionDetection={collisionDetectionStrategy}
|
|
185
|
+
onDragStart={handleDragStart}
|
|
186
|
+
onDragOver={handleDragOver}
|
|
187
|
+
onDragEnd={handleDragEnd}
|
|
188
|
+
onDragCancel={() => {
|
|
189
|
+
setActiveCard(null);
|
|
190
|
+
lastOverId.current = null;
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<div ref={ref} role="group" className={cn("flex items-start gap-4 overflow-x-auto font-sans", className)} {...props}>
|
|
194
|
+
{columns.map((column) => (
|
|
195
|
+
<KanbanColumnView key={column.id} column={column} />
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
<DragOverlay>{activeCard ? <KanbanCardView card={activeCard} /> : null}</DragOverlay>
|
|
199
|
+
</DndContext>
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
KanbanBoard.displayName = "KanbanBoard";
|
|
204
|
+
|
|
205
|
+
function KanbanColumnView({ column }: { column: KanbanColumn }) {
|
|
206
|
+
const { setNodeRef } = useDroppable({ id: column.id });
|
|
207
|
+
return (
|
|
208
|
+
<div className="flex w-64 shrink-0 flex-col gap-2 rounded-md border bg-muted/30 p-2">
|
|
209
|
+
<div className="flex items-center justify-between px-1 py-1">
|
|
210
|
+
<span className="text-sm font-medium text-foreground">{column.title}</span>
|
|
211
|
+
<span className="text-xs text-muted-foreground">{column.cards.length}</span>
|
|
212
|
+
</div>
|
|
213
|
+
<SortableContext items={column.cards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
|
214
|
+
<div ref={setNodeRef} className="flex min-h-10 flex-col gap-2">
|
|
215
|
+
{column.cards.map((card) => (
|
|
216
|
+
<SortableKanbanCard key={card.id} card={card} />
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
</SortableContext>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function SortableKanbanCard({ card }: { card: KanbanCard }) {
|
|
225
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: card.id });
|
|
226
|
+
const style: React.CSSProperties = {
|
|
227
|
+
transform: CSS.Transform.toString(transform),
|
|
228
|
+
transition,
|
|
229
|
+
opacity: isDragging ? 0.4 : 1,
|
|
230
|
+
};
|
|
231
|
+
return (
|
|
232
|
+
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
|
233
|
+
<KanbanCardView card={card} />
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function KanbanCardView({ card }: { card: KanbanCard }) {
|
|
239
|
+
return (
|
|
240
|
+
<div className="cursor-grab touch-none rounded-md border bg-background p-3 text-sm text-foreground shadow-sm outline-none active:cursor-grabbing focus-visible:ring-1 focus-visible:ring-ring">
|
|
241
|
+
{card.content}
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export { KanbanBoard };
|