@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/dist/index.d.ts +344 -1
- package/dist/index.js +1705 -2
- package/package.json +5 -2
- package/src/components/color-picker.tsx +352 -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/message-bubble.tsx +83 -0
- package/src/components/multi-select.tsx +134 -0
- package/src/components/notification-center.tsx +119 -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 +20 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinetixui/ui",
|
|
3
|
-
"version": "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.
|
|
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 };
|