1import {
2 useId,
3 useRef,
4 useState,
5 type KeyboardEvent,
6 type PointerEvent,
7 type RefObject,
8} from "react";
9
10import type { HsvColor } from "../../lib/color";
11import { cn } from "../../lib/utils";
12
13function clamp(value: number) {
14 return Math.min(1, Math.max(0, value));
15}
16
17/** Keep pointer capture and drag completion consistent across color controls. */
18function useColorDrag(
19 update: (event: PointerEvent<HTMLDivElement>) => void,
20 onInteractionEnd?: () => void,
21 focusTarget?: RefObject<HTMLElement | null>,
22) {
23 const [isDragging, setIsDragging] = useState(false);
24 const pointerId = useRef<number | null>(null);
25 const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
26 if (pointerId.current !== event.pointerId) return;
27 pointerId.current = null;
28 setIsDragging(false);
29 onInteractionEnd?.();
30 };
31 return {
32 // The thumb stays inside the control at its extremes, and only animates
33 // keyboard adjustments, never continuous pointer movement.
34 thumbTransition: isDragging
35 ? undefined
36 : "left 80ms linear, top 80ms linear, background-color 80ms linear",
37 handlers: {
38 onPointerDown(event: PointerEvent<HTMLDivElement>) {
39 if (pointerId.current !== null || event.button !== 0) return;
40 pointerId.current = event.pointerId;
41 (focusTarget?.current ?? event.currentTarget).focus({ preventScroll: true });
42 event.currentTarget.setPointerCapture(event.pointerId);
43 setIsDragging(true);
44 update(event);
45 },
46 onPointerMove(event: PointerEvent<HTMLDivElement>) {
47 if (pointerId.current === event.pointerId) update(event);
48 },
49 onPointerUp: stopDragging,
50 onPointerCancel: stopDragging,
51 onLostPointerCapture: stopDragging,
52 },
53 };
54}
55
56type ColorControlProps<T> = {
57 label: string;
58 value: T;
59 onChange: (value: T) => void;
60 /** Flush pending consumer updates when pointer interaction finishes. */
61 onInteractionEnd?: () => void;
62 className?: string;
63};
64
65export function ColorSaturationValuePlane({
66 label,
67 value,
68 onChange,
69 onInteractionEnd,
70 className,
71 variant = "inset",
72}: ColorControlProps<HsvColor> & { variant?: "inset" | "edge" }) {
73 const instructionsId = useId();
74 const saturationRef = useRef<HTMLInputElement>(null);
75 const { handlers, thumbTransition } = useColorDrag(
76 (event) => {
77 const bounds = event.currentTarget.getBoundingClientRect();
78 if (!bounds.width || !bounds.height) return;
79 onChange({
80 ...value,
81 s: clamp((event.clientX - bounds.left) / bounds.width),
82 v: 1 - clamp((event.clientY - bounds.top) / bounds.height),
83 });
84 },
85 onInteractionEnd,
86 saturationRef,
87 );
88
89 const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>, axis: "s" | "v") => {
90 const step = event.shiftKey ? 0.1 : 0.02;
91 let nextValue: number;
92 switch (event.key) {
93 case "ArrowRight":
94 case "ArrowUp":
95 nextValue = clamp(value[axis] + step);
96 break;
97 case "ArrowLeft":
98 case "ArrowDown":
99 nextValue = clamp(value[axis] - step);
100 break;
101 case "Home":
102 nextValue = 0;
103 break;
104 case "End":
105 nextValue = 1;
106 break;
107 default:
108 return;
109 }
110 event.preventDefault();
111 onChange({ ...value, [axis]: nextValue });
112 };
113
114 return (
115 <div
116 aria-label={`${label} saturation and brightness`}
117 role="group"
118 className={cn(
119 "relative cursor-crosshair touch-none overflow-hidden bg-[linear-gradient(to_top,#000,transparent),linear-gradient(to_right,#fff,transparent)] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-inset has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-popover",
120 variant === "edge" ? "h-36 rounded-none" : "h-32 rounded-lg",
121 className,
122 )}
123 style={{
124 backgroundColor: `hsl(${value.h} 100% 50%)`,
125 }}
126 {...handlers}
127 >
128 <span id={instructionsId} className="sr-only">
129 Use arrow keys to adjust the focused value. Hold Shift for larger steps. Use Home and End
130 for the minimum and maximum. Press Tab to move between saturation and brightness.
131 </span>
132 {(
133 [
134 ["s", "Saturation"],
135 ["v", "Brightness"],
136 ] as const
137 ).map(([axis, axisLabel]) => (
138 <label key={axis} className="contents">
139 <input
140 ref={axis === "s" ? saturationRef : undefined}
141 type="range"
142 min={0}
143 max={100}
144 step="any"
145 value={value[axis] * 100}
146 aria-label={`${label} ${axisLabel.toLowerCase()}`}
147 aria-describedby={instructionsId}
148 aria-valuetext={`${Math.round(value[axis] * 100)}%`}
149 className="peer sr-only"
150 onKeyDown={(event) => handleKeyDown(event, axis)}
151 onChange={(event) =>
152 onChange({ ...value, [axis]: event.currentTarget.valueAsNumber / 100 })
153 }
154 />
155 <span
156 aria-hidden
157 className="pointer-events-none invisible absolute bottom-2 left-2 z-10 rounded bg-popover px-1.5 py-0.5 text-xs text-popover-foreground peer-focus-visible:visible"
158 >
159 {axisLabel} {Math.round(value[axis] * 100)}%
160 </span>
161 </label>
162 ))}
163 <span
164 className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
165 style={{
166 left: `calc(${value.s} * (100% - 0.75rem) + 0.375rem)`,
167 top: `calc(${1 - value.v} * (100% - 0.75rem) + 0.375rem)`,
168 transition: thumbTransition,
169 }}
170 />
171 </div>
172 );
173}
174
175export function ColorHueSlider({
176 label,
177 value,
178 onChange,
179 onInteractionEnd,
180 className,
181}: ColorControlProps<number>) {
182 const { handlers, thumbTransition } = useColorDrag((event) => {
183 const bounds = event.currentTarget.getBoundingClientRect();
184 if (!bounds.width) return;
185 onChange(clamp((event.clientX - bounds.left) / bounds.width) * 360);
186 }, onInteractionEnd);
187
188 const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
189 if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return;
190 event.preventDefault();
191 const step = event.shiftKey ? 10 : 1;
192 const direction = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
193 onChange((value + direction * step + 360) % 360);
194 };
195
196 return (
197 <div
198 aria-label={label}
199 aria-valuemax={360}
200 aria-valuemin={0}
201 aria-valuenow={Math.round(value)}
202 className={cn(
203 "relative flex h-6 cursor-pointer touch-none items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
204 className,
205 )}
206 role="slider"
207 tabIndex={0}
208 onKeyDown={handleKeyDown}
209 {...handlers}
210 >
211 <span
212 aria-hidden
213 className="h-2.5 w-full rounded-full bg-[linear-gradient(to_right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)] shadow-[inset_0_0_0_1px_rgb(0_0_0_/_12%)]"
214 />
215 <span
216 className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
217 style={{
218 left: `calc(${value / 360} * (100% - 1rem) + 0.5rem)`,
219 backgroundColor: `hsl(${value} 100% 50%)`,
220 transition: thumbTransition,
221 }}
222 />
223 </div>
224 );
225}