@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.
@@ -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 };