@kinetixui/ui 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +403 -1
- package/dist/index.js +1832 -2
- package/package.json +5 -2
- package/src/components/color-picker.tsx +352 -0
- package/src/components/comparison-slider.tsx +98 -0
- package/src/components/data-grid.tsx +304 -0
- package/src/components/diff-viewer.tsx +155 -0
- package/src/components/json-viewer.tsx +168 -0
- package/src/components/kanban-board.tsx +246 -0
- package/src/components/markdown-editor.tsx +288 -0
- package/src/components/marquee.tsx +57 -0
- package/src/components/message-bubble.tsx +83 -0
- package/src/components/multi-select.tsx +134 -0
- package/src/components/notification-center.tsx +119 -0
- package/src/components/page-header.tsx +43 -0
- package/src/components/tour.tsx +156 -0
- package/src/components/tree-view.tsx +273 -0
- package/src/components/virtual-list.tsx +92 -0
- package/src/index.ts +23 -0
- package/src/stories/ColorPicker.stories.tsx +28 -0
- package/src/stories/ComparisonSlider.stories.tsx +27 -0
- package/src/stories/DataGrid.stories.tsx +54 -0
- package/src/stories/DiffViewer.stories.tsx +26 -0
- package/src/stories/JsonViewer.stories.tsx +31 -0
- package/src/stories/KanbanBoard.stories.tsx +44 -0
- package/src/stories/MarkdownEditor.stories.tsx +25 -0
- package/src/stories/Marquee.stories.tsx +28 -0
- package/src/stories/MessageBubble.stories.tsx +32 -0
- package/src/stories/MultiSelect.stories.tsx +35 -0
- package/src/stories/NotificationCenter.stories.tsx +41 -0
- package/src/stories/PageHeader.stories.tsx +47 -0
- package/src/stories/Tour.stories.tsx +70 -0
- package/src/stories/TreeView.stories.tsx +38 -0
- package/src/stories/VirtualList.stories.tsx +36 -0
- package/tailwind.config.ts +4 -0
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,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import * as SliderPrimitive from "@radix-ui/react-slider";
|
|
5
|
+
import { GripVertical } from "lucide-react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ComparisonSlider — a drag handle wiping between two stacked layers
|
|
10
|
+
* (before/after image, redesign preview, …). Built on Radix's `Slider`
|
|
11
|
+
* for the drag + keyboard + ARIA behavior (a plain 0–100 value), but
|
|
12
|
+
* fully custom-drawn: Radix's own `Range` fill can't be reused as the
|
|
13
|
+
* divider line (it's Radix's own inline `width` style, which would win
|
|
14
|
+
* over any Tailwind width class), so the line is a separate element
|
|
15
|
+
* positioned from the same value instead. Gap-fill addition (not in the
|
|
16
|
+
* original Figma source).
|
|
17
|
+
*/
|
|
18
|
+
export interface ComparisonSliderProps
|
|
19
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
20
|
+
before: React.ReactNode;
|
|
21
|
+
after: React.ReactNode;
|
|
22
|
+
beforeLabel?: React.ReactNode;
|
|
23
|
+
afterLabel?: React.ReactNode;
|
|
24
|
+
value?: number;
|
|
25
|
+
defaultValue?: number;
|
|
26
|
+
onValueChange?: (value: number) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ComparisonSlider = React.forwardRef<HTMLDivElement, ComparisonSliderProps>(
|
|
30
|
+
(
|
|
31
|
+
{ className, before, after, beforeLabel, afterLabel, value, defaultValue = 50, onValueChange, ...props },
|
|
32
|
+
ref,
|
|
33
|
+
) => {
|
|
34
|
+
const [internal, setInternal] = React.useState(defaultValue);
|
|
35
|
+
const current = value ?? internal;
|
|
36
|
+
|
|
37
|
+
const handleChange = (vals: number[]) => {
|
|
38
|
+
const v = vals[0] ?? current;
|
|
39
|
+
setInternal(v);
|
|
40
|
+
onValueChange?.(v);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
ref={ref}
|
|
46
|
+
className={cn(
|
|
47
|
+
"relative aspect-video w-full select-none overflow-hidden rounded-md bg-muted font-sans",
|
|
48
|
+
"[&_img]:size-full [&_img]:object-cover [&_video]:size-full [&_video]:object-cover",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
<div className="absolute inset-0">{before}</div>
|
|
54
|
+
<div className="absolute inset-0 overflow-hidden" style={{ clipPath: `inset(0 0 0 ${current}%)` }}>
|
|
55
|
+
{after}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div
|
|
59
|
+
aria-hidden
|
|
60
|
+
className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-background shadow-sm"
|
|
61
|
+
style={{ left: `${current}%` }}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
{beforeLabel && (
|
|
65
|
+
<span className="absolute left-2 top-2 rounded bg-background/80 px-2 py-0.5 text-label-sm text-foreground backdrop-blur-sm">
|
|
66
|
+
{beforeLabel}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
{afterLabel && (
|
|
70
|
+
<span className="absolute right-2 top-2 rounded bg-background/80 px-2 py-0.5 text-label-sm text-foreground backdrop-blur-sm">
|
|
71
|
+
{afterLabel}
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<SliderPrimitive.Root
|
|
76
|
+
value={[current]}
|
|
77
|
+
onValueChange={handleChange}
|
|
78
|
+
max={100}
|
|
79
|
+
step={0.1}
|
|
80
|
+
className="absolute inset-0 flex touch-none items-center"
|
|
81
|
+
>
|
|
82
|
+
<SliderPrimitive.Track className="relative h-full w-full grow bg-transparent">
|
|
83
|
+
<SliderPrimitive.Range className="absolute h-full bg-transparent" />
|
|
84
|
+
</SliderPrimitive.Track>
|
|
85
|
+
<SliderPrimitive.Thumb
|
|
86
|
+
aria-label="Comparison position"
|
|
87
|
+
className="flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-background bg-background/90 text-foreground shadow-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
88
|
+
>
|
|
89
|
+
<GripVertical className="size-4" />
|
|
90
|
+
</SliderPrimitive.Thumb>
|
|
91
|
+
</SliderPrimitive.Root>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
ComparisonSlider.displayName = "ComparisonSlider";
|
|
97
|
+
|
|
98
|
+
export { ComparisonSlider };
|