@kinetixui/ui 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinetixui/ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "description": "KinetixUI React components — CVA + Radix + Tailwind on the KinetixUI token contract. Also distributed via the kinetixui CLI as a component registry.",
6
6
  "keywords": [
@@ -40,6 +40,9 @@
40
40
  "tailwind.config.ts"
41
41
  ],
42
42
  "dependencies": {
43
+ "@dnd-kit/core": "^6.3.1",
44
+ "@dnd-kit/sortable": "^10.0.0",
45
+ "@dnd-kit/utilities": "^3.2.2",
43
46
  "@hookform/resolvers": "^5.9.1",
44
47
  "@radix-ui/react-accordion": "^1.2.20",
45
48
  "@radix-ui/react-alert-dialog": "^1.1.23",
@@ -90,7 +93,7 @@
90
93
  "react-dom": ">=18"
91
94
  },
92
95
  "devDependencies": {
93
- "@kinetixui/tokens": "0.11.0",
96
+ "@kinetixui/tokens": "0.12.0",
94
97
  "@testing-library/jest-dom": "^7.0.1",
95
98
  "@testing-library/react": "^16.3.3",
96
99
  "@testing-library/user-event": "^14.6.7",
@@ -0,0 +1,352 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SliderPrimitive from "@radix-ui/react-slider";
5
+ import { Pipette } from "lucide-react";
6
+ import { cn } from "../lib/utils";
7
+ import { Input } from "./input";
8
+ import { Button } from "./button";
9
+
10
+ declare global {
11
+ interface Window {
12
+ EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> };
13
+ }
14
+ }
15
+
16
+ const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
17
+
18
+ function hexToRgba(hex: string): [number, number, number, number] {
19
+ const h = hex.replace("#", "");
20
+ if (h.length === 8) {
21
+ const n = parseInt(h, 16);
22
+ return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, (n & 255) / 255];
23
+ }
24
+ const n = parseInt(h.length === 3 ? h.replace(/(.)/g, "$1$1") : h, 16);
25
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1];
26
+ }
27
+
28
+ function rgbaToHex(r: number, g: number, b: number, a: number, includeAlpha: boolean): string {
29
+ const toHex = (v: number) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0");
30
+ const base = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
31
+ return includeAlpha ? `${base}${toHex(a * 255)}` : base;
32
+ }
33
+
34
+ function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
35
+ s /= 100;
36
+ v /= 100;
37
+ const c = v * s;
38
+ const hh = h / 60;
39
+ const x = c * (1 - Math.abs((hh % 2) - 1));
40
+ let r = 0;
41
+ let g = 0;
42
+ let b = 0;
43
+ if (hh < 1) [r, g, b] = [c, x, 0];
44
+ else if (hh < 2) [r, g, b] = [x, c, 0];
45
+ else if (hh < 3) [r, g, b] = [0, c, x];
46
+ else if (hh < 4) [r, g, b] = [0, x, c];
47
+ else if (hh < 5) [r, g, b] = [x, 0, c];
48
+ else [r, g, b] = [c, 0, x];
49
+ const m = v - c;
50
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
51
+ }
52
+
53
+ function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
54
+ r /= 255;
55
+ g /= 255;
56
+ b /= 255;
57
+ const max = Math.max(r, g, b);
58
+ const min = Math.min(r, g, b);
59
+ const d = max - min;
60
+ let h = 0;
61
+ if (d !== 0) {
62
+ switch (max) {
63
+ case r:
64
+ h = ((g - b) / d) % 6;
65
+ break;
66
+ case g:
67
+ h = (b - r) / d + 2;
68
+ break;
69
+ default:
70
+ h = (r - g) / d + 4;
71
+ }
72
+ h *= 60;
73
+ if (h < 0) h += 360;
74
+ }
75
+ const s = max === 0 ? 0 : d / max;
76
+ return [h, s * 100, max * 100];
77
+ }
78
+
79
+ const HEX_PATTERN_ALPHA = /^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
80
+ const HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
81
+
82
+ export interface ColorPickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
83
+ /** hex color, e.g. `#3b82f6` (or `#3b82f6ff` when `alpha` is on) */
84
+ value: string;
85
+ onChange: (hex: string) => void;
86
+ /** show an alpha slider and read/emit 8-digit hex */
87
+ alpha?: boolean;
88
+ /** preset swatches row */
89
+ swatches?: string[];
90
+ }
91
+
92
+ /**
93
+ * ColorPicker — a saturation/value square, hue and (optional) alpha
94
+ * sliders, a hex field, and swatches. Deliberately stateful rather than
95
+ * deriving hue/saturation/value from `value` on every render: HSV is a
96
+ * degenerate representation at the edges — saturation 0 (grayscale) or
97
+ * value 0 (black) both erase hue information, since every hue produces
98
+ * the same RGB there. Re-deriving HSV from the emitted hex on every
99
+ * change would make the hue thumb jump to red the moment a drag crosses
100
+ * either edge. Internal HSV state persists across those edges instead,
101
+ * reconciled with the `value` prop only when it changes from something
102
+ * other than this component's own last emission (tracked via a ref) — the
103
+ * standard fix for this class of controlled color-picker bug.
104
+ *
105
+ * The 2D square is hand-rolled (no Radix primitive for a 2D slider); the
106
+ * hue and alpha rails reuse `@radix-ui/react-slider` directly (not the
107
+ * package's own `Slider` wrapper, whose track styling is fixed) for their
108
+ * built-in keyboard and ARIA handling. The eyedropper button only renders
109
+ * when `window.EyeDropper` exists (Chromium-only, behind a feature flag
110
+ * on some browsers) — no polyfill, it just disappears elsewhere.
111
+ */
112
+ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
113
+ ({ value, onChange, alpha = false, swatches, className, ...props }, ref) => {
114
+ const [hsv, setHsv] = React.useState<[number, number, number]>(() => {
115
+ const [r, g, b] = hexToRgba(value);
116
+ return rgbToHsv(r, g, b);
117
+ });
118
+ const [a, setA] = React.useState(() => hexToRgba(value)[3]);
119
+ const lastEmitted = React.useRef(value);
120
+ const [h, s, v] = hsv;
121
+
122
+ React.useEffect(() => {
123
+ if (value === lastEmitted.current) return;
124
+ const [r, g, b, na] = hexToRgba(value);
125
+ setHsv(rgbToHsv(r, g, b));
126
+ setA(na);
127
+ lastEmitted.current = value;
128
+ }, [value]);
129
+
130
+ const commit = React.useCallback(
131
+ (nh: number, ns: number, nv: number, na: number) => {
132
+ setHsv([nh, ns, nv]);
133
+ setA(na);
134
+ const [r, g, b] = hsvToRgb(nh, ns, nv);
135
+ const hex = rgbaToHex(r, g, b, na, alpha);
136
+ lastEmitted.current = hex;
137
+ onChange(hex);
138
+ },
139
+ [alpha, onChange],
140
+ );
141
+
142
+ const squareRef = React.useRef<HTMLDivElement>(null);
143
+ const draggingRef = React.useRef(false);
144
+
145
+ const updateFromPoint = React.useCallback(
146
+ (clientX: number, clientY: number) => {
147
+ const el = squareRef.current;
148
+ if (!el) return;
149
+ const rect = el.getBoundingClientRect();
150
+ const ns = clamp(((clientX - rect.left) / rect.width) * 100, 0, 100);
151
+ const nv = clamp(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100);
152
+ commit(h, ns, nv, a);
153
+ },
154
+ [commit, h, a],
155
+ );
156
+
157
+ const handleSquarePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
158
+ draggingRef.current = true;
159
+ e.currentTarget.setPointerCapture(e.pointerId);
160
+ updateFromPoint(e.clientX, e.clientY);
161
+ };
162
+ const handleSquarePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
163
+ if (!draggingRef.current) return;
164
+ updateFromPoint(e.clientX, e.clientY);
165
+ };
166
+ const handleSquarePointerUp = () => {
167
+ draggingRef.current = false;
168
+ };
169
+ const handleSquareKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
170
+ const step = e.shiftKey ? 10 : 1;
171
+ if (e.key === "ArrowLeft") commit(h, clamp(s - step, 0, 100), v, a);
172
+ else if (e.key === "ArrowRight") commit(h, clamp(s + step, 0, 100), v, a);
173
+ else if (e.key === "ArrowUp") commit(h, s, clamp(v + step, 0, 100), a);
174
+ else if (e.key === "ArrowDown") commit(h, s, clamp(v - step, 0, 100), a);
175
+ else return;
176
+ e.preventDefault();
177
+ };
178
+
179
+ const [supportsEyeDropper, setSupportsEyeDropper] = React.useState(false);
180
+ React.useEffect(() => setSupportsEyeDropper(typeof window !== "undefined" && !!window.EyeDropper), []);
181
+
182
+ const pickFromScreen = async () => {
183
+ if (!window.EyeDropper) return;
184
+ try {
185
+ const result = await new window.EyeDropper().open();
186
+ const [r, g, b] = hexToRgba(result.sRGBHex);
187
+ commit(...rgbToHsv(r, g, b), a);
188
+ } catch {
189
+ // cancelled — no-op
190
+ }
191
+ };
192
+
193
+ return (
194
+ <div ref={ref} className={cn("flex w-64 flex-col gap-3 font-sans", className)} {...props}>
195
+ <div
196
+ ref={squareRef}
197
+ role="slider"
198
+ tabIndex={0}
199
+ aria-label="Saturation and value"
200
+ aria-valuetext={`saturation ${Math.round(s)}%, value ${Math.round(v)}%`}
201
+ onPointerDown={handleSquarePointerDown}
202
+ onPointerMove={handleSquarePointerMove}
203
+ onPointerUp={handleSquarePointerUp}
204
+ onKeyDown={handleSquareKeyDown}
205
+ className="relative h-40 w-full touch-none rounded-md outline-none focus-visible:ring-1 focus-visible:ring-ring"
206
+ style={{
207
+ backgroundColor: `hsl(${h} 100% 50%)`,
208
+ backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
209
+ }}
210
+ >
211
+ <div
212
+ className="absolute size-3.5 -translate-x-1/2 translate-y-1/2 rounded-full border-2 border-white shadow"
213
+ style={{ left: `${s}%`, bottom: `${v}%`, backgroundColor: `hsl(${h} ${s}% ${v}%)` }}
214
+ />
215
+ </div>
216
+
217
+ <SliderPrimitive.Root
218
+ value={[h]}
219
+ min={0}
220
+ max={360}
221
+ step={1}
222
+ onValueChange={([nh]) => commit(nh ?? h, s, v, a)}
223
+ aria-label="Hue"
224
+ className="relative flex h-4 w-full touch-none select-none items-center"
225
+ >
226
+ <SliderPrimitive.Track
227
+ className="relative h-3 w-full grow rounded-full"
228
+ style={{
229
+ backgroundImage:
230
+ "linear-gradient(to right, hsl(0 100% 50%), hsl(60 100% 50%), hsl(120 100% 50%), hsl(180 100% 50%), hsl(240 100% 50%), hsl(300 100% 50%), hsl(360 100% 50%))",
231
+ }}
232
+ />
233
+ <SliderPrimitive.Thumb className="block size-4 rounded-full border-2 border-white bg-background shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
234
+ </SliderPrimitive.Root>
235
+
236
+ {alpha && (
237
+ <SliderPrimitive.Root
238
+ value={[Math.round(a * 100)]}
239
+ min={0}
240
+ max={100}
241
+ step={1}
242
+ onValueChange={([na]) => commit(h, s, v, (na ?? a * 100) / 100)}
243
+ aria-label="Alpha"
244
+ className="relative flex h-4 w-full touch-none select-none items-center"
245
+ >
246
+ <SliderPrimitive.Track
247
+ className="relative h-3 w-full grow overflow-hidden rounded-full"
248
+ style={{
249
+ backgroundImage:
250
+ "conic-gradient(#0000001a 90deg, transparent 90deg 180deg, #0000001a 180deg 270deg, transparent 270deg)",
251
+ backgroundSize: "8px 8px",
252
+ }}
253
+ >
254
+ <div
255
+ className="absolute inset-0"
256
+ style={{ backgroundImage: `linear-gradient(to right, transparent, hsl(${h} ${s}% ${v}%))` }}
257
+ />
258
+ </SliderPrimitive.Track>
259
+ <SliderPrimitive.Thumb className="block size-4 rounded-full border-2 border-white bg-background shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
260
+ </SliderPrimitive.Root>
261
+ )}
262
+
263
+ <div className="flex items-center gap-2">
264
+ <div
265
+ className="size-8 shrink-0 rounded-md border"
266
+ style={{
267
+ backgroundImage:
268
+ "conic-gradient(#0000001a 90deg, transparent 90deg 180deg, #0000001a 180deg 270deg, transparent 270deg), linear-gradient(hsl(0 0% 0% / 0), hsl(0 0% 0% / 0))",
269
+ backgroundSize: "8px 8px",
270
+ }}
271
+ >
272
+ <div className="size-full rounded-md" style={{ backgroundColor: value }} />
273
+ </div>
274
+ <div className="flex flex-1 items-center gap-1">
275
+ <span className="text-muted-foreground">#</span>
276
+ <HexField value={value} alpha={alpha} onCommit={onChange} />
277
+ </div>
278
+ {supportsEyeDropper && (
279
+ <Button
280
+ type="button"
281
+ variant="Ghost"
282
+ size="icon"
283
+ aria-label="Pick color from screen"
284
+ onClick={pickFromScreen}
285
+ >
286
+ <Pipette className="size-4" />
287
+ </Button>
288
+ )}
289
+ </div>
290
+
291
+ {swatches && swatches.length > 0 && (
292
+ <div className="flex flex-wrap gap-1.5">
293
+ {swatches.map((sw) => (
294
+ <button
295
+ key={sw}
296
+ type="button"
297
+ aria-label={sw}
298
+ onClick={() => {
299
+ const [r, g, b] = hexToRgba(sw);
300
+ commit(...rgbToHsv(r, g, b), a);
301
+ }}
302
+ className="size-6 rounded-sm border transition-shadow hover:ring-1 hover:ring-ring hover:ring-offset-1"
303
+ style={{ backgroundColor: sw }}
304
+ />
305
+ ))}
306
+ </div>
307
+ )}
308
+ </div>
309
+ );
310
+ },
311
+ );
312
+ ColorPicker.displayName = "ColorPicker";
313
+
314
+ function HexField({
315
+ value,
316
+ alpha,
317
+ onCommit,
318
+ }: {
319
+ value: string;
320
+ alpha: boolean;
321
+ onCommit: (hex: string) => void;
322
+ }) {
323
+ const [draft, setDraft] = React.useState(value.replace("#", "").toUpperCase());
324
+
325
+ React.useEffect(() => setDraft(value.replace("#", "").toUpperCase()), [value]);
326
+
327
+ const commit = () => {
328
+ const pattern = alpha ? HEX_PATTERN_ALPHA : HEX_PATTERN;
329
+ if (pattern.test(draft)) {
330
+ onCommit(`#${draft.toLowerCase()}`);
331
+ } else {
332
+ setDraft(value.replace("#", "").toUpperCase());
333
+ }
334
+ };
335
+
336
+ return (
337
+ <Input
338
+ value={draft}
339
+ onChange={(e) => setDraft(e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, alpha ? 8 : 6))}
340
+ onBlur={commit}
341
+ onKeyDown={(e) => {
342
+ if (e.key === "Enter") {
343
+ commit();
344
+ e.currentTarget.blur();
345
+ }
346
+ }}
347
+ className="h-8 flex-1 px-2 py-1 font-mono text-xs uppercase"
348
+ />
349
+ );
350
+ }
351
+
352
+ export { ColorPicker };
@@ -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 };