@kinetixui/ui 0.11.0 → 0.13.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 +364 -1
- package/dist/index.js +2209 -493
- package/package.json +6 -2
- package/src/components/alert.tsx +1 -1
- package/src/components/app-bar.tsx +2 -2
- package/src/components/color-picker.tsx +352 -0
- package/src/components/data-grid.tsx +304 -0
- package/src/components/dialog.tsx +3 -2
- package/src/components/diff-viewer.tsx +155 -0
- package/src/components/direction-provider.tsx +27 -0
- package/src/components/drawer.tsx +1 -1
- package/src/components/dropdown-menu.tsx +9 -9
- package/src/components/input-group.tsx +2 -2
- package/src/components/json-viewer.tsx +168 -0
- package/src/components/kanban-board.tsx +247 -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/native-select.tsx +2 -2
- package/src/components/notification-center.tsx +119 -0
- package/src/components/select.tsx +2 -2
- package/src/components/sheet.tsx +5 -2
- 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 +24 -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,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 };
|
|
@@ -34,6 +34,7 @@ const DialogContent = React.forwardRef<
|
|
|
34
34
|
<DialogPrimitive.Content
|
|
35
35
|
ref={ref}
|
|
36
36
|
className={cn(
|
|
37
|
+
// rtl-ok: centering trick (left-1/2 + -translate-x-1/2), direction-agnostic by construction
|
|
37
38
|
"fixed left-1/2 top-1/2 z-overlay grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg sm:rounded-xl font-sans",
|
|
38
39
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
39
40
|
className,
|
|
@@ -41,7 +42,7 @@ const DialogContent = React.forwardRef<
|
|
|
41
42
|
{...props}
|
|
42
43
|
>
|
|
43
44
|
{children}
|
|
44
|
-
<DialogPrimitive.Close className="absolute
|
|
45
|
+
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-muted transition-opacity hover:opacity-visible focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
|
|
45
46
|
<X className="size-4" />
|
|
46
47
|
<span className="sr-only">Close</span>
|
|
47
48
|
</DialogPrimitive.Close>
|
|
@@ -51,7 +52,7 @@ const DialogContent = React.forwardRef<
|
|
|
51
52
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
52
53
|
|
|
53
54
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
54
|
-
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-
|
|
55
|
+
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
|
|
55
56
|
);
|
|
56
57
|
DialogHeader.displayName = "DialogHeader";
|
|
57
58
|
|
|
@@ -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,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { DirectionProvider as RadixDirectionProvider } from "@radix-ui/react-direction";
|
|
5
|
+
|
|
6
|
+
export interface KinetixDirectionProviderProps {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
dir: "ltr" | "rtl";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every Radix primitive this library builds on (Select, DropdownMenu, Popover,
|
|
13
|
+
* Tooltip, ContextMenu, Menubar, NavigationMenu, HoverCard, …) reads direction
|
|
14
|
+
* from a `DirectionContext`, not from the ambient `dir` attribute — with no
|
|
15
|
+
* provider in the tree it defaults hard to `"ltr"` and stamps that onto its own
|
|
16
|
+
* portaled content, regardless of what `<html dir="rtl">` says. Logical-property
|
|
17
|
+
* CSS (see RTL.md) mirrors layout; this is what makes Radix's own positioning
|
|
18
|
+
* and portaled content agree with it. Wrap your app root in this alongside
|
|
19
|
+
* setting `dir` on `<html>`:
|
|
20
|
+
*
|
|
21
|
+
* <html dir={dir}>
|
|
22
|
+
* <KinetixDirectionProvider dir={dir}>{children}</KinetixDirectionProvider>
|
|
23
|
+
* </html>
|
|
24
|
+
*/
|
|
25
|
+
export function KinetixDirectionProvider({ dir, children }: KinetixDirectionProviderProps) {
|
|
26
|
+
return <RadixDirectionProvider dir={dir}>{children}</RadixDirectionProvider>;
|
|
27
|
+
}
|
|
@@ -43,7 +43,7 @@ const DrawerContent = React.forwardRef<
|
|
|
43
43
|
DrawerContent.displayName = "DrawerContent";
|
|
44
44
|
|
|
45
45
|
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
46
|
-
<div className={cn("grid gap-1.5 p-4 text-center sm:text-
|
|
46
|
+
<div className={cn("grid gap-1.5 p-4 text-center sm:text-start", className)} {...props} />
|
|
47
47
|
);
|
|
48
48
|
DrawerHeader.displayName = "DrawerHeader";
|
|
49
49
|
|
|
@@ -31,7 +31,7 @@ const DropdownMenuItem = React.forwardRef<
|
|
|
31
31
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
|
32
32
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
|
33
33
|
>(({ className, inset, ...props }, ref) => (
|
|
34
|
-
<DropdownMenuPrimitive.Item ref={ref} className={cn(itemCls, inset && "
|
|
34
|
+
<DropdownMenuPrimitive.Item ref={ref} className={cn(itemCls, inset && "ps-8", className)} {...props} />
|
|
35
35
|
));
|
|
36
36
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
|
37
37
|
|
|
@@ -39,8 +39,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|
|
39
39
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
40
40
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
41
41
|
>(({ className, children, checked, ...props }, ref) => (
|
|
42
|
-
<DropdownMenuPrimitive.CheckboxItem ref={ref} className={cn(itemCls, "
|
|
43
|
-
<span className="absolute
|
|
42
|
+
<DropdownMenuPrimitive.CheckboxItem ref={ref} className={cn(itemCls, "ps-8", className)} checked={checked} {...props}>
|
|
43
|
+
<span className="absolute start-2 flex size-3.5 items-center justify-center">
|
|
44
44
|
<DropdownMenuPrimitive.ItemIndicator>
|
|
45
45
|
<Check className="size-4" />
|
|
46
46
|
</DropdownMenuPrimitive.ItemIndicator>
|
|
@@ -54,8 +54,8 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
|
54
54
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
55
55
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
|
56
56
|
>(({ className, children, ...props }, ref) => (
|
|
57
|
-
<DropdownMenuPrimitive.RadioItem ref={ref} className={cn(itemCls, "
|
|
58
|
-
<span className="absolute
|
|
57
|
+
<DropdownMenuPrimitive.RadioItem ref={ref} className={cn(itemCls, "ps-8", className)} {...props}>
|
|
58
|
+
<span className="absolute start-2 flex size-3.5 items-center justify-center">
|
|
59
59
|
<DropdownMenuPrimitive.ItemIndicator>
|
|
60
60
|
<Circle className="size-2 fill-current" />
|
|
61
61
|
</DropdownMenuPrimitive.ItemIndicator>
|
|
@@ -71,7 +71,7 @@ const DropdownMenuLabel = React.forwardRef<
|
|
|
71
71
|
>(({ className, inset, ...props }, ref) => (
|
|
72
72
|
<DropdownMenuPrimitive.Label
|
|
73
73
|
ref={ref}
|
|
74
|
-
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "
|
|
74
|
+
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "ps-8", className)}
|
|
75
75
|
{...props}
|
|
76
76
|
/>
|
|
77
77
|
));
|
|
@@ -88,7 +88,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
|
|
88
88
|
// opacity-60 (not --opacity-muted's 0.7) is a pre-existing inconsistency —
|
|
89
89
|
// left as a literal rather than silently changing the rendered value.
|
|
90
90
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
|
91
|
-
<span className={cn("
|
|
91
|
+
<span className={cn("ms-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
|
92
92
|
);
|
|
93
93
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
|
94
94
|
|
|
@@ -98,11 +98,11 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|
|
98
98
|
>(({ className, inset, children, ...props }, ref) => (
|
|
99
99
|
<DropdownMenuPrimitive.SubTrigger
|
|
100
100
|
ref={ref}
|
|
101
|
-
className={cn(itemCls, "data-[state=open]:bg-accent", inset && "
|
|
101
|
+
className={cn(itemCls, "data-[state=open]:bg-accent", inset && "ps-8", className)}
|
|
102
102
|
{...props}
|
|
103
103
|
>
|
|
104
104
|
{children}
|
|
105
|
-
<ChevronRight className="
|
|
105
|
+
<ChevronRight className="ms-auto size-4 rtl:-scale-x-100" />
|
|
106
106
|
</DropdownMenuPrimitive.SubTrigger>
|
|
107
107
|
));
|
|
108
108
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
|
@@ -52,7 +52,7 @@ const InputGroupInput = React.forwardRef<
|
|
|
52
52
|
InputGroupInput.displayName = "InputGroupInput";
|
|
53
53
|
|
|
54
54
|
const addonVariants = cva("flex shrink-0 items-center gap-2 text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0", {
|
|
55
|
-
variants: { align: { start: "
|
|
55
|
+
variants: { align: { start: "ps-3", end: "pe-3" } },
|
|
56
56
|
defaultVariants: { align: "start" },
|
|
57
57
|
});
|
|
58
58
|
|
|
@@ -83,7 +83,7 @@ const InputGroupButton = React.forwardRef<
|
|
|
83
83
|
ref={ref}
|
|
84
84
|
type={type}
|
|
85
85
|
className={cn(
|
|
86
|
-
"flex h-full shrink-0 items-center gap-1.5 self-stretch border-
|
|
86
|
+
"flex h-full shrink-0 items-center gap-1.5 self-stretch border-s border-input bg-muted px-3 text-label-md text-foreground",
|
|
87
87
|
// base is bg-muted and muted==accent on dark, so the fill is a no-op there — the ring carries it
|
|
88
88
|
"outline-none transition-colors hover:bg-accent hover:ring-1 hover:ring-inset hover:ring-ring focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-disabled",
|
|
89
89
|
"[&_svg]:size-4 [&_svg]:shrink-0",
|