@kinetixui/ui 0.10.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 +403 -1
- package/dist/index.js +1832 -2
- package/package.json +5 -2
- package/src/components/color-picker.tsx +352 -0
- package/src/components/comparison-slider.tsx +98 -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/marquee.tsx +57 -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/page-header.tsx +43 -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 +23 -0
- package/src/stories/ColorPicker.stories.tsx +28 -0
- package/src/stories/ComparisonSlider.stories.tsx +27 -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/Marquee.stories.tsx +28 -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/PageHeader.stories.tsx +47 -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 +4 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_WIDTH = 160;
|
|
7
|
+
const DEFAULT_MIN_WIDTH = 60;
|
|
8
|
+
const DEFAULT_ROW_HEIGHT = 40;
|
|
9
|
+
|
|
10
|
+
export interface DataGridColumn<TData> {
|
|
11
|
+
id: string;
|
|
12
|
+
header: React.ReactNode;
|
|
13
|
+
/** rendered cell content */
|
|
14
|
+
cell: (row: TData, rowIndex: number) => React.ReactNode;
|
|
15
|
+
/** raw comparable value — required for `sortable` and used to seed inline edits */
|
|
16
|
+
value?: (row: TData) => string | number;
|
|
17
|
+
width?: number;
|
|
18
|
+
minWidth?: number;
|
|
19
|
+
/** fixed to this side, not draggable, and excluded from the reorder sequence */
|
|
20
|
+
pinned?: "left" | "right";
|
|
21
|
+
sortable?: boolean;
|
|
22
|
+
/** double-click swaps the cell for a text input; commit fires on Enter/blur, Escape cancels */
|
|
23
|
+
editable?: boolean;
|
|
24
|
+
onCellEdit?: (row: TData, rowIndex: number, value: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DataGridProps<TData> {
|
|
28
|
+
columns: DataGridColumn<TData>[];
|
|
29
|
+
data: TData[];
|
|
30
|
+
/** viewport height — a number (px) or any CSS height value; enables row virtualization */
|
|
31
|
+
height: number | string;
|
|
32
|
+
rowHeight?: number;
|
|
33
|
+
overscan?: number;
|
|
34
|
+
getRowId?: (row: TData, index: number) => React.Key;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type SortState = { columnId: string; direction: "asc" | "desc" } | null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* DataGrid — the "product on its own" the `COMPONENT-ADDITIONS.md` Tier 3
|
|
42
|
+
* entry calls for: row-virtualized (same fixed-row-height windowing as
|
|
43
|
+
* `VirtualList`), with column resize, drag-to-reorder, left/right pin, single-
|
|
44
|
+
* column sort, and double-click-to-edit cells. `DataTable` stays the
|
|
45
|
+
* TanStack-Table-light option for a plain sortable/paginated table; reach for
|
|
46
|
+
* `DataGrid` once the row count or column-manipulation needs outgrow it.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately out of scope: column *virtualization* (only rows are
|
|
49
|
+
* windowed — a grid with enough columns to need it is rare enough that this
|
|
50
|
+
* isn't worth the added complexity) and multi-column sort. Rendered as ARIA
|
|
51
|
+
* `grid`/`row`/`columnheader`/`gridcell` divs rather than a real `<table>` —
|
|
52
|
+
* sticky-positioned pinned columns and a sticky header row need that
|
|
53
|
+
* flexibility, at the cost of the native table-navigation semantics
|
|
54
|
+
* `DataTable` still gets for free.
|
|
55
|
+
*/
|
|
56
|
+
function DataGrid<TData>({
|
|
57
|
+
columns,
|
|
58
|
+
data,
|
|
59
|
+
height,
|
|
60
|
+
rowHeight = DEFAULT_ROW_HEIGHT,
|
|
61
|
+
overscan = 6,
|
|
62
|
+
getRowId,
|
|
63
|
+
className,
|
|
64
|
+
}: DataGridProps<TData>) {
|
|
65
|
+
const columnById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
|
|
66
|
+
const [order, setOrder] = React.useState(() => columns.map((c) => c.id));
|
|
67
|
+
const [widths, setWidths] = React.useState<Record<string, number>>(() =>
|
|
68
|
+
Object.fromEntries(columns.map((c) => [c.id, c.width ?? DEFAULT_WIDTH])),
|
|
69
|
+
);
|
|
70
|
+
const [sort, setSort] = React.useState<SortState>(null);
|
|
71
|
+
const [editing, setEditing] = React.useState<{ rowIndex: number; columnId: string } | null>(null);
|
|
72
|
+
const dragIdRef = React.useRef<string | null>(null);
|
|
73
|
+
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
const knownIds = columns.map((c) => c.id);
|
|
76
|
+
const known = new Set(knownIds);
|
|
77
|
+
setOrder((prev) => {
|
|
78
|
+
const kept = prev.filter((id) => known.has(id));
|
|
79
|
+
const added = knownIds.filter((id) => !kept.includes(id));
|
|
80
|
+
return [...kept, ...added];
|
|
81
|
+
});
|
|
82
|
+
setWidths((prev) => {
|
|
83
|
+
const next = { ...prev };
|
|
84
|
+
for (const c of columns) if (!(c.id in next)) next[c.id] = c.width ?? DEFAULT_WIDTH;
|
|
85
|
+
return next;
|
|
86
|
+
});
|
|
87
|
+
}, [columns]);
|
|
88
|
+
|
|
89
|
+
const leftPinned = columns.filter((c) => c.pinned === "left");
|
|
90
|
+
const rightPinned = columns.filter((c) => c.pinned === "right");
|
|
91
|
+
const unpinnedOrdered = order
|
|
92
|
+
.filter((id) => columnById.get(id) && !columnById.get(id)!.pinned)
|
|
93
|
+
.map((id) => columnById.get(id)!);
|
|
94
|
+
const renderedColumns = [...leftPinned, ...unpinnedOrdered, ...rightPinned];
|
|
95
|
+
|
|
96
|
+
const leftOffsets = React.useMemo(() => {
|
|
97
|
+
const offsets: Record<string, number> = {};
|
|
98
|
+
let acc = 0;
|
|
99
|
+
for (const c of leftPinned) {
|
|
100
|
+
offsets[c.id] = acc;
|
|
101
|
+
acc += widths[c.id] ?? c.width ?? DEFAULT_WIDTH;
|
|
102
|
+
}
|
|
103
|
+
return offsets;
|
|
104
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
+
}, [columns, widths]);
|
|
106
|
+
|
|
107
|
+
const rightOffsets = React.useMemo(() => {
|
|
108
|
+
const offsets: Record<string, number> = {};
|
|
109
|
+
let acc = 0;
|
|
110
|
+
for (const c of [...rightPinned].reverse()) {
|
|
111
|
+
offsets[c.id] = acc;
|
|
112
|
+
acc += widths[c.id] ?? c.width ?? DEFAULT_WIDTH;
|
|
113
|
+
}
|
|
114
|
+
return offsets;
|
|
115
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
116
|
+
}, [columns, widths]);
|
|
117
|
+
|
|
118
|
+
const totalWidth = renderedColumns.reduce((sum, c) => sum + (widths[c.id] ?? c.width ?? DEFAULT_WIDTH), 0);
|
|
119
|
+
|
|
120
|
+
const sortedData = React.useMemo(() => {
|
|
121
|
+
if (!sort) return data;
|
|
122
|
+
const col = columnById.get(sort.columnId);
|
|
123
|
+
if (!col?.value) return data;
|
|
124
|
+
const indexed = data.map((row, index) => ({ row, index }));
|
|
125
|
+
indexed.sort((a, b) => {
|
|
126
|
+
const av = col.value!(a.row);
|
|
127
|
+
const bv = col.value!(b.row);
|
|
128
|
+
if (av === bv) return a.index - b.index;
|
|
129
|
+
const cmp = av < bv ? -1 : 1;
|
|
130
|
+
return sort.direction === "asc" ? cmp : -cmp;
|
|
131
|
+
});
|
|
132
|
+
return indexed.map((entry) => entry.row);
|
|
133
|
+
}, [data, sort, columnById]);
|
|
134
|
+
|
|
135
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
136
|
+
const [scrollTop, setScrollTop] = React.useState(0);
|
|
137
|
+
const [viewportHeight, setViewportHeight] = React.useState(typeof height === "number" ? height : 0);
|
|
138
|
+
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
const el = containerRef.current;
|
|
141
|
+
if (!el) return;
|
|
142
|
+
const observer = new ResizeObserver((entries) => {
|
|
143
|
+
const entry = entries[0];
|
|
144
|
+
if (entry) setViewportHeight(entry.contentRect.height);
|
|
145
|
+
});
|
|
146
|
+
observer.observe(el);
|
|
147
|
+
return () => observer.disconnect();
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const totalHeight = sortedData.length * rowHeight;
|
|
151
|
+
const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
|
|
152
|
+
const visibleCount = Math.ceil(viewportHeight / rowHeight) + overscan * 2;
|
|
153
|
+
const endIndex = Math.min(sortedData.length, startIndex + Math.max(visibleCount, 0));
|
|
154
|
+
const visibleRows = sortedData.slice(startIndex, endIndex);
|
|
155
|
+
|
|
156
|
+
function toggleSort(columnId: string) {
|
|
157
|
+
setSort((prev) => {
|
|
158
|
+
if (!prev || prev.columnId !== columnId) return { columnId, direction: "asc" };
|
|
159
|
+
if (prev.direction === "asc") return { columnId, direction: "desc" };
|
|
160
|
+
return null;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function beginResize(columnId: string, minWidth: number, startX: number, startWidth: number) {
|
|
165
|
+
const handleMove = (ev: PointerEvent) => {
|
|
166
|
+
setWidths((prev) => ({ ...prev, [columnId]: Math.max(minWidth, startWidth + (ev.clientX - startX)) }));
|
|
167
|
+
};
|
|
168
|
+
const handleUp = () => {
|
|
169
|
+
window.removeEventListener("pointermove", handleMove);
|
|
170
|
+
window.removeEventListener("pointerup", handleUp);
|
|
171
|
+
};
|
|
172
|
+
window.addEventListener("pointermove", handleMove);
|
|
173
|
+
window.addEventListener("pointerup", handleUp);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function commitEdit(column: DataGridColumn<TData>, row: TData, rowIndex: number, value: string) {
|
|
177
|
+
column.onCellEdit?.(row, rowIndex, value);
|
|
178
|
+
setEditing(null);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
ref={containerRef}
|
|
184
|
+
role="grid"
|
|
185
|
+
className={cn("relative overflow-auto rounded-md border font-sans text-sm", className)}
|
|
186
|
+
style={{ height }}
|
|
187
|
+
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
188
|
+
>
|
|
189
|
+
<div style={{ width: totalWidth, minWidth: "100%" }}>
|
|
190
|
+
<div role="row" className="sticky top-0 z-[2] flex border-b bg-background">
|
|
191
|
+
{renderedColumns.map((column) => {
|
|
192
|
+
const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
|
|
193
|
+
const pinnedStyle: React.CSSProperties =
|
|
194
|
+
column.pinned === "left"
|
|
195
|
+
? { position: "sticky", left: leftOffsets[column.id], zIndex: 3 }
|
|
196
|
+
: column.pinned === "right"
|
|
197
|
+
? { position: "sticky", right: rightOffsets[column.id], zIndex: 3 }
|
|
198
|
+
: {};
|
|
199
|
+
const isSorted = sort?.columnId === column.id;
|
|
200
|
+
return (
|
|
201
|
+
<div
|
|
202
|
+
key={column.id}
|
|
203
|
+
role="columnheader"
|
|
204
|
+
{...(column.sortable
|
|
205
|
+
? { "aria-sort": isSorted ? (sort!.direction === "asc" ? "ascending" : "descending") : "none" }
|
|
206
|
+
: {})}
|
|
207
|
+
draggable={!column.pinned}
|
|
208
|
+
onDragStart={() => {
|
|
209
|
+
dragIdRef.current = column.id;
|
|
210
|
+
}}
|
|
211
|
+
onDragOver={(e) => {
|
|
212
|
+
if (!column.pinned) e.preventDefault();
|
|
213
|
+
}}
|
|
214
|
+
onDrop={(e) => {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
const draggedId = dragIdRef.current;
|
|
217
|
+
dragIdRef.current = null;
|
|
218
|
+
if (!draggedId || draggedId === column.id) return;
|
|
219
|
+
setOrder((prev) => {
|
|
220
|
+
const next = prev.filter((id) => id !== draggedId);
|
|
221
|
+
const targetIndex = next.indexOf(column.id);
|
|
222
|
+
next.splice(targetIndex, 0, draggedId);
|
|
223
|
+
return next;
|
|
224
|
+
});
|
|
225
|
+
}}
|
|
226
|
+
onClick={() => column.sortable && toggleSort(column.id)}
|
|
227
|
+
className={cn(
|
|
228
|
+
"relative flex h-10 shrink-0 select-none items-center gap-1 px-2 font-medium text-muted-foreground",
|
|
229
|
+
column.sortable && "cursor-pointer hover:text-foreground",
|
|
230
|
+
column.pinned && "bg-background",
|
|
231
|
+
)}
|
|
232
|
+
style={{ width, ...pinnedStyle }}
|
|
233
|
+
>
|
|
234
|
+
<span className="truncate">{column.header}</span>
|
|
235
|
+
{column.sortable && isSorted && (
|
|
236
|
+
<span aria-hidden="true" className="text-xs">
|
|
237
|
+
{sort!.direction === "asc" ? "▲" : "▼"}
|
|
238
|
+
</span>
|
|
239
|
+
)}
|
|
240
|
+
<div
|
|
241
|
+
onPointerDown={(e) => {
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
beginResize(column.id, column.minWidth ?? DEFAULT_MIN_WIDTH, e.clientX, width);
|
|
244
|
+
}}
|
|
245
|
+
className="absolute right-0 top-0 h-full w-1 touch-none cursor-col-resize hover:bg-primary/50"
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
</div>
|
|
251
|
+
<div style={{ height: totalHeight, position: "relative" }}>
|
|
252
|
+
{visibleRows.map((row, i) => {
|
|
253
|
+
const rowIndex = startIndex + i;
|
|
254
|
+
return (
|
|
255
|
+
<div
|
|
256
|
+
key={getRowId ? getRowId(row, rowIndex) : rowIndex}
|
|
257
|
+
role="row"
|
|
258
|
+
className="absolute flex w-full border-b hover:bg-muted/50"
|
|
259
|
+
style={{ top: rowIndex * rowHeight, height: rowHeight }}
|
|
260
|
+
>
|
|
261
|
+
{renderedColumns.map((column) => {
|
|
262
|
+
const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
|
|
263
|
+
const pinnedStyle: React.CSSProperties =
|
|
264
|
+
column.pinned === "left"
|
|
265
|
+
? { position: "sticky", left: leftOffsets[column.id], zIndex: 1 }
|
|
266
|
+
: column.pinned === "right"
|
|
267
|
+
? { position: "sticky", right: rightOffsets[column.id], zIndex: 1 }
|
|
268
|
+
: {};
|
|
269
|
+
const isEditing = editing?.rowIndex === rowIndex && editing.columnId === column.id;
|
|
270
|
+
return (
|
|
271
|
+
<div
|
|
272
|
+
key={column.id}
|
|
273
|
+
role="gridcell"
|
|
274
|
+
onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
|
|
275
|
+
className={cn("flex shrink-0 items-center px-2", column.pinned && "bg-background")}
|
|
276
|
+
style={{ width, ...pinnedStyle }}
|
|
277
|
+
>
|
|
278
|
+
{isEditing ? (
|
|
279
|
+
<input
|
|
280
|
+
autoFocus
|
|
281
|
+
defaultValue={column.value ? String(column.value(row)) : ""}
|
|
282
|
+
className="h-7 w-full rounded-sm border-0 bg-transparent px-1 text-sm outline-none ring-1 ring-inset ring-ring"
|
|
283
|
+
onKeyDown={(e) => {
|
|
284
|
+
if (e.key === "Enter") commitEdit(column, row, rowIndex, e.currentTarget.value);
|
|
285
|
+
else if (e.key === "Escape") setEditing(null);
|
|
286
|
+
}}
|
|
287
|
+
onBlur={(e) => commitEdit(column, row, rowIndex, e.currentTarget.value)}
|
|
288
|
+
/>
|
|
289
|
+
) : (
|
|
290
|
+
<span className="truncate">{column.cell(row, rowIndex)}</span>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
})}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
})}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export { DataGrid };
|
|
@@ -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 };
|