@hilum/ui 3.4.0 → 3.5.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/README.md +4 -0
- package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
- package/dist/chunk-RI6QJR2L.js.map +1 -0
- package/dist/index.d.ts +789 -115
- package/dist/index.js +10404 -1099
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +17 -0
- package/dist/tokens.d.ts +3 -0
- package/dist/tokens.js +1 -1
- package/llms.txt +18 -4
- package/package.json +19 -1
- package/src/components/accordion.tsx +746 -58
- package/src/components/alert-dialog.tsx +11 -5
- package/src/components/ask-user-questions.tsx +1366 -0
- package/src/components/badge.tsx +105 -24
- package/src/components/button.tsx +207 -38
- package/src/components/card.tsx +3 -2
- package/src/components/chat-message.tsx +116 -0
- package/src/components/checkbox-group.tsx +403 -0
- package/src/components/checkbox.tsx +6 -3
- package/src/components/color-input.tsx +3 -1
- package/src/components/color-picker.tsx +1992 -80
- package/src/components/dialog.tsx +141 -81
- package/src/components/dropdown-menu.tsx +21 -13
- package/src/components/dropdown.tsx +245 -0
- package/src/components/file-thumbnail.tsx +156 -0
- package/src/components/hover-card.tsx +2 -0
- package/src/components/input-copy.tsx +307 -0
- package/src/components/input-group.tsx +303 -108
- package/src/components/input-message.tsx +883 -0
- package/src/components/input.tsx +6 -3
- package/src/components/menu-item.tsx +156 -0
- package/src/components/mobile-drawer.tsx +50 -0
- package/src/components/nav-item.tsx +43 -0
- package/src/components/nav-menu.tsx +47 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/radio-group.tsx +381 -35
- package/src/components/scroll-area.tsx +9 -2
- package/src/components/select.tsx +708 -121
- package/src/components/sheet.tsx +15 -6
- package/src/components/slider.tsx +1583 -20
- package/src/components/status-badge.tsx +2 -0
- package/src/components/switch.tsx +245 -28
- package/src/components/table.tsx +191 -77
- package/src/components/tabs-subtle.tsx +411 -0
- package/src/components/tabs.tsx +459 -49
- package/src/components/textarea.tsx +7 -3
- package/src/components/thinking-indicator.tsx +106 -0
- package/src/components/thinking-steps.tsx +382 -0
- package/src/components/tooltip.tsx +158 -33
- package/src/hooks/use-merge-split.tsx +359 -0
- package/src/hooks/use-proximity-hover.ts +208 -0
- package/src/index.ts +14 -0
- package/src/lib/elevated.tsx +52 -0
- package/src/lib/font-weight.ts +18 -0
- package/src/lib/icon-context.tsx +98 -0
- package/src/lib/icon-map.tsx +669 -0
- package/src/lib/interaction.ts +102 -0
- package/src/lib/scroll-fade.tsx +241 -0
- package/src/lib/shape-context.tsx +121 -0
- package/src/lib/springs.ts +30 -0
- package/src/lib/surface-classes.ts +43 -0
- package/src/lib/surface-context.tsx +17 -0
- package/src/tokens/tokens.ts +3 -0
- package/dist/chunk-MAHPAP65.js.map +0 -1
|
@@ -1,107 +1,2019 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useContext,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useEffect,
|
|
10
|
+
useCallback,
|
|
11
|
+
useMemo,
|
|
12
|
+
type CSSProperties,
|
|
13
|
+
type HTMLAttributes,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from "react";
|
|
16
|
+
import { createPortal } from "react-dom";
|
|
17
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
4
18
|
import { cn } from "../lib/utils";
|
|
5
|
-
import {
|
|
19
|
+
import { spring } from "../lib/springs";
|
|
20
|
+
import { fontWeights } from "../lib/font-weight";
|
|
21
|
+
import { useShape } from "../lib/shape-context";
|
|
22
|
+
import { useSurface, SurfaceProvider } from "../lib/surface-context";
|
|
23
|
+
import { surfaceClasses } from "../lib/surface-classes";
|
|
24
|
+
import { useIcon } from "../lib/icon-context";
|
|
25
|
+
import { Slider } from "./slider";
|
|
26
|
+
import { Dropdown, useDropdown } from "./dropdown";
|
|
27
|
+
import { Tooltip } from "./tooltip";
|
|
6
28
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
type ColorFormat = "hex" | "rgb" | "hsl" | "oklch";
|
|
34
|
+
|
|
35
|
+
// Allows consumers (e.g. the /demo carousel) to portal popups inside a
|
|
36
|
+
// CSS-scaled ancestor so menu/popover layers visually scale with the picker.
|
|
37
|
+
const ColorPickerPortalContainerContext = createContext<HTMLElement | null>(null);
|
|
38
|
+
|
|
39
|
+
function ColorPickerPortalContainer({
|
|
40
|
+
value,
|
|
41
|
+
children,
|
|
42
|
+
}: {
|
|
43
|
+
value: HTMLElement | null;
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<ColorPickerPortalContainerContext.Provider value={value}>
|
|
48
|
+
{children}
|
|
49
|
+
</ColorPickerPortalContainerContext.Provider>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ParsedColor {
|
|
54
|
+
// HSV (canonical, 0..360 / 0..1 / 0..1)
|
|
55
|
+
h: number;
|
|
56
|
+
s: number;
|
|
57
|
+
v: number;
|
|
58
|
+
a: number;
|
|
59
|
+
// sRGB 0..255
|
|
60
|
+
r: number;
|
|
61
|
+
g: number;
|
|
62
|
+
b: number;
|
|
63
|
+
// Formatted strings
|
|
64
|
+
hex: string;
|
|
65
|
+
rgb: string;
|
|
66
|
+
hsl: string;
|
|
67
|
+
oklch: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ColorPickerProps extends Omit<
|
|
71
|
+
HTMLAttributes<HTMLDivElement>,
|
|
72
|
+
"onChange" | "defaultValue"
|
|
73
|
+
> {
|
|
74
|
+
value?: string;
|
|
75
|
+
defaultValue?: string;
|
|
76
|
+
onChange?: (value: string) => void;
|
|
77
|
+
onValueChange?: (value: string, parsed: ParsedColor) => void;
|
|
15
78
|
ariaLabel?: string;
|
|
79
|
+
format?: ColorFormat;
|
|
80
|
+
defaultFormat?: ColorFormat;
|
|
81
|
+
onFormatChange?: (format: ColorFormat) => void;
|
|
82
|
+
swatches?: string[];
|
|
83
|
+
presets?: string[];
|
|
84
|
+
hideEyedropper?: boolean;
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
/** Controls the format dropdown's open state. When provided, the dropdown
|
|
87
|
+
* is fully controlled and ignores user toggles. */
|
|
88
|
+
formatOpen?: boolean;
|
|
89
|
+
/** Initial open state for the format dropdown (uncontrolled). */
|
|
90
|
+
defaultFormatOpen?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ColorPickerPopoverProps extends ColorPickerProps {
|
|
94
|
+
triggerLabel?: string;
|
|
95
|
+
triggerLabelPosition?: "left" | "right";
|
|
96
|
+
triggerShowValue?: boolean;
|
|
97
|
+
triggerShowRemove?: boolean;
|
|
98
|
+
onTriggerRemove?: () => void;
|
|
99
|
+
triggerClassName?: string;
|
|
100
|
+
/** Controls the popover's open state. When provided, the popover is fully
|
|
101
|
+
* controlled and ignores trigger clicks. */
|
|
102
|
+
open?: boolean;
|
|
103
|
+
/** Initial open state for the popover (uncontrolled). */
|
|
104
|
+
defaultOpen?: boolean;
|
|
105
|
+
/** Called when the open state would change (fires even when controlled). */
|
|
106
|
+
onOpenChange?: (open: boolean) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ColorSwatchProps extends Omit<HTMLAttributes<HTMLButtonElement>, "color"> {
|
|
110
|
+
color: string;
|
|
111
|
+
size?: number;
|
|
112
|
+
selected?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Color math (no deps)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function clamp01(n: number) {
|
|
120
|
+
return Math.max(0, Math.min(1, n));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clamp255(n: number) {
|
|
124
|
+
return Math.max(0, Math.min(255, n));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
|
|
128
|
+
const c = v * s;
|
|
129
|
+
const hh = (((h % 360) + 360) % 360) / 60;
|
|
130
|
+
const x = c * (1 - Math.abs((hh % 2) - 1));
|
|
131
|
+
let r = 0,
|
|
132
|
+
g = 0,
|
|
133
|
+
b = 0;
|
|
134
|
+
if (hh < 1) {
|
|
135
|
+
r = c;
|
|
136
|
+
g = x;
|
|
137
|
+
b = 0;
|
|
138
|
+
} else if (hh < 2) {
|
|
139
|
+
r = x;
|
|
140
|
+
g = c;
|
|
141
|
+
b = 0;
|
|
142
|
+
} else if (hh < 3) {
|
|
143
|
+
r = 0;
|
|
144
|
+
g = c;
|
|
145
|
+
b = x;
|
|
146
|
+
} else if (hh < 4) {
|
|
147
|
+
r = 0;
|
|
148
|
+
g = x;
|
|
149
|
+
b = c;
|
|
150
|
+
} else if (hh < 5) {
|
|
151
|
+
r = x;
|
|
152
|
+
g = 0;
|
|
153
|
+
b = c;
|
|
154
|
+
} else {
|
|
155
|
+
r = c;
|
|
156
|
+
g = 0;
|
|
157
|
+
b = x;
|
|
158
|
+
}
|
|
159
|
+
const m = v - c;
|
|
160
|
+
return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
|
|
164
|
+
r /= 255;
|
|
165
|
+
g /= 255;
|
|
166
|
+
b /= 255;
|
|
167
|
+
const max = Math.max(r, g, b);
|
|
168
|
+
const min = Math.min(r, g, b);
|
|
169
|
+
const v = max;
|
|
170
|
+
const d = max - min;
|
|
171
|
+
const s = max === 0 ? 0 : d / max;
|
|
172
|
+
let h = 0;
|
|
173
|
+
if (d > 0) {
|
|
174
|
+
if (max === r) h = ((g - b) / d) % 6;
|
|
175
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
176
|
+
else h = (r - g) / d + 4;
|
|
177
|
+
h *= 60;
|
|
178
|
+
if (h < 0) h += 360;
|
|
179
|
+
}
|
|
180
|
+
return { h, s, v };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
|
184
|
+
r /= 255;
|
|
185
|
+
g /= 255;
|
|
186
|
+
b /= 255;
|
|
187
|
+
const max = Math.max(r, g, b);
|
|
188
|
+
const min = Math.min(r, g, b);
|
|
189
|
+
const l = (max + min) / 2;
|
|
190
|
+
const d = max - min;
|
|
191
|
+
let h = 0,
|
|
192
|
+
s = 0;
|
|
193
|
+
if (d > 0) {
|
|
194
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
195
|
+
if (max === r) h = ((g - b) / d) % 6;
|
|
196
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
197
|
+
else h = (r - g) / d + 4;
|
|
198
|
+
h *= 60;
|
|
199
|
+
if (h < 0) h += 360;
|
|
200
|
+
}
|
|
201
|
+
return { h, s, l };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
|
205
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
206
|
+
const hh = (((h % 360) + 360) % 360) / 60;
|
|
207
|
+
const x = c * (1 - Math.abs((hh % 2) - 1));
|
|
208
|
+
let r = 0,
|
|
209
|
+
g = 0,
|
|
210
|
+
b = 0;
|
|
211
|
+
if (hh < 1) {
|
|
212
|
+
r = c;
|
|
213
|
+
g = x;
|
|
214
|
+
} else if (hh < 2) {
|
|
215
|
+
r = x;
|
|
216
|
+
g = c;
|
|
217
|
+
} else if (hh < 3) {
|
|
218
|
+
g = c;
|
|
219
|
+
b = x;
|
|
220
|
+
} else if (hh < 4) {
|
|
221
|
+
g = x;
|
|
222
|
+
b = c;
|
|
223
|
+
} else if (hh < 5) {
|
|
224
|
+
r = x;
|
|
225
|
+
b = c;
|
|
226
|
+
} else {
|
|
227
|
+
r = c;
|
|
228
|
+
b = x;
|
|
229
|
+
}
|
|
230
|
+
const m = l - c / 2;
|
|
231
|
+
return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
|
|
16
232
|
}
|
|
17
233
|
|
|
18
|
-
function
|
|
19
|
-
|
|
234
|
+
function srgbToLinear(c: number): number {
|
|
235
|
+
c = c / 255;
|
|
236
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
20
237
|
}
|
|
21
238
|
|
|
22
|
-
function
|
|
239
|
+
function linearToSrgb(c: number): number {
|
|
240
|
+
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
241
|
+
return clamp01(v) * 255;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function linearRgbToOklab(r: number, g: number, b: number): { L: number; a: number; b: number } {
|
|
245
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
246
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
247
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
248
|
+
const l_ = Math.cbrt(l);
|
|
249
|
+
const m_ = Math.cbrt(m);
|
|
250
|
+
const s_ = Math.cbrt(s);
|
|
251
|
+
return {
|
|
252
|
+
L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
|
|
253
|
+
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
|
|
254
|
+
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function oklabToLinearRgb(L: number, a: number, b: number): { r: number; g: number; b: number } {
|
|
259
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
260
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
261
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
262
|
+
const l = l_ * l_ * l_;
|
|
263
|
+
const m = m_ * m_ * m_;
|
|
264
|
+
const s = s_ * s_ * s_;
|
|
265
|
+
return {
|
|
266
|
+
r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
267
|
+
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
268
|
+
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function rgbToOklch(r: number, g: number, b: number): { L: number; C: number; H: number } {
|
|
273
|
+
const lr = srgbToLinear(r);
|
|
274
|
+
const lg = srgbToLinear(g);
|
|
275
|
+
const lb = srgbToLinear(b);
|
|
276
|
+
const lab = linearRgbToOklab(lr, lg, lb);
|
|
277
|
+
const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
|
|
278
|
+
let H = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
|
|
279
|
+
if (H < 0) H += 360;
|
|
280
|
+
return { L: lab.L, C, H };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function oklchToRgb(L: number, C: number, H: number): { r: number; g: number; b: number } {
|
|
284
|
+
const a = C * Math.cos((H * Math.PI) / 180);
|
|
285
|
+
const b = C * Math.sin((H * Math.PI) / 180);
|
|
286
|
+
const lin = oklabToLinearRgb(L, a, b);
|
|
287
|
+
// Clamp to sRGB silently (option a from plan)
|
|
288
|
+
return {
|
|
289
|
+
r: clamp255(linearToSrgb(lin.r)),
|
|
290
|
+
g: clamp255(linearToSrgb(lin.g)),
|
|
291
|
+
b: clamp255(linearToSrgb(lin.b)),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function to2hex(n: number): string {
|
|
296
|
+
return Math.round(clamp255(n)).toString(16).padStart(2, "0");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function rgbToHexStr(r: number, g: number, b: number, a: number): string {
|
|
300
|
+
if (a >= 1) return `#${to2hex(r)}${to2hex(g)}${to2hex(b)}`;
|
|
301
|
+
return `#${to2hex(r)}${to2hex(g)}${to2hex(b)}${to2hex(a * 255)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function expandShortHex(h: string): string {
|
|
305
|
+
if (h.length === 3)
|
|
306
|
+
return h
|
|
307
|
+
.split("")
|
|
308
|
+
.map((c) => c + c)
|
|
309
|
+
.join("");
|
|
310
|
+
if (h.length === 4)
|
|
311
|
+
return h
|
|
312
|
+
.split("")
|
|
313
|
+
.map((c) => c + c)
|
|
314
|
+
.join("");
|
|
315
|
+
return h;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseHex(input: string): { r: number; g: number; b: number; a: number } | null {
|
|
319
|
+
const m = input.trim().match(/^#?([0-9a-fA-F]{3,8})$/);
|
|
320
|
+
if (!m) return null;
|
|
321
|
+
let h = m[1];
|
|
322
|
+
if (h.length === 3 || h.length === 4) h = expandShortHex(h);
|
|
323
|
+
if (h.length === 6) {
|
|
324
|
+
return {
|
|
325
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
326
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
327
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
328
|
+
a: 1,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (h.length === 8) {
|
|
332
|
+
return {
|
|
333
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
334
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
335
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
336
|
+
a: parseInt(h.slice(6, 8), 16) / 255,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseColor(input: string): { r: number; g: number; b: number; a: number } | null {
|
|
343
|
+
const s = input.trim();
|
|
344
|
+
if (!s) return null;
|
|
345
|
+
if (s.startsWith("#") || /^[0-9a-fA-F]{3,8}$/.test(s)) {
|
|
346
|
+
return parseHex(s);
|
|
347
|
+
}
|
|
348
|
+
const rgbM = s.match(/^rgba?\(\s*([^)]+)\)$/i);
|
|
349
|
+
if (rgbM) {
|
|
350
|
+
const parts = rgbM[1].split(/[\s,/]+/).filter(Boolean);
|
|
351
|
+
if (parts.length < 3) return null;
|
|
352
|
+
const r = parseFloat(parts[0]);
|
|
353
|
+
const g = parseFloat(parts[1]);
|
|
354
|
+
const b = parseFloat(parts[2]);
|
|
355
|
+
let a = 1;
|
|
356
|
+
if (parts[3] !== undefined) {
|
|
357
|
+
a = parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
|
|
358
|
+
}
|
|
359
|
+
if ([r, g, b, a].some(Number.isNaN)) return null;
|
|
360
|
+
return { r: clamp255(r), g: clamp255(g), b: clamp255(b), a: clamp01(a) };
|
|
361
|
+
}
|
|
362
|
+
const hslM = s.match(/^hsla?\(\s*([^)]+)\)$/i);
|
|
363
|
+
if (hslM) {
|
|
364
|
+
const parts = hslM[1].split(/[\s,/]+/).filter(Boolean);
|
|
365
|
+
if (parts.length < 3) return null;
|
|
366
|
+
const h = parseFloat(parts[0]);
|
|
367
|
+
const sat = parts[1].endsWith("%") ? parseFloat(parts[1]) / 100 : parseFloat(parts[1]);
|
|
368
|
+
const l = parts[2].endsWith("%") ? parseFloat(parts[2]) / 100 : parseFloat(parts[2]);
|
|
369
|
+
let a = 1;
|
|
370
|
+
if (parts[3] !== undefined) {
|
|
371
|
+
a = parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
|
|
372
|
+
}
|
|
373
|
+
if ([h, sat, l, a].some(Number.isNaN)) return null;
|
|
374
|
+
const rgb = hslToRgb(h, clamp01(sat), clamp01(l));
|
|
375
|
+
return { r: clamp255(rgb.r), g: clamp255(rgb.g), b: clamp255(rgb.b), a: clamp01(a) };
|
|
376
|
+
}
|
|
377
|
+
const oklchM = s.match(/^oklch\(\s*([^)]+)\)$/i);
|
|
378
|
+
if (oklchM) {
|
|
379
|
+
const parts = oklchM[1].split(/[\s,/]+/).filter(Boolean);
|
|
380
|
+
if (parts.length < 3) return null;
|
|
381
|
+
const L = parts[0].endsWith("%") ? parseFloat(parts[0]) / 100 : parseFloat(parts[0]);
|
|
382
|
+
const C = parseFloat(parts[1]);
|
|
383
|
+
const H = parseFloat(parts[2]);
|
|
384
|
+
let a = 1;
|
|
385
|
+
if (parts[3] !== undefined) {
|
|
386
|
+
a = parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
|
|
387
|
+
}
|
|
388
|
+
if ([L, C, H, a].some(Number.isNaN)) return null;
|
|
389
|
+
const rgb = oklchToRgb(clamp01(L), Math.max(0, C), H);
|
|
390
|
+
return { r: clamp255(rgb.r), g: clamp255(rgb.g), b: clamp255(rgb.b), a: clamp01(a) };
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildParsed(h: number, s: number, v: number, a: number): ParsedColor {
|
|
396
|
+
const { r, g, b } = hsvToRgb(h, s, v);
|
|
397
|
+
const hsl = rgbToHsl(r, g, b);
|
|
398
|
+
const oklch = rgbToOklch(r, g, b);
|
|
399
|
+
const hex = rgbToHexStr(r, g, b, a);
|
|
400
|
+
const rgbStr =
|
|
401
|
+
a >= 1
|
|
402
|
+
? `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`
|
|
403
|
+
: `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${Number(a.toFixed(3))})`;
|
|
404
|
+
const hslStr =
|
|
405
|
+
a >= 1
|
|
406
|
+
? `hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`
|
|
407
|
+
: `hsla(${Math.round(hsl.h)}, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${Number(a.toFixed(3))})`;
|
|
408
|
+
const oklchStr =
|
|
409
|
+
a >= 1
|
|
410
|
+
? `oklch(${(oklch.L * 100).toFixed(1)}% ${oklch.C.toFixed(3)} ${oklch.H.toFixed(1)})`
|
|
411
|
+
: `oklch(${(oklch.L * 100).toFixed(1)}% ${oklch.C.toFixed(3)} ${oklch.H.toFixed(1)} / ${Number(a.toFixed(3))})`;
|
|
412
|
+
return {
|
|
413
|
+
h,
|
|
414
|
+
s,
|
|
415
|
+
v,
|
|
416
|
+
a,
|
|
417
|
+
r: Math.round(r),
|
|
418
|
+
g: Math.round(g),
|
|
419
|
+
b: Math.round(b),
|
|
420
|
+
hex,
|
|
421
|
+
rgb: rgbStr,
|
|
422
|
+
hsl: hslStr,
|
|
423
|
+
oklch: oklchStr,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function formatValueByFormat(parsed: ParsedColor, fmt: ColorFormat): string {
|
|
428
|
+
switch (fmt) {
|
|
429
|
+
case "hex":
|
|
430
|
+
return parsed.hex;
|
|
431
|
+
case "rgb":
|
|
432
|
+
return parsed.rgb;
|
|
433
|
+
case "hsl":
|
|
434
|
+
return parsed.hsl;
|
|
435
|
+
case "oklch":
|
|
436
|
+
return parsed.oklch;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Constants
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
const PANEL_WIDTH = 280;
|
|
445
|
+
const SQUARE_HEIGHT = 156;
|
|
446
|
+
const CHECKER_BG: CSSProperties = {
|
|
447
|
+
backgroundImage:
|
|
448
|
+
"conic-gradient(var(--checker-a) 0 25%, var(--checker-b) 0 50%, var(--checker-a) 0 75%, var(--checker-b) 0)",
|
|
449
|
+
backgroundSize: "8px 8px",
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// SaturationSquare
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
interface SaturationSquareProps {
|
|
457
|
+
h: number;
|
|
458
|
+
s: number;
|
|
459
|
+
v: number;
|
|
460
|
+
onChange: (s: number, v: number) => void;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function SaturationSquare({ h, s, v, onChange }: SaturationSquareProps) {
|
|
464
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
465
|
+
const dragging = useRef(false);
|
|
466
|
+
const hasMoved = useRef(false);
|
|
467
|
+
const [focused, setFocused] = useState(false);
|
|
468
|
+
const [hovered, setHovered] = useState(false);
|
|
469
|
+
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null);
|
|
470
|
+
const shape = useShape();
|
|
471
|
+
|
|
472
|
+
const updateFromPointer = useCallback(
|
|
473
|
+
(clientX: number, clientY: number) => {
|
|
474
|
+
const rect = ref.current?.getBoundingClientRect();
|
|
475
|
+
if (!rect) return;
|
|
476
|
+
const x = clamp01((clientX - rect.left) / rect.width);
|
|
477
|
+
const y = clamp01((clientY - rect.top) / rect.height);
|
|
478
|
+
onChange(x, 1 - y);
|
|
479
|
+
},
|
|
480
|
+
[onChange],
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const updateCursorPos = useCallback((clientX: number, clientY: number) => {
|
|
484
|
+
const rect = ref.current?.getBoundingClientRect();
|
|
485
|
+
if (!rect) return;
|
|
486
|
+
setCursorPos({
|
|
487
|
+
x: clamp01((clientX - rect.left) / rect.width) * 100,
|
|
488
|
+
y: clamp01((clientY - rect.top) / rect.height) * 100,
|
|
489
|
+
});
|
|
490
|
+
}, []);
|
|
491
|
+
|
|
492
|
+
const onPointerDown = useCallback(
|
|
493
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
494
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
dragging.current = true;
|
|
497
|
+
hasMoved.current = false;
|
|
498
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
499
|
+
updateFromPointer(e.clientX, e.clientY);
|
|
500
|
+
},
|
|
501
|
+
[updateFromPointer],
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const onPointerMove = useCallback(
|
|
505
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
506
|
+
updateCursorPos(e.clientX, e.clientY);
|
|
507
|
+
if (!dragging.current) return;
|
|
508
|
+
hasMoved.current = true;
|
|
509
|
+
updateFromPointer(e.clientX, e.clientY);
|
|
510
|
+
},
|
|
511
|
+
[updateFromPointer, updateCursorPos],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const onPointerUp = useCallback(() => {
|
|
515
|
+
dragging.current = false;
|
|
516
|
+
hasMoved.current = false;
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
const onKeyDown = useCallback(
|
|
520
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
521
|
+
const step = e.shiftKey ? 0.1 : 0.01;
|
|
522
|
+
let nextS = s,
|
|
523
|
+
nextV = v,
|
|
524
|
+
handled = true;
|
|
525
|
+
if (e.key === "ArrowLeft") nextS = clamp01(s - step);
|
|
526
|
+
else if (e.key === "ArrowRight") nextS = clamp01(s + step);
|
|
527
|
+
else if (e.key === "ArrowUp") nextV = clamp01(v + step);
|
|
528
|
+
else if (e.key === "ArrowDown") nextV = clamp01(v - step);
|
|
529
|
+
else handled = false;
|
|
530
|
+
if (handled) {
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
onChange(nextS, nextV);
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
[onChange, s, v],
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const { r, g, b } = hsvToRgb(h, s, v);
|
|
539
|
+
const thumbColor = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<div
|
|
543
|
+
ref={ref}
|
|
544
|
+
role="application"
|
|
545
|
+
aria-label="Saturation and brightness"
|
|
546
|
+
tabIndex={0}
|
|
547
|
+
onFocus={(e) => {
|
|
548
|
+
if (e.currentTarget.matches(":focus-visible")) setFocused(true);
|
|
549
|
+
}}
|
|
550
|
+
onBlur={() => setFocused(false)}
|
|
551
|
+
onPointerEnter={() => setHovered(true)}
|
|
552
|
+
onPointerLeave={() => {
|
|
553
|
+
setHovered(false);
|
|
554
|
+
setCursorPos(null);
|
|
555
|
+
}}
|
|
556
|
+
onPointerDown={onPointerDown}
|
|
557
|
+
onPointerMove={onPointerMove}
|
|
558
|
+
onPointerUp={onPointerUp}
|
|
559
|
+
onKeyDown={onKeyDown}
|
|
560
|
+
className={cn("relative w-full select-none touch-none cursor-none outline-none", shape.bg)}
|
|
561
|
+
style={{
|
|
562
|
+
height: SQUARE_HEIGHT,
|
|
563
|
+
boxShadow: focused ? "0 0 0 2px #6B97FF" : undefined,
|
|
564
|
+
}}
|
|
565
|
+
>
|
|
566
|
+
<div
|
|
567
|
+
className={cn(
|
|
568
|
+
"absolute inset-0 overflow-hidden",
|
|
569
|
+
shape.bg === "rounded-[20px]" ? "rounded-2xl" : shape.bg,
|
|
570
|
+
)}
|
|
571
|
+
style={{
|
|
572
|
+
background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))`,
|
|
573
|
+
}}
|
|
574
|
+
/>
|
|
575
|
+
<motion.div
|
|
576
|
+
className="absolute pointer-events-none rounded-full"
|
|
577
|
+
initial={false}
|
|
578
|
+
animate={{
|
|
579
|
+
left: `${s * 100}%`,
|
|
580
|
+
top: `${(1 - v) * 100}%`,
|
|
581
|
+
width: 18,
|
|
582
|
+
height: 18,
|
|
583
|
+
}}
|
|
584
|
+
transition={{ duration: 0 }}
|
|
585
|
+
style={{
|
|
586
|
+
transform: "translate(-50%, -50%)",
|
|
587
|
+
border: "1px solid white",
|
|
588
|
+
boxShadow: "0 0 0 1px rgba(0,0,0,1)",
|
|
589
|
+
backgroundColor: thumbColor,
|
|
590
|
+
}}
|
|
591
|
+
/>
|
|
592
|
+
{hovered && !dragging.current && cursorPos && (
|
|
593
|
+
<div
|
|
594
|
+
className="absolute pointer-events-none rounded-full"
|
|
595
|
+
style={{
|
|
596
|
+
left: `${cursorPos.x}%`,
|
|
597
|
+
top: `${cursorPos.y}%`,
|
|
598
|
+
width: 18,
|
|
599
|
+
height: 18,
|
|
600
|
+
transform: "translate(-50%, -50%)",
|
|
601
|
+
border: "2px solid rgba(255, 255, 255, 0.55)",
|
|
602
|
+
boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.2)",
|
|
603
|
+
}}
|
|
604
|
+
/>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
// HueSlider
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
function HueSlider({ h, onChange }: { h: number; onChange: (h: number) => void }) {
|
|
615
|
+
const hueColor = `hsl(${h}, 100%, 50%)`;
|
|
616
|
+
return (
|
|
617
|
+
<Slider
|
|
618
|
+
value={h}
|
|
619
|
+
onChange={(v) => onChange(typeof v === "number" ? v : v[0])}
|
|
620
|
+
min={0}
|
|
621
|
+
max={360}
|
|
622
|
+
step={1}
|
|
623
|
+
showValue={false}
|
|
624
|
+
hideFill
|
|
625
|
+
thumbColor={hueColor}
|
|
626
|
+
thumbBorderColor="rgba(255,255,255,0.9)"
|
|
627
|
+
trackStyle={{
|
|
628
|
+
background:
|
|
629
|
+
"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%))",
|
|
630
|
+
borderColor: "transparent",
|
|
631
|
+
}}
|
|
632
|
+
aria-label="Hue"
|
|
633
|
+
/>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// AlphaSlider
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
function AlphaSlider({
|
|
642
|
+
a,
|
|
643
|
+
solidColor,
|
|
644
|
+
solidR,
|
|
645
|
+
solidG,
|
|
646
|
+
solidB,
|
|
647
|
+
onChange,
|
|
648
|
+
}: {
|
|
649
|
+
a: number;
|
|
650
|
+
solidColor: string;
|
|
651
|
+
solidR: number;
|
|
652
|
+
solidG: number;
|
|
653
|
+
solidB: number;
|
|
654
|
+
onChange: (a: number) => void;
|
|
655
|
+
}) {
|
|
656
|
+
// Use color-aware transparent stop (same hue, alpha 0) so the gradient stays
|
|
657
|
+
// chromatically consistent and reaches fully opaque at 100% with no edge gap.
|
|
658
|
+
const transparentColor = `rgba(${solidR}, ${solidG}, ${solidB}, 0)`;
|
|
659
|
+
return (
|
|
660
|
+
<Slider
|
|
661
|
+
value={Math.round(a * 100)}
|
|
662
|
+
onChange={(v) => onChange((typeof v === "number" ? v : v[0]) / 100)}
|
|
663
|
+
min={0}
|
|
664
|
+
max={100}
|
|
665
|
+
step={1}
|
|
666
|
+
showValue={false}
|
|
667
|
+
hideFill
|
|
668
|
+
thumbColor={solidColor}
|
|
669
|
+
thumbBorderColor="rgba(255,255,255,0.9)"
|
|
670
|
+
trackStyle={{
|
|
671
|
+
backgroundImage: `linear-gradient(to right, ${transparentColor} 0%, ${solidColor} 98%), conic-gradient(var(--checker-a) 0 25%, var(--checker-b) 0 50%, var(--checker-a) 0 75%, var(--checker-b) 0)`,
|
|
672
|
+
backgroundSize: "100% 100%, 8px 8px",
|
|
673
|
+
borderWidth: 0,
|
|
674
|
+
}}
|
|
675
|
+
aria-label="Alpha"
|
|
676
|
+
/>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
// FormatDropdown (custom, lightweight)
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
const FORMAT_LABELS: Record<ColorFormat, string> = {
|
|
685
|
+
hex: "HEX",
|
|
686
|
+
rgb: "RGB",
|
|
687
|
+
hsl: "HSL",
|
|
688
|
+
oklch: "OKLCH",
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
function FormatItem({
|
|
692
|
+
index,
|
|
693
|
+
label,
|
|
694
|
+
checked,
|
|
695
|
+
onSelect,
|
|
696
|
+
}: {
|
|
697
|
+
index: number;
|
|
698
|
+
label: string;
|
|
699
|
+
checked: boolean;
|
|
700
|
+
onSelect: () => void;
|
|
701
|
+
}) {
|
|
702
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
703
|
+
const { registerItem, activeIndex, checkedIndex } = useDropdown();
|
|
704
|
+
const shape = useShape();
|
|
705
|
+
|
|
706
|
+
useEffect(() => {
|
|
707
|
+
registerItem(index, ref.current);
|
|
708
|
+
return () => registerItem(index, null);
|
|
709
|
+
}, [index, registerItem]);
|
|
710
|
+
|
|
711
|
+
const isActive = activeIndex === index;
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
<div
|
|
715
|
+
ref={ref}
|
|
716
|
+
data-proximity-index={index}
|
|
717
|
+
role="menuitemradio"
|
|
718
|
+
aria-checked={checked}
|
|
719
|
+
aria-label={label}
|
|
720
|
+
tabIndex={index === (checkedIndex ?? 0) ? 0 : -1}
|
|
721
|
+
onClick={onSelect}
|
|
722
|
+
onKeyDown={(e) => {
|
|
723
|
+
if (e.key === " " || e.key === "Enter") {
|
|
724
|
+
e.preventDefault();
|
|
725
|
+
onSelect();
|
|
726
|
+
}
|
|
727
|
+
}}
|
|
728
|
+
className={cn(
|
|
729
|
+
`relative z-10 flex items-center px-3 py-2 text-[13px] cursor-pointer outline-none`,
|
|
730
|
+
shape.item,
|
|
731
|
+
)}
|
|
732
|
+
>
|
|
733
|
+
<span className="inline-grid">
|
|
734
|
+
<span
|
|
735
|
+
className="col-start-1 row-start-1 invisible"
|
|
736
|
+
style={{ fontVariationSettings: fontWeights.semibold }}
|
|
737
|
+
aria-hidden="true"
|
|
738
|
+
>
|
|
739
|
+
{label}
|
|
740
|
+
</span>
|
|
741
|
+
<span
|
|
742
|
+
className={cn(
|
|
743
|
+
"col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80",
|
|
744
|
+
isActive || checked ? "text-foreground" : "text-muted-foreground",
|
|
745
|
+
)}
|
|
746
|
+
style={{
|
|
747
|
+
fontVariationSettings: checked ? fontWeights.semibold : fontWeights.normal,
|
|
748
|
+
}}
|
|
749
|
+
>
|
|
750
|
+
{label}
|
|
751
|
+
</span>
|
|
752
|
+
</span>
|
|
753
|
+
</div>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function FormatDropdown({
|
|
23
758
|
value,
|
|
24
759
|
onChange,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
760
|
+
open: openProp,
|
|
761
|
+
defaultOpen = false,
|
|
762
|
+
}: {
|
|
763
|
+
value: ColorFormat;
|
|
764
|
+
onChange: (f: ColorFormat) => void;
|
|
765
|
+
open?: boolean;
|
|
766
|
+
defaultOpen?: boolean;
|
|
767
|
+
}) {
|
|
768
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
769
|
+
const isControlled = openProp !== undefined;
|
|
770
|
+
const open = isControlled ? openProp : internalOpen;
|
|
771
|
+
const setOpen: (next: boolean | ((prev: boolean) => boolean)) => void = (next) => {
|
|
772
|
+
if (isControlled) return;
|
|
773
|
+
setInternalOpen(next);
|
|
38
774
|
};
|
|
775
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
776
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
777
|
+
const shape = useShape();
|
|
778
|
+
const portalContainer = useContext(ColorPickerPortalContainerContext);
|
|
779
|
+
const [pos, setPos] = useState<
|
|
780
|
+
| { mode: "fixed"; top: number; left: number; width: number }
|
|
781
|
+
| { mode: "absolute"; top: number; left: number; width: number }
|
|
782
|
+
| null
|
|
783
|
+
>(null);
|
|
784
|
+
|
|
785
|
+
useEffect(() => {
|
|
786
|
+
if (!open || !triggerRef.current) {
|
|
787
|
+
setPos(null);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
791
|
+
if (portalContainer) {
|
|
792
|
+
const cRect = portalContainer.getBoundingClientRect();
|
|
793
|
+
const cWidth = portalContainer.offsetWidth;
|
|
794
|
+
const scale = cWidth > 0 ? cRect.width / cWidth : 1;
|
|
795
|
+
// Convert viewport coords into the portal container's pre-scale frame so
|
|
796
|
+
// an ancestor CSS scale visually scales the menu alongside the trigger.
|
|
797
|
+
setPos({
|
|
798
|
+
mode: "absolute",
|
|
799
|
+
top: (triggerRect.bottom - cRect.top) / scale + 6,
|
|
800
|
+
left: (triggerRect.left - cRect.left) / scale,
|
|
801
|
+
width: triggerRect.width / scale,
|
|
802
|
+
});
|
|
803
|
+
} else {
|
|
804
|
+
setPos({
|
|
805
|
+
mode: "fixed",
|
|
806
|
+
top: triggerRect.bottom + 6,
|
|
807
|
+
left: triggerRect.left,
|
|
808
|
+
width: triggerRect.width,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}, [open, portalContainer]);
|
|
812
|
+
|
|
813
|
+
useEffect(() => {
|
|
814
|
+
if (!open) return;
|
|
815
|
+
const onClick = (e: MouseEvent) => {
|
|
816
|
+
if (
|
|
817
|
+
!panelRef.current?.contains(e.target as Node) &&
|
|
818
|
+
!triggerRef.current?.contains(e.target as Node)
|
|
819
|
+
) {
|
|
820
|
+
setOpen(false);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
const onKey = (e: KeyboardEvent) => {
|
|
824
|
+
if (e.key === "Escape") setOpen(false);
|
|
825
|
+
};
|
|
826
|
+
document.addEventListener("mousedown", onClick);
|
|
827
|
+
document.addEventListener("keydown", onKey);
|
|
828
|
+
return () => {
|
|
829
|
+
document.removeEventListener("mousedown", onClick);
|
|
830
|
+
document.removeEventListener("keydown", onKey);
|
|
831
|
+
};
|
|
832
|
+
}, [open]);
|
|
833
|
+
|
|
834
|
+
const formats = ["hex", "rgb", "hsl", "oklch"] as const;
|
|
835
|
+
const checkedIdx = formats.indexOf(value);
|
|
836
|
+
const ChevronDownIcon = useIcon("chevron-down");
|
|
39
837
|
|
|
40
838
|
return (
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
839
|
+
<>
|
|
840
|
+
<button
|
|
841
|
+
ref={triggerRef}
|
|
842
|
+
type="button"
|
|
843
|
+
onClick={() => setOpen((o) => !o)}
|
|
844
|
+
aria-haspopup="menu"
|
|
845
|
+
aria-expanded={open}
|
|
846
|
+
className={cn(
|
|
847
|
+
"flex items-center justify-between gap-2 h-9 px-3 text-[13px] bg-transparent hover:bg-hover hover:text-foreground transition-colors duration-80 outline-none focus-visible:ring-1 focus-visible:ring-[#6B97FF] cursor-pointer",
|
|
848
|
+
open ? "bg-active text-foreground" : "text-muted-foreground active:bg-active",
|
|
849
|
+
shape.input,
|
|
850
|
+
)}
|
|
851
|
+
style={{ fontVariationSettings: fontWeights.medium }}
|
|
852
|
+
>
|
|
853
|
+
<span>{FORMAT_LABELS[value]}</span>
|
|
854
|
+
<ChevronDownIcon
|
|
855
|
+
size={14}
|
|
856
|
+
strokeWidth={1.5}
|
|
857
|
+
className={cn(
|
|
858
|
+
"text-muted-foreground transition-transform duration-150",
|
|
859
|
+
open && "rotate-180",
|
|
860
|
+
)}
|
|
861
|
+
/>
|
|
862
|
+
</button>
|
|
863
|
+
{open &&
|
|
864
|
+
pos &&
|
|
865
|
+
typeof document !== "undefined" &&
|
|
866
|
+
createPortal(
|
|
867
|
+
<div
|
|
868
|
+
style={{
|
|
869
|
+
position: pos.mode,
|
|
870
|
+
top: pos.top,
|
|
871
|
+
left: pos.left,
|
|
872
|
+
zIndex: 60,
|
|
873
|
+
}}
|
|
874
|
+
>
|
|
875
|
+
<motion.div
|
|
876
|
+
ref={panelRef}
|
|
877
|
+
initial={{ opacity: 0, y: -4, scaleY: 0.96 }}
|
|
878
|
+
animate={{ opacity: 1, y: 0, scaleY: 1 }}
|
|
879
|
+
transition={spring.fast}
|
|
880
|
+
style={{ transformOrigin: "top center", minWidth: pos.width }}
|
|
881
|
+
>
|
|
882
|
+
<Dropdown checkedIndex={checkedIdx} className="!w-auto min-w-full">
|
|
883
|
+
{formats.map((fmt, i) => (
|
|
884
|
+
<FormatItem
|
|
885
|
+
key={fmt}
|
|
886
|
+
index={i}
|
|
887
|
+
label={FORMAT_LABELS[fmt]}
|
|
888
|
+
checked={value === fmt}
|
|
889
|
+
onSelect={() => {
|
|
890
|
+
onChange(fmt);
|
|
891
|
+
setOpen(false);
|
|
892
|
+
}}
|
|
893
|
+
/>
|
|
894
|
+
))}
|
|
895
|
+
</Dropdown>
|
|
896
|
+
</motion.div>
|
|
897
|
+
</div>,
|
|
898
|
+
portalContainer ?? document.body,
|
|
899
|
+
)}
|
|
900
|
+
</>
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// ColorInput (a single styled text input, used for hex)
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
|
|
908
|
+
interface ColorInputProps {
|
|
909
|
+
value: string;
|
|
910
|
+
onCommit: (next: string) => void;
|
|
911
|
+
ariaLabel: string;
|
|
912
|
+
width?: string;
|
|
913
|
+
className?: string;
|
|
914
|
+
inputClassName?: string;
|
|
915
|
+
align?: "left" | "center" | "right";
|
|
916
|
+
prefix?: ReactNode;
|
|
917
|
+
inputMode?: "numeric" | "decimal" | "text";
|
|
918
|
+
nudgeStep?: number;
|
|
919
|
+
nudgeShiftStep?: number;
|
|
920
|
+
hasPercent?: boolean;
|
|
921
|
+
decimals?: number;
|
|
922
|
+
scrubbable?: boolean;
|
|
923
|
+
min?: number;
|
|
924
|
+
max?: number;
|
|
925
|
+
/** When true with min and max, wrap (modulo) instead of clamping. Used for angular values like hue. */
|
|
926
|
+
wrap?: boolean;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const ColorInput = forwardRef<HTMLInputElement, ColorInputProps>(
|
|
930
|
+
(
|
|
931
|
+
{
|
|
932
|
+
value,
|
|
933
|
+
onCommit,
|
|
934
|
+
ariaLabel,
|
|
935
|
+
width,
|
|
936
|
+
className,
|
|
937
|
+
inputClassName,
|
|
938
|
+
align = "left",
|
|
939
|
+
prefix,
|
|
940
|
+
inputMode = "text",
|
|
941
|
+
nudgeStep,
|
|
942
|
+
nudgeShiftStep,
|
|
943
|
+
hasPercent = false,
|
|
944
|
+
decimals,
|
|
945
|
+
scrubbable = false,
|
|
946
|
+
min,
|
|
947
|
+
max,
|
|
948
|
+
wrap = false,
|
|
949
|
+
},
|
|
950
|
+
ref,
|
|
951
|
+
) => {
|
|
952
|
+
const [draft, setDraft] = useState(value);
|
|
953
|
+
const [editing, setEditing] = useState(false);
|
|
954
|
+
const interactingRef = useRef(false);
|
|
955
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
956
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
957
|
+
const scrubRef = useRef<{
|
|
958
|
+
startX: number;
|
|
959
|
+
startValue: number;
|
|
960
|
+
scrubbing: boolean;
|
|
961
|
+
pointerId: number;
|
|
962
|
+
} | null>(null);
|
|
963
|
+
const shape = useShape();
|
|
964
|
+
|
|
965
|
+
useEffect(() => {
|
|
966
|
+
if (!interactingRef.current) setDraft(value);
|
|
967
|
+
}, [value]);
|
|
968
|
+
|
|
969
|
+
const setInputRef = useCallback(
|
|
970
|
+
(node: HTMLInputElement | null) => {
|
|
971
|
+
inputRef.current = node;
|
|
972
|
+
if (typeof ref === "function") ref(node);
|
|
973
|
+
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
|
|
974
|
+
},
|
|
975
|
+
[ref],
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
const formatNumber = (n: number) =>
|
|
979
|
+
decimals != null ? n.toFixed(decimals) : String(Math.round(n));
|
|
980
|
+
|
|
981
|
+
const commitNumber = (n: number) => {
|
|
982
|
+
let bounded = n;
|
|
983
|
+
if (wrap && min != null && max != null) {
|
|
984
|
+
const range = max - min;
|
|
985
|
+
bounded = ((((bounded - min) % range) + range) % range) + min;
|
|
986
|
+
} else {
|
|
987
|
+
if (min != null) bounded = Math.max(min, bounded);
|
|
988
|
+
if (max != null) bounded = Math.min(max, bounded);
|
|
989
|
+
}
|
|
990
|
+
const formatted = formatNumber(bounded);
|
|
991
|
+
const withSuffix = hasPercent ? `${formatted}%` : formatted;
|
|
992
|
+
setDraft(withSuffix);
|
|
993
|
+
onCommit(withSuffix);
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const nudge = (direction: 1 | -1, shift: boolean) => {
|
|
997
|
+
const baseStep = shift ? (nudgeShiftStep ?? 10) : (nudgeStep ?? 1);
|
|
998
|
+
const cur = parseFloat(draft.replace("%", ""));
|
|
999
|
+
if (Number.isNaN(cur)) return;
|
|
1000
|
+
commitNumber(cur + direction * baseStep);
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const onWrapperPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
1004
|
+
if (!scrubbable || editing) return;
|
|
1005
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
1006
|
+
const cur = parseFloat(draft.replace("%", ""));
|
|
1007
|
+
if (Number.isNaN(cur)) return;
|
|
1008
|
+
scrubRef.current = {
|
|
1009
|
+
startX: e.clientX,
|
|
1010
|
+
startValue: cur,
|
|
1011
|
+
scrubbing: false,
|
|
1012
|
+
pointerId: e.pointerId,
|
|
1013
|
+
};
|
|
1014
|
+
// Block focus while we wait to see if this is a click or a drag
|
|
1015
|
+
e.preventDefault();
|
|
1016
|
+
wrapperRef.current?.setPointerCapture(e.pointerId);
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
const onWrapperPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
1020
|
+
const state = scrubRef.current;
|
|
1021
|
+
if (!state) return;
|
|
1022
|
+
const dx = e.clientX - state.startX;
|
|
1023
|
+
if (!state.scrubbing && Math.abs(dx) > 3) {
|
|
1024
|
+
state.scrubbing = true;
|
|
1025
|
+
interactingRef.current = true;
|
|
1026
|
+
}
|
|
1027
|
+
if (state.scrubbing) {
|
|
1028
|
+
const baseStep = e.shiftKey ? (nudgeShiftStep ?? 10) : (nudgeStep ?? 1);
|
|
1029
|
+
commitNumber(state.startValue + dx * baseStep);
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
const onWrapperPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
1034
|
+
const state = scrubRef.current;
|
|
1035
|
+
if (!state) return;
|
|
1036
|
+
scrubRef.current = null;
|
|
1037
|
+
try {
|
|
1038
|
+
wrapperRef.current?.releasePointerCapture(e.pointerId);
|
|
1039
|
+
} catch {}
|
|
1040
|
+
if (state.scrubbing) {
|
|
1041
|
+
interactingRef.current = false;
|
|
1042
|
+
// Sync draft back to the (possibly clamped) value from parent
|
|
1043
|
+
setDraft(value);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
// Click without drag → enter edit mode and focus the input
|
|
1047
|
+
setEditing(true);
|
|
1048
|
+
requestAnimationFrame(() => {
|
|
1049
|
+
inputRef.current?.focus();
|
|
1050
|
+
inputRef.current?.select();
|
|
1051
|
+
});
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
return (
|
|
1055
|
+
<div
|
|
1056
|
+
ref={wrapperRef}
|
|
1057
|
+
onPointerDown={onWrapperPointerDown}
|
|
1058
|
+
onPointerMove={onWrapperPointerMove}
|
|
1059
|
+
onPointerUp={onWrapperPointerUp}
|
|
1060
|
+
onPointerCancel={onWrapperPointerUp}
|
|
1061
|
+
className={cn(
|
|
1062
|
+
"flex items-center h-9 px-2 bg-transparent hover:bg-hover active:bg-active transition-colors duration-80 focus-within:ring-1 focus-within:ring-[#6B97FF] select-none",
|
|
1063
|
+
shape.input,
|
|
1064
|
+
scrubbable && !editing && "cursor-ew-resize",
|
|
1065
|
+
className,
|
|
1066
|
+
)}
|
|
1067
|
+
style={{ width }}
|
|
1068
|
+
>
|
|
1069
|
+
{prefix && (
|
|
1070
|
+
<span className="text-[12px] text-muted-foreground mr-1 select-none">{prefix}</span>
|
|
1071
|
+
)}
|
|
1072
|
+
<input
|
|
1073
|
+
ref={setInputRef}
|
|
1074
|
+
value={draft}
|
|
1075
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
1076
|
+
onFocus={(e) => {
|
|
1077
|
+
interactingRef.current = true;
|
|
1078
|
+
setEditing(true);
|
|
1079
|
+
e.currentTarget.select();
|
|
1080
|
+
}}
|
|
1081
|
+
onBlur={() => {
|
|
1082
|
+
interactingRef.current = false;
|
|
1083
|
+
setEditing(false);
|
|
1084
|
+
if (draft !== value) {
|
|
1085
|
+
const numeric = parseFloat(draft.replace("%", ""));
|
|
1086
|
+
if (!Number.isNaN(numeric) && (min != null || max != null)) {
|
|
1087
|
+
commitNumber(numeric);
|
|
1088
|
+
} else {
|
|
1089
|
+
onCommit(draft);
|
|
1090
|
+
}
|
|
1091
|
+
} else setDraft(value);
|
|
1092
|
+
}}
|
|
1093
|
+
onKeyDown={(e) => {
|
|
1094
|
+
if (e.key === "Enter") {
|
|
1095
|
+
(e.currentTarget as HTMLInputElement).blur();
|
|
1096
|
+
} else if (e.key === "Escape") {
|
|
1097
|
+
setDraft(value);
|
|
1098
|
+
(e.currentTarget as HTMLInputElement).blur();
|
|
1099
|
+
} else if (
|
|
1100
|
+
(nudgeStep != null || nudgeShiftStep != null) &&
|
|
1101
|
+
(e.key === "ArrowUp" || e.key === "ArrowDown")
|
|
1102
|
+
) {
|
|
1103
|
+
e.preventDefault();
|
|
1104
|
+
nudge(e.key === "ArrowUp" ? 1 : -1, e.shiftKey);
|
|
1105
|
+
}
|
|
1106
|
+
}}
|
|
1107
|
+
inputMode={inputMode}
|
|
46
1108
|
aria-label={ariaLabel}
|
|
47
1109
|
className={cn(
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
1110
|
+
"flex-1 min-w-0 bg-transparent text-foreground text-[13px] outline-none tabular-nums",
|
|
1111
|
+
align === "center" && "text-center",
|
|
1112
|
+
align === "right" && "text-right",
|
|
1113
|
+
scrubbable && !editing && "pointer-events-none",
|
|
1114
|
+
inputClassName,
|
|
1115
|
+
)}
|
|
1116
|
+
style={{ fontVariationSettings: fontWeights.medium }}
|
|
1117
|
+
/>
|
|
1118
|
+
</div>
|
|
1119
|
+
);
|
|
1120
|
+
},
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
ColorInput.displayName = "ColorInput";
|
|
1124
|
+
|
|
1125
|
+
// ---------------------------------------------------------------------------
|
|
1126
|
+
// EyeDropperButton
|
|
1127
|
+
// ---------------------------------------------------------------------------
|
|
1128
|
+
|
|
1129
|
+
interface EyeDropperGlobal {
|
|
1130
|
+
open(): Promise<{ sRGBHex: string }>;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function EyeDropperButton({ onPick }: { onPick: (hex: string) => void }) {
|
|
1134
|
+
const [supported, setSupported] = useState(false);
|
|
1135
|
+
const shape = useShape();
|
|
1136
|
+
const PipetteIcon = useIcon("pipette");
|
|
1137
|
+
|
|
1138
|
+
useEffect(() => {
|
|
1139
|
+
setSupported(typeof window !== "undefined" && "EyeDropper" in window);
|
|
1140
|
+
}, []);
|
|
1141
|
+
|
|
1142
|
+
if (!supported) return null;
|
|
1143
|
+
|
|
1144
|
+
const handleClick = async () => {
|
|
1145
|
+
try {
|
|
1146
|
+
const Ctor = (window as unknown as { EyeDropper: new () => EyeDropperGlobal }).EyeDropper;
|
|
1147
|
+
const eye = new Ctor();
|
|
1148
|
+
const result = await eye.open();
|
|
1149
|
+
onPick(result.sRGBHex);
|
|
1150
|
+
} catch {
|
|
1151
|
+
// user cancelled
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
return (
|
|
1156
|
+
<button
|
|
1157
|
+
type="button"
|
|
1158
|
+
onClick={handleClick}
|
|
1159
|
+
aria-label="Pick color from screen"
|
|
1160
|
+
className={cn(
|
|
1161
|
+
"flex items-center justify-center h-9 px-3 text-muted-foreground bg-transparent hover:bg-hover hover:text-foreground active:bg-active transition-colors duration-80 outline-none focus-visible:ring-1 focus-visible:ring-[#6B97FF] cursor-pointer",
|
|
1162
|
+
shape.input,
|
|
1163
|
+
)}
|
|
1164
|
+
>
|
|
1165
|
+
<PipetteIcon size={16} strokeWidth={1.5} />
|
|
1166
|
+
</button>
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// ---------------------------------------------------------------------------
|
|
1171
|
+
// ColorTile (small colored square — checker behind alpha)
|
|
1172
|
+
// ---------------------------------------------------------------------------
|
|
1173
|
+
|
|
1174
|
+
interface ColorTileProps {
|
|
1175
|
+
color: string;
|
|
1176
|
+
size?: number;
|
|
1177
|
+
className?: string;
|
|
1178
|
+
style?: CSSProperties;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function ColorTile({ color, size = 24, className, style }: ColorTileProps) {
|
|
1182
|
+
const shape = useShape();
|
|
1183
|
+
return (
|
|
1184
|
+
<span
|
|
1185
|
+
className={cn("inline-block relative shrink-0 overflow-hidden", shape.bg, className)}
|
|
1186
|
+
style={{
|
|
1187
|
+
width: size,
|
|
1188
|
+
height: size,
|
|
1189
|
+
...CHECKER_BG,
|
|
1190
|
+
boxShadow: "inset 0 0 0 1px rgba(127,127,127,0.25)",
|
|
1191
|
+
...style,
|
|
1192
|
+
}}
|
|
1193
|
+
>
|
|
1194
|
+
<span className="absolute inset-0" style={{ backgroundColor: color }} />
|
|
1195
|
+
</span>
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ---------------------------------------------------------------------------
|
|
1200
|
+
// ColorSwatch (clickable strip swatch)
|
|
1201
|
+
// ---------------------------------------------------------------------------
|
|
1202
|
+
|
|
1203
|
+
const ColorSwatch = forwardRef<HTMLButtonElement, ColorSwatchProps>(
|
|
1204
|
+
({ color, size = 28, selected, className, onMouseEnter, onMouseLeave, ...props }, ref) => {
|
|
1205
|
+
const shape = useShape();
|
|
1206
|
+
const [hovered, setHovered] = useState(false);
|
|
1207
|
+
const ring = selected
|
|
1208
|
+
? "inset 0 0 0 1px rgba(127,127,127,0.25), 0 0 0 2px var(--background), 0 0 0 4px #6B97FF"
|
|
1209
|
+
: hovered
|
|
1210
|
+
? "inset 0 0 0 1px rgba(127,127,127,0.25), 0 0 0 2px var(--background), 0 0 0 4px rgba(127,127,127,0.4)"
|
|
1211
|
+
: "inset 0 0 0 1px rgba(127,127,127,0.25)";
|
|
1212
|
+
return (
|
|
1213
|
+
<button
|
|
1214
|
+
ref={ref}
|
|
1215
|
+
type="button"
|
|
1216
|
+
aria-label={`Select color ${color}`}
|
|
1217
|
+
className={cn(
|
|
1218
|
+
"relative shrink-0 overflow-hidden cursor-pointer outline-none transition-shadow duration-100",
|
|
1219
|
+
shape.bg,
|
|
1220
|
+
className,
|
|
1221
|
+
)}
|
|
1222
|
+
style={{
|
|
1223
|
+
width: size,
|
|
1224
|
+
height: size,
|
|
1225
|
+
...CHECKER_BG,
|
|
1226
|
+
boxShadow: ring,
|
|
1227
|
+
}}
|
|
1228
|
+
onMouseEnter={(e) => {
|
|
1229
|
+
setHovered(true);
|
|
1230
|
+
onMouseEnter?.(e);
|
|
1231
|
+
}}
|
|
1232
|
+
onMouseLeave={(e) => {
|
|
1233
|
+
setHovered(false);
|
|
1234
|
+
onMouseLeave?.(e);
|
|
1235
|
+
}}
|
|
1236
|
+
{...props}
|
|
1237
|
+
>
|
|
1238
|
+
<span className="absolute inset-0" style={{ backgroundColor: color }} />
|
|
1239
|
+
</button>
|
|
1240
|
+
);
|
|
1241
|
+
},
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
ColorSwatch.displayName = "ColorSwatch";
|
|
1245
|
+
|
|
1246
|
+
// ---------------------------------------------------------------------------
|
|
1247
|
+
// SwatchStrip
|
|
1248
|
+
// ---------------------------------------------------------------------------
|
|
1249
|
+
|
|
1250
|
+
function SwatchStrip({
|
|
1251
|
+
swatches,
|
|
1252
|
+
current,
|
|
1253
|
+
onPick,
|
|
1254
|
+
}: {
|
|
1255
|
+
swatches: string[];
|
|
1256
|
+
current: string;
|
|
1257
|
+
onPick: (color: string) => void;
|
|
1258
|
+
}) {
|
|
1259
|
+
const normalizedCurrent = useMemo(() => {
|
|
1260
|
+
const p = parseColor(current);
|
|
1261
|
+
return p ? rgbToHexStr(p.r, p.g, p.b, p.a).toLowerCase() : "";
|
|
1262
|
+
}, [current]);
|
|
1263
|
+
|
|
1264
|
+
return (
|
|
1265
|
+
<div className="flex flex-wrap gap-2">
|
|
1266
|
+
{swatches.map((sw, i) => {
|
|
1267
|
+
const parsed = parseColor(sw);
|
|
1268
|
+
const normalized = parsed
|
|
1269
|
+
? rgbToHexStr(parsed.r, parsed.g, parsed.b, parsed.a).toLowerCase()
|
|
1270
|
+
: sw.toLowerCase();
|
|
1271
|
+
const isSelected = normalized === normalizedCurrent;
|
|
1272
|
+
return (
|
|
1273
|
+
<ColorSwatch
|
|
1274
|
+
key={`${sw}-${i}`}
|
|
1275
|
+
color={sw}
|
|
1276
|
+
size={28}
|
|
1277
|
+
selected={isSelected}
|
|
1278
|
+
onClick={() => onPick(sw)}
|
|
1279
|
+
/>
|
|
1280
|
+
);
|
|
1281
|
+
})}
|
|
1282
|
+
</div>
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// ---------------------------------------------------------------------------
|
|
1287
|
+
// ColorPicker (panel)
|
|
1288
|
+
// ---------------------------------------------------------------------------
|
|
1289
|
+
|
|
1290
|
+
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|
1291
|
+
(
|
|
1292
|
+
{
|
|
1293
|
+
value,
|
|
1294
|
+
defaultValue = "#6B97FF",
|
|
1295
|
+
onChange,
|
|
1296
|
+
onValueChange,
|
|
1297
|
+
ariaLabel,
|
|
1298
|
+
format,
|
|
1299
|
+
defaultFormat = "hex",
|
|
1300
|
+
onFormatChange,
|
|
1301
|
+
swatches,
|
|
1302
|
+
presets,
|
|
1303
|
+
hideEyedropper,
|
|
1304
|
+
formatOpen,
|
|
1305
|
+
defaultFormatOpen,
|
|
1306
|
+
className,
|
|
1307
|
+
...props
|
|
1308
|
+
},
|
|
1309
|
+
ref,
|
|
1310
|
+
) => {
|
|
1311
|
+
const isControlled = value !== undefined;
|
|
1312
|
+
const [internalValue, setInternalValue] = useState(value ?? defaultValue);
|
|
1313
|
+
const currentRawValue = isControlled ? (value as string) : internalValue;
|
|
1314
|
+
|
|
1315
|
+
const isFormatControlled = format !== undefined;
|
|
1316
|
+
const [internalFormat, setInternalFormat] = useState<ColorFormat>(defaultFormat);
|
|
1317
|
+
const currentFormat = isFormatControlled ? (format as ColorFormat) : internalFormat;
|
|
1318
|
+
|
|
1319
|
+
// Internal HSV state (canonical). H is preserved across S=0 / V=0 transitions.
|
|
1320
|
+
const initialParsed = useMemo(() => {
|
|
1321
|
+
const p = parseColor(currentRawValue);
|
|
1322
|
+
if (!p) return { h: 0, s: 1, v: 1, a: 1 };
|
|
1323
|
+
const hsv = rgbToHsv(p.r, p.g, p.b);
|
|
1324
|
+
return { h: hsv.s === 0 ? 0 : hsv.h, s: hsv.s, v: hsv.v, a: p.a };
|
|
1325
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1326
|
+
|
|
1327
|
+
const [hsv, setHsv] = useState(initialParsed);
|
|
1328
|
+
|
|
1329
|
+
// Sticky OKLCH hue: preserves the user's stated OKLCH H across the lossy
|
|
1330
|
+
// RGB round-trip (so the displayed H doesn't drift after release) and
|
|
1331
|
+
// across achromatic colors (where RGB-derived H would collapse to 0).
|
|
1332
|
+
// Cleared whenever the color changes through a non-OKLCH-internal channel.
|
|
1333
|
+
const oklchHueRef = useRef<number | null>(null);
|
|
1334
|
+
|
|
1335
|
+
// External value sync — when controlled value changes from outside, sync HSV
|
|
1336
|
+
const lastEmittedRef = useRef<string>("");
|
|
1337
|
+
useEffect(() => {
|
|
1338
|
+
if (!isControlled) return;
|
|
1339
|
+
const emitted = lastEmittedRef.current;
|
|
1340
|
+
const cur = value as string;
|
|
1341
|
+
if (cur === emitted) return;
|
|
1342
|
+
const p = parseColor(cur);
|
|
1343
|
+
if (!p) return;
|
|
1344
|
+
oklchHueRef.current = null;
|
|
1345
|
+
const newHsv = rgbToHsv(p.r, p.g, p.b);
|
|
1346
|
+
setHsv((prev) => ({
|
|
1347
|
+
h: newHsv.s === 0 ? prev.h : newHsv.h,
|
|
1348
|
+
s: newHsv.s,
|
|
1349
|
+
v: newHsv.v,
|
|
1350
|
+
a: p.a,
|
|
1351
|
+
}));
|
|
1352
|
+
}, [value, isControlled]);
|
|
1353
|
+
|
|
1354
|
+
const parsed = useMemo(() => buildParsed(hsv.h, hsv.s, hsv.v, hsv.a), [hsv]);
|
|
1355
|
+
|
|
1356
|
+
const updateHsv = useCallback(
|
|
1357
|
+
(next: { h?: number; s?: number; v?: number; a?: number }) => {
|
|
1358
|
+
const merged = { ...hsv, ...next };
|
|
1359
|
+
setHsv(merged);
|
|
1360
|
+
const p = buildParsed(merged.h, merged.s, merged.v, merged.a);
|
|
1361
|
+
const formatted = formatValueByFormat(p, currentFormat);
|
|
1362
|
+
lastEmittedRef.current = formatted;
|
|
1363
|
+
if (!isControlled) setInternalValue(formatted);
|
|
1364
|
+
onChange?.(formatted);
|
|
1365
|
+
onValueChange?.(formatted, p);
|
|
1366
|
+
},
|
|
1367
|
+
[hsv, currentFormat, isControlled, onChange, onValueChange],
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
const handleFormatChange = useCallback(
|
|
1371
|
+
(f: ColorFormat) => {
|
|
1372
|
+
if (!isFormatControlled) setInternalFormat(f);
|
|
1373
|
+
onFormatChange?.(f);
|
|
1374
|
+
// Re-emit value in new format
|
|
1375
|
+
const formatted = formatValueByFormat(parsed, f);
|
|
1376
|
+
lastEmittedRef.current = formatted;
|
|
1377
|
+
if (!isControlled) setInternalValue(formatted);
|
|
1378
|
+
onChange?.(formatted);
|
|
1379
|
+
onValueChange?.(formatted, parsed);
|
|
1380
|
+
},
|
|
1381
|
+
[isFormatControlled, isControlled, onFormatChange, onChange, onValueChange, parsed],
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
const handleHexCommit = useCallback(
|
|
1385
|
+
(input: string) => {
|
|
1386
|
+
const p = parseColor(input);
|
|
1387
|
+
if (!p) return;
|
|
1388
|
+
oklchHueRef.current = null;
|
|
1389
|
+
const newHsv = rgbToHsv(p.r, p.g, p.b);
|
|
1390
|
+
const merged = {
|
|
1391
|
+
h: newHsv.s === 0 ? hsv.h : newHsv.h,
|
|
1392
|
+
s: newHsv.s,
|
|
1393
|
+
v: newHsv.v,
|
|
1394
|
+
a: p.a,
|
|
1395
|
+
};
|
|
1396
|
+
setHsv(merged);
|
|
1397
|
+
const next = buildParsed(merged.h, merged.s, merged.v, merged.a);
|
|
1398
|
+
const formatted = formatValueByFormat(next, currentFormat);
|
|
1399
|
+
lastEmittedRef.current = formatted;
|
|
1400
|
+
if (!isControlled) setInternalValue(formatted);
|
|
1401
|
+
onChange?.(formatted);
|
|
1402
|
+
onValueChange?.(formatted, next);
|
|
1403
|
+
},
|
|
1404
|
+
[hsv.h, currentFormat, isControlled, onChange, onValueChange],
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
const handleSwatchPick = useCallback(
|
|
1408
|
+
(sw: string) => {
|
|
1409
|
+
handleHexCommit(sw);
|
|
1410
|
+
},
|
|
1411
|
+
[handleHexCommit],
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
const handleEyedrop = useCallback(
|
|
1415
|
+
(hex: string) => {
|
|
1416
|
+
handleHexCommit(hex);
|
|
1417
|
+
},
|
|
1418
|
+
[handleHexCommit],
|
|
1419
|
+
);
|
|
1420
|
+
|
|
1421
|
+
const solidHueRgb = useMemo(() => hsvToRgb(hsv.h, hsv.s, hsv.v), [hsv.h, hsv.s, hsv.v]);
|
|
1422
|
+
const solidR = Math.round(solidHueRgb.r);
|
|
1423
|
+
const solidG = Math.round(solidHueRgb.g);
|
|
1424
|
+
const solidB = Math.round(solidHueRgb.b);
|
|
1425
|
+
const solidColorString = `rgb(${solidR}, ${solidG}, ${solidB})`;
|
|
1426
|
+
const shape = useShape();
|
|
1427
|
+
const substrate = useSurface();
|
|
1428
|
+
// The picker panel uses bg-card (surface-3) by default; when wrapped in
|
|
1429
|
+
// ColorPickerPopover the className override pushes it higher. Either way,
|
|
1430
|
+
// announce the panel's effective level so descendants (FormatDropdown,
|
|
1431
|
+
// etc.) elevate above it instead of colliding at the same surface.
|
|
1432
|
+
const pickerLevel = Math.max(substrate, 3);
|
|
1433
|
+
|
|
1434
|
+
return (
|
|
1435
|
+
<SurfaceProvider value={pickerLevel}>
|
|
1436
|
+
<div
|
|
1437
|
+
ref={ref}
|
|
1438
|
+
aria-label={ariaLabel}
|
|
1439
|
+
className={cn(
|
|
1440
|
+
"flex flex-col gap-2 p-3",
|
|
1441
|
+
surfaceClasses(pickerLevel, 1),
|
|
1442
|
+
shape.container,
|
|
52
1443
|
className,
|
|
53
1444
|
)}
|
|
1445
|
+
style={{ width: PANEL_WIDTH }}
|
|
1446
|
+
{...props}
|
|
54
1447
|
>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1448
|
+
<SaturationSquare
|
|
1449
|
+
h={hsv.h}
|
|
1450
|
+
s={hsv.s}
|
|
1451
|
+
v={hsv.v}
|
|
1452
|
+
onChange={(s, v) => updateHsv({ s, v })}
|
|
59
1453
|
/>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
1454
|
+
|
|
1455
|
+
<div className="flex flex-col [&>*]:mb-0 [&>*+*]:-mt-px">
|
|
1456
|
+
<HueSlider
|
|
1457
|
+
h={hsv.h}
|
|
1458
|
+
onChange={(h) => {
|
|
1459
|
+
oklchHueRef.current = null;
|
|
1460
|
+
updateHsv({ h });
|
|
1461
|
+
}}
|
|
1462
|
+
/>
|
|
1463
|
+
<AlphaSlider
|
|
1464
|
+
a={hsv.a}
|
|
1465
|
+
solidColor={solidColorString}
|
|
1466
|
+
solidR={solidR}
|
|
1467
|
+
solidG={solidG}
|
|
1468
|
+
solidB={solidB}
|
|
1469
|
+
onChange={(a) => updateHsv({ a })}
|
|
1470
|
+
/>
|
|
1471
|
+
</div>
|
|
1472
|
+
|
|
1473
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1474
|
+
<FormatDropdown
|
|
1475
|
+
value={currentFormat}
|
|
1476
|
+
onChange={handleFormatChange}
|
|
1477
|
+
{...(formatOpen !== undefined ? { open: formatOpen } : {})}
|
|
1478
|
+
{...(defaultFormatOpen !== undefined ? { defaultOpen: defaultFormatOpen } : {})}
|
|
1479
|
+
/>
|
|
1480
|
+
{!hideEyedropper && <EyeDropperButton onPick={handleEyedrop} />}
|
|
1481
|
+
</div>
|
|
1482
|
+
|
|
1483
|
+
<ColorInputsRow
|
|
1484
|
+
parsed={parsed}
|
|
1485
|
+
format={currentFormat}
|
|
1486
|
+
oklchHue={oklchHueRef.current}
|
|
1487
|
+
onChannelChange={(channel, value) => {
|
|
1488
|
+
const p = { ...parsed };
|
|
1489
|
+
switch (channel) {
|
|
1490
|
+
case "hex":
|
|
1491
|
+
handleHexCommit(value as string);
|
|
1492
|
+
return;
|
|
1493
|
+
case "r":
|
|
1494
|
+
case "g":
|
|
1495
|
+
case "b": {
|
|
1496
|
+
oklchHueRef.current = null;
|
|
1497
|
+
const r = channel === "r" ? Number(value) : p.r;
|
|
1498
|
+
const g = channel === "g" ? Number(value) : p.g;
|
|
1499
|
+
const b = channel === "b" ? Number(value) : p.b;
|
|
1500
|
+
const hsvVal = rgbToHsv(r, g, b);
|
|
1501
|
+
updateHsv({
|
|
1502
|
+
h: hsvVal.s === 0 ? hsv.h : hsvVal.h,
|
|
1503
|
+
s: hsvVal.s,
|
|
1504
|
+
v: hsvVal.v,
|
|
1505
|
+
});
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
case "hSL":
|
|
1509
|
+
case "sSL":
|
|
1510
|
+
case "lSL": {
|
|
1511
|
+
if (channel === "hSL") oklchHueRef.current = null;
|
|
1512
|
+
const hsl = rgbToHsl(p.r, p.g, p.b);
|
|
1513
|
+
const h2 = channel === "hSL" ? Number(value) : hsl.h;
|
|
1514
|
+
const s2 = channel === "sSL" ? Number(value) / 100 : hsl.s;
|
|
1515
|
+
const l2 = channel === "lSL" ? Number(value) / 100 : hsl.l;
|
|
1516
|
+
const rgb = hslToRgb(h2, clamp01(s2), clamp01(l2));
|
|
1517
|
+
const hsvVal = rgbToHsv(rgb.r, rgb.g, rgb.b);
|
|
1518
|
+
updateHsv({
|
|
1519
|
+
h: hsvVal.s === 0 ? h2 : hsvVal.h,
|
|
1520
|
+
s: hsvVal.s,
|
|
1521
|
+
v: hsvVal.v,
|
|
1522
|
+
});
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
case "L":
|
|
1526
|
+
case "C":
|
|
1527
|
+
case "H": {
|
|
1528
|
+
const cur = rgbToOklch(p.r, p.g, p.b);
|
|
1529
|
+
// For L/C edits, anchor on the user's last stated H so we
|
|
1530
|
+
// don't drift along with chroma changes.
|
|
1531
|
+
const baseH = oklchHueRef.current ?? cur.H;
|
|
1532
|
+
const L = channel === "L" ? Number(value) / 100 : cur.L;
|
|
1533
|
+
const C = channel === "C" ? Number(value) : cur.C;
|
|
1534
|
+
const H = channel === "H" ? Number(value) : baseH;
|
|
1535
|
+
oklchHueRef.current = H;
|
|
1536
|
+
const rgb = oklchToRgb(clamp01(L), Math.max(0, C), H);
|
|
1537
|
+
const hsvVal = rgbToHsv(rgb.r, rgb.g, rgb.b);
|
|
1538
|
+
updateHsv({
|
|
1539
|
+
h: hsvVal.s === 0 ? hsv.h : hsvVal.h,
|
|
1540
|
+
s: hsvVal.s,
|
|
1541
|
+
v: hsvVal.v,
|
|
1542
|
+
});
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
case "alphaPercent": {
|
|
1546
|
+
const a = clamp01(Number(value) / 100);
|
|
1547
|
+
updateHsv({ a });
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
81
1551
|
}}
|
|
82
|
-
className="h-9 flex-1 rounded-md border border-border bg-card px-2 caption text-foreground focus:outline-none focus:border-brand-primary"
|
|
83
1552
|
/>
|
|
1553
|
+
|
|
1554
|
+
{(swatches ?? presets) && (swatches ?? presets)!.length > 0 && (
|
|
1555
|
+
<SwatchStrip
|
|
1556
|
+
swatches={(swatches ?? presets)!}
|
|
1557
|
+
current={parsed.hex}
|
|
1558
|
+
onPick={handleSwatchPick}
|
|
1559
|
+
/>
|
|
1560
|
+
)}
|
|
84
1561
|
</div>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1562
|
+
</SurfaceProvider>
|
|
1563
|
+
);
|
|
1564
|
+
},
|
|
1565
|
+
);
|
|
1566
|
+
|
|
1567
|
+
ColorPicker.displayName = "ColorPicker";
|
|
1568
|
+
|
|
1569
|
+
// ---------------------------------------------------------------------------
|
|
1570
|
+
// ColorInputsRow — adapts inputs to format
|
|
1571
|
+
// ---------------------------------------------------------------------------
|
|
1572
|
+
|
|
1573
|
+
type ChannelKey =
|
|
1574
|
+
| "hex"
|
|
1575
|
+
| "r"
|
|
1576
|
+
| "g"
|
|
1577
|
+
| "b"
|
|
1578
|
+
| "hSL"
|
|
1579
|
+
| "sSL"
|
|
1580
|
+
| "lSL"
|
|
1581
|
+
| "L"
|
|
1582
|
+
| "C"
|
|
1583
|
+
| "H"
|
|
1584
|
+
| "alphaPercent";
|
|
1585
|
+
|
|
1586
|
+
function ColorInputsRow({
|
|
1587
|
+
parsed,
|
|
1588
|
+
format,
|
|
1589
|
+
oklchHue,
|
|
1590
|
+
onChannelChange,
|
|
1591
|
+
}: {
|
|
1592
|
+
parsed: ParsedColor;
|
|
1593
|
+
format: ColorFormat;
|
|
1594
|
+
/** Sticky OKLCH hue override for display (preserves user's stated H across round-trip drift). */
|
|
1595
|
+
oklchHue?: number | null;
|
|
1596
|
+
onChannelChange: (key: ChannelKey, value: string) => void;
|
|
1597
|
+
}) {
|
|
1598
|
+
const alphaPct = Math.round(parsed.a * 100);
|
|
1599
|
+
|
|
1600
|
+
if (format === "hex") {
|
|
1601
|
+
const hexNoHash = parsed.hex.replace(/^#/, "").toUpperCase();
|
|
1602
|
+
return (
|
|
1603
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1604
|
+
<ChannelTooltip label="Hex">
|
|
1605
|
+
<ColorInput
|
|
1606
|
+
value={hexNoHash}
|
|
1607
|
+
onCommit={(next) => onChannelChange("hex", next.startsWith("#") ? next : `#${next}`)}
|
|
1608
|
+
ariaLabel="Hex value"
|
|
1609
|
+
prefix="#"
|
|
1610
|
+
/>
|
|
1611
|
+
</ChannelTooltip>
|
|
1612
|
+
<AlphaInput value={alphaPct} onCommit={(n) => onChannelChange("alphaPercent", String(n))} />
|
|
1613
|
+
</div>
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (format === "rgb") {
|
|
1618
|
+
return (
|
|
1619
|
+
<div className="grid grid-cols-4 gap-1">
|
|
1620
|
+
<ChannelTooltip label="Red">
|
|
1621
|
+
<ColorInput
|
|
1622
|
+
value={String(parsed.r)}
|
|
1623
|
+
onCommit={(n) => onChannelChange("r", n)}
|
|
1624
|
+
ariaLabel="Red"
|
|
1625
|
+
align="center"
|
|
1626
|
+
inputMode="numeric"
|
|
1627
|
+
nudgeStep={1}
|
|
1628
|
+
nudgeShiftStep={10}
|
|
1629
|
+
scrubbable
|
|
1630
|
+
min={0}
|
|
1631
|
+
max={255}
|
|
1632
|
+
/>
|
|
1633
|
+
</ChannelTooltip>
|
|
1634
|
+
<ChannelTooltip label="Green">
|
|
1635
|
+
<ColorInput
|
|
1636
|
+
value={String(parsed.g)}
|
|
1637
|
+
onCommit={(n) => onChannelChange("g", n)}
|
|
1638
|
+
ariaLabel="Green"
|
|
1639
|
+
align="center"
|
|
1640
|
+
inputMode="numeric"
|
|
1641
|
+
nudgeStep={1}
|
|
1642
|
+
nudgeShiftStep={10}
|
|
1643
|
+
scrubbable
|
|
1644
|
+
min={0}
|
|
1645
|
+
max={255}
|
|
1646
|
+
/>
|
|
1647
|
+
</ChannelTooltip>
|
|
1648
|
+
<ChannelTooltip label="Blue">
|
|
1649
|
+
<ColorInput
|
|
1650
|
+
value={String(parsed.b)}
|
|
1651
|
+
onCommit={(n) => onChannelChange("b", n)}
|
|
1652
|
+
ariaLabel="Blue"
|
|
1653
|
+
align="center"
|
|
1654
|
+
inputMode="numeric"
|
|
1655
|
+
nudgeStep={1}
|
|
1656
|
+
nudgeShiftStep={10}
|
|
1657
|
+
scrubbable
|
|
1658
|
+
min={0}
|
|
1659
|
+
max={255}
|
|
1660
|
+
/>
|
|
1661
|
+
</ChannelTooltip>
|
|
1662
|
+
<AlphaInput value={alphaPct} onCommit={(n) => onChannelChange("alphaPercent", String(n))} />
|
|
1663
|
+
</div>
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (format === "hsl") {
|
|
1668
|
+
const hsl = rgbToHsl(parsed.r, parsed.g, parsed.b);
|
|
1669
|
+
return (
|
|
1670
|
+
<div className="grid grid-cols-4 gap-1">
|
|
1671
|
+
<ChannelTooltip label="Hue">
|
|
1672
|
+
<ColorInput
|
|
1673
|
+
value={String(Math.round(hsl.h))}
|
|
1674
|
+
onCommit={(n) => onChannelChange("hSL", n)}
|
|
1675
|
+
ariaLabel="Hue"
|
|
1676
|
+
align="center"
|
|
1677
|
+
inputMode="numeric"
|
|
1678
|
+
nudgeStep={1}
|
|
1679
|
+
nudgeShiftStep={10}
|
|
1680
|
+
scrubbable
|
|
1681
|
+
min={0}
|
|
1682
|
+
max={360}
|
|
1683
|
+
wrap
|
|
1684
|
+
/>
|
|
1685
|
+
</ChannelTooltip>
|
|
1686
|
+
<ChannelTooltip label="Saturation">
|
|
1687
|
+
<ColorInput
|
|
1688
|
+
value={String(Math.round(hsl.s * 100))}
|
|
1689
|
+
onCommit={(n) => onChannelChange("sSL", n)}
|
|
1690
|
+
ariaLabel="Saturation"
|
|
1691
|
+
align="center"
|
|
1692
|
+
inputMode="numeric"
|
|
1693
|
+
nudgeStep={1}
|
|
1694
|
+
nudgeShiftStep={10}
|
|
1695
|
+
scrubbable
|
|
1696
|
+
min={0}
|
|
1697
|
+
max={100}
|
|
1698
|
+
/>
|
|
1699
|
+
</ChannelTooltip>
|
|
1700
|
+
<ChannelTooltip label="Lightness">
|
|
1701
|
+
<ColorInput
|
|
1702
|
+
value={String(Math.round(hsl.l * 100))}
|
|
1703
|
+
onCommit={(n) => onChannelChange("lSL", n)}
|
|
1704
|
+
ariaLabel="Lightness"
|
|
1705
|
+
align="center"
|
|
1706
|
+
inputMode="numeric"
|
|
1707
|
+
nudgeStep={1}
|
|
1708
|
+
nudgeShiftStep={10}
|
|
1709
|
+
scrubbable
|
|
1710
|
+
min={0}
|
|
1711
|
+
max={100}
|
|
1712
|
+
/>
|
|
1713
|
+
</ChannelTooltip>
|
|
1714
|
+
<AlphaInput value={alphaPct} onCommit={(n) => onChannelChange("alphaPercent", String(n))} />
|
|
1715
|
+
</div>
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// oklch
|
|
1720
|
+
const oklch = rgbToOklch(parsed.r, parsed.g, parsed.b);
|
|
1721
|
+
const displayH = oklchHue ?? oklch.H;
|
|
1722
|
+
return (
|
|
1723
|
+
<div className="grid grid-cols-4 gap-1">
|
|
1724
|
+
<ChannelTooltip label="Lightness">
|
|
1725
|
+
<ColorInput
|
|
1726
|
+
value={(oklch.L * 100).toFixed(0)}
|
|
1727
|
+
onCommit={(n) => onChannelChange("L", n)}
|
|
1728
|
+
ariaLabel="Lightness"
|
|
1729
|
+
align="center"
|
|
1730
|
+
inputMode="decimal"
|
|
1731
|
+
nudgeStep={1}
|
|
1732
|
+
nudgeShiftStep={10}
|
|
1733
|
+
scrubbable
|
|
1734
|
+
min={0}
|
|
1735
|
+
max={100}
|
|
1736
|
+
/>
|
|
1737
|
+
</ChannelTooltip>
|
|
1738
|
+
<ChannelTooltip label="Chroma">
|
|
1739
|
+
<ColorInput
|
|
1740
|
+
value={oklch.C.toFixed(2)}
|
|
1741
|
+
onCommit={(n) => onChannelChange("C", n)}
|
|
1742
|
+
ariaLabel="Chroma"
|
|
1743
|
+
align="center"
|
|
1744
|
+
inputMode="decimal"
|
|
1745
|
+
nudgeStep={0.01}
|
|
1746
|
+
nudgeShiftStep={0.1}
|
|
1747
|
+
decimals={2}
|
|
1748
|
+
scrubbable
|
|
1749
|
+
min={0}
|
|
1750
|
+
max={0.4}
|
|
1751
|
+
/>
|
|
1752
|
+
</ChannelTooltip>
|
|
1753
|
+
<ChannelTooltip label="Hue">
|
|
1754
|
+
<ColorInput
|
|
1755
|
+
value={displayH.toFixed(0)}
|
|
1756
|
+
onCommit={(n) => onChannelChange("H", n)}
|
|
1757
|
+
ariaLabel="Hue"
|
|
1758
|
+
align="center"
|
|
1759
|
+
inputMode="numeric"
|
|
1760
|
+
nudgeStep={1}
|
|
1761
|
+
nudgeShiftStep={10}
|
|
1762
|
+
scrubbable
|
|
1763
|
+
min={0}
|
|
1764
|
+
max={360}
|
|
1765
|
+
wrap
|
|
1766
|
+
/>
|
|
1767
|
+
</ChannelTooltip>
|
|
1768
|
+
<AlphaInput value={alphaPct} onCommit={(n) => onChannelChange("alphaPercent", String(n))} />
|
|
1769
|
+
</div>
|
|
101
1770
|
);
|
|
102
1771
|
}
|
|
103
1772
|
|
|
104
|
-
|
|
1773
|
+
function ChannelTooltip({ label, children }: { label: string; children: ReactNode }) {
|
|
1774
|
+
return (
|
|
1775
|
+
<Tooltip content={label} delayDuration={300}>
|
|
1776
|
+
<div>{children}</div>
|
|
1777
|
+
</Tooltip>
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function AlphaInput({ value, onCommit }: { value: number; onCommit: (n: number) => void }) {
|
|
1782
|
+
return (
|
|
1783
|
+
<ChannelTooltip label="Alpha">
|
|
1784
|
+
<ColorInput
|
|
1785
|
+
value={`${value}%`}
|
|
1786
|
+
onCommit={(input) => {
|
|
1787
|
+
const n = parseFloat(input.replace("%", ""));
|
|
1788
|
+
if (Number.isNaN(n)) return;
|
|
1789
|
+
onCommit(Math.max(0, Math.min(100, Math.round(n))));
|
|
1790
|
+
}}
|
|
1791
|
+
ariaLabel="Alpha"
|
|
1792
|
+
align="center"
|
|
1793
|
+
inputMode="numeric"
|
|
1794
|
+
nudgeStep={1}
|
|
1795
|
+
nudgeShiftStep={10}
|
|
1796
|
+
hasPercent
|
|
1797
|
+
scrubbable
|
|
1798
|
+
min={0}
|
|
1799
|
+
max={100}
|
|
1800
|
+
/>
|
|
1801
|
+
</ChannelTooltip>
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// ---------------------------------------------------------------------------
|
|
1806
|
+
// ColorPickerPopover (trigger button + portal panel)
|
|
1807
|
+
// ---------------------------------------------------------------------------
|
|
1808
|
+
|
|
1809
|
+
const ColorPickerPopover = forwardRef<HTMLDivElement, ColorPickerPopoverProps>(
|
|
1810
|
+
(
|
|
1811
|
+
{
|
|
1812
|
+
triggerLabel,
|
|
1813
|
+
triggerLabelPosition = "left",
|
|
1814
|
+
triggerShowValue = true,
|
|
1815
|
+
triggerShowRemove = false,
|
|
1816
|
+
onTriggerRemove,
|
|
1817
|
+
triggerClassName,
|
|
1818
|
+
open: openProp,
|
|
1819
|
+
defaultOpen = false,
|
|
1820
|
+
onOpenChange,
|
|
1821
|
+
...pickerProps
|
|
1822
|
+
},
|
|
1823
|
+
ref,
|
|
1824
|
+
) => {
|
|
1825
|
+
const isOpenControlled = openProp !== undefined;
|
|
1826
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
1827
|
+
const open = isOpenControlled ? openProp : internalOpen;
|
|
1828
|
+
const setOpen: (next: boolean | ((prev: boolean) => boolean)) => void = (next) => {
|
|
1829
|
+
const resolved = typeof next === "function" ? next(open) : next;
|
|
1830
|
+
if (!isOpenControlled) setInternalOpen(resolved);
|
|
1831
|
+
onOpenChange?.(resolved);
|
|
1832
|
+
};
|
|
1833
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
1834
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
1835
|
+
const [panelEl, setPanelEl] = useState<HTMLDivElement | null>(null);
|
|
1836
|
+
const [rect, setRect] = useState<DOMRect | null>(null);
|
|
1837
|
+
const shape = useShape();
|
|
1838
|
+
const substrate = useSurface();
|
|
1839
|
+
const level = Math.min(substrate + 2, 8);
|
|
1840
|
+
|
|
1841
|
+
const isControlled = pickerProps.value !== undefined;
|
|
1842
|
+
const [internalValue, setInternalValue] = useState(
|
|
1843
|
+
pickerProps.value ?? pickerProps.defaultValue ?? "#6B97FF",
|
|
1844
|
+
);
|
|
1845
|
+
const currentValue = isControlled ? (pickerProps.value as string) : internalValue;
|
|
1846
|
+
|
|
1847
|
+
const onValueChange = useCallback(
|
|
1848
|
+
(v: string, parsed: ParsedColor) => {
|
|
1849
|
+
if (!isControlled) setInternalValue(v);
|
|
1850
|
+
pickerProps.onValueChange?.(v, parsed);
|
|
1851
|
+
},
|
|
1852
|
+
[isControlled, pickerProps],
|
|
1853
|
+
);
|
|
1854
|
+
|
|
1855
|
+
useEffect(() => {
|
|
1856
|
+
if (!open || !triggerRef.current) {
|
|
1857
|
+
setRect(null);
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
const update = () => {
|
|
1861
|
+
if (triggerRef.current) {
|
|
1862
|
+
setRect(triggerRef.current.getBoundingClientRect());
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
update();
|
|
1866
|
+
window.addEventListener("scroll", update, { passive: true, capture: true });
|
|
1867
|
+
window.addEventListener("resize", update);
|
|
1868
|
+
return () => {
|
|
1869
|
+
window.removeEventListener("scroll", update, { capture: true } as EventListenerOptions);
|
|
1870
|
+
window.removeEventListener("resize", update);
|
|
1871
|
+
};
|
|
1872
|
+
}, [open]);
|
|
1873
|
+
|
|
1874
|
+
useEffect(() => {
|
|
1875
|
+
if (!open) return;
|
|
1876
|
+
const onClick = (e: MouseEvent) => {
|
|
1877
|
+
if (
|
|
1878
|
+
!panelRef.current?.contains(e.target as Node) &&
|
|
1879
|
+
!triggerRef.current?.contains(e.target as Node)
|
|
1880
|
+
) {
|
|
1881
|
+
setOpen(false);
|
|
1882
|
+
}
|
|
1883
|
+
};
|
|
1884
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1885
|
+
if (e.key === "Escape") setOpen(false);
|
|
1886
|
+
};
|
|
1887
|
+
document.addEventListener("mousedown", onClick);
|
|
1888
|
+
document.addEventListener("keydown", onKey);
|
|
1889
|
+
return () => {
|
|
1890
|
+
document.removeEventListener("mousedown", onClick);
|
|
1891
|
+
document.removeEventListener("keydown", onKey);
|
|
1892
|
+
};
|
|
1893
|
+
}, [open]);
|
|
1894
|
+
|
|
1895
|
+
const XIcon = useIcon("x");
|
|
1896
|
+
const parsed = useMemo(() => parseColor(currentValue), [currentValue]);
|
|
1897
|
+
const swatchColor = parsed ? rgbToHexStr(parsed.r, parsed.g, parsed.b, parsed.a) : currentValue;
|
|
1898
|
+
const valueLabel = parsed
|
|
1899
|
+
? rgbToHexStr(parsed.r, parsed.g, parsed.b, 1).replace(/^#/, "").toUpperCase()
|
|
1900
|
+
: currentValue;
|
|
1901
|
+
|
|
1902
|
+
return (
|
|
1903
|
+
<div ref={ref} className="inline-flex">
|
|
1904
|
+
<button
|
|
1905
|
+
ref={triggerRef}
|
|
1906
|
+
type="button"
|
|
1907
|
+
onClick={() => setOpen((o) => !o)}
|
|
1908
|
+
aria-haspopup="dialog"
|
|
1909
|
+
aria-expanded={open}
|
|
1910
|
+
className={cn(
|
|
1911
|
+
"flex items-center gap-2 h-9 px-2 border border-border bg-transparent hover:bg-hover transition-colors duration-80 outline-none focus-visible:ring-1 focus-visible:ring-[#6B97FF] cursor-pointer",
|
|
1912
|
+
shape.input,
|
|
1913
|
+
triggerClassName,
|
|
1914
|
+
)}
|
|
1915
|
+
style={{ fontVariationSettings: fontWeights.medium }}
|
|
1916
|
+
>
|
|
1917
|
+
{triggerLabel && triggerLabelPosition === "left" && (
|
|
1918
|
+
<span className="text-[13px] text-muted-foreground px-1 select-none">
|
|
1919
|
+
{triggerLabel}
|
|
1920
|
+
</span>
|
|
1921
|
+
)}
|
|
1922
|
+
<ColorTile color={swatchColor} size={20} />
|
|
1923
|
+
{triggerShowValue && (
|
|
1924
|
+
<span className="text-[13px] text-foreground tabular-nums">{valueLabel}</span>
|
|
1925
|
+
)}
|
|
1926
|
+
{triggerLabel && triggerLabelPosition === "right" && (
|
|
1927
|
+
<span className="text-[13px] text-muted-foreground px-1 select-none">
|
|
1928
|
+
{triggerLabel}
|
|
1929
|
+
</span>
|
|
1930
|
+
)}
|
|
1931
|
+
{triggerShowRemove && (
|
|
1932
|
+
<span
|
|
1933
|
+
role="button"
|
|
1934
|
+
aria-label="Remove color"
|
|
1935
|
+
tabIndex={0}
|
|
1936
|
+
onClick={(e) => {
|
|
1937
|
+
e.stopPropagation();
|
|
1938
|
+
onTriggerRemove?.();
|
|
1939
|
+
}}
|
|
1940
|
+
onKeyDown={(e) => {
|
|
1941
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1942
|
+
e.stopPropagation();
|
|
1943
|
+
e.preventDefault();
|
|
1944
|
+
onTriggerRemove?.();
|
|
1945
|
+
}
|
|
1946
|
+
}}
|
|
1947
|
+
className="ml-1 text-muted-foreground hover:text-foreground cursor-pointer flex items-center"
|
|
1948
|
+
>
|
|
1949
|
+
<XIcon size={14} strokeWidth={1.5} />
|
|
1950
|
+
</span>
|
|
1951
|
+
)}
|
|
1952
|
+
</button>
|
|
1953
|
+
{open &&
|
|
1954
|
+
rect &&
|
|
1955
|
+
typeof document !== "undefined" &&
|
|
1956
|
+
createPortal(
|
|
1957
|
+
<div
|
|
1958
|
+
style={{
|
|
1959
|
+
position: "fixed",
|
|
1960
|
+
top: rect.bottom + 6,
|
|
1961
|
+
left: rect.left,
|
|
1962
|
+
zIndex: 50,
|
|
1963
|
+
}}
|
|
1964
|
+
>
|
|
1965
|
+
<AnimatePresence>
|
|
1966
|
+
<motion.div
|
|
1967
|
+
ref={(node) => {
|
|
1968
|
+
(panelRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
1969
|
+
setPanelEl(node);
|
|
1970
|
+
}}
|
|
1971
|
+
initial={{ opacity: 0, y: -4, scaleY: 0.96 }}
|
|
1972
|
+
animate={{ opacity: 1, y: 0, scaleY: 1 }}
|
|
1973
|
+
exit={{ opacity: 0, y: -4, scaleY: 0.96 }}
|
|
1974
|
+
transition={spring.moderate}
|
|
1975
|
+
style={{ transformOrigin: "top left" }}
|
|
1976
|
+
>
|
|
1977
|
+
<ColorPickerPortalContainer value={panelEl}>
|
|
1978
|
+
<SurfaceProvider value={level}>
|
|
1979
|
+
<ColorPicker
|
|
1980
|
+
{...pickerProps}
|
|
1981
|
+
value={currentValue}
|
|
1982
|
+
onValueChange={onValueChange}
|
|
1983
|
+
className={cn(surfaceClasses(level, 3), pickerProps.className)}
|
|
1984
|
+
/>
|
|
1985
|
+
</SurfaceProvider>
|
|
1986
|
+
</ColorPickerPortalContainer>
|
|
1987
|
+
</motion.div>
|
|
1988
|
+
</AnimatePresence>
|
|
1989
|
+
</div>,
|
|
1990
|
+
document.body,
|
|
1991
|
+
)}
|
|
1992
|
+
</div>
|
|
1993
|
+
);
|
|
1994
|
+
},
|
|
1995
|
+
);
|
|
1996
|
+
|
|
1997
|
+
ColorPickerPopover.displayName = "ColorPickerPopover";
|
|
1998
|
+
|
|
1999
|
+
// ---------------------------------------------------------------------------
|
|
2000
|
+
// Exports
|
|
2001
|
+
// ---------------------------------------------------------------------------
|
|
2002
|
+
|
|
2003
|
+
export {
|
|
2004
|
+
ColorPicker,
|
|
2005
|
+
ColorPickerPopover,
|
|
2006
|
+
ColorPickerPortalContainer,
|
|
2007
|
+
ColorSwatch,
|
|
2008
|
+
ColorTile,
|
|
2009
|
+
parseColor,
|
|
2010
|
+
buildParsed,
|
|
2011
|
+
};
|
|
105
2012
|
|
|
106
|
-
export {
|
|
107
|
-
|
|
2013
|
+
export type {
|
|
2014
|
+
ColorPickerProps,
|
|
2015
|
+
ColorPickerPopoverProps,
|
|
2016
|
+
ColorSwatchProps,
|
|
2017
|
+
ColorFormat,
|
|
2018
|
+
ParsedColor,
|
|
2019
|
+
};
|