@varialkit/colorpicker 0.1.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/docs.md +124 -0
- package/examples.tsx +376 -0
- package/package.json +27 -0
- package/src/ColorPicker.scss +471 -0
- package/src/ColorPicker.tsx +905 -0
- package/src/ColorPicker.types.ts +270 -0
- package/src/ColorPreview.tsx +48 -0
- package/src/index.ts +12 -0
|
@@ -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
|
+
}
|