{
  "name": "color-picker",
  "type": "registry:ui",
  "title": "Color Picker",
  "description": "Color Picker lets users choose and submit short hexadecimal color values through a visual control.",
  "registryDependencies": [
    "@siteplane/base",
    "@siteplane/button",
    "@siteplane/input",
    "@siteplane/popover"
  ],
  "dependencies": [
    "@uiw/react-color@2.10.3",
    "class-variance-authority@0.7.1",
    "lucide-react@1.18.0"
  ],
  "files": [
    {
      "path": "src/components/ui/action-icons.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { EyeIcon, EyeOffIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type CopyIconPhase = \"idle\" | \"check\" | \"check-out\" | \"redraw\";\n\nexport function useCopyIconPhase() {\n  const [copyPhase, setCopyPhase] = useState<CopyIconPhase>(\"idle\");\n  const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n  useEffect(() => {\n    return () => {\n      for (const timer of timersRef.current) {\n        clearTimeout(timer);\n      }\n    };\n  }, []);\n\n  function playCopyFeedback() {\n    for (const timer of timersRef.current) {\n      clearTimeout(timer);\n    }\n\n    setCopyPhase(\"check\");\n    timersRef.current = [\n      setTimeout(() => setCopyPhase(\"check-out\"), 1300),\n      setTimeout(() => setCopyPhase(\"redraw\"), 1550),\n      setTimeout(() => setCopyPhase(\"idle\"), 2400),\n    ];\n  }\n\n  return {\n    copyPhase,\n    isShowingCheck: copyPhase === \"check\" || copyPhase === \"check-out\",\n    playCopyFeedback,\n  };\n}\n\nexport function CopyRedrawIcon({\n  className,\n  phase,\n}: {\n  className?: string;\n  phase: CopyIconPhase;\n}) {\n  return (\n    <span\n      className={cn(\"siteplane-copy relative inline-flex size-4\", className)}\n      data-phase={phase}\n    >\n      <svg\n        aria-hidden=\"true\"\n        className=\"siteplane-copy-check absolute inset-0 size-4 !mx-0 text-success-foreground opacity-0\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"2\"\n        viewBox=\"0 0 24 24\"\n      >\n        <path d=\"M20 6 9 17l-5-5\" pathLength={1} />\n      </svg>\n      <svg\n        aria-hidden=\"true\"\n        className=\"siteplane-copy-draw absolute inset-0 size-4 !mx-0\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"2\"\n        viewBox=\"0 0 24 24\"\n      >\n        <path\n          d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"\n          data-draw=\"1\"\n          pathLength={1}\n        />\n        <rect\n          data-draw=\"2\"\n          height=\"14\"\n          pathLength={1}\n          rx=\"2\"\n          ry=\"2\"\n          width=\"14\"\n          x=\"8\"\n          y=\"8\"\n        />\n      </svg>\n    </span>\n  );\n}\n\nexport function RevealIcon({\n  className,\n  revealed,\n}: {\n  className?: string;\n  revealed: boolean;\n}) {\n  return revealed ? (\n    <EyeOffIcon className={cn(\"siteplane-icon-pop size-4\", className)} />\n  ) : (\n    <EyeIcon className={cn(\"siteplane-icon-pop size-4\", className)} />\n  );\n}\n"
    },
    {
      "path": "src/components/ui/color-picker.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport type * as React from \"react\";\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport {\n  Hue,\n  Saturation,\n  hexToHsva,\n  hsvaToHex,\n  hsvaToRgba,\n  rgbToHex,\n  validHex,\n} from \"@uiw/react-color\";\nimport type { HsvaColor } from \"@uiw/react-color\";\nimport {\n  CopyRedrawIcon,\n  useCopyIconPhase,\n} from \"@/components/ui/action-icons\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"@/components/ui/siteplane-provider\";\nimport { Input } from \"@/components/ui/input\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\n\nconst colorPickerTriggerSizeVariants = cva(\"\", {\n  defaultVariants: {\n    size: \"default\",\n  },\n  variants: {\n    size: {\n      default: \"h-9 gap-2 sm:h-8\",\n      lg: \"h-10 gap-2.5 sm:h-9\",\n      sm: \"h-8 gap-1.5 sm:h-7\",\n      xl: \"h-14 gap-3\",\n    },\n  },\n});\n\nconst colorPickerValueSizeVariants = cva(\"\", {\n  defaultVariants: {\n    size: \"default\",\n  },\n  variants: {\n    size: {\n      default: \"w-fit min-w-36 max-w-40 ps-1.5 pe-2.5\",\n      lg: \"w-fit min-w-40 max-w-44 ps-1.5 pe-3\",\n      sm: \"w-fit min-w-32 max-w-36 ps-1.5 pe-2\",\n      xl: \"w-fit min-w-44 max-w-48 ps-2 pe-3.5\",\n    },\n  },\n});\n\nconst colorPickerSwatchOnlySizeVariants = cva(\"justify-center\", {\n  defaultVariants: {\n    size: \"default\",\n  },\n  variants: {\n    size: {\n      default: \"size-9 p-1.5\",\n      lg: \"size-10 p-1.5 sm:size-9\",\n      sm: \"size-8 p-1.5 sm:size-7\",\n      xl: \"size-14 p-2\",\n    },\n  },\n});\n\nconst colorPickerSwatchSizeVariants = cva(\n  \"shrink-0 rounded-[calc(var(--radius)-4px)] border border-border\",\n  {\n    defaultVariants: {\n      size: \"default\",\n    },\n    variants: {\n      size: {\n        default: \"size-6\",\n        lg: \"size-7 sm:size-6\",\n        sm: \"size-5 sm:size-4.5\",\n        xl: \"size-8 sm:size-7\",\n      },\n    },\n  },\n);\n\nconst colorPickerContentSizeVariants = cva(\"\", {\n  defaultVariants: {\n    size: \"default\",\n  },\n  variants: {\n    size: {\n      default: \"w-64\",\n      lg: \"w-72\",\n      sm: \"w-60\",\n      xl: \"w-80\",\n    },\n  },\n});\n\nconst colorPickerSaturationSizeVariants = cva(\n  \"w-full overflow-hidden rounded-lg border border-border bg-clip-padding\",\n  {\n    defaultVariants: {\n      size: \"default\",\n    },\n    variants: {\n      size: {\n        default: \"h-32\",\n        lg: \"h-36\",\n        sm: \"h-28\",\n        xl: \"h-40\",\n      },\n    },\n  },\n);\n\nexport interface ColorPickerProps\n  extends Omit<\n    React.ComponentProps<\"input\">,\n    \"type\" | \"value\" | \"onChange\" | \"defaultValue\" | \"size\"\n  >,\n    VariantProps<typeof colorPickerTriggerSizeVariants> {\n  /** Controlled hex value. Omit for uncontrolled usage. */\n  value?: string;\n  /** Uncontrolled initial hex value. */\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  showValue?: boolean;\n}\n\ntype ColorPointerProps = {\n  left?: React.CSSProperties[\"left\"];\n  top?: React.CSSProperties[\"top\"];\n  color?: string;\n};\n\ntype ColorCommitOptions = {\n  defer?: boolean;\n};\n\nexport function ColorPicker({\n  value,\n  defaultValue = \"#000000\",\n  onValueChange,\n  showValue = true,\n  size,\n  className,\n  disabled,\n  form,\n  id,\n  name,\n  required,\n  style,\n  tabIndex,\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  ...inputProps\n}: ColorPickerProps): React.ReactElement {\n  const isControlled = value !== undefined;\n  const resolvedSize = useResolvedControlSize(size);\n  const { copyPhase, isShowingCheck, playCopyFeedback } = useCopyIconPhase();\n  const [internal, setInternal] = useState(() => normalizeHex(defaultValue));\n  const current = normalizeHex(isControlled ? value : internal);\n  const [draftHex, setDraftHex] = useState<string | null>(null);\n  const hexInputValue = draftHex ?? current.toUpperCase();\n  const hsva = hexToHsva(current);\n  const rgba = hsvaToRgba(hsva);\n  const animationFrameRef = useRef<number | null>(null);\n  const queuedHexRef = useRef<string | null>(null);\n  const currentRef = useRef(current);\n  const isControlledRef = useRef(isControlled);\n  const onValueChangeRef = useRef(onValueChange);\n\n  useEffect(() => {\n    currentRef.current = current;\n  }, [current]);\n\n  useEffect(() => {\n    isControlledRef.current = isControlled;\n  }, [isControlled]);\n\n  useEffect(() => {\n    onValueChangeRef.current = onValueChange;\n  }, [onValueChange]);\n\n  const cancelQueuedColorCommit = useCallback(() => {\n    if (animationFrameRef.current !== null) {\n      cancelAnimationFrame(animationFrameRef.current);\n      animationFrameRef.current = null;\n    }\n\n    queuedHexRef.current = null;\n  }, []);\n\n  const flushColorCommit = useCallback(\n    (nextHex: string) => {\n      if (!isControlledRef.current) {\n        setInternal(nextHex);\n      }\n\n      setDraftHex(null);\n\n      onValueChangeRef.current?.(nextHex);\n    },\n    [setDraftHex, setInternal],\n  );\n\n  const commitColorChange = useCallback(\n    (nextValue: string, { defer = false }: ColorCommitOptions = {}) => {\n      const nextHex = normalizeHex(nextValue, currentRef.current);\n\n      if (!defer) {\n        cancelQueuedColorCommit();\n        flushColorCommit(nextHex);\n        return;\n      }\n\n      queuedHexRef.current = nextHex;\n\n      if (animationFrameRef.current !== null) {\n        return;\n      }\n\n      animationFrameRef.current = requestAnimationFrame(() => {\n        animationFrameRef.current = null;\n\n        const queuedHex = queuedHexRef.current;\n        queuedHexRef.current = null;\n\n        if (queuedHex) {\n          flushColorCommit(queuedHex);\n        }\n      });\n    },\n    [cancelQueuedColorCommit, flushColorCommit],\n  );\n\n  useEffect(() => cancelQueuedColorCommit, [cancelQueuedColorCommit]);\n\n  const updateValue = (nextValue: string) => {\n    commitColorChange(nextValue);\n  };\n\n  const updateHsvaFromDrag = (nextHsva: HsvaColor) => {\n    commitColorChange(hsvaToHex({ ...nextHsva, a: 1 }), { defer: true });\n  };\n\n  const updateChannel = (channel: \"r\" | \"g\" | \"b\", nextValue: number) => {\n    updateValue(\n      rgbToHex({\n        b: channel === \"b\" ? nextValue : rgba.b,\n        g: channel === \"g\" ? nextValue : rgba.g,\n        r: channel === \"r\" ? nextValue : rgba.r,\n      }),\n    );\n  };\n\n  const copyHexValue = async () => {\n    if (await copyTextToClipboard(current)) {\n      playCopyFeedback();\n    }\n  };\n\n  const triggerAriaLabel =\n    ariaLabel ?? (ariaLabelledBy ? undefined : `Choose color ${current.toUpperCase()}`);\n\n  return (\n    <>\n      <Popover>\n        <PopoverTrigger\n          aria-describedby={ariaDescribedBy}\n          aria-invalid={ariaInvalid}\n          aria-label={triggerAriaLabel}\n          aria-labelledby={ariaLabelledBy}\n          aria-required={required || undefined}\n          className={cn(\n            \"relative inline-flex min-w-0 cursor-pointer items-center rounded-lg border border-input bg-background text-sm text-foreground shadow-xs/5 outline-none transition-shadow hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 disabled:cursor-not-allowed disabled:opacity-64\",\n            colorPickerTriggerSizeVariants({ size: resolvedSize }),\n            showValue\n              ? colorPickerValueSizeVariants({ size: resolvedSize })\n              : colorPickerSwatchOnlySizeVariants({ size: resolvedSize }),\n            className,\n          )}\n          data-size={resolvedSize}\n          data-slot=\"color-picker\"\n          disabled={disabled}\n          id={id}\n          style={style}\n          tabIndex={tabIndex}\n          type=\"button\"\n        >\n          <span\n            aria-hidden=\"true\"\n            className={colorPickerSwatchSizeVariants({ size: resolvedSize })}\n            style={{ backgroundColor: current }}\n          />\n          {showValue ? (\n            <span className=\"select-none font-mono text-muted-foreground text-xs uppercase in-[[data-size=lg]]:text-sm\">\n              {current}\n            </span>\n          ) : null}\n        </PopoverTrigger>\n        <PopoverContent\n          align=\"start\"\n          className={cn(\n            \"w-64 [--viewport-inline-padding:--spacing(3)]\",\n            colorPickerContentSizeVariants({ size: resolvedSize }),\n          )}\n          sideOffset={6}\n        >\n          <div className=\"grid gap-3\" data-slot=\"color-picker-panel\">\n            <div\n              className={colorPickerSaturationSizeVariants({ size: resolvedSize })}\n              data-slot=\"color-picker-saturation-field\"\n            >\n              <Saturation\n                aria-label=\"Color saturation and brightness\"\n                className=\"size-full\"\n                data-slot=\"color-picker-saturation\"\n                hsva={hsva}\n                onChange={updateHsvaFromDrag}\n                pointer={ColorPickerPointer}\n                radius={0}\n                style={{ height: \"100%\", width: \"100%\" }}\n              />\n            </div>\n            <div className=\"flex items-center gap-3\">\n              <span\n                aria-hidden=\"true\"\n                className=\"size-7 shrink-0 rounded-full border border-border\"\n                style={{ backgroundColor: current }}\n              />\n              <Hue\n                aria-label=\"Color hue\"\n                className=\"h-3.5 flex-1 cursor-pointer\"\n                height=\"var(--spacing(3.5))\"\n                hue={hsva.h}\n                onChange={(nextHue) =>\n                  updateHsvaFromDrag({ ...hsva, ...nextHue })\n                }\n                pointer={ColorPickerSliderPointer}\n                radius=\"999px\"\n                width=\"100%\"\n              />\n            </div>\n            <div className=\"grid grid-cols-[minmax(0,1fr)_auto] gap-2\">\n              <Input\n                aria-label=\"Hex color value\"\n                className=\"w-full font-mono text-xs [&_input]:uppercase\"\n                nativeInput\n                onBlur={() => updateValue(hexInputValue)}\n                onChange={(event) => setDraftHex(event.target.value)}\n                onKeyDown={(event) => {\n                  if (event.key === \"Enter\") {\n                    event.preventDefault();\n                    updateValue(hexInputValue);\n                  }\n\n                  if (event.key === \"Escape\") {\n                    setDraftHex(null);\n                  }\n                }}\n                size={resolvedSize}\n                spellCheck={false}\n                value={hexInputValue}\n              />\n              <Button\n                aria-label={isShowingCheck ? \"Copied hex value\" : \"Copy hex value\"}\n                disabled={disabled}\n                onClick={copyHexValue}\n                onPointerDown={copyHexValue}\n                size={getColorPickerIconButtonSize(resolvedSize)}\n                type=\"button\"\n                variant=\"outline\"\n              >\n                <CopyRedrawIcon phase={copyPhase} />\n              </Button>\n            </div>\n            <div className=\"grid grid-cols-3 gap-2\">\n              <ColorChannelInput\n                label=\"R\"\n                onValueChange={(nextValue) => updateChannel(\"r\", nextValue)}\n                size={resolvedSize}\n                value={rgba.r}\n              />\n              <ColorChannelInput\n                label=\"G\"\n                onValueChange={(nextValue) => updateChannel(\"g\", nextValue)}\n                size={resolvedSize}\n                value={rgba.g}\n              />\n              <ColorChannelInput\n                label=\"B\"\n                onValueChange={(nextValue) => updateChannel(\"b\", nextValue)}\n                size={resolvedSize}\n                value={rgba.b}\n              />\n            </div>\n          </div>\n        </PopoverContent>\n      </Popover>\n      <input\n        {...inputProps}\n        disabled={disabled}\n        form={form}\n        name={name}\n        readOnly\n        required={required}\n        type=\"hidden\"\n        value={current}\n      />\n    </>\n  );\n}\n\nfunction ColorChannelInput({\n  label,\n  onValueChange,\n  size,\n  value,\n}: {\n  label: \"R\" | \"G\" | \"B\";\n  onValueChange: (value: number) => void;\n  size: SiteplaneControlSize;\n  value: number;\n}): React.ReactElement {\n  return (\n    <label className=\"grid gap-1 text-center\">\n      <Input\n        aria-label={`${label} color channel`}\n        className=\"w-full font-mono text-xs [&_input]:text-center\"\n        inputMode=\"numeric\"\n        maxLength={3}\n        nativeInput\n        onChange={(event) => onValueChange(clampColorChannel(event.target.value))}\n        pattern=\"[0-9]*\"\n        size={size}\n        type=\"text\"\n        value={value}\n      />\n      <span className=\"font-medium text-muted-foreground text-xs\">{label}</span>\n    </label>\n  );\n}\n\nfunction getColorPickerIconButtonSize(\n  size: SiteplaneControlSize,\n): \"icon-sm\" | \"icon\" | \"icon-lg\" {\n  if (size === \"sm\") {\n    return \"icon-sm\";\n  }\n\n  if (size === \"lg\") {\n    return \"icon-lg\";\n  }\n\n  return \"icon\";\n}\n\nfunction ColorPickerPointer({\n  color,\n  left,\n  top,\n}: ColorPointerProps): React.ReactElement {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"-translate-x-1/2 -translate-y-1/2 absolute size-4 rounded-full border-2 border-white shadow-sm ring-1 ring-black/20\"\n      style={{ backgroundColor: color, left, top }}\n    />\n  );\n}\n\nfunction ColorPickerSliderPointer({\n  left,\n}: ColorPointerProps): React.ReactElement {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"-translate-x-1/2 -translate-y-1/2 absolute top-1/2 size-4 rounded-full border-2 border-white bg-background shadow-sm ring-1 ring-black/20\"\n      style={{ left }}\n    />\n  );\n}\n\nfunction normalizeHex(value: string | undefined, fallback = \"#000000\"): string {\n  const candidate = value?.trim();\n\n  if (!candidate) {\n    return fallback;\n  }\n\n  const withHash = candidate.startsWith(\"#\") ? candidate : `#${candidate}`;\n\n  if (!validHex(withHash)) {\n    return fallback;\n  }\n\n  return hsvaToHex(hexToHsva(withHash)).toLowerCase();\n}\n\nfunction clampColorChannel(value: string): number {\n  const numericValue = Number.parseInt(value, 10);\n\n  if (Number.isNaN(numericValue)) {\n    return 0;\n  }\n\n  return Math.min(255, Math.max(0, numericValue));\n}\n\nasync function copyTextToClipboard(value: string): Promise<boolean> {\n  if (navigator.clipboard?.writeText) {\n    try {\n      await navigator.clipboard.writeText(value);\n      return true;\n    } catch {\n      // Fall back to selection-based copy below.\n    }\n  }\n\n  return copyTextWithSelection(value);\n}\n\nfunction copyTextWithSelection(value: string): boolean {\n  const textArea = document.createElement(\"textarea\");\n\n  textArea.value = value;\n  textArea.readOnly = true;\n  textArea.style.left = \"-9999px\";\n  textArea.style.position = \"fixed\";\n  textArea.style.top = \"0\";\n\n  document.body.appendChild(textArea);\n  textArea.focus({ preventScroll: true });\n  textArea.select();\n  textArea.setSelectionRange(0, value.length);\n\n  try {\n    return document.execCommand(\"copy\");\n  } finally {\n    document.body.removeChild(textArea);\n  }\n}\n"
    }
  ],
  "categories": [
    "form-controls"
  ],
  "docs": "# Color Picker\n\n> Siteplane UI release `0.1.1`.\n\n## Public Purpose\n\nColor Picker lets users choose and submit short hexadecimal color values through\na visual control.\n\n## Import\n\n`import { ColorPicker } from \"@/components/ui/color-picker\";`\n\n## Public API\n\n- The submitted form value is a normalized hexadecimal string.\n- `name`, `form`, `required`, and `disabled` are mirrored through the\n  built-in hidden form field.\n- `size` accepts `\"sm\"`, `\"default\"`, `\"lg\"`, and `\"xl\"` and otherwise\n  inherits `SiteplaneUIProvider controlSize`.\n- Use the swatch-only form only when visible label or surrounding context names\n  the control.\n\n## Public Motion\n\n- The picker popup follows the native Popover motion contract.\n- Hue and swatch controls keep native pointer and focus behavior.\n- Copy feedback uses the shared icon feedback sequence and respects reduced\n  motion.\n\n## Public Accessibility\n\n- Give the trigger and every text field an accessible name.\n- Keep visible focus throughout the trigger, popup, and text fields.\n- Preserve hidden-field semantics for `name`, `form`, `required`, and\n  `disabled`.\n- Do not use a native browser color input as a parallel visible control.\n\n## Public Agent Guidance\n\n- Keep hexadecimal value as the public form contract.\n- Use Color Picker rather than a raw visible color input.\n- Preserve normalization, form participation, popup, and focus behavior.\n\n## Public Links\n\n- [Documentation and preview](https://ui.siteplane.io/docs/components/form-controls/color-picker)\n\n- [Registry source](https://ui.siteplane.io/r/color-picker.json)\n",
  "meta": {
    "siteplane:agentContractVersion": 1,
    "siteplane:docs": "https://ui.siteplane.io/docs/components/form-controls/color-picker",
    "siteplane:releaseVersion": "0.1.1",
    "siteplane:sourceOwned": true
  },
  "devDependencies": []
}
