| 1 | import { |
| 2 | useId, |
| 3 | useRef, |
| 4 | useState, |
| 5 | type KeyboardEvent, |
| 6 | type PointerEvent, |
| 7 | type RefObject, |
| 8 | } from "react"; |
| 9 | |
| 10 | import type { HsvColor } from "../../lib/color"; |
| 11 | import { cn } from "../../lib/utils"; |
| 12 | |
| 13 | function 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. */ |
| 18 | function 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 | |
| 56 | type 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 | |
| 65 | export 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 | |
| 175 | export 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 | } |