@varialkit/colorpicker 0.1.1

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.
@@ -0,0 +1,905 @@
1
+ import React from "react";
2
+ import { Menu, MenuDropdown } from "@solara/menu";
3
+ import { TextField } from "@solara/textfield";
4
+ import type {
5
+ ColorPickerContentProps,
6
+ ColorPickerPaletteEntry,
7
+ ColorPickerPaletteOption,
8
+ ColorPickerPaletteProps,
9
+ ColorPickerProps,
10
+ ColorPickerSwatchProps,
11
+ ColorPickerValueFormat,
12
+ } from "./ColorPicker.types";
13
+ import { ColorPreview } from "./ColorPreview";
14
+ import "./ColorPicker.scss";
15
+
16
+ type RgbaColor = { r: number; g: number; b: number; a: number };
17
+ type HsvaColor = { h: number; s: number; v: number; a: number };
18
+ type SpectrumPoint = { s: number; v: number };
19
+ type EyeDropperResult = { sRGBHex: string };
20
+ type EyeDropperConstructor = new () => { open: () => Promise<EyeDropperResult> };
21
+
22
+ const DEFAULT_COLORS: ColorPickerPaletteEntry[] = [
23
+ "#ef4444",
24
+ "#f97316",
25
+ "#f59e0b",
26
+ "#eab308",
27
+ "#22c55e",
28
+ "#16a34a",
29
+ "#14b8a6",
30
+ "#06b6d4",
31
+ "#3b82f6",
32
+ "#2563eb",
33
+ "#6366f1",
34
+ "#8b5cf6",
35
+ "#a855f7",
36
+ "#ec4899",
37
+ "#f43f5e",
38
+ "#6b7280",
39
+ "#111827",
40
+ "#000000",
41
+ "#ffffff",
42
+ ];
43
+
44
+ const DEFAULT_TRIGGER_LABEL = "Color";
45
+ const DEFAULT_COLOR = "#3b82f6";
46
+ const TEXTFIELD_SIZE_MAP = {
47
+ sm: "small",
48
+ md: "medium",
49
+ lg: "large",
50
+ } as const;
51
+ const VALUE_FORMAT_OPTIONS: { label: string; value: ColorPickerValueFormat }[] = [
52
+ { label: "Hex", value: "hex" },
53
+ { label: "RGBA", value: "rgba" },
54
+ { label: "HSLA", value: "hsla" },
55
+ ];
56
+
57
+ function cx(...values: Array<string | false | null | undefined>) {
58
+ return values.filter(Boolean).join(" ");
59
+ }
60
+
61
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
62
+
63
+ const clamp01 = (value: number) => clamp(value, 0, 1);
64
+
65
+ const round = (value: number, precision = 2) => {
66
+ const factor = 10 ** precision;
67
+ return Math.round(value * factor) / factor;
68
+ };
69
+
70
+ const wrapHue = (value: number) => {
71
+ const wrapped = value % 360;
72
+ return wrapped < 0 ? wrapped + 360 : wrapped;
73
+ };
74
+
75
+ const channelToHex = (value: number) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0");
76
+
77
+ const alphaToHex = (alpha: number) => channelToHex(Math.round(clamp01(alpha) * 255));
78
+
79
+ const stripHexPrefix = (value: string) => value.replace(/^#/, "");
80
+
81
+ const formatAlpha = (alpha: number) => {
82
+ const normalized = round(clamp01(alpha), 3);
83
+ if (normalized === 1 || normalized === 0) return normalized.toString();
84
+ return normalized.toString().replace(/0+$/, "").replace(/\.$/, "");
85
+ };
86
+
87
+ const formatPercent = (value: number) => Math.round(clamp01(value) * 100).toString();
88
+
89
+ const parseChannel = (value: string, max = 255) => {
90
+ if (value.trim().endsWith("%")) {
91
+ const percentage = Number.parseFloat(value);
92
+ if (!Number.isFinite(percentage)) return null;
93
+ return clamp((percentage / 100) * max, 0, max);
94
+ }
95
+ const numeric = Number.parseFloat(value);
96
+ return Number.isFinite(numeric) ? clamp(numeric, 0, max) : null;
97
+ };
98
+
99
+ const parseAlphaValue = (value: string) => {
100
+ if (value.trim().endsWith("%")) {
101
+ const percentage = Number.parseFloat(value);
102
+ if (!Number.isFinite(percentage)) return null;
103
+ return clamp01(percentage / 100);
104
+ }
105
+ const numeric = Number.parseFloat(value);
106
+ return Number.isFinite(numeric) ? clamp01(numeric) : null;
107
+ };
108
+
109
+ const parseHueValue = (value: string) => {
110
+ const numeric = Number.parseFloat(value);
111
+ return Number.isFinite(numeric) ? wrapHue(numeric) : null;
112
+ };
113
+
114
+ const parsePercentUnit = (value: string) => {
115
+ const numeric = Number.parseFloat(value);
116
+ return Number.isFinite(numeric) ? clamp01(numeric / 100) : null;
117
+ };
118
+
119
+ const isValidHexColor = (hex: string): boolean => /^#?([A-Fa-f0-9]{3,4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(hex);
120
+
121
+ const normalizeHexColor = (hex: string): string => {
122
+ const raw = stripHexPrefix(hex.trim());
123
+ if (!isValidHexColor(raw)) return `#${raw.toUpperCase()}`;
124
+
125
+ if (raw.length === 3 || raw.length === 4) {
126
+ const expanded = raw
127
+ .split("")
128
+ .map((char) => `${char}${char}`)
129
+ .join("");
130
+ return `#${expanded.toUpperCase()}`;
131
+ }
132
+
133
+ return `#${raw.toUpperCase()}`;
134
+ };
135
+
136
+ const parseHexColor = (value: string): RgbaColor | null => {
137
+ if (!isValidHexColor(value)) return null;
138
+ const normalized = normalizeHexColor(value);
139
+ const raw = stripHexPrefix(normalized);
140
+ const hasAlpha = raw.length === 8;
141
+
142
+ const r = Number.parseInt(raw.slice(0, 2), 16);
143
+ const g = Number.parseInt(raw.slice(2, 4), 16);
144
+ const b = Number.parseInt(raw.slice(4, 6), 16);
145
+ const a = hasAlpha ? Number.parseInt(raw.slice(6, 8), 16) / 255 : 1;
146
+
147
+ return { r, g, b, a };
148
+ };
149
+
150
+ const parseRgbColor = (value: string): RgbaColor | null => {
151
+ const match = value
152
+ .trim()
153
+ .match(/^rgba?\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,/\s]+)(?:\s*(?:,|\/)\s*([^)]+))?\s*\)$/i);
154
+ if (!match) return null;
155
+
156
+ const r = parseChannel(match[1]);
157
+ const g = parseChannel(match[2]);
158
+ const b = parseChannel(match[3]);
159
+ const a = match[4] ? parseAlphaValue(match[4]) : 1;
160
+
161
+ if (r === null || g === null || b === null || a === null) return null;
162
+ return { r, g, b, a };
163
+ };
164
+
165
+ const parseHslColor = (value: string): RgbaColor | null => {
166
+ const match = value
167
+ .trim()
168
+ .match(/^hsla?\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,/\s]+)(?:\s*(?:,|\/)\s*([^)]+))?\s*\)$/i);
169
+ if (!match) return null;
170
+
171
+ const h = parseHueValue(match[1]);
172
+ const s = parsePercentUnit(match[2]);
173
+ const l = parsePercentUnit(match[3]);
174
+ const a = match[4] ? parseAlphaValue(match[4]) : 1;
175
+
176
+ if (h === null || s === null || l === null || a === null) return null;
177
+
178
+ const c = (1 - Math.abs(2 * l - 1)) * s;
179
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
180
+ const m = l - c / 2;
181
+
182
+ let rPrime = 0;
183
+ let gPrime = 0;
184
+ let bPrime = 0;
185
+
186
+ if (h < 60) {
187
+ rPrime = c;
188
+ gPrime = x;
189
+ } else if (h < 120) {
190
+ rPrime = x;
191
+ gPrime = c;
192
+ } else if (h < 180) {
193
+ gPrime = c;
194
+ bPrime = x;
195
+ } else if (h < 240) {
196
+ gPrime = x;
197
+ bPrime = c;
198
+ } else if (h < 300) {
199
+ rPrime = x;
200
+ bPrime = c;
201
+ } else {
202
+ rPrime = c;
203
+ bPrime = x;
204
+ }
205
+
206
+ return {
207
+ r: Math.round((rPrime + m) * 255),
208
+ g: Math.round((gPrime + m) * 255),
209
+ b: Math.round((bPrime + m) * 255),
210
+ a,
211
+ };
212
+ };
213
+
214
+ const parseColorString = (value?: string): RgbaColor | null => {
215
+ if (!value) return null;
216
+ return parseHexColor(value) ?? parseRgbColor(value) ?? parseHslColor(value);
217
+ };
218
+
219
+ const rgbaToHsva = ({ r, g, b, a }: RgbaColor): HsvaColor => {
220
+ const red = r / 255;
221
+ const green = g / 255;
222
+ const blue = b / 255;
223
+ const max = Math.max(red, green, blue);
224
+ const min = Math.min(red, green, blue);
225
+ const delta = max - min;
226
+
227
+ let h = 0;
228
+ if (delta !== 0) {
229
+ if (max === red) {
230
+ h = 60 * (((green - blue) / delta) % 6);
231
+ } else if (max === green) {
232
+ h = 60 * ((blue - red) / delta + 2);
233
+ } else {
234
+ h = 60 * ((red - green) / delta + 4);
235
+ }
236
+ }
237
+
238
+ return {
239
+ h: wrapHue(h),
240
+ s: max === 0 ? 0 : delta / max,
241
+ v: max,
242
+ a: clamp01(a),
243
+ };
244
+ };
245
+
246
+ const hsvaToRgba = ({ h, s, v, a }: HsvaColor): RgbaColor => {
247
+ const hue = wrapHue(h);
248
+ const saturation = clamp01(s);
249
+ const value = clamp01(v);
250
+ const chroma = value * saturation;
251
+ const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
252
+ const m = value - chroma;
253
+
254
+ let rPrime = 0;
255
+ let gPrime = 0;
256
+ let bPrime = 0;
257
+
258
+ if (hue < 60) {
259
+ rPrime = chroma;
260
+ gPrime = x;
261
+ } else if (hue < 120) {
262
+ rPrime = x;
263
+ gPrime = chroma;
264
+ } else if (hue < 180) {
265
+ gPrime = chroma;
266
+ bPrime = x;
267
+ } else if (hue < 240) {
268
+ gPrime = x;
269
+ bPrime = chroma;
270
+ } else if (hue < 300) {
271
+ rPrime = x;
272
+ bPrime = chroma;
273
+ } else {
274
+ rPrime = chroma;
275
+ bPrime = x;
276
+ }
277
+
278
+ return {
279
+ r: Math.round((rPrime + m) * 255),
280
+ g: Math.round((gPrime + m) * 255),
281
+ b: Math.round((bPrime + m) * 255),
282
+ a: clamp01(a),
283
+ };
284
+ };
285
+
286
+ const rgbaToHsla = ({ r, g, b, a }: RgbaColor) => {
287
+ const red = r / 255;
288
+ const green = g / 255;
289
+ const blue = b / 255;
290
+ const max = Math.max(red, green, blue);
291
+ const min = Math.min(red, green, blue);
292
+ const delta = max - min;
293
+ const lightness = (max + min) / 2;
294
+
295
+ let hue = 0;
296
+ let saturation = 0;
297
+
298
+ if (delta !== 0) {
299
+ saturation = delta / (1 - Math.abs(2 * lightness - 1));
300
+
301
+ if (max === red) {
302
+ hue = 60 * (((green - blue) / delta) % 6);
303
+ } else if (max === green) {
304
+ hue = 60 * ((blue - red) / delta + 2);
305
+ } else {
306
+ hue = 60 * ((red - green) / delta + 4);
307
+ }
308
+ }
309
+
310
+ return {
311
+ h: wrapHue(hue),
312
+ s: clamp01(Number.isFinite(saturation) ? saturation : 0),
313
+ l: clamp01(lightness),
314
+ a: clamp01(a),
315
+ };
316
+ };
317
+
318
+ const rgbaToComparableHex = (color: RgbaColor) =>
319
+ `#${channelToHex(color.r)}${channelToHex(color.g)}${channelToHex(color.b)}`.toUpperCase();
320
+
321
+ const formatColorValue = (rgba: RgbaColor, format: ColorPickerValueFormat) => {
322
+ if (format === "rgba") {
323
+ return `rgba(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)}, ${formatAlpha(rgba.a)})`;
324
+ }
325
+
326
+ if (format === "hsla") {
327
+ const hsla = rgbaToHsla(rgba);
328
+ return `hsla(${Math.round(hsla.h)}, ${Math.round(hsla.s * 100)}%, ${Math.round(hsla.l * 100)}%, ${formatAlpha(
329
+ hsla.a
330
+ )})`;
331
+ }
332
+
333
+ const alphaSuffix = rgba.a < 1 ? alphaToHex(rgba.a) : "";
334
+ return `#${channelToHex(rgba.r)}${channelToHex(rgba.g)}${channelToHex(rgba.b)}${alphaSuffix}`.toUpperCase();
335
+ };
336
+
337
+ const getValueInputString = (hsva: HsvaColor, format: ColorPickerValueFormat) =>
338
+ formatColorValue(hsvaToRgba(hsva), format);
339
+
340
+ const toComparableColor = (value?: string) => {
341
+ const parsed = parseColorString(value);
342
+ return parsed ? rgbaToComparableHex(parsed) : value?.trim().toUpperCase();
343
+ };
344
+
345
+ const getResolvedHsva = (value?: string) => rgbaToHsva(parseColorString(value) ?? parseHexColor(DEFAULT_COLOR)!);
346
+
347
+ const getSpectrumPoint = (event: React.PointerEvent<HTMLDivElement> | PointerEvent, element: HTMLDivElement) => {
348
+ const rect = element.getBoundingClientRect();
349
+ const s = clamp01((event.clientX - rect.left) / rect.width);
350
+ const v = clamp01(1 - (event.clientY - rect.top) / rect.height);
351
+ return { s, v };
352
+ };
353
+
354
+ // Normalize palette entries so the palette can accept both strings and objects.
355
+ const normalizePaletteEntry = (entry: ColorPickerPaletteEntry): ColorPickerPaletteOption => {
356
+ if (typeof entry === "string") {
357
+ return { value: entry, label: entry };
358
+ }
359
+ return entry;
360
+ };
361
+
362
+ function useControllableOpenState({
363
+ isOpen,
364
+ defaultOpen,
365
+ onOpenChange,
366
+ }: Pick<ColorPickerProps, "isOpen" | "defaultOpen" | "onOpenChange">) {
367
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(Boolean(defaultOpen));
368
+ const open = isOpen ?? uncontrolledOpen;
369
+
370
+ const setOpen = React.useCallback(
371
+ (next: boolean) => {
372
+ if (isOpen === undefined) setUncontrolledOpen(next);
373
+ onOpenChange?.(next);
374
+ },
375
+ [isOpen, onOpenChange]
376
+ );
377
+
378
+ return [open, setOpen] as const;
379
+ }
380
+
381
+ function EyeDropperIcon() {
382
+ return (
383
+ <svg viewBox="0 0 16 16" aria-hidden="true" className="solara-colorpicker__eyedropper-icon">
384
+ <path
385
+ d="M10.84 1.47a1.5 1.5 0 0 1 2.12 0l1.57 1.57a1.5 1.5 0 0 1 0 2.12l-1.3 1.3-.93-.93-1.06 1.06 1.33 1.33-5.9 5.9H4.54v-2.12l5.9-5.9 1.33 1.33 1.06-1.06-.93-.93 1.3-1.3a1.5 1.5 0 0 1-2.36-2.2ZM3.5 12.85v.65h.65l1.77-1.77-1.3-1.3-1.12 1.12c-.32.32-.5.75-.5 1.3Z"
386
+ fill="currentColor"
387
+ />
388
+ </svg>
389
+ );
390
+ }
391
+
392
+ export function ColorPickerSwatch({
393
+ color,
394
+ size = "md",
395
+ radius,
396
+ selected = false,
397
+ disabled = false,
398
+ className,
399
+ children,
400
+ style,
401
+ onClick,
402
+ ...props
403
+ }: ColorPickerSwatchProps) {
404
+ return (
405
+ <button
406
+ type="button"
407
+ className={cx("solara-colorpicker__swatch", className, selected && "is-selected")}
408
+ style={{
409
+ backgroundColor: color,
410
+ ...(radius ? { ["--colorpicker-swatch-radius" as const]: radius } : null),
411
+ ...style,
412
+ }}
413
+ onClick={onClick}
414
+ disabled={disabled}
415
+ aria-label={props["aria-label"] ?? `Select color ${color}`}
416
+ data-selected={selected ? "true" : undefined}
417
+ data-size={size}
418
+ {...props}>
419
+ {children ? <span className="solara-colorpicker__swatch-content">{children}</span> : null}
420
+ </button>
421
+ );
422
+ }
423
+
424
+ export function ColorPickerPalette({
425
+ value,
426
+ colors = DEFAULT_COLORS,
427
+ onSelectColor,
428
+ size = "md",
429
+ disabled = false,
430
+ className,
431
+ swatchRadius,
432
+ swatchContent,
433
+ }: ColorPickerPaletteProps) {
434
+ const normalizedValue = toComparableColor(value) ?? toComparableColor(DEFAULT_COLOR);
435
+ const entries = colors.map(normalizePaletteEntry);
436
+
437
+ return (
438
+ <div className={cx("solara-colorpicker__grid", className)}>
439
+ {entries.map((entry) => {
440
+ const isSelected = normalizedValue === toComparableColor(entry.value);
441
+ const isDisabled = disabled || Boolean(entry.disabled);
442
+ const overlayContent = entry.content ?? (swatchContent ? swatchContent(entry.value) : null);
443
+
444
+ return (
445
+ <ColorPickerSwatch
446
+ key={entry.value}
447
+ color={entry.value}
448
+ size={size}
449
+ radius={swatchRadius}
450
+ selected={isSelected}
451
+ disabled={isDisabled}
452
+ aria-label={entry.label ? `Select color ${entry.label}` : `Select color ${entry.value}`}
453
+ onClick={() => onSelectColor?.(entry.value)}>
454
+ {overlayContent}
455
+ </ColorPickerSwatch>
456
+ );
457
+ })}
458
+ </div>
459
+ );
460
+ }
461
+
462
+ export function ColorPickerContent({
463
+ value,
464
+ onChange,
465
+ onSelectColor,
466
+ presets,
467
+ colors,
468
+ showValueInput,
469
+ showHexInput,
470
+ valueInputPlaceholder,
471
+ hexInputPlaceholder,
472
+ valueFormat = "hex",
473
+ defaultInputFormat,
474
+ inputFormats = VALUE_FORMAT_OPTIONS.map((option) => option.value),
475
+ showSpectrum = false,
476
+ showHueSlider = false,
477
+ showOpacitySlider = false,
478
+ showEyedropper = false,
479
+ showFormatSelector = false,
480
+ showOpacityInput = false,
481
+ size = "md",
482
+ swatchRadius,
483
+ swatchContent,
484
+ className,
485
+ disabled = false,
486
+ }: ColorPickerContentProps) {
487
+ const spectrumRef = React.useRef<HTMLDivElement>(null);
488
+ const [hsva, setHsva] = React.useState<HsvaColor>(() => getResolvedHsva(value));
489
+ const [inputFormat, setInputFormat] = React.useState<ColorPickerValueFormat>(
490
+ defaultInputFormat ?? valueFormat
491
+ );
492
+ const [valueInput, setValueInput] = React.useState(() => getValueInputString(getResolvedHsva(value), inputFormat));
493
+ const [opacityInput, setOpacityInput] = React.useState(() => formatPercent(getResolvedHsva(value).a));
494
+
495
+ const resolvedPresets = presets ?? colors ?? DEFAULT_COLORS;
496
+ const shouldShowValueInput = showValueInput ?? showHexInput ?? true;
497
+ const resolvedValueInputPlaceholder = valueInputPlaceholder ?? hexInputPlaceholder ?? "Enter color value";
498
+ const availableInputFormats = React.useMemo(
499
+ () => VALUE_FORMAT_OPTIONS.filter((option) => inputFormats.includes(option.value)),
500
+ [inputFormats]
501
+ );
502
+ const supportsEyeDropper =
503
+ typeof window !== "undefined" &&
504
+ "EyeDropper" in window &&
505
+ typeof (window as Window & { EyeDropper?: EyeDropperConstructor }).EyeDropper === "function";
506
+ const controlsVisible = showSpectrum || showHueSlider || showOpacitySlider || showEyedropper;
507
+
508
+ const rgba = React.useMemo(() => hsvaToRgba(hsva), [hsva]);
509
+ const currentValue = React.useMemo(() => formatColorValue(rgba, valueFormat), [rgba, valueFormat]);
510
+
511
+ React.useEffect(() => {
512
+ const nextHsva = getResolvedHsva(value);
513
+ setHsva(nextHsva);
514
+ }, [value]);
515
+
516
+ React.useEffect(() => {
517
+ setValueInput(getValueInputString(hsva, inputFormat));
518
+ setOpacityInput(formatPercent(hsva.a));
519
+ }, [hsva, inputFormat]);
520
+
521
+ React.useEffect(() => {
522
+ if (!availableInputFormats.some((option) => option.value === inputFormat)) {
523
+ setInputFormat(availableInputFormats[0]?.value ?? "hex");
524
+ }
525
+ }, [availableInputFormats, inputFormat]);
526
+
527
+ const emitColorChange = React.useCallback(
528
+ (nextHsva: HsvaColor) => {
529
+ const nextValue = formatColorValue(hsvaToRgba(nextHsva), valueFormat);
530
+ onChange?.(nextValue);
531
+ return nextValue;
532
+ },
533
+ [onChange, valueFormat]
534
+ );
535
+
536
+ const commitHsva = React.useCallback(
537
+ (updater: HsvaColor | ((previous: HsvaColor) => HsvaColor), options?: { fromPreset?: boolean }) => {
538
+ const previous = hsva;
539
+ const nextValue =
540
+ typeof updater === "function" ? clampHsva((updater as (previous: HsvaColor) => HsvaColor)(previous)) : clampHsva(updater);
541
+ setHsva(nextValue);
542
+ const emittedValue = emitColorChange(nextValue);
543
+ if (options?.fromPreset) {
544
+ onSelectColor?.(emittedValue);
545
+ }
546
+ return nextValue;
547
+ },
548
+ [emitColorChange, hsva, onSelectColor]
549
+ );
550
+
551
+ const handleColorSelect = React.useCallback(
552
+ (color: string) => {
553
+ const parsed = parseColorString(color);
554
+ if (!parsed) return;
555
+ commitHsva(rgbaToHsva(parsed), { fromPreset: true });
556
+ },
557
+ [commitHsva]
558
+ );
559
+
560
+ const handleValueInputChange = React.useCallback(
561
+ (event: React.ChangeEvent<HTMLInputElement>) => {
562
+ const nextValue = event.target.value;
563
+ setValueInput(nextValue);
564
+ const parsed =
565
+ inputFormat === "hex"
566
+ ? parseHexColor(nextValue)
567
+ : inputFormat === "rgba"
568
+ ? parseRgbColor(nextValue)
569
+ : parseHslColor(nextValue);
570
+ if (!parsed) return;
571
+ commitHsva(rgbaToHsva(parsed));
572
+ },
573
+ [commitHsva, inputFormat]
574
+ );
575
+
576
+ const handleValueInputBlur = React.useCallback(() => {
577
+ setValueInput(getValueInputString(hsva, inputFormat));
578
+ }, [hsva, inputFormat]);
579
+
580
+ const handleOpacityInputChange = React.useCallback(
581
+ (event: React.ChangeEvent<HTMLInputElement>) => {
582
+ const nextValue = event.target.value.replace(/[^\d.]/g, "");
583
+ setOpacityInput(nextValue);
584
+ if (nextValue === "") return;
585
+ const parsed = Number.parseFloat(nextValue);
586
+ if (!Number.isFinite(parsed)) return;
587
+ commitHsva((previous) => ({ ...previous, a: clamp01(parsed / 100) }));
588
+ },
589
+ [commitHsva]
590
+ );
591
+
592
+ const handleOpacityInputBlur = React.useCallback(() => {
593
+ setOpacityInput(formatPercent(hsva.a));
594
+ }, [hsva.a]);
595
+
596
+ const handleSpectrumPointerDown = React.useCallback(
597
+ (event: React.PointerEvent<HTMLDivElement>) => {
598
+ if (disabled || !spectrumRef.current) return;
599
+ const point = getSpectrumPoint(event, spectrumRef.current);
600
+ commitHsva((previous) => ({ ...previous, ...point }));
601
+
602
+ const target = event.currentTarget;
603
+ target.setPointerCapture?.(event.pointerId);
604
+
605
+ const moveListener = (moveEvent: PointerEvent) => {
606
+ if (!spectrumRef.current) return;
607
+ const nextPoint = getSpectrumPoint(moveEvent, spectrumRef.current);
608
+ commitHsva((previous) => ({ ...previous, ...nextPoint }));
609
+ };
610
+
611
+ const cleanup = () => {
612
+ window.removeEventListener("pointermove", moveListener);
613
+ window.removeEventListener("pointerup", cleanup);
614
+ };
615
+
616
+ window.addEventListener("pointermove", moveListener);
617
+ window.addEventListener("pointerup", cleanup, { once: true });
618
+ },
619
+ [commitHsva, disabled]
620
+ );
621
+
622
+ const handleSpectrumKeyDown = React.useCallback(
623
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
624
+ if (disabled) return;
625
+ const step = event.shiftKey ? 0.1 : 0.02;
626
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
627
+ event.preventDefault();
628
+
629
+ commitHsva((previous) => {
630
+ if (event.key === "Home") return { ...previous, s: 0, v: 1 };
631
+ if (event.key === "End") return { ...previous, s: 1, v: 0 };
632
+ if (event.key === "ArrowLeft") return { ...previous, s: clamp01(previous.s - step) };
633
+ if (event.key === "ArrowRight") return { ...previous, s: clamp01(previous.s + step) };
634
+ if (event.key === "ArrowUp") return { ...previous, v: clamp01(previous.v + step) };
635
+ return { ...previous, v: clamp01(previous.v - step) };
636
+ });
637
+ },
638
+ [commitHsva, disabled]
639
+ );
640
+
641
+ const handleEyeDropperClick = React.useCallback(async () => {
642
+ if (!supportsEyeDropper || disabled) return;
643
+ const EyeDropperApi = (window as Window & { EyeDropper?: EyeDropperConstructor }).EyeDropper;
644
+ if (!EyeDropperApi) return;
645
+
646
+ try {
647
+ const picker = new EyeDropperApi();
648
+ const result = await picker.open();
649
+ const parsed = parseHexColor(result.sRGBHex);
650
+ if (!parsed) return;
651
+ commitHsva(rgbaToHsva(parsed));
652
+ } catch {
653
+ // Ignore cancellation or platform errors.
654
+ }
655
+ }, [commitHsva, disabled, supportsEyeDropper]);
656
+
657
+ const spectrumHue = `hsl(${Math.round(hsva.h)} 100% 50%)`;
658
+ const alphaGradient = `linear-gradient(90deg, rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, 0) 0%, rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, 1) 100%)`;
659
+ const hueGradient =
660
+ "linear-gradient(90deg, rgb(255 0 0) 0%, rgb(255 255 0) 16.66%, rgb(0 255 0) 33.33%, rgb(0 255 255) 50%, rgb(0 0 255) 66.66%, rgb(255 0 255) 83.33%, rgb(255 0 0) 100%)";
661
+
662
+ return (
663
+ <div
664
+ className={cx("solara-colorpicker", className)}
665
+ data-size={size}
666
+ data-disabled={disabled ? "true" : undefined}>
667
+ {controlsVisible ? (
668
+ <div className="solara-colorpicker__section solara-colorpicker__section--controls">
669
+ <div className="solara-colorpicker__controls">
670
+ {showSpectrum ? (
671
+ <div
672
+ ref={spectrumRef}
673
+ className="solara-colorpicker__spectrum"
674
+ style={{ ["--colorpicker-spectrum-hue" as const]: spectrumHue } as React.CSSProperties}
675
+ role="slider"
676
+ aria-label="Saturation and brightness"
677
+ aria-valuemin={0}
678
+ aria-valuemax={100}
679
+ aria-valuenow={Math.round(hsva.s * 100)}
680
+ aria-valuetext={`Saturation ${Math.round(hsva.s * 100)} percent, brightness ${Math.round(
681
+ hsva.v * 100
682
+ )} percent`}
683
+ tabIndex={disabled ? -1 : 0}
684
+ onPointerDown={handleSpectrumPointerDown}
685
+ onKeyDown={handleSpectrumKeyDown}>
686
+ <span
687
+ className="solara-colorpicker__spectrum-thumb"
688
+ style={{
689
+ left: `${hsva.s * 100}%`,
690
+ top: `${(1 - hsva.v) * 100}%`,
691
+ }}
692
+ />
693
+ </div>
694
+ ) : null}
695
+
696
+ <div
697
+ className={cx(
698
+ "solara-colorpicker__control-row",
699
+ showEyedropper && "solara-colorpicker__control-row--with-eyedropper"
700
+ )}>
701
+ {showEyedropper ? (
702
+ <div className="solara-colorpicker__eyedropper">
703
+ <button
704
+ type="button"
705
+ className="solara-colorpicker__eyedropper-button"
706
+ aria-label="Pick color from screen"
707
+ disabled={disabled || !supportsEyeDropper}
708
+ onClick={handleEyeDropperClick}>
709
+ <EyeDropperIcon />
710
+ </button>
711
+ </div>
712
+ ) : null}
713
+
714
+ <div className="solara-colorpicker__control-stack">
715
+ {showHueSlider ? (
716
+ <div className="solara-colorpicker__slider-row">
717
+ <input
718
+ type="range"
719
+ min={0}
720
+ max={360}
721
+ step={1}
722
+ value={Math.round(hsva.h)}
723
+ disabled={disabled}
724
+ className="solara-colorpicker__slider solara-colorpicker__slider--hue"
725
+ onChange={(event) =>
726
+ commitHsva((previous) => ({
727
+ ...previous,
728
+ h: Number.parseFloat(event.target.value),
729
+ }))
730
+ }
731
+ style={{ ["--colorpicker-slider-gradient" as const]: hueGradient } as React.CSSProperties}
732
+ aria-label="Hue"
733
+ />
734
+ </div>
735
+ ) : null}
736
+
737
+ {showOpacitySlider ? (
738
+ <div className="solara-colorpicker__slider-row">
739
+ <input
740
+ type="range"
741
+ min={0}
742
+ max={100}
743
+ step={1}
744
+ value={Math.round(hsva.a * 100)}
745
+ disabled={disabled}
746
+ className="solara-colorpicker__slider solara-colorpicker__slider--alpha"
747
+ onChange={(event) =>
748
+ commitHsva((previous) => ({
749
+ ...previous,
750
+ a: clamp01(Number.parseFloat(event.target.value) / 100),
751
+ }))
752
+ }
753
+ style={{ ["--colorpicker-slider-gradient" as const]: alphaGradient } as React.CSSProperties}
754
+ aria-label="Opacity"
755
+ />
756
+ </div>
757
+ ) : null}
758
+ </div>
759
+ </div>
760
+ </div>
761
+ </div>
762
+ ) : null}
763
+
764
+ {shouldShowValueInput ? (
765
+ <div className="solara-colorpicker__section solara-colorpicker__section--value">
766
+ <div className="solara-colorpicker__value-row">
767
+ {showFormatSelector ? (
768
+ <div className="solara-colorpicker__value-format">
769
+ <select
770
+ className="solara-colorpicker__format-select"
771
+ value={inputFormat}
772
+ onChange={(event) => setInputFormat(event.target.value as ColorPickerValueFormat)}
773
+ disabled={disabled}
774
+ aria-label="Color value type"
775
+ >
776
+ {availableInputFormats.map((option) => (
777
+ <option key={option.value} value={option.value}>
778
+ {option.label}
779
+ </option>
780
+ ))}
781
+ </select>
782
+ </div>
783
+ ) : null}
784
+
785
+ <div className="solara-colorpicker__value-input">
786
+ <TextField
787
+ value={valueInput}
788
+ onChange={handleValueInputChange}
789
+ onBlur={handleValueInputBlur}
790
+ placeholder={resolvedValueInputPlaceholder}
791
+ isDisabled={disabled}
792
+ size={TEXTFIELD_SIZE_MAP[size]}
793
+ aria-label={`${inputFormat.toUpperCase()} color value`}
794
+ fullWidth
795
+ />
796
+ </div>
797
+
798
+ {showOpacityInput ? (
799
+ <div className="solara-colorpicker__opacity-input">
800
+ <TextField
801
+ value={opacityInput}
802
+ onChange={handleOpacityInputChange}
803
+ onBlur={handleOpacityInputBlur}
804
+ isDisabled={disabled}
805
+ size={TEXTFIELD_SIZE_MAP[size]}
806
+ aria-label="Opacity percent"
807
+ fullWidth
808
+ />
809
+ <span className="solara-colorpicker__percent">%</span>
810
+ </div>
811
+ ) : null}
812
+ </div>
813
+ </div>
814
+ ) : null}
815
+
816
+ <div className="solara-colorpicker__section solara-colorpicker__section--presets">
817
+ <div className="solara-colorpicker__subhead">Presets</div>
818
+ <ColorPickerPalette
819
+ value={currentValue}
820
+ colors={resolvedPresets}
821
+ onSelectColor={handleColorSelect}
822
+ size={size}
823
+ disabled={disabled}
824
+ swatchRadius={swatchRadius}
825
+ swatchContent={swatchContent}
826
+ />
827
+ </div>
828
+ </div>
829
+ );
830
+ }
831
+
832
+ function clampHsva(value: HsvaColor): HsvaColor {
833
+ return {
834
+ h: wrapHue(value.h),
835
+ s: clamp01(value.s),
836
+ v: clamp01(value.v),
837
+ a: clamp01(value.a),
838
+ };
839
+ }
840
+
841
+ export function ColorPicker({
842
+ trigger,
843
+ triggerLabel = DEFAULT_TRIGGER_LABEL,
844
+ isOpen,
845
+ defaultOpen,
846
+ onOpenChange,
847
+ align = "start",
848
+ side = "bottom",
849
+ closeOnSelect = true,
850
+ menuWidth,
851
+ menuHeight,
852
+ menuProps,
853
+ contentAriaLabel = "Color picker",
854
+ wrapperClassName,
855
+ ...contentProps
856
+ }: ColorPickerProps) {
857
+ const [open, setOpen] = useControllableOpenState({
858
+ isOpen,
859
+ defaultOpen,
860
+ onOpenChange,
861
+ });
862
+
863
+ const triggerColor = contentProps.value ?? DEFAULT_COLOR;
864
+
865
+ const defaultTrigger = (
866
+ <ColorPreview
867
+ color={triggerColor}
868
+ size={contentProps.size}
869
+ disabled={contentProps.disabled}
870
+ label={triggerLabel}
871
+ className="solara-colorpicker__trigger"
872
+ />
873
+ );
874
+
875
+ return (
876
+ <div className={cx("solara-colorpicker__wrapper", wrapperClassName)}>
877
+ <MenuDropdown
878
+ trigger={trigger ?? defaultTrigger}
879
+ align={align}
880
+ side={side}
881
+ isOpen={open}
882
+ onOpenChange={setOpen}
883
+ closeOnSelect={false}
884
+ contentAriaLabel={contentAriaLabel}>
885
+ <Menu
886
+ {...menuProps}
887
+ style={{
888
+ ...(menuWidth ? { ["--colorpicker-menu-width" as const]: menuWidth } : null),
889
+ ...(menuHeight ? { ["--colorpicker-menu-height" as const]: menuHeight } : null),
890
+ ...(menuHeight ? { ["--colorpicker-presets-max-height" as const]: "none" } : null),
891
+ ...menuProps?.style,
892
+ } as React.CSSProperties}
893
+ className={cx("solara-colorpicker__menu", menuProps?.className)}>
894
+ <ColorPickerContent
895
+ {...contentProps}
896
+ onSelectColor={(color) => {
897
+ contentProps.onSelectColor?.(color);
898
+ if (closeOnSelect) setOpen(false);
899
+ }}
900
+ />
901
+ </Menu>
902
+ </MenuDropdown>
903
+ </div>
904
+ );
905
+ }