{"$schema":"https://ui.logic2b.com/schema/registry-item.json","name":"color-picker","type":"registry:ui","title":"Color Picker","description":"A self-contained HSV color picker: a draggable saturation/value area, a hue slider and a hex input. Controlled via value/onValueChange or uncontrolled via defaultValue; emits #rrggbb. Pointer-driven, zero dependencies.","registryDependencies":["utils"],"files":[{"path":"ui/color-picker.tsx","type":"registry:ui","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* ---------------------------------------------------------------- color math */\n\ntype Hsv = [h: number, s: number, v: number]\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n))\n\nfunction hsvToRgb(h: number, s: number, v: number): [number, number, number] {\n  const c = v * s\n  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n  const m = v - c\n  let r = 0\n  let g = 0\n  let b = 0\n  if (h < 60) [r, g, b] = [c, x, 0]\n  else if (h < 120) [r, g, b] = [x, c, 0]\n  else if (h < 180) [r, g, b] = [0, c, x]\n  else if (h < 240) [r, g, b] = [0, x, c]\n  else if (h < 300) [r, g, b] = [x, 0, c]\n  else [r, g, b] = [c, 0, x]\n  return [\n    Math.round((r + m) * 255),\n    Math.round((g + m) * 255),\n    Math.round((b + m) * 255),\n  ]\n}\n\nfunction rgbToHex(r: number, g: number, b: number): string {\n  return (\n    \"#\" +\n    [r, g, b].map((n) => n.toString(16).padStart(2, \"0\")).join(\"\")\n  )\n}\n\nfunction hexToHsv(hex: string): Hsv | null {\n  const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())\n  if (!m) return null\n  const int = parseInt(m[1], 16)\n  let r = (int >> 16) & 255\n  let g = (int >> 8) & 255\n  let b = int & 255\n  r /= 255\n  g /= 255\n  b /= 255\n  const max = Math.max(r, g, b)\n  const min = Math.min(r, g, b)\n  const d = max - min\n  let h = 0\n  if (d) {\n    if (max === r) h = ((g - b) / d) % 6\n    else if (max === g) h = (b - r) / d + 2\n    else h = (r - g) / d + 4\n    h *= 60\n    if (h < 0) h += 360\n  }\n  return [h, max === 0 ? 0 : d / max, max]\n}\n\nconst hsvToHex = ([h, s, v]: Hsv) => rgbToHex(...hsvToRgb(h, s, v))\n\n/* ------------------------------------------------------------------ component */\n\nexport interface ColorPickerProps\n  extends Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Current color as a `#rrggbb` hex string (controlled). */\n  value?: string\n  defaultValue?: string\n  onValueChange?: (hex: string) => void\n}\n\n/**\n * A self-contained HSV color picker: a saturation/value area, a hue slider and\n * a hex input. Controlled via `value`/`onValueChange` or uncontrolled via\n * `defaultValue`. Emits `#rrggbb`. Zero dependencies.\n */\nfunction ColorPicker({\n  className,\n  value,\n  defaultValue,\n  onValueChange,\n  ...props\n}: ColorPickerProps) {\n  const [hsv, setHsv] = React.useState<Hsv>(\n    () => hexToHsv(value ?? defaultValue ?? \"#000000\") ?? [0, 0, 0]\n  )\n  const hex = hsvToHex(hsv)\n  const lastEmit = React.useRef(hex)\n  const [hexDraft, setHexDraft] = React.useState(hex)\n\n  // Sync from a controlled value that changed outside our own emissions.\n  React.useEffect(() => {\n    if (value && value.toLowerCase() !== lastEmit.current.toLowerCase()) {\n      const parsed = hexToHsv(value)\n      if (parsed) {\n        setHsv(parsed)\n        setHexDraft(hsvToHex(parsed))\n      }\n    }\n  }, [value])\n\n  const commit = (next: Hsv) => {\n    setHsv(next)\n    const h = hsvToHex(next)\n    setHexDraft(h)\n    lastEmit.current = h\n    onValueChange?.(h)\n  }\n\n  const [hue, sat, val] = hsv\n  const svRef = React.useRef<HTMLDivElement>(null)\n  const hueRef = React.useRef<HTMLDivElement>(null)\n\n  const moveSv = (e: React.PointerEvent) => {\n    const rect = svRef.current?.getBoundingClientRect()\n    if (!rect) return\n    commit([\n      hue,\n      clamp01((e.clientX - rect.left) / rect.width),\n      clamp01(1 - (e.clientY - rect.top) / rect.height),\n    ])\n  }\n\n  const moveHue = (e: React.PointerEvent) => {\n    const rect = hueRef.current?.getBoundingClientRect()\n    if (!rect) return\n    commit([clamp01((e.clientX - rect.left) / rect.width) * 360, sat, val])\n  }\n\n  const startDrag =\n    (handler: (e: React.PointerEvent) => void) => (e: React.PointerEvent) => {\n      e.currentTarget.setPointerCapture(e.pointerId)\n      handler(e)\n    }\n\n  const hueHex = hsvToHex([hue, 1, 1])\n\n  return (\n    <div\n      data-slot=\"color-picker\"\n      className={cn(\"flex w-56 flex-col gap-3\", className)}\n      {...props}\n    >\n      {/* Saturation / value area */}\n      <div\n        ref={svRef}\n        data-slot=\"color-picker-area\"\n        onPointerDown={startDrag(moveSv)}\n        onPointerMove={(e) => e.buttons === 1 && moveSv(e)}\n        className=\"relative h-40 w-full cursor-crosshair touch-none overflow-hidden rounded-md border\"\n        style={{\n          backgroundColor: hueHex,\n          backgroundImage:\n            \"linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)\",\n        }}\n      >\n        <span\n          className=\"pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm ring-1 ring-black/30\"\n          style={{ left: `${sat * 100}%`, top: `${(1 - val) * 100}%` }}\n        />\n      </div>\n\n      {/* Hue slider */}\n      <div\n        ref={hueRef}\n        data-slot=\"color-picker-hue\"\n        onPointerDown={startDrag(moveHue)}\n        onPointerMove={(e) => e.buttons === 1 && moveHue(e)}\n        className=\"relative h-3 w-full cursor-pointer touch-none rounded-full border\"\n        style={{\n          backgroundImage:\n            \"linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)\",\n        }}\n      >\n        <span\n          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-sm ring-1 ring-black/30\"\n          style={{ left: `${(hue / 360) * 100}%` }}\n        />\n      </div>\n\n      {/* Swatch + hex input */}\n      <div className=\"flex items-center gap-2\">\n        <span\n          data-slot=\"color-picker-swatch\"\n          className=\"size-8 shrink-0 rounded-md border\"\n          style={{ backgroundColor: hex }}\n        />\n        <input\n          data-slot=\"color-picker-hex\"\n          aria-label=\"Hex color\"\n          value={hexDraft}\n          spellCheck={false}\n          onChange={(e) => {\n            const next = e.target.value\n            setHexDraft(next)\n            const parsed = hexToHsv(next)\n            if (parsed) commit(parsed)\n          }}\n          onBlur={() => setHexDraft(hex)}\n          className={cn(\n            \"border-input h-8 w-full min-w-0 rounded-md border bg-transparent px-2 font-mono text-sm uppercase shadow-xs outline-none\",\n            \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\"\n          )}\n        />\n      </div>\n    </div>\n  )\n}\n\nexport { ColorPicker }\n"}],"categories":["form"],"accessibility":{"support":"authored","pattern":"color input","keyboard":[],"aria":["The hexadecimal text input has an accessible name and provides a keyboard-editable fallback."],"consumer":["Add a visible label and communicate validation for invalid hex values."],"limitations":["The saturation/value area and hue rail are pointer-only; keyboard users must edit the hex field."]},"api":{"source":"src/ui/color-picker.tsx","exports":[{"name":"ColorPickerProps","kind":"type","definition":"interface ColorPickerProps extends Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> { /** Current color as a `#rrggbb` hex string (controlled). */ value?: string defaultValue?: string onValueChange?: (hex: string) => void }"},{"name":"ColorPicker","kind":"component","description":"A self-contained HSV color picker: a saturation/value area, a hue slider and a hex input. Controlled via `value`/`onValueChange` or uncontrolled via `defaultValue`. Emits `#rrggbb`. Zero dependencies.","propsType":"ColorPickerProps","props":[{"name":"value","type":"string","required":false,"description":"Current color as a `#rrggbb` hex string (controlled)."},{"name":"defaultValue","type":"string","required":false},{"name":"onValueChange","type":"(hex: string) => void","required":false}]}]}}