Live Preview
Export
Demo Code (JSX)
1"use client";
2
3import React, { useState, useCallback } from "react";
4import ColorPicker from "./ColorPicker";
5
6export default function MyColorPicker() {
7 const [color, setColor] = useState("#06B5EF");
8
9 const handleColorChange = useCallback((newColor: string) => {
10 setColor(newColor);
11 }, []);
12
13 return (
14 <div style={{ maxWidth: "364px", width: "100%" }}>
15 <ColorPicker
16 value={color}
17 onValueChange={handleColorChange}
18 />
19 </div>
20 );
21}Component Source
1"use client";
2
3import React, {
4 useState,
5 useEffect,
6 useRef,
7 useCallback,
8 useMemo,
9} from "react";
10import {
11 hexToHsva,
12 hsvaToHex,
13 hsvaToHsla,
14 hsvaToRgba,
15 rgbaToHsva,
16 hslaToHsva,
17 type HsvaColor,
18} from "@uiw/color-convert";
19import { Check, ChevronDown, CheckCircle2, XCircle } from "lucide-react";
20import { cn } from "@/lib/utils";
21
22// --- Utility: Color Math for Contrast ---
23
24const parseToHsva = (color: string): HsvaColor => {
25 const str = color.toLowerCase().trim();
26
27 try {
28 if (str.startsWith("#")) return hexToHsva(str);
29
30 if (str.startsWith("rgb")) {
31 const p = str.match(/[d.]+/g);
32 if (p)
33 return rgbaToHsva({
34 r: +p[0],
35 g: +p[1],
36 b: +p[2],
37 a: p[3] ? +p[3] : 1,
38 });
39 }
40
41 if (str.startsWith("hsl")) {
42 const p = str.match(/[d.]+/g);
43 if (p)
44 return hslaToHsva({
45 h: +p[0],
46 s: +p[1],
47 l: +p[2],
48 a: p[3] ? +p[3] : 1,
49 });
50 }
51 } catch (e) {}
52
53 return { h: 0, s: 0, v: 0, a: 1 };
54};
55
56const getRelativeLuminance = (color: string) => {
57 const hsva = parseToHsva(color);
58 const { r, g, b } = hsvaToRgba(hsva);
59 const [rs, gs, bs] = [r, g, b].map((c) => {
60 const s = c / 255;
61 return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
62 });
63 return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
64};
65
66const getContrastRatio = (lum1: number, lum2: number) => {
67 const brightest = Math.max(lum1, lum2);
68 const darkest = Math.min(lum1, lum2);
69 return (brightest + 0.05) / (darkest + 0.05);
70};
71
72// --- Component Props ---
73
74export interface ColorPickerProps {
75 value?: string;
76 onValueChange?: (val: string) => void;
77 isRTL?: boolean;
78 showContrast?: boolean;
79
80 // NEW: channel fields & modes
81 enabledModes?: Array<"hex" | "rgb" | "hsl">;
82 defaultFormat?: "hex" | "rgb" | "hsl";
83
84 // Mode selector labels (full names)
85 hexLabel?: string;
86 rgbLabel?: string;
87 hslLabel?: string;
88 modeLabel?: string;
89
90 // Channel input labels (single letters)
91 rLabel?: string;
92 gLabel?: string;
93 bLabel?: string;
94 hLabel?: string;
95 sLabel?: string;
96 lLabel?: string;
97
98 contrastBgLuminance?: number;
99 colorPreviewAreaText?: string;
100
101 // Container styling
102 containerBg?: string;
103 containerBorderColor?: string;
104 containerBorderWidth?: number;
105 containerRadius?: string;
106 containerPadding?: string;
107 containerElementGap?: string;
108
109 // Saturation area
110 saturationHeight?: number;
111 saturationRadius?: number;
112 saturationBorderColor?: string;
113 saturationBorderWidth?: number;
114
115 // Saturation THUMB (draggable circle inside the saturation area)
116 saturationThumbWidth?: number | string;
117 saturationThumbHeight?: number | string;
118 saturationThumbRadius?: number | string;
119 saturationThumbBorderStyle?: "solid" | "dashed" | "none";
120 saturationThumbBorderWidth?: number;
121 saturationThumbBorderColor?: string;
122 saturationThumbBgColor?: string;
123
124 // Hue TRACK (static colorful background)
125 hueTrackHeight?: number | string;
126 hueTrackRadius?: number | string;
127 hueTrackBorderWidth?: number;
128 hueTrackBorderColor?: string;
129
130 // Hue THUMB (draggable slider circle) - base
131 hueThumbSize?: number | string;
132 hueThumbRadius?: number | string;
133 hueThumbBorderWidth?: number;
134
135 // Hue THUMB states (hover/active colors)
136 hueThumbBgDefault?: string;
137 hueThumbBgHover?: string;
138 hueThumbBgActive?: string;
139 hueThumbBorderDefault?: string;
140 hueThumbBorderHover?: string;
141 hueThumbBorderActive?: string;
142
143 // Contrast stuff
144 contrastLabel?: string;
145 contrastLabelSize?: string;
146 contrastLabelColor?: string;
147 contrastLabelWeight?: number;
148 contrastValueSize?: string;
149 contrastValueColor?: string;
150 contrastValueWeight?: number;
151 contrastFormat?: "value:1" | "1:value" | "value";
152 contrastLabelGap?: string;
153 contrastItemGap?: string;
154 contrastBadgeGap?: string;
155 showContrastAALabel?: boolean;
156 showContrastAAALabel?: boolean;
157 contrastAreaTopMargin?: string;
158
159 // Input / display field (HEX only)
160 inputHeight?: number;
161 inputBg?: string;
162 inputBorderColor?: string;
163 inputBorderWidth?: number;
164 inputRadius?: number;
165 inputTextColor?: string;
166 floatingLabelFocusBorderColor?: string;
167 inputFocusRingColor?: string;
168 inputErrorOutlineColor?: string;
169
170 // Floating label styling
171 floatingLabelBg?: string;
172 floatingLabelTextColor?: string;
173 floatingLabelActiveTextColor?: string;
174 floatingLabelRadius?: number;
175 floatingLabelBorderColor?: string;
176 floatingLabelBorderWidth?: number;
177 floatingLabelMainTextSize?: number;
178
179 // Dropdown / combobox button
180 dropdownHeight?: number;
181 dropdownBg?: string;
182 dropdownBorderColor?: string;
183 dropdownBorderWidth?: number;
184 dropdownRadius?: number;
185 dropdownTextColor?: string;
186 dropdownFocusBorderColor?: string;
187 dropdownFocusRingColor?: string;
188 dropdownChevronColor?: string;
189
190 // Dropdown menu
191 dropdownMenuBg?: string;
192 dropdownMenuBorderColor?: string;
193 dropdownMenuBorderWidth?: number;
194 dropdownMenuRadius?: number;
195 dropdownMenuTextColor?: string;
196 dropdownMenuActiveTextColor?: string;
197 dropdownMenuHoverBg?: string;
198 dropdownMenuActiveBg?: string;
199 modeDropdownWidth?: string;
200 modeDropdownFullWidth?: boolean;
201
202 // Preview / letter area
203 showLetterTop?: boolean;
204 previewBgFallback?: string;
205 previewBorderColor?: string;
206 previewBorderWidth?: number;
207 previewRadius?: number;
208 previewFontSize?: number;
209 previewFontWeight?: number;
210 previewTextColor?: string;
211 colorPreviewPosition?: "top" | "contrast" | "none"
212 previewWidth?: number | string;
213 previewHeight?: number;
214
215 // Badge group customization
216 badgeBorderWidth?: number | string;
217 badgeBorderRadius?: number | string;
218 badgeFontSize?: number | string;
219 badgeFontWeight?: number;
220 badgeIconSize?: number | string;
221 badgePadding?: string;
222 badgeIconStrokeWidth?: number;
223 badgeBgPass?: string;
224 badgeBgFail?: string;
225 badgeBorderPass?: string;
226 badgeBorderFail?: string;
227 badgeTextPass?: string;
228 badgeTextFail?: string;
229}
230
231// --- Main Component ---
232
233export default function ColorPicker({
234 value = "#06B5EF",
235 onValueChange,
236 isRTL = false,
237 showContrast = true,
238 colorPreviewAreaText = "A",
239 enabledModes = ["hex", "rgb", "hsl"],
240 defaultFormat = "hex",
241 contrastBgLuminance = 0,
242 hexLabel = "HEX",
243 rgbLabel = "RGB",
244 hslLabel = "HSL",
245 modeLabel = "Mode",
246 contrastLabel = "Contrast",
247 rLabel = "R",
248 gLabel = "G",
249 bLabel = "B",
250 hLabel = "H",
251 sLabel = "S",
252 lLabel = "L",
253
254 // Container
255 containerBg = "#000",
256 containerBorderColor = "#242424",
257 containerBorderWidth = 1,
258 containerRadius = "12px",
259 containerPadding = "16px",
260 containerElementGap = "16px",
261
262 // Saturation
263 saturationHeight = 140,
264 saturationRadius = 8,
265 saturationBorderColor = "#242424",
266 saturationBorderWidth = 1,
267
268 // Saturation thumb (indicator)
269 saturationThumbWidth = 14,
270 saturationThumbHeight = 14,
271 saturationThumbRadius = 50,
272 saturationThumbBorderStyle = "solid",
273 saturationThumbBorderWidth = 2,
274 saturationThumbBorderColor = "#ffffff",
275 saturationThumbBgColor = "transparent",
276
277 // NEW: Hue TRACK (static colorful background)
278 hueTrackHeight = 10,
279 hueTrackRadius = "8px",
280 hueTrackBorderWidth = 1,
281 hueTrackBorderColor = "transparent",
282
283 // NEW: Hue THUMB (draggable slider circle) - base
284 hueThumbSize = 16,
285 hueThumbRadius = "50%",
286 hueThumbBorderWidth = 3,
287
288 // NEW: Hue THUMB states (hover/active colors)
289 hueThumbBgDefault = "#f0f0f0",
290 hueThumbBgHover = "#e5e5e5",
291 hueThumbBgActive = "#f0f0f0",
292 hueThumbBorderDefault = "#e5e5e5",
293 hueThumbBorderHover = "#f0f0f0",
294 hueThumbBorderActive = "#fff",
295
296 // NEW: Badge group (AA/AAA contrast badges)
297 badgeBorderWidth = 1,
298 badgeBorderRadius = "50px",
299 badgeFontSize = "10px",
300 badgeFontWeight = 600,
301 badgeIconSize = 10,
302 badgePadding = "0.25rem 0.5rem",
303 badgeIconStrokeWidth = 2.25,
304 badgeBgPass = "rgba(65, 239, 6, 0.1)",
305 badgeBgFail = "rgba(239, 6, 65, 0.1)",
306 badgeBorderPass = "rgba(65, 239, 6, 0.5)",
307 badgeBorderFail = "rgba(239, 6, 65, 0.5)",
308 badgeTextPass = "#41EF06",
309 badgeTextFail = "#EF0641",
310
311 // Contrast
312 contrastLabelSize = "12px",
313 contrastLabelColor = "#737373",
314 contrastLabelWeight = 700,
315 contrastValueSize = "14px",
316 contrastValueColor = "#ffffff",
317 contrastValueWeight = 400,
318 contrastFormat = "value:1",
319 contrastLabelGap = "0.125rem",
320 contrastItemGap = "16px",
321 contrastBadgeGap = "8px",
322 showContrastAALabel = true,
323 showContrastAAALabel = true,
324 contrastAreaTopMargin = "0px",
325
326 // Inputs
327 inputHeight = 44,
328 inputBg = "#000",
329 inputBorderColor = "#242424",
330 inputBorderWidth = 1,
331 inputRadius = 8,
332 inputTextColor = "#ffffff",
333 floatingLabelFocusBorderColor = "#06B5EF",
334 inputErrorOutlineColor = "#EF0641",
335
336 // Floating labels
337 floatingLabelBg,
338 floatingLabelTextColor = "#777777",
339 floatingLabelActiveTextColor = "#ffffff",
340 floatingLabelRadius = 4,
341 floatingLabelBorderColor,
342 floatingLabelBorderWidth = 0,
343 floatingLabelMainTextSize = 14,
344
345 // Dropdown
346 dropdownHeight = 44,
347 dropdownBg,
348 dropdownBorderColor,
349 dropdownBorderWidth,
350 dropdownRadius,
351 dropdownTextColor,
352 dropdownFocusBorderColor,
353 dropdownChevronColor = "#6b7280",
354
355 // Dropdown menu
356 dropdownMenuBg = "#111111",
357 dropdownMenuBorderColor = "#242424",
358 dropdownMenuBorderWidth = 1,
359 dropdownMenuRadius = 10,
360 dropdownMenuTextColor = "#d1d5db",
361 dropdownMenuActiveTextColor = "#ffffff",
362 dropdownMenuHoverBg = "rgba(255,255,255,0.05)",
363 dropdownMenuActiveBg = "rgba(255,255,255,0.10)",
364 modeDropdownWidth = "128px",
365 modeDropdownFullWidth = false,
366
367 // Preview area
368 previewBgFallback = "#111111",
369 previewBorderColor = "rgba(255,255,255,0.14)",
370 previewBorderWidth = 1,
371 previewRadius = 8,
372 previewFontSize = 18,
373 previewFontWeight = 600,
374 previewTextColor = "#ffffff",
375
376 colorPreviewPosition = "contrast",
377 previewWidth = 44,
378 previewHeight = inputHeight,
379}: ColorPickerProps) {
380 // Normalize modes
381 const normalizedModes = enabledModes.length ? enabledModes : ["hex"];
382 const initialFormat = normalizedModes.includes(defaultFormat) ? defaultFormat : normalizedModes[0];
383
384 const [hsva, setHsva] = useState<HsvaColor>(parseToHsva(value));
385 const [format, setFormat] = useState<"hex" | "rgb" | "hsl">(initialFormat);
386
387 // ✅ FIXED: Track source of change + hex completely independent
388 const [hexInput, setHexInput] = useState("");
389 const [hexIsFocused, setHexIsFocused] = useState(false);
390 const [hexError, setHexError] = useState(false);
391 const [changeSource, setChangeSource] = useState<'hex' | 'external' | null>(null);
392
393 const [rgbInputs, setRgbInputs] = useState({ r: "0", g: "0", b: "0" });
394 const [rgbIsFocused, setRgbIsFocused] = useState<string | null>(null);
395 const [rgbErrors, setRgbErrors] = useState({ r: false, g: false, b: false });
396
397 const [hslInputs, setHslInputs] = useState({ h: "0", s: "0", l: "0" });
398 const [hslIsFocused, setHslIsFocused] = useState<string | null>(null);
399 const [hslErrors, setHslErrors] = useState({ h: false, s: false, l: false });
400
401 const satContainerRef = useRef<HTMLDivElement>(null);
402
403 // Computed defaults
404 const resolvedDropdownBg = dropdownBg ?? inputBg;
405 const resolvedDropdownBorderColor = dropdownBorderColor ?? inputBorderColor;
406 const resolvedDropdownBorderWidth = dropdownBorderWidth ?? inputBorderWidth;
407 const resolvedDropdownRadius = dropdownRadius ?? inputRadius;
408 const resolvedDropdownTextColor = dropdownTextColor ?? inputTextColor;
409 const resolvedDropdownFocusBorderColor = dropdownFocusBorderColor ?? floatingLabelFocusBorderColor;
410 const resolvedFloatingLabelBg = floatingLabelBg ?? containerBg;
411
412 // ✅ FIXED: Manual hex sync ONLY for external changes
413 const updateHexFromColor = useCallback(() => {
414 if (changeSource !== 'hex' && !hexIsFocused) {
415 const newHex = hsvaToHex(hsva).toUpperCase();
416 setHexInput(newHex);
417 setHexError(false);
418 }
419 }, [hsva, hexIsFocused, changeSource]);
420
421 // External prop sync
422 useEffect(() => {
423 const next = parseToHsva(value);
424 setHsva(next);
425 setChangeSource('external');
426 updateHexFromColor();
427 }, [value]);
428
429 // RGB/HSL sync ONLY
430 useEffect(() => {
431 if (!rgbIsFocused) {
432 const { r, g, b } = hsvaToRgba(hsva);
433 setRgbInputs({
434 r: Math.round(r).toString(),
435 g: Math.round(g).toString(),
436 b: Math.round(b).toString()
437 });
438 setRgbErrors({ r: false, g: false, b: false });
439 }
440 if (!hslIsFocused) {
441 const { h, s, l } = hsvaToHsla(hsva);
442 setHslInputs({
443 h: Math.round(h).toString(),
444 s: Math.round(s).toString(),
445 l: Math.round(l).toString()
446 });
447 setHslErrors({ h: false, s: false, l: false });
448 }
449 }, [hsva, format, rgbIsFocused, hslIsFocused]);
450
451 const clamp = (val: number, min: number, max: number) =>
452 Number.isNaN(val) ? min : Math.min(max, Math.max(min, val));
453
454 const parseNumericChannel = (raw: string, min: number, max: number) => {
455 const trimmed = raw.trim();
456 if (!trimmed.length) return { value: 0, isValid: false };
457 const n = Number(trimmed);
458 if (Number.isNaN(n)) return { value: 0, isValid: false };
459 return { value: clamp(n, min, max), isValid: true };
460 };
461
462 const triggerChange = useCallback((next: HsvaColor, source: 'hex' | 'external' = 'external') => {
463 setChangeSource(source);
464 setHsva(next);
465 let out = "";
466 if (format === "hex") out = hsvaToHex(next);
467 else if (format === "rgb") {
468 const { r, g, b } = hsvaToRgba(next);
469 out = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
470 } else {
471 const { h, s, l } = hsvaToHsla(next);
472 out = `hsl(${Math.round(h)}, ${Math.round(s)}%, ${Math.round(l)}%)`;
473 }
474 onValueChange?.(out);
475 }, [format, onValueChange]);
476
477 // ✅ FIXED HEX - TRACKS SOURCE + NEVER AUTO-CORRECTS
478 const handleHexChange = (val: string) => {
479 if (val.length > 7) return;
480 setHexInput(val); // ALWAYS free - NO corrections ever
481
482 const trimmed = val.trim();
483 if (!trimmed) {
484 setHexError(false);
485 return;
486 }
487
488 const parsed = parseToHsva(trimmed);
489 const isFallbackZero = parsed.h === 0 && parsed.s === 0 && parsed.v === 0;
490 setHexError(!!trimmed && isFallbackZero && trimmed !== "#000");
491
492 triggerChange(parsed, 'hex'); // Mark as hex source - NO input correction
493 };
494
495 const handleHexFocus = () => setHexIsFocused(true);
496 const handleHexBlur = () => setHexIsFocused(false);
497
498 // RGB handlers (mark source)
499 const handleRgbChannelChange = (channel: "r"|"g"|"b", raw: string) => {
500 if (raw.length > 3) return;
501 const newInputs = { ...rgbInputs, [channel]: raw };
502 setRgbInputs(newInputs);
503 const ranges = { r: [0,255], g: [0,255], b: [0,255] } as const;
504 const parsed = parseNumericChannel(raw, ranges[channel][0], ranges[channel][1]);
505 setRgbErrors(prev => ({ ...prev, [channel]: !parsed.isValid }));
506 const allValid = ["r","g","b"].every(c => {
507 const [min, max] = ranges[c];
508 return parseNumericChannel(newInputs[c], min, max).isValid;
509 });
510 if (allValid) {
511 const r = parseNumericChannel(newInputs.r, 0, 255).value;
512 const g = parseNumericChannel(newInputs.g, 0, 255).value;
513 const b = parseNumericChannel(newInputs.b, 0, 255).value;
514 triggerChange(rgbaToHsva({ r, g, b, a: 1 }), 'external');
515 }
516 };
517
518 const handleRgbFocus = (channel: "r"|"g"|"b") => setRgbIsFocused(channel);
519 const handleRgbBlur = () => setRgbIsFocused(null);
520
521 // HSL handlers (mark source)
522 const handleHslChannelChange = (channel: "h"|"s"|"l", raw: string) => {
523 if (raw.length > 3) return;
524 const newInputs = { ...hslInputs, [channel]: raw };
525 setHslInputs(newInputs);
526 const ranges = { h: [0,360], s: [0,100], l: [0,100] } as const;
527 const parsed = parseNumericChannel(raw, ranges[channel][0], ranges[channel][1]);
528 setHslErrors(prev => ({ ...prev, [channel]: !parsed.isValid }));
529 const allValid = ["h","s","l"].every(c => {
530 const [min, max] = ranges[c];
531 return parseNumericChannel(newInputs[c], min, max).isValid;
532 });
533 if (allValid) {
534 const h = parseNumericChannel(newInputs.h, 0, 360).value;
535 const s = parseNumericChannel(newInputs.s, 0, 100).value;
536 const l = parseNumericChannel(newInputs.l, 0, 100).value;
537 triggerChange(hslaToHsva({ h, s, l, a: 1 }), 'external');
538 }
539 };
540
541 const handleHslFocus = (channel: "h"|"s"|"l") => setHslIsFocused(channel);
542 const handleHslBlur = () => setHslIsFocused(null);
543
544 // ✅ FIXED Saturation - external source
545 const handleSatMouseDown = (e: React.MouseEvent) => {
546 e.preventDefault();
547 const handleMove = (moveEvent: MouseEvent) => {
548 if (!satContainerRef.current) return;
549 const rect = satContainerRef.current.getBoundingClientRect();
550 const x = Math.max(0, Math.min((moveEvent.clientX - rect.left) / rect.width, 1));
551 const y = Math.max(0, Math.min(1 - (moveEvent.clientY - rect.top) / rect.height, 1));
552 const newSat = isRTL ? (1 - x) * 100 : x * 100;
553 const newBright = y * 100;
554 triggerChange({ ...hsva, s: newSat, v: newBright }, 'external');
555 updateHexFromColor();
556 };
557 const handleUp = () => {
558 window.removeEventListener("mousemove", handleMove);
559 window.removeEventListener("mouseup", handleUp);
560 };
561 window.addEventListener("mousemove", handleMove);
562 window.addEventListener("mouseup", handleUp);
563 handleMove(e.nativeEvent as unknown as MouseEvent);
564 };
565
566 const formatContrast = (ratio: number) => {
567 switch (contrastFormat) {
568 case "1:value": return `1:${ratio.toFixed(2)}`;
569 case "value": return ratio.toFixed(2);
570 default: return `${ratio.toFixed(2)}:1`;
571 }
572 };
573
574 const contrastRatio = useMemo(() => {
575 const lum = getRelativeLuminance(hsvaToHex(hsva));
576 return getContrastRatio(lum, contrastBgLuminance);
577 }, [hsva, contrastBgLuminance]);
578
579 const autoPreviewTextColor = hsva.v > 60 && hsva.s < 30 ? "#000000" : "#ffffff";
580 const showModeDropdown = normalizedModes.length > 1;
581
582 // ✅ FIXED: Add autocomplete="off" to prevent browser interference
583 return (
584 <div className={cn("flex flex-col select-none", isRTL && "rtl")} style={{
585 backgroundColor: containerBg, borderStyle: "solid", borderColor: containerBorderColor,
586 borderWidth: containerBorderWidth, borderRadius: containerRadius, padding: containerPadding,
587 gap: containerElementGap, direction: isRTL ? "rtl" : "ltr"
588 }}>
589 {/* Saturation Area */}
590 <div ref={satContainerRef} onMouseDown={handleSatMouseDown} className="relative w-full cursor-crosshair" style={{
591 height: saturationHeight, borderRadius: saturationRadius, borderStyle: "solid",
592 borderColor: saturationBorderColor, borderWidth: saturationBorderWidth, overflow: "hidden",
593 background: `linear-gradient(to top, #000000, transparent), ${isRTL ? "linear-gradient(to left, #ffffff, transparent)" : "linear-gradient(to right, #ffffff, transparent)"}, hsl(${hsva.h}, 100%, 50%)`,
594 backgroundOrigin: "border-box", backgroundClip: "border-box"
595 }}>
596 <div
597 className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none"
598 style={{
599 left: isRTL ? `${100 - hsva.s}%` : `${hsva.s}%`,
600 top: `${100 - hsva.v}%`,
601 width: saturationThumbWidth,
602 height: saturationThumbHeight,
603 borderRadius: saturationThumbRadius,
604 borderStyle: saturationThumbBorderStyle,
605 borderWidth: saturationThumbBorderWidth,
606 backgroundColor: saturationThumbBgColor,
607 borderColor: saturationThumbBorderColor,
608 }}
609 />
610 </div>
611
612 {/* Hue Slider - Custom thumb states + keyboard */}
613 <CustomSlider
614 id="hue"
615 min={0}
616 max={360}
617 step={0.1}
618 value={hsva.h}
619 onValueChange={(newHue) => {
620 triggerChange({ ...hsva, h: newHue }, 'external');
621 updateHexFromColor();
622 }}
623 trackHeight={typeof hueTrackHeight === 'number' ? `${hueTrackHeight}px` : hueTrackHeight}
624 thumbWidth={typeof hueThumbSize === 'number' ? `${hueThumbSize}px` : hueThumbSize}
625 thumbHeight={typeof hueThumbSize === 'number' ? `${hueThumbSize}px` : hueThumbSize}
626 thumbBorderWidth={`${hueThumbBorderWidth}px`}
627 thumbBorderRadius={typeof hueThumbRadius === 'number' ? `${hueThumbRadius}px` : hueThumbRadius}
628 // ✅ FIXED: All 3 static track props
629 trackBorderRadius={typeof hueTrackRadius === 'number' ? `${hueTrackRadius}px` : hueTrackRadius}
630 trackBorderWidth={`${hueTrackBorderWidth}px`}
631 trackBorderColor={hueTrackBorderColor}
632 trackFillBorderRadius={typeof hueTrackRadius === 'number' ? `${hueTrackRadius}px` : hueTrackRadius}
633 colorTrackBackground={isRTL
634 ? "linear-gradient(to left, red, yellow, lime, cyan, blue, magenta, red)"
635 : "linear-gradient(to right, red, yellow, lime, cyan, blue, magenta, red)"
636 }
637 colorFillDefault="transparent"
638 colorFillHover="transparent"
639 colorFillActive="transparent"
640 colorThumbDefault={hueThumbBgDefault}
641 colorThumbHover={hueThumbBgHover}
642 colorThumbActive={hueThumbBgActive}
643 colorThumbBorderDefault={hueThumbBorderDefault}
644 colorThumbBorderHover={hueThumbBorderHover}
645 colorThumbBorderActive={hueThumbBorderActive}
646 isRTL={isRTL}
647 keyStep={1}
648 />
649
650 {/* Controls Row */}
651 <div className={cn("flex gap-2", modeDropdownFullWidth ? "flex-col" : "items-start")}>
652 <div className={cn("flex w-full gap-2", modeDropdownFullWidth ? "flex-col" : "items-start")}>
653 {colorPreviewPosition === "top" && (
654 <ColorPreviewBox
655 bg={hsvaToHex(hsva) || previewBgFallback}
656 label={colorPreviewAreaText}
657 width={previewWidth}
658 height={previewHeight}
659 radius={previewRadius}
660 borderColor={previewBorderColor}
661 borderWidth={previewBorderWidth}
662 fontSize={previewFontSize}
663 fontWeight={previewFontWeight}
664 fontColor={previewTextColor ?? autoPreviewTextColor}
665 />
666 )}
667
668 {showModeDropdown && (
669 <div style={{width: modeDropdownFullWidth ? "100%" : modeDropdownWidth, flexShrink: 0}}>
670 <FloatingLabelSelect
671 value={format}
672 onChange={(f) => {
673 setFormat(f as "hex" | "rgb" | "hsl");
674 if (f === "hex") {
675 setChangeSource('external');
676 updateHexFromColor();
677 }
678 }}
679 label={modeLabel}
680 options={normalizedModes}
681 isRTL={isRTL}
682 t={(key) => key === "hex" ? hexLabel : key === "rgb" ? rgbLabel : hslLabel}
683 height={dropdownHeight}
684 bg={resolvedDropdownBg}
685 borderColor={resolvedDropdownBorderColor}
686 borderWidth={resolvedDropdownBorderWidth}
687 radius={resolvedDropdownRadius}
688 fontSize={floatingLabelMainTextSize}
689 textColor={resolvedDropdownTextColor}
690 focusBorderColor={resolvedDropdownFocusBorderColor}
691 chevronColor={dropdownChevronColor}
692 floatingLabelBg={resolvedFloatingLabelBg}
693 floatingLabelTextColor={floatingLabelTextColor}
694 floatingLabelActiveTextColor={floatingLabelActiveTextColor}
695 floatingLabelRadius={floatingLabelRadius}
696 floatingLabelBorderColor={floatingLabelBorderColor}
697 floatingLabelBorderWidth={floatingLabelBorderWidth}
698 menuBg={dropdownMenuBg}
699 menuBorderColor={dropdownMenuBorderColor}
700 menuBorderWidth={dropdownMenuBorderWidth}
701 menuRadius={dropdownMenuRadius}
702 menuTextColor={dropdownMenuTextColor}
703 menuActiveTextColor={dropdownMenuActiveTextColor}
704 menuHoverBg={dropdownMenuHoverBg}
705 menuActiveBg={dropdownMenuActiveBg}
706 />
707 </div>
708 )}
709
710 <div className="flex flex-col flex-1 gap-2">
711 {format === "hex" && (
712 <FloatingLabelInput
713 value={hexInput}
714 onChange={handleHexChange}
715 onFocus={handleHexFocus}
716 onBlur={handleHexBlur}
717 label={hexLabel}
718 isRTL={isRTL}
719 maxLength={7}
720 height={inputHeight}
721 bg={inputBg}
722 borderColor={inputBorderColor}
723 borderWidth={inputBorderWidth}
724 radius={inputRadius}
725 fontSize={floatingLabelMainTextSize}
726 textColor={inputTextColor}
727 focusBorderColor={floatingLabelFocusBorderColor}
728 floatingLabelBg={resolvedFloatingLabelBg}
729 floatingLabelTextColor={floatingLabelTextColor}
730 floatingLabelActiveTextColor={floatingLabelActiveTextColor}
731 floatingLabelRadius={floatingLabelRadius}
732 floatingLabelBorderColor={floatingLabelBorderColor}
733 floatingLabelBorderWidth={floatingLabelBorderWidth}
734 hasError={hexError}
735 errorOutlineColor={inputErrorOutlineColor}
736 />
737 )}
738 {/* RGB/HSL inputs unchanged */}
739 {format === "rgb" && (
740 <div className="grid grid-cols-3 gap-2">
741 {(["r", "g", "b"] as const).map((ch) => (
742 <FloatingLabelInput
743 key={ch}
744 value={rgbInputs[ch]}
745 onChange={(v) => handleRgbChannelChange(ch, v)}
746 onFocus={() => handleRgbFocus(ch)}
747 onBlur={handleRgbBlur}
748 label={ch === "r" ? rLabel : ch === "g" ? gLabel : bLabel}
749 isRTL={isRTL}
750 maxLength={3}
751 height={inputHeight}
752 bg={inputBg}
753 borderColor={inputBorderColor}
754 borderWidth={inputBorderWidth}
755 radius={inputRadius}
756 fontSize={floatingLabelMainTextSize}
757 textColor={inputTextColor}
758 focusBorderColor={floatingLabelFocusBorderColor}
759 floatingLabelBg={resolvedFloatingLabelBg}
760 floatingLabelTextColor={floatingLabelTextColor}
761 floatingLabelActiveTextColor={floatingLabelActiveTextColor}
762 floatingLabelRadius={floatingLabelRadius}
763 floatingLabelBorderColor={floatingLabelBorderColor}
764 floatingLabelBorderWidth={floatingLabelBorderWidth}
765 hasError={rgbErrors[ch]}
766 errorOutlineColor={inputErrorOutlineColor}
767 />
768 ))}
769 </div>
770 )}
771 {format === "hsl" && (
772 <div className="grid grid-cols-3 gap-2">
773 {(["h", "s", "l"] as const).map((ch) => (
774 <FloatingLabelInput
775 key={ch}
776 value={hslInputs[ch]}
777 onChange={(v) => handleHslChannelChange(ch, v)}
778 onFocus={() => handleHslFocus(ch)}
779 onBlur={handleHslBlur}
780 label={ch === "h" ? hLabel : ch === "s" ? sLabel : lLabel}
781 isRTL={isRTL}
782 maxLength={3}
783 height={inputHeight}
784 bg={inputBg}
785 borderColor={inputBorderColor}
786 borderWidth={inputBorderWidth}
787 radius={inputRadius}
788 fontSize={floatingLabelMainTextSize}
789 textColor={inputTextColor}
790 focusBorderColor={floatingLabelFocusBorderColor}
791 floatingLabelBg={resolvedFloatingLabelBg}
792 floatingLabelTextColor={floatingLabelTextColor}
793 floatingLabelActiveTextColor={floatingLabelActiveTextColor}
794 floatingLabelRadius={floatingLabelRadius}
795 floatingLabelBorderColor={floatingLabelBorderColor}
796 floatingLabelBorderWidth={floatingLabelBorderWidth}
797 hasError={hslErrors[ch]}
798 errorOutlineColor={inputErrorOutlineColor}
799 />
800 ))}
801 </div>
802 )}
803 </div>
804 </div>
805 </div>
806
807 {/* 5. Contrast Row */}
808 {showContrast && (
809 <div
810 className="flex items-center justify-between"
811 style={{
812 marginTop: contrastAreaTopMargin || undefined
813 }}
814 >
815 <div className="flex items-center" style={{ gap: contrastItemGap }}>
816 {colorPreviewPosition === "contrast" && (
817 <div className="inline-flex items-center justify-center select-none" style={{
818 backgroundColor: hsvaToHex(hsva) || previewBgFallback,
819 width: previewWidth, height: previewHeight,
820 borderColor: previewBorderColor, borderWidth: previewBorderWidth,
821 borderStyle: "solid", borderRadius: previewRadius,
822 color: previewTextColor ?? autoPreviewTextColor,
823 fontSize: previewFontSize, fontWeight: previewFontWeight,
824 }}>
825 {colorPreviewAreaText}
826 </div>
827 )}
828 <div className="flex flex-col" style={{ gap: contrastLabelGap }}>
829 <span style={{fontSize: contrastLabelSize, color: contrastLabelColor, fontWeight: contrastLabelWeight}}>
830 {contrastLabel}
831 </span>
832 <span style={{fontSize: contrastValueSize, color: contrastValueColor, fontWeight: contrastValueWeight, fontFamily: "monospace"}}>
833 {formatContrast(contrastRatio)}
834 </span>
835 </div>
836 </div>
837 <div className="flex" style={{ gap: contrastBadgeGap }}>
838 {showContrastAALabel && (
839 <Badge
840 pass={contrastRatio >= 4.5}
841 label="AA"
842 badgeBorderWidth={badgeBorderWidth}
843 badgeBorderRadius={badgeBorderRadius}
844 badgeFontSize={badgeFontSize}
845 badgeFontWeight={badgeFontWeight}
846 badgeIconSize={badgeIconSize}
847 badgePadding={badgePadding}
848 badgeIconStrokeWidth={badgeIconStrokeWidth}
849 badgeBgPass={badgeBgPass}
850 badgeBgFail={badgeBgFail}
851 badgeBorderPass={badgeBorderPass}
852 badgeBorderFail={badgeBorderFail}
853 badgeTextPass={badgeTextPass}
854 badgeTextFail={badgeTextFail}
855 />
856 )}
857 {showContrastAAALabel && (
858 <Badge
859 pass={contrastRatio >= 7}
860 label="AAA"
861 badgeBorderWidth={badgeBorderWidth}
862 badgeBorderRadius={badgeBorderRadius}
863 badgeFontSize={badgeFontSize}
864 badgeFontWeight={badgeFontWeight}
865 badgeIconSize={badgeIconSize}
866 badgePadding={badgePadding}
867 badgeIconStrokeWidth={badgeIconStrokeWidth}
868 badgeBgPass={badgeBgPass}
869 badgeBgFail={badgeBgFail}
870 badgeBorderPass={badgeBorderPass}
871 badgeBorderFail={badgeBorderFail}
872 badgeTextPass={badgeTextPass}
873 badgeTextFail={badgeTextFail}
874 />
875 )}
876 </div>
877 </div>
878 )}
879 </div>
880 );
881}
882
883
884// --- Sub-Components ---
885
886function Badge({
887 pass,
888 label,
889 // All props now support string | number for sizes
890 badgeBorderWidth = 1,
891 badgeBorderRadius = "999px",
892 badgeFontSize = "10px",
893 badgeFontWeight = 700,
894 badgeIconSize = 10,
895 badgePadding = "0.25rem 0.5rem",
896 badgeIconStrokeWidth = 1.5,
897 badgeBgPass = "rgba(6, 181, 239, 0.1)",
898 badgeBgFail = "rgba(255, 59, 59, 0.1)",
899 badgeBorderPass = "rgba(6, 181, 239, 0.5)",
900 badgeBorderFail = "rgba(255, 59, 59, 0.5)",
901 badgeTextPass = "#06B5EF",
902 badgeTextFail = "#EF0641",
903}: {
904 pass: boolean;
905 label: string;
906 badgeBorderWidth?: number | string;
907 badgeBorderRadius?: number | string;
908 badgeFontSize?: number | string;
909 badgeFontWeight?: number;
910 badgeIconSize?: number | string;
911 badgePadding?: string;
912 badgeIconStrokeWidth?: number;
913 badgeBgPass?: string;
914 badgeBgFail?: string;
915 badgeBorderPass?: string;
916 badgeBorderFail?: string;
917 badgeTextPass?: string;
918 badgeTextFail?: string;
919}) {
920 const borderWidthStr = typeof badgeBorderWidth === 'number' ? `${badgeBorderWidth}px` : badgeBorderWidth;
921 const borderRadiusStr = typeof badgeBorderRadius === 'number' ? `${badgeBorderRadius}px` : badgeBorderRadius;
922 const fontSizeStr = typeof badgeFontSize === 'number' ? `${badgeFontSize}px` : badgeFontSize;
923 const iconSizeStr = typeof badgeIconSize === 'number' ? `${badgeIconSize}px` : badgeIconSize;
924
925 return (
926 <div
927 className="flex items-center gap-1 px-2 py-0.5 font-bold border select-none"
928 style={{
929 backgroundColor: pass ? badgeBgPass : badgeBgFail,
930 borderColor: pass ? badgeBorderPass : badgeBorderFail,
931 borderWidth: borderWidthStr,
932 borderRadius: borderRadiusStr,
933 fontSize: fontSizeStr,
934 fontWeight: badgeFontWeight,
935 padding: badgePadding,
936 color: pass ? badgeTextPass : badgeTextFail,
937 }}
938 >
939 {pass ? (
940 <CheckCircle2 size={Number(iconSizeStr.replace('px', ''))} strokeWidth={badgeIconStrokeWidth} />
941 ) : (
942 <XCircle size={Number(iconSizeStr.replace('px', ''))} strokeWidth={badgeIconStrokeWidth} />
943 )}
944 {label}
945 </div>
946 );
947}
948
949// --- FIXED Floating Label Input ---
950// IMMEDIATE parent prop sync + perfect color erasing respect
951
952type FloatingLabelInputProps = {
953 value: string;
954 onChange: (v: string) => void;
955 label: string;
956 isRTL: boolean;
957 maxLength?: number;
958 height: number;
959 bg: string;
960 borderColor: string; // ↠IMMEDIATELY responsive
961 borderWidth: number;
962 radius: number;
963 fontSize: number;
964 textColor: string;
965 focusBorderColor: string;
966 floatingLabelBg: string; // ↠IMMEDIATELY responsive
967 floatingLabelTextColor: string;
968 floatingLabelActiveTextColor: string;
969 floatingLabelRadius: number;
970 floatingLabelBorderColor?: string;
971 floatingLabelBorderWidth: number;
972 hasError?: boolean;
973 errorOutlineColor: string;
974};
975
976export const FloatingLabelInput: React.FC<FloatingLabelInputProps> = ({
977 value, onChange, label, isRTL, hasError = false,
978 maxLength = 64, height, bg, borderColor, borderWidth, radius, fontSize,
979 textColor, focusBorderColor, floatingLabelBg, floatingLabelTextColor,
980 floatingLabelActiveTextColor, floatingLabelRadius, floatingLabelBorderColor,
981 floatingLabelBorderWidth, errorOutlineColor,
982}) => {
983 const [isFocused, setIsFocused] = React.useState(false);
984 const hasValue = value.length > 0;
985
986 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
987 const newVal = e.target.value;
988 onChange(newVal);
989 };
990
991 // ✅ FIXED: IMMEDIATE parent prop reactivity
992 const labelTextColor = hasError
993 ? errorOutlineColor
994 : isFocused ? floatingLabelActiveTextColor : floatingLabelTextColor;
995
996 const currentBorderColor = hasError ? errorOutlineColor : borderColor;
997 const currentFocusBorderColor = hasError ? errorOutlineColor : focusBorderColor;
998
999 const inputRef = React.useRef<HTMLInputElement>(null);
1000
1001 // ✅ FIXED: useEffect for immediate parent prop changes
1002 React.useEffect(() => {
1003 if (inputRef.current) {
1004 inputRef.current.style.borderColor = isFocused
1005 ? currentFocusBorderColor
1006 : currentBorderColor;
1007 }
1008 }, [borderColor, focusBorderColor, hasError, errorOutlineColor, isFocused]);
1009
1010 // ✅ FIXED: Manual style updates respect parent changes instantly
1011 const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
1012 setIsFocused(true);
1013 e.currentTarget.style.borderColor = currentFocusBorderColor;
1014 };
1015
1016 const handleBlur = (e: React.BlurEvent<HTMLInputElement>) => {
1017 setIsFocused(false);
1018 e.currentTarget.style.borderColor = currentBorderColor;
1019 };
1020
1021 return (
1022 <div
1023 className={[
1024 "floating-input-group",
1025 isRTL ? "rtl" : "",
1026 isFocused ? "active" : "",
1027 hasValue ? "has-value" : "",
1028 ].join(" ")}
1029 style={{
1030 "--input-height": `${height}px`,
1031 "--input-font-size": `${fontSize}px`,
1032 "--label-font-size": `${fontSize}px`,
1033 "--label-active-font-size": `${fontSize * 0.75}px`,
1034 "--input-padding": "12px 16px 8px",
1035 "--label-padding": "0 8px",
1036 "--label-active-padding": "0 6px",
1037 "--input-outline": currentBorderColor,
1038 "--input-outline-focus": currentFocusBorderColor,
1039 "--foreground": textColor,
1040 "--muted-foreground": floatingLabelTextColor,
1041 "--accent-color": floatingLabelActiveTextColor,
1042 "--parent-background": floatingLabelBg, // ↠IMMEDIATELY updates
1043 "--general-rounding": `${radius}px`,
1044 }}
1045 >
1046 <input
1047 ref={inputRef}
1048 type="text"
1049 value={value}
1050 onChange={handleChange}
1051 className="floating-input"
1052 maxLength={maxLength}
1053 autoComplete="off" // ✅ Browser autofill prevention
1054 inputMode="text" // ✅ Better mobile
1055 spellCheck="false" // ✅ No autocorrect
1056 style={{
1057 backgroundColor: bg,
1058 borderRadius: radius,
1059 borderStyle: "solid",
1060 borderWidth,
1061 color: textColor,
1062 direction: "ltr",
1063 textAlign: isRTL ? "right" : "left",
1064 }}
1065 onFocus={handleFocus}
1066 onBlur={handleBlur}
1067 />
1068 <label
1069 className="floating-label"
1070 style={{
1071 color: labelTextColor, // ↠IMMEDIATELY responsive
1072 borderRadius: floatingLabelRadius,
1073 borderStyle: floatingLabelBorderColor ? "solid" : "none",
1074 borderColor: floatingLabelBorderColor,
1075 borderWidth: floatingLabelBorderWidth,
1076 backgroundColor: floatingLabelBg, // ↠EXPLICIT background
1077 }}
1078 >
1079 {label}
1080 </label>
1081
1082 <style jsx>{`
1083 .floating-input-group {
1084 position: relative;
1085 width: 100%;
1086 margin-top: 8px;
1087 }
1088
1089 .floating-input {
1090 width: 100%;
1091 height: var(--input-height);
1092 padding: var(--input-padding);
1093 font-size: var(--input-font-size);
1094 font-weight: 400;
1095 font-family: 'SF Mono', Monaco, 'Roboto Mono', monospace;
1096 color: var(--foreground);
1097 background: var(--parent-background);
1098 border: 2px solid var(--input-outline);
1099 border-radius: var(--general-rounding);
1100 outline: none;
1101 box-sizing: border-box;
1102 text-transform: uppercase;
1103 box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
1104 transition: border-color 0.15s ease; // ✅ Faster transition
1105 line-height: 1.4;
1106 }
1107
1108 .floating-input:focus {
1109 border-color: var(--input-outline-focus);
1110 }
1111
1112 .floating-input-group.rtl .floating-input {
1113 direction: rtl;
1114 text-align: right;
1115 }
1116
1117 .floating-label {
1118 position: absolute;
1119 left: 12px;
1120 top: 50%;
1121 transform: translateY(-50%);
1122 font-size: var(--label-font-size);
1123 pointer-events: none;
1124 padding: var(--label-padding);
1125 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1126 z-index: 2;
1127 white-space: nowrap;
1128 overflow: hidden;
1129 text-overflow: ellipsis;
1130 transform-origin: top left;
1131 }
1132
1133 .floating-input-group.rtl .floating-label {
1134 right: 12px;
1135 left: auto;
1136 transform-origin: top right;
1137 }
1138
1139 .floating-input-group:not(.active):not(.has-value) .floating-label {
1140 top: 50%;
1141 transform: translateY(-50%);
1142 font-size: var(--label-font-size);
1143 padding: var(--label-padding);
1144 }
1145
1146 .floating-input-group.active .floating-label,
1147 .floating-input-group.has-value .floating-label {
1148 top: 1px;
1149 left: 12px;
1150 font-size: var(--label-active-font-size);
1151 padding: var(--label-active-padding);
1152 }
1153
1154 .floating-input-group.rtl.active .floating-label,
1155 .floating-input-group.rtl.has-value .floating-label {
1156 right: 12px;
1157 left: auto;
1158 }
1159 `}</style>
1160 </div>
1161 );
1162};
1163
1164// --- Floating Label Select ---
1165type FloatingLabelSelectProps = {
1166 value: string;
1167 onChange: (v: string) => void;
1168 label: string;
1169 options: string[];
1170 isRTL: boolean;
1171 t: (key: string) => string;
1172 height: number;
1173 bg: string;
1174 borderColor: string;
1175 borderWidth: number;
1176 radius: number;
1177 fontSize: number;
1178 textColor: string;
1179 focusBorderColor: string;
1180 focusRingColor: string;
1181 chevronColor: string;
1182 floatingLabelBg: string;
1183 floatingLabelTextColor: string;
1184 floatingLabelActiveTextColor: string;
1185 floatingLabelRadius: number;
1186 floatingLabelBorderColor?: string;
1187 floatingLabelBorderWidth: number;
1188 menuBg: string;
1189 menuBorderColor: string;
1190 menuBorderWidth: number;
1191 menuRadius: number;
1192 menuTextColor: string;
1193 menuActiveTextColor: string;
1194 menuHoverBg: string;
1195 menuActiveBg: string;
1196};
1197
1198export const FloatingLabelSelect: React.FC<FloatingLabelSelectProps> = ({
1199 value,
1200 onChange,
1201 label,
1202 options,
1203 isRTL,
1204 t,
1205 height,
1206 bg,
1207 borderColor,
1208 borderWidth,
1209 radius,
1210 fontSize,
1211 textColor,
1212 focusBorderColor,
1213 chevronColor,
1214 floatingLabelBg,
1215 floatingLabelTextColor,
1216 floatingLabelActiveTextColor,
1217 floatingLabelRadius,
1218 floatingLabelBorderColor,
1219 floatingLabelBorderWidth,
1220 menuBg,
1221 menuBorderColor,
1222 menuBorderWidth,
1223 menuRadius,
1224 menuTextColor,
1225 menuActiveTextColor,
1226 menuHoverBg,
1227 menuActiveBg,
1228}) => {
1229 const [open, setOpen] = React.useState(false);
1230 const [isFocused, setIsFocused] = React.useState(false);
1231 const hasValue = value !== null && value !== undefined && value !== ""; // Tracks if select has selection
1232
1233 const buttonRef = React.useRef<HTMLButtonElement | null>(null);
1234 const listRef = React.useRef<HTMLDivElement | null>(null);
1235
1236 React.useEffect(() => {
1237 if (!open) return;
1238 const handler = (e: MouseEvent) => {
1239 if (
1240 !buttonRef.current?.contains(e.target as Node) &&
1241 !listRef.current?.contains(e.target as Node)
1242 ) {
1243 setOpen(false);
1244 }
1245 };
1246 window.addEventListener("mousedown", handler);
1247 return () => window.removeEventListener("mousedown", handler);
1248 }, [open]);
1249
1250 const currentLabel = value ? t(value) : label;
1251
1252 return (
1253 <div
1254 className={[
1255 "floating-select-group",
1256 isRTL ? "rtl" : "",
1257 isFocused ? "active" : "",
1258 hasValue ? "has-value" : "",
1259 ].join(" ")}
1260 style={{
1261 "--input-height": `${height}px`,
1262 "--input-font-size": `${fontSize}px`,
1263 "--label-font-size": `${fontSize}px`,
1264 "--label-active-font-size": `${fontSize * 0.75}px`,
1265 "--input-padding": "12px 16px 8px",
1266 "--label-padding": "0 8px",
1267 "--label-active-padding": "0 6px",
1268 "--input-outline": borderColor,
1269 "--input-outline-focus": focusBorderColor,
1270 "--foreground": textColor,
1271 "--muted-foreground": floatingLabelTextColor,
1272 "--accent-color": floatingLabelActiveTextColor,
1273 "--parent-background": floatingLabelBg,
1274 "--general-rounding": `${radius}px`,
1275 }}
1276 >
1277 <button
1278 ref={buttonRef}
1279 type="button"
1280 className="floating-select"
1281 style={{
1282 backgroundColor: bg,
1283 borderRadius: radius,
1284 borderStyle: "solid",
1285 borderWidth,
1286 color: textColor,
1287 direction: "ltr",
1288 textAlign: isRTL ? "right" : "left",
1289 }}
1290 onClick={() => setOpen((o) => !o)}
1291 onFocus={(e) => {
1292 setIsFocused(true);
1293 e.currentTarget.style.borderColor = focusBorderColor;
1294 }}
1295 onBlur={(e) => {
1296 setIsFocused(false);
1297 e.currentTarget.style.borderColor = borderColor;
1298 }}
1299 >
1300 <span className="truncate">{currentLabel}</span>
1301 <ChevronDown size={16} className="ml-2 shrink-0" color={chevronColor} />
1302 </button>
1303
1304 <label
1305 className="floating-label"
1306 style={{
1307 color: isFocused ? floatingLabelActiveTextColor : floatingLabelTextColor,
1308 borderRadius: floatingLabelRadius,
1309 borderStyle: floatingLabelBorderColor ? "solid" : "none",
1310 borderColor: floatingLabelBorderColor,
1311 borderWidth: floatingLabelBorderWidth,
1312 }}
1313 >
1314 {label}
1315 </label>
1316
1317 {open && (
1318 <div
1319 ref={listRef}
1320 className={cn(
1321 "absolute z-50 mt-1 py-1 shadow-2xl border min-w-[120px] max-h-48 overflow-auto",
1322 isRTL ? "right-0" : "left-0"
1323 )}
1324 style={{
1325 backgroundColor: menuBg,
1326 borderRadius: menuRadius,
1327 borderStyle: "solid",
1328 borderColor: menuBorderColor,
1329 borderWidth: menuBorderWidth,
1330 }}
1331 >
1332 {options.map((opt) => {
1333 const active = opt === value;
1334 return (
1335 <button
1336 key={opt}
1337 type="button"
1338 onClick={() => {
1339 onChange(opt);
1340 setOpen(false);
1341 }}
1342 className="w-full px-3 py-2 text-[11px] uppercase cursor-pointer flex items-center justify-between gap-2 transition-colors"
1343 style={{
1344 direction: isRTL ? "rtl" : "ltr",
1345 backgroundColor: active ? menuActiveBg : "transparent",
1346 color: active ? menuActiveTextColor : menuTextColor,
1347 }}
1348 onMouseEnter={(e) => {
1349 if (!active) e.currentTarget.style.backgroundColor = menuHoverBg;
1350 }}
1351 onMouseLeave={(e) => {
1352 if (!active) e.currentTarget.style.backgroundColor = "transparent";
1353 }}
1354 >
1355 <span className="truncate">{t(opt)}</span>
1356 {active && <Check size={12} />}
1357 </button>
1358 );
1359 })}
1360 </div>
1361 )}
1362
1363 <style jsx>{`
1364 .floating-select-group {
1365 position: relative;
1366 width: 100%;
1367 margin-top: 8px;
1368 }
1369
1370 .floating-select {
1371 width: 100%;
1372 height: var(--input-height);
1373 padding: var(--input-padding);
1374 font-size: var(--input-font-size);
1375 font-weight: 400;
1376 font-family: 'SF Mono', Monaco, 'Roboto Mono', monospace;
1377 color: var(--foreground);
1378 background: var(--parent-background);
1379 border: 2px solid var(--input-outline);
1380 border-radius: var(--general-rounding);
1381 outline: none;
1382 box-sizing: border-box;
1383 text-transform: uppercase;
1384 box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
1385 transition: border-color 0.3s ease;
1386 line-height: 1.4;
1387 cursor: pointer;
1388 display: flex;
1389 align-items: center;
1390 justify-content: space-between;
1391 }
1392
1393 .floating-select:focus {
1394 border-color: var(--input-outline-focus);
1395 }
1396
1397 .floating-select-group.rtl .floating-select {
1398 direction: rtl;
1399 text-align: right;
1400 }
1401
1402 .floating-label {
1403 position: absolute;
1404 left: 12px;
1405 top: 50%;
1406 transform: translateY(-50%);
1407 font-size: var(--label-font-size);
1408 pointer-events: none;
1409 background: var(--parent-background);
1410 padding: var(--label-padding);
1411 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1412 z-index: 2;
1413 white-space: nowrap;
1414 overflow: hidden;
1415 text-overflow: ellipsis;
1416 transform-origin: top left;
1417 }
1418
1419 .floating-select-group.rtl .floating-label {
1420 right: 12px;
1421 left: auto;
1422 transform-origin: top right;
1423 }
1424
1425 .floating-select-group:not(.active):not(.has-value) .floating-label {
1426 top: 50%;
1427 transform: translateY(-50%);
1428 font-size: var(--label-font-size);
1429 padding: var(--label-padding);
1430 }
1431
1432 .floating-select-group.active .floating-label,
1433 .floating-select-group.has-value .floating-label {
1434 top: 1px;
1435 left: 12px;
1436 font-size: var(--label-active-font-size);
1437 padding: var(--label-active-padding);
1438 }
1439
1440 .floating-select-group.rtl.active .floating-label,
1441 .floating-select-group.rtl.has-value .floating-label {
1442 right: 12px;
1443 left: auto;
1444 }
1445 `}</style>
1446 </div>
1447 );
1448};
1449
1450type ColorPreviewBoxProps = {
1451 label?: string;
1452 height?: string | number;
1453 width?: string | number;
1454 bg?: string;
1455 borderColor?: string;
1456 borderWidth?: string | number;
1457 radius?: string | number;
1458 fontSize?: string | number;
1459 fontWeight?: string | number;
1460 fontColor?: string;
1461};
1462
1463export const ColorPreviewBox: React.FC<ColorPreviewBoxProps> = ({
1464 label,
1465 height,
1466 width,
1467 bg,
1468 borderColor,
1469 borderWidth,
1470 radius,
1471 fontSize,
1472 fontWeight,
1473 fontColor,
1474}) => {
1475 return (
1476 <div
1477 className="relative flex items-center justify-center mt-2"
1478 style={{ width, height }}
1479 >
1480 <input
1481 type="text"
1482 readOnly
1483 tabIndex={-1}
1484 value=""
1485 className="absolute inset-0 font-mono shadow-inner uppercase transition-all outline-none"
1486 style={{
1487 pointerEvents: "none",
1488 userSelect: "none",
1489 backgroundColor: bg,
1490 borderRadius: radius,
1491 borderStyle: "solid",
1492 borderColor,
1493 borderWidth,
1494 color: fontColor,
1495 fontSize,
1496 fontWeight,
1497 margin: 0,
1498 }}
1499 />
1500 <label
1501 className="absolute text-center"
1502 style={{
1503 color: fontColor,
1504 fontSize,
1505 fontWeight,
1506 pointerEvents: "none",
1507 userSelect: "none",
1508 }}
1509 >
1510 {label}
1511 </label>
1512 </div>
1513 );
1514};
1515
1516export interface CustomSliderProps {
1517 id: string;
1518 min?: number;
1519 max?: number;
1520 step?: number;
1521 value: number;
1522 onValueChange: (value: number) => void;
1523 disabled?: boolean;
1524 trackHeight?: string;
1525 thumbWidth?: string;
1526 thumbHeight?: string;
1527 width?: string;
1528 trackBorderRadius?: string;
1529 trackBorderWidth?: string;
1530 trackBorderColor?: string;
1531 thumbBorderRadius?: string;
1532 thumbBorderWidth?: string;
1533 colorTrackBackground?: string;
1534 colorFillDefault?: string;
1535 colorFillHover?: string;
1536 colorFillActive?: string;
1537 colorThumbDefault?: string;
1538 colorThumbHover?: string;
1539 colorThumbActive?: string;
1540 colorThumbBorderDefault?: string;
1541 colorThumbBorderHover?: string;
1542 colorThumbBorderActive?: string;
1543 ariaLabel?: string;
1544 isRTL?: boolean;
1545 keyStep?: number;
1546}
1547
1548export function CustomSlider({
1549 id,
1550 min = 0,
1551 max = 100,
1552 step = 1,
1553 value,
1554 onValueChange,
1555 disabled = false,
1556 trackHeight = '8px',
1557 thumbWidth = '20px',
1558 thumbHeight = '20px',
1559 width = '100%',
1560 trackBorderRadius = '8px', // ✅ NEW DEFAULT
1561 trackBorderWidth = '0px', // ✅ NEW DEFAULT (0px = no border)
1562 trackBorderColor = 'transparent', // ✅ NEW DEFAULT
1563 thumbBorderRadius = '50%',
1564 thumbBorderWidth = '2px',
1565 colorTrackBackground = '#262626',
1566 colorFillDefault = '#00A7FA',
1567 colorFillHover = '#55C7FF',
1568 colorFillActive = '#55C7FF',
1569 colorThumbDefault = '#262626',
1570 colorThumbHover = '#121212',
1571 colorThumbActive = '#262626',
1572 colorThumbBorderDefault = '#0083C4',
1573 colorThumbBorderHover = '#0079B5',
1574 colorThumbBorderActive = '#FFFFFF',
1575 ariaLabel = 'slider',
1576 isRTL = false,
1577 keyStep = 1,
1578}: CustomSliderProps) {
1579 const sliderRef = useRef<HTMLDivElement>(null);
1580 const [isDragging, setIsDragging] = useState(false);
1581 const [isFocused, setIsFocused] = useState(false);
1582 const [isHovered, setIsHovered] = useState(false);
1583 const [sliderWidth, setSliderWidth] = useState(0);
1584
1585 const DEAD_ZONE = 8;
1586 const BASE_FILL_LENGTH = 20;
1587
1588 const updateSliderWidth = useCallback(() => {
1589 if (sliderRef.current) {
1590 setSliderWidth(sliderRef.current.clientWidth);
1591 }
1592 }, []);
1593
1594 useEffect(() => {
1595 updateSliderWidth();
1596 window.addEventListener('resize', updateSliderWidth);
1597 return () => window.removeEventListener('resize', updateSliderWidth);
1598 }, [updateSliderWidth]);
1599
1600 const getPercentage = useCallback(() => {
1601 return ((value - min) / (max - min)) * 100;
1602 }, [value, min, max]);
1603
1604 const handleInteraction = useCallback(
1605 (clientX: number) => {
1606 if (disabled || !sliderRef.current) return;
1607 const rect = sliderRef.current.getBoundingClientRect();
1608 const effectiveWidth = rect.width - DEAD_ZONE * 2;
1609 let percentage;
1610
1611 if (isRTL) {
1612 percentage = ((rect.right - clientX - DEAD_ZONE) / effectiveWidth) * 100;
1613 } else {
1614 percentage = ((clientX - rect.left - DEAD_ZONE) / effectiveWidth) * 100;
1615 }
1616
1617 percentage = Math.max(0, Math.min(100, percentage));
1618 let newValue = min + (percentage / 100) * (max - min);
1619 if (step !== 0) newValue = Math.round(newValue / step) * step;
1620 newValue = Math.max(min, Math.min(max, newValue));
1621 onValueChange(newValue);
1622 },
1623 [disabled, min, max, step, onValueChange, isRTL, DEAD_ZONE]
1624 );
1625
1626 const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
1627 if (disabled) return;
1628 e.preventDefault();
1629 setIsDragging(true);
1630 handleInteraction(e.clientX);
1631 sliderRef.current?.focus();
1632 };
1633
1634 const handleMouseMove = useCallback(
1635 (e: MouseEvent) => {
1636 if (isDragging) handleInteraction(e.clientX);
1637 },
1638 [isDragging, handleInteraction]
1639 );
1640
1641 const handleMouseUp = useCallback(() => {
1642 setIsDragging(false);
1643 }, []);
1644
1645 const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
1646 if (disabled) return;
1647 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
1648 e.preventDefault();
1649 const direction = (e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : -1) * (isRTL ? -1 : 1);
1650 const increment = keyStep ?? step;
1651 const newValue = Math.max(min, Math.min(max, value + direction * increment));
1652 onValueChange(newValue);
1653 }
1654 };
1655
1656 useEffect(() => {
1657 if (isDragging) {
1658 window.addEventListener('mousemove', handleMouseMove);
1659 window.addEventListener('mouseup', handleMouseUp);
1660 }
1661 return () => {
1662 window.removeEventListener('mousemove', handleMouseMove);
1663 window.removeEventListener('mouseup', handleMouseUp);
1664 };
1665 }, [handleMouseMove, handleMouseUp, isDragging]);
1666
1667 const percentage = getPercentage();
1668 const fillColor = isDragging || isFocused ? colorFillActive : isHovered ? colorFillHover : colorFillDefault;
1669 const thumbColor = isDragging || isFocused ? colorThumbActive : isHovered ? colorThumbHover : colorThumbDefault;
1670 const thumbBorderColor = isDragging || isFocused ? colorThumbBorderActive : isHovered ? colorThumbBorderHover : colorThumbBorderDefault;
1671
1672 const usableWidth = Math.max(sliderWidth - DEAD_ZONE * 2, 0);
1673 const computedFillWidth = (percentage / 100) * usableWidth;
1674 const baseFillWidth = Math.min(BASE_FILL_LENGTH, usableWidth);
1675
1676 const wrapperClass = `custom-slider-${id}-wrapper${disabled ? ' disabled' : ''}`;
1677 const trackClass = `custom-slider-${id}-track`;
1678 const rangeClass = `custom-slider-${id}-range`;
1679 const thumbClass = `custom-slider-${id}-thumb`;
1680
1681 return (
1682 <div className="w-full">
1683 <style jsx>{`
1684 .${wrapperClass} {
1685 position: relative;
1686 width: ${width};
1687 height: ${thumbHeight};
1688 display: flex;
1689 align-items: center;
1690 cursor: ${disabled ? 'not-allowed' : 'pointer'};
1691 touch-action: none;
1692 outline: none;
1693 }
1694 .${trackClass}, .${rangeClass} {
1695 position: absolute;
1696 height: ${trackHeight};
1697 top: 50%;
1698 transform: translateY(-50%);
1699 width: 100%;
1700 transition: background-color 0.2s ease;
1701 box-sizing: border-box;
1702 }
1703 .${trackClass} {
1704 background: ${colorTrackBackground} !important;
1705 border-radius: ${trackBorderRadius} !important; // ✅ NEW: Static rounding
1706 border: ${trackBorderWidth} solid ${trackBorderColor} !important; // ✅ NEW: Static border (0px = none)
1707 z-index: 1;
1708 }
1709 .${rangeClass} {
1710 background: ${fillColor};
1711 border-radius: ${trackBorderRadius}; // ✅ Matches track rounding
1712 ${isRTL ? 'right: 0;' : 'left: 0;'}
1713 width: ${percentage}%;
1714 z-index: 2;
1715 }
1716 .${thumbClass} {
1717 position: absolute;
1718 width: ${thumbWidth};
1719 height: ${thumbHeight};
1720 border-radius: ${thumbBorderRadius};
1721 top: 50%;
1722 transform: translateY(-50%);
1723 background: ${thumbColor};
1724 border: ${thumbBorderWidth} solid ${thumbBorderColor};
1725 box-shadow: 0 2px 6px rgba(0,0,0,0.2);
1726 transition: all 0.2s ease;
1727 z-index: 3;
1728 pointer-events: none;
1729 ${isRTL
1730 ? `right: calc(${DEAD_ZONE}px + ${computedFillWidth}px - ${parseFloat(thumbWidth.replace('px', ''))}px / 2);`
1731 : `left: calc(${DEAD_ZONE}px + ${computedFillWidth}px - ${parseFloat(thumbWidth.replace('px', ''))}px / 2);`
1732 }
1733 }
1734 .${wrapperClass}:hover .${thumbClass} {
1735 box-shadow: 0 4px 8px rgba(0,0,0,0.3);
1736 }
1737 `}</style>
1738
1739 <div
1740 ref={sliderRef}
1741 className={wrapperClass}
1742 onMouseDown={handleMouseDown}
1743 onMouseEnter={() => !disabled && setIsHovered(true)}
1744 onMouseLeave={() => setIsHovered(false)}
1745 onKeyDown={handleKeyDown}
1746 onFocus={() => setIsFocused(true)}
1747 onBlur={() => setIsFocused(false)}
1748 tabIndex={disabled ? -1 : 0}
1749 role="slider"
1750 aria-label={ariaLabel}
1751 aria-valuemin={min}
1752 aria-valuemax={max}
1753 aria-valuenow={value}
1754 aria-disabled={disabled}
1755 dir={isRTL ? 'rtl' : 'ltr'}
1756 >
1757 <div className={trackClass} />
1758 <div className={rangeClass} />
1759 <div className={thumbClass} />
1760 </div>
1761 </div>
1762 );
1763}