@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.
Files changed (39) hide show
  1. package/dist/index.d.ts +364 -1
  2. package/dist/index.js +2209 -493
  3. package/package.json +6 -2
  4. package/src/components/alert.tsx +1 -1
  5. package/src/components/app-bar.tsx +2 -2
  6. package/src/components/color-picker.tsx +352 -0
  7. package/src/components/data-grid.tsx +304 -0
  8. package/src/components/dialog.tsx +3 -2
  9. package/src/components/diff-viewer.tsx +155 -0
  10. package/src/components/direction-provider.tsx +27 -0
  11. package/src/components/drawer.tsx +1 -1
  12. package/src/components/dropdown-menu.tsx +9 -9
  13. package/src/components/input-group.tsx +2 -2
  14. package/src/components/json-viewer.tsx +168 -0
  15. package/src/components/kanban-board.tsx +247 -0
  16. package/src/components/markdown-editor.tsx +288 -0
  17. package/src/components/message-bubble.tsx +83 -0
  18. package/src/components/multi-select.tsx +134 -0
  19. package/src/components/native-select.tsx +2 -2
  20. package/src/components/notification-center.tsx +119 -0
  21. package/src/components/select.tsx +2 -2
  22. package/src/components/sheet.tsx +5 -2
  23. package/src/components/tour.tsx +156 -0
  24. package/src/components/tree-view.tsx +273 -0
  25. package/src/components/virtual-list.tsx +92 -0
  26. package/src/index.ts +24 -0
  27. package/src/stories/ColorPicker.stories.tsx +28 -0
  28. package/src/stories/DataGrid.stories.tsx +54 -0
  29. package/src/stories/DiffViewer.stories.tsx +26 -0
  30. package/src/stories/JsonViewer.stories.tsx +31 -0
  31. package/src/stories/KanbanBoard.stories.tsx +44 -0
  32. package/src/stories/MarkdownEditor.stories.tsx +25 -0
  33. package/src/stories/MessageBubble.stories.tsx +32 -0
  34. package/src/stories/MultiSelect.stories.tsx +35 -0
  35. package/src/stories/NotificationCenter.stories.tsx +41 -0
  36. package/src/stories/Tour.stories.tsx +70 -0
  37. package/src/stories/TreeView.stories.tsx +38 -0
  38. package/src/stories/VirtualList.stories.tsx +36 -0
  39. 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 right-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
+ <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-left", className)} {...props} />
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-left", className)} {...props} />
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 && "pl-8", className)} {...props} />
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, "pl-8", className)} checked={checked} {...props}>
43
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
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, "pl-8", className)} {...props}>
58
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
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 && "pl-8", className)}
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("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
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 && "pl-8", className)}
101
+ className={cn(itemCls, "data-[state=open]:bg-accent", inset && "ps-8", className)}
102
102
  {...props}
103
103
  >
104
104
  {children}
105
- <ChevronRight className="ml-auto size-4" />
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: "pl-3", end: "pr-3" } },
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-l border-input bg-muted px-3 text-label-md text-foreground",
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",