"use client"; import { Pipette } from "lucide-react"; import { AnimatePresence, LayoutGroup, type MotionValue, motion, type Transition, useAnimate, useReducedMotion, useSpring, useTransform, useVelocity, } from "motion/react"; import { Children, type ComponentPropsWithRef, createContext, isValidElement, type KeyboardEvent, type PointerEvent, type ReactNode, type RefObject, useContext, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { EASE_OUT, SPRING_GLIDE, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; import { useDismiss } from "@/lib/hooks/use-dismiss"; import { PresenceGate } from "@/lib/presence-gate"; import { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from "@/lib/touch"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // Color math // --------------------------------------------------------------------------- /** Hue 0-360, saturation and value 0-100, alpha 0-1. */ export type Hsva = { h: number; s: number; v: number; a: number }; /** Channels 0-255, alpha 0-1. */ export type Rgba = { r: number; g: number; b: number; a: number }; const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); const round = (n: number, places = 0) => { const f = 10 ** places; return Math.round(n * f) / f; }; export function hsvaToRgba({ h, s, v, a }: Hsva): Rgba { const sat = s / 100; const val = v / 100; const f = (n: number) => { const k = (n + h / 60) % 6; return val - val * sat * Math.max(0, Math.min(k, 4 - k, 1)); }; return { r: Math.round(f(5) * 255), g: Math.round(f(3) * 255), b: Math.round(f(1) * 255), a }; } /** `previous` keeps hue for greys and saturation for black, which RGB cannot carry. */ export function rgbaToHsva({ r, g, b, a }: Rgba, previous?: Hsva): Hsva { const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max(rn, gn, bn); const d = max - Math.min(rn, gn, bn); let h = previous?.h ?? 0; if (d) { if (max === rn) h = ((gn - bn) / d) % 6; else if (max === gn) h = (bn - rn) / d + 2; else h = (rn - gn) / d + 4; h = (h * 60 + 360) % 360; } const v = max * 100; const s = max ? (d / max) * 100 : (previous?.s ?? 0); return { h, s, v, a }; } const HEX_PATTERN = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; /** Parses #rgb, #rgba, #rrggbb and #rrggbbaa. Returns null for anything else. */ export function parseHex(input: string): Rgba | null { const match = HEX_PATTERN.exec(input.trim()); if (!match) return null; let hex = match[1]; if (hex.length <= 4) hex = [...hex].map((c) => c + c).join(""); const n = (i: number) => Number.parseInt(hex.slice(i, i + 2), 16); return { r: n(0), g: n(2), b: n(4), a: hex.length === 8 ? round(n(6) / 255, 2) : 1 }; } /** Lowercase #rrggbb, or #rrggbbaa when the color is translucent. */ export function formatHex({ r, g, b, a }: Rgba): string { const pair = (n: number) => Math.round(n).toString(16).padStart(2, "0"); return `#${pair(r)}${pair(g)}${pair(b)}${a < 1 ? pair(a * 255) : ""}`; } function hsvToHsl({ h, s, v }: Hsva) { const sv = s / 100; const vv = v / 100; const l = vv * (1 - sv / 2); const sl = l === 0 || l === 1 ? 0 : (vv - l) / Math.min(l, 1 - l); return { h, s: sl * 100, l: l * 100 }; } function hslToHsv(h: number, s: number, l: number, previous: Hsva, a: number): Hsva { const sl = s / 100; const ll = l / 100; const v = ll + sl * Math.min(ll, 1 - ll); return { h, s: v ? 2 * (1 - ll / v) * 100 : previous.s, v: v * 100, a }; } const BLACK: Rgba = { r: 0, g: 0, b: 0, a: 1 }; // --------------------------------------------------------------------------- // Root // --------------------------------------------------------------------------- type ColorPickerContextValue = { id: string; hsva: Hsva; rgba: Rgba; hex: string; alpha: boolean; disabled: boolean; update: (next: Hsva) => void; commit: () => void; open: boolean; setOpen: (open: boolean) => void; triggerRef: RefObject; }; const ColorPickerContext = createContext(null); function usePickerContext(component: string) { const context = useContext(ColorPickerContext); if (!context) throw new Error(`${component} must be used within ColorPicker`); return context; } /** Current color and a setter, for custom parts inside a ColorPicker. */ export function useColorPicker() { const { hex, rgba, hsva, alpha, disabled, update, commit } = usePickerContext("useColorPicker"); return { hex, rgba, hsva, alpha, disabled, /** Accepts any hex form. Invalid input is ignored. */ setColor: (value: string) => { const parsed = parseHex(value); if (!parsed) return; update(rgbaToHsva(parsed, hsva)); commit(); }, }; } export interface ColorPickerProps extends Omit, "onChange" | "defaultValue"> { /** Controlled hex color (#rgb, #rrggbb or #rrggbbaa). */ value?: string; defaultValue?: string; /** Fires on every change while dragging or typing, with a lowercase hex. */ onValueChange?: (value: string) => void; /** Fires once an interaction settles: pointer release, key, preset or field commit. */ onValueCommit?: (value: string) => void; /** Allow transparency. When false the value is always opaque. Default true. */ alpha?: boolean; /** Submits the hex value with a form under this name. */ name?: string; /** Controlled open state for ColorPickerTrigger + ColorPickerContent. */ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; } /** Give it an aria-label, or a legend through children. */ export function ColorPicker({ value, defaultValue = "#3478f6", onValueChange, onValueCommit, alpha = true, disabled = false, name, open: openProp, defaultOpen = false, onOpenChange, form, className, children, ...props }: ColorPickerProps) { const id = useId(); const triggerRef = useRef(null); const [openInternal, setOpenInternal] = useState(defaultOpen); const open = openProp ?? openInternal; const setOpen = (next: boolean) => { if (next === open) return; if (openProp === undefined) setOpenInternal(next); onOpenChange?.(next); }; const controlled = value !== undefined; const incoming = controlled ? value : defaultValue; const [state, setState] = useState(() => ({ source: incoming, hsva: rgbaToHsva(parseHex(incoming) ?? BLACK), })); // A controlled value the picker did not emit itself is re-read here, during // render, so the thumbs never paint a stale color for a frame. let hsva = state.hsva; if (controlled && value !== state.source) { const parsed = parseHex(value); hsva = parsed ? rgbaToHsva(parsed, state.hsva) : state.hsva; setState({ source: value, hsva }); } if (!alpha && hsva.a !== 1) hsva = { ...hsva, a: 1 }; const rgba = hsvaToRgba(hsva); const hex = formatHex(rgba); // Event handlers read the latest emitted hex, which may be ahead of render. const hexRef = useRef(hex); useLayoutEffect(() => { hexRef.current = hex; }, [hex]); const update = (next: Hsva) => { const clean: Hsva = { h: clamp(next.h, 0, 360), s: clamp(next.s, 0, 100), v: clamp(next.v, 0, 100), a: alpha ? clamp(next.a, 0, 1) : 1, }; const nextHex = formatHex(hsvaToRgba(clean)); const changed = nextHex !== hexRef.current; hexRef.current = nextHex; setState({ source: nextHex, hsva: clean }); if (changed) onValueChange?.(nextHex); }; return ( onValueCommit?.(hexRef.current), open, setOpen, triggerRef, }} >
[data-color-picker-trigger]]:w-fit has-[>[data-color-picker-trigger]]:self-start", disabled && "opacity-50", className, )} > {children} {name && }
); } // --------------------------------------------------------------------------- // Pointer + spring plumbing shared by the area and the sliders // --------------------------------------------------------------------------- function useGlide(target: number) { const reduce = useReducedMotion(); const spring = useSpring(target, SPRING_GLIDE); useEffect(() => { if (reduce) spring.jump(target); else spring.set(target); }, [reduce, spring, target]); return { spring, offset: useTransform(spring, (n) => `${n}%`) }; } function useDrag( onPoint: (x: number, y: number) => void, onEnd: () => void, disabled: boolean, ) { const railRef = useRef(null); const thumbRef = useRef(null); const draggingRef = useRef(false); const [dragging, setDragging] = useState(false); const point = (event: PointerEvent) => { const rect = railRef.current?.getBoundingClientRect(); if (!rect || rect.width === 0 || rect.height === 0) return; onPoint( clamp((event.clientX - rect.left) / rect.width, 0, 1), clamp((event.clientY - rect.top) / rect.height, 0, 1), ); }; const end = (event: PointerEvent) => { if (!draggingRef.current) return; releasePointer(event.currentTarget, event.pointerId); draggingRef.current = false; setDragging(false); onEnd(); }; return { railRef, thumbRef, dragging, handlers: { onPointerDown: (event: PointerEvent) => { if (disabled || event.button !== 0) return; draggingRef.current = true; setDragging(true); capturePointer(event.currentTarget, event.pointerId); thumbRef.current?.focus({ preventScroll: true }); point(event); }, onPointerMove: (event: PointerEvent) => { if (draggingRef.current) point(event); }, onPointerUp: end, onPointerCancel: end, }, }; } /** Arrow, Page and Home/End stepping. Returns the delta, or null when unhandled. */ function keyStep(event: KeyboardEvent, big: number): { axis: "x" | "y"; delta: number } | "min" | "max" | null { const step = event.shiftKey ? big : 1; switch (event.key) { case "ArrowRight": return { axis: "x", delta: step }; case "ArrowLeft": return { axis: "x", delta: -step }; case "ArrowUp": return { axis: "y", delta: step }; case "ArrowDown": return { axis: "y", delta: -step }; case "PageUp": return { axis: "y", delta: big }; case "PageDown": return { axis: "y", delta: -big }; case "Home": return "min"; case "End": return "max"; default: return null; } } const CHECKERBOARD = "repeating-conic-gradient(#d4d4d8 0% 25%, #fafafa 0% 50%) 0 0 / 8px 8px"; // The loupe lifts with a visible overshoot so it reads as picked up, unlike // the critically damped glide the thumb itself rides on. const LOUPE_LIFT: Transition = { type: "spring", stiffness: 520, damping: 24, mass: 0.6 }; const LOUPE_DROP: Transition = { duration: 0.12, ease: EASE_OUT }; // Glide velocity in %/s mapped to a pendulum swing, so the loupe trails the drag. const LOUPE_TILT_INPUT = [-400, 400]; const LOUPE_TILT_DEGREES = [16, -16]; function Thumb({ thumbRef, dragging, color, glide, x, y, className, ...props }: ComponentPropsWithRef<"div"> & { thumbRef: RefObject; dragging: boolean; color: string; /** Horizontal glide spring, read for the loupe's tilt. */ glide: MotionValue; x: MotionValue; y?: MotionValue; }) { const reduce = useReducedMotion(); const velocity = useVelocity(glide); const tilt = useSpring( useTransform(velocity, LOUPE_TILT_INPUT, LOUPE_TILT_DEGREES, { clamp: true }), SPRING_PRESS, ); const lifted = dragging && !reduce; return (
); } // --------------------------------------------------------------------------- // Saturation / brightness area // --------------------------------------------------------------------------- export interface ColorPickerAreaProps extends Omit, "children"> { /** Accessible name for the 2D thumb. Default "Saturation and brightness". */ label?: string; } // One surface: the trigger chip grows into the area and shrinks back into it. const MORPH: Transition = { type: "spring", duration: 0.5, bounce: 0.2 }; const AREA_RADIUS = 12; const CHIP_RADIUS = 6; /** True inside ColorPickerContent, where the area morphs out of the trigger chip. */ const MorphContext = createContext(false); export function ColorPickerArea({ label = "Saturation and brightness", className, style, ...props }: ColorPickerAreaProps) { const { id, hsva, rgba, hex, disabled, update, commit, open } = usePickerContext("ColorPickerArea"); const morph = useContext(MorphContext); const reduce = useReducedMotion(); const { railRef, thumbRef, dragging, handlers } = useDrag( (x, y) => update({ ...hsva, s: x * 100, v: (1 - y) * 100 }), commit, disabled, ); const x = useGlide(hsva.s); const y = useGlide(100 - hsva.v); const s = Math.round(hsva.s); const v = Math.round(hsva.v); const morphing = morph && !reduce; const rgb = `rgb(${rgba.r} ${rgba.g} ${rgba.b})`; return ( )} {...handlers} ref={railRef} layoutId={morphing ? `${id}-chip` : undefined} // Measure only when open flips: every drag frame would otherwise force a // layout read, and the close render must snapshot so the chip can morph // back from here and the exit can finish. layoutDependency={open} transition={MORPH} className={cn( "relative h-40 w-full touch-none", TOUCH_GESTURE_CLASS, disabled ? "cursor-not-allowed" : "cursor-crosshair", className, )} style={{ ...style, borderRadius: AREA_RADIUS, background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hsva.h} 100% 50%))`, }} > {/* Arriving from the chip, the solid color dissolves to reveal the spectrum behind it. */} {morphing && (