{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "title": "Color Picker",
  "description": "Color Picker from the Get IT Done design system.",
  "dependencies": [
    "@radix-ui/react-radio-group@^1.5.1",
    "class-variance-authority@^0.7.0"
  ],
  "registryDependencies": [
    "https://zero-design.itdone.de/r/button.json",
    "https://zero-design.itdone.de/r/input.json",
    "https://zero-design.itdone.de/r/labels-context.json",
    "https://zero-design.itdone.de/r/popover.json",
    "https://zero-design.itdone.de/r/recipes.json",
    "https://zero-design.itdone.de/r/utils.json",
    "https://zero-design.itdone.de/r/theme.json"
  ],
  "files": [
    {
      "path": "registry/ui/color-picker.tsx",
      "content": "'use client';\n\nimport * as RadioGroupPrimitive from '@radix-ui/react-radio-group';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport * as React from 'react';\n\nimport { focusRing } from '@/lib/recipes';\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { useLabels } from '@/components/ui/labels-context';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\n\n// Project colours 1..8 (`colors/data/project/N`), the same set Avatar and LogoTile use. Full class strings for the Tailwind scan.\nconst projectSwatchClass = {\n  'project-1': 'bg-data-project-1',\n  'project-2': 'bg-data-project-2',\n  'project-3': 'bg-data-project-3',\n  'project-4': 'bg-data-project-4',\n  'project-5': 'bg-data-project-5',\n  'project-6': 'bg-data-project-6',\n  'project-7': 'bg-data-project-7',\n  'project-8': 'bg-data-project-8',\n} as const;\n\nexport type ProjectSwatch = keyof typeof projectSwatchClass;\n\nexport interface ColorSwatchOption {\n  // The value reported on selection. For `color` options this is usually the CSS colour itself.\n  value: string;\n  // Accessible name, e.g. \"Teal\". Without it the position is used (\"Colour 3\").\n  label?: string;\n  // Any CSS colour, applied as the swatch fill. Use it for user data such as stored calendar colours.\n  color?: string;\n  // Token class for the fill, e.g. `bg-data-project-3`. Wins over `color`.\n  className?: string;\n}\n\n// The eight project colours as options (values `project-1`..`project-8`).\nconst projectColorOptions: ColorSwatchOption[] = (Object.keys(projectSwatchClass) as ProjectSwatch[]).map((value) => ({\n  value,\n  className: projectSwatchClass[value],\n}));\n\nconst swatchVariants = cva(\n  [\n    'relative inline-flex shrink-0 items-center justify-center rounded-full border border-solid border-border-bold',\n    'transition-shadow',\n    focusRing,\n    'disabled:cursor-not-allowed disabled:opacity-disabled',\n    // Selected: a ring in the foreground colour plus a gap to the surface (shape cue, not colour only).\n    'data-[state=checked]:ring-2 data-[state=checked]:ring-foreground data-[state=checked]:ring-offset-2 data-[state=checked]:ring-offset-background',\n    'hover:data-[state=unchecked]:ring-2 hover:data-[state=unchecked]:ring-border-bold hover:data-[state=unchecked]:ring-offset-2 hover:data-[state=unchecked]:ring-offset-background',\n  ],\n  {\n    variants: {\n      size: {\n        sm: 'h-icon-md w-icon-md',\n        md: 'h-avatar-xs w-avatar-xs',\n        lg: 'h-avatar-sm w-avatar-sm',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n);\n\nexport interface ColorSwatchPickerProps\n  extends Omit<React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>, 'onChange' | 'children'>,\n    VariantProps<typeof swatchVariants> {\n  // Swatches in display order. Defaults to the eight project colours.\n  options?: ColorSwatchOption[];\n  // Called with the option value when the selection changes.\n  onValueChange?: (value: string) => void;\n  invalid?: boolean;\n  // Shows only a small swatch button for the current colour; the swatches open in a popover and picking one closes it.\n  compact?: boolean;\n  // Accessible name of the compact trigger. Defaults to the provider text plus the current colour.\n  triggerLabel?: string;\n  // Adds an apply button next to the swatches. It is disabled until a colour is selected and calls `onApply` with the selected value. In `compact` mode picking a swatch keeps the popover open so the button can be reached, and applying closes it.\n  onApply?: (value: string) => void;\n  // Text of the apply button. Defaults to the provider text `apply`.\n  applyLabel?: string;\n}\n\n// Single-choice colour swatches (calendar and sales colours). A radio group: Tab enters, arrow keys move and select.\nconst ColorSwatchPicker = React.forwardRef<React.ElementRef<typeof RadioGroupPrimitive.Root>, ColorSwatchPickerProps>(\n  ({ className, options = projectColorOptions, size, invalid, disabled, compact, triggerLabel, onApply, applyLabel, value, defaultValue, onValueChange, ...props }, ref) => {\n    const l = useLabels();\n    const controlled = value !== undefined;\n    const [inner, setInner] = React.useState(defaultValue);\n    const current = controlled ? value : inner;\n    if (compact) {\n      return (\n        <CompactColorSwatchPicker\n          ref={ref}\n          className={className}\n          options={options}\n          size={size}\n          invalid={invalid}\n          disabled={disabled}\n          triggerLabel={triggerLabel}\n          onApply={onApply}\n          applyLabel={applyLabel}\n          value={value}\n          defaultValue={defaultValue}\n          onValueChange={onValueChange}\n          {...props}\n        />\n      );\n    }\n    const handleChange = (next: string) => {\n      if (!controlled) setInner(next);\n      onValueChange?.(next);\n    };\n    const group = (\n      <RadioGroupPrimitive.Root\n        ref={ref}\n        orientation=\"horizontal\"\n        disabled={disabled}\n        aria-invalid={invalid || undefined}\n        value={current}\n        onValueChange={handleChange}\n        className={cn('flex flex-wrap items-center gap-3 p-1', invalid && 'rounded-control ring-1 ring-general-error-default', !onApply && className)}\n        {...props}\n      >\n        {options.map((option, index) => (\n          <RadioGroupPrimitive.Item\n            key={option.value}\n            value={option.value}\n            aria-label={option.label ?? l.colorOption(index + 1)}\n            data-color={option.value}\n            className={cn(swatchVariants({ size }), option.className)}\n            style={option.className || !option.color ? undefined : { backgroundColor: option.color }}\n          />\n        ))}\n      </RadioGroupPrimitive.Root>\n    );\n    if (!onApply) return group;\n    return (\n      <div data-color-apply=\"\" className={cn('flex flex-wrap items-center gap-3', className)}>\n        {group}\n        <Button type=\"button\" variant=\"outline\" size=\"control-sm\" disabled={disabled || current === undefined || current === ''} onClick={() => current && onApply(current)}>\n          {applyLabel ?? l.apply}\n        </Button>\n      </div>\n    );\n  },\n);\nColorSwatchPicker.displayName = 'ColorSwatchPicker';\n\n// Compact mode: a swatch-sized button for the current colour that opens the swatch group in a popover.\nconst CompactColorSwatchPicker = React.forwardRef<React.ElementRef<typeof RadioGroupPrimitive.Root>, ColorSwatchPickerProps>(\n  ({ className, options = projectColorOptions, size, invalid, disabled, triggerLabel, value, defaultValue, onValueChange, onApply, applyLabel, ...props }, ref) => {\n    const l = useLabels();\n    const [open, setOpen] = React.useState(false);\n    const controlled = value !== undefined;\n    const [inner, setInner] = React.useState(defaultValue);\n    const current = controlled ? value : inner;\n    const index = options.findIndex((option) => option.value === current);\n    const selected = index >= 0 ? options[index] : undefined;\n    const selectedName = selected ? (selected.label ?? l.colorOption(index + 1)) : undefined;\n    const name = triggerLabel ?? (selectedName ? `${l.pickColor}: ${selectedName}` : l.pickColor);\n    const handleChange = (next: string) => {\n      if (!controlled) setInner(next);\n      onValueChange?.(next);\n      // With an apply button the popover stays open until the colour is applied.\n      if (!onApply) setOpen(false);\n    };\n    return (\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverTrigger asChild>\n          <button\n            type=\"button\"\n            disabled={disabled}\n            aria-label={name}\n            data-invalid={invalid || undefined}\n            data-compact=\"\"\n            data-color={selected?.value}\n            className={cn(\n              'relative inline-flex shrink-0 items-center justify-center rounded-full border border-solid border-border-bold bg-muted transition-shadow',\n              focusRing,\n              'disabled:cursor-not-allowed disabled:opacity-disabled',\n              'hover:ring-2 hover:ring-border-bold hover:ring-offset-2 hover:ring-offset-background',\n              invalid && 'ring-1 ring-general-error-default',\n              size === 'sm' && 'h-icon-md w-icon-md',\n              (size === 'md' || size === undefined || size === null) && 'h-avatar-xs w-avatar-xs',\n              size === 'lg' && 'h-avatar-sm w-avatar-sm',\n              selected?.className,\n              className,\n            )}\n            style={selected?.className || !selected?.color ? undefined : { backgroundColor: selected.color }}\n          />\n        </PopoverTrigger>\n        <PopoverContent align=\"start\" className=\"w-auto p-3\">\n          <div className=\"flex flex-col items-start gap-3\">\n            <ColorSwatchPicker ref={ref} options={options} size={size} invalid={invalid} aria-label={l.pickColor} value={current} onValueChange={handleChange} {...props} />\n            {onApply ? (\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"control-sm\"\n                disabled={!selected}\n                onClick={() => {\n                  if (selected) onApply(selected.value);\n                  setOpen(false);\n                }}\n              >\n                {applyLabel ?? l.apply}\n              </Button>\n            ) : null}\n          </div>\n        </PopoverContent>\n      </Popover>\n    );\n  },\n);\nCompactColorSwatchPicker.displayName = 'CompactColorSwatchPicker';\n\nconst HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;\n\n// `#abc` becomes `#aabbcc`, everything is lowercase. Returns null when the text is not a hex colour.\nexport function normalizeHexColor(text: string): string | null {\n  const t = text.trim().startsWith('#') ? text.trim() : `#${text.trim()}`;\n  if (!HEX.test(t)) return null;\n  const lower = t.toLowerCase();\n  return lower.length === 4 ? `#${lower[1]}${lower[1]}${lower[2]}${lower[2]}${lower[3]}${lower[3]}` : lower;\n}\n\nexport interface ColorInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' | 'type' | 'size'> {\n  // Controlled hex value like `#3b82f6`.\n  value?: string;\n  defaultValue?: string;\n  // Called with a normalized `#rrggbb` value when the picker or a valid hex text changes it.\n  onValueChange?: (value: string) => void;\n  invalid?: boolean;\n  compact?: boolean;\n  // Control size. `md` (default) is the 24px swatch with the standard field, `sm` a 16px swatch with the `sm` field height.\n  size?: 'sm' | 'md';\n  // Accessible names. Win over the provider text.\n  pickLabel?: string;\n  hexLabel?: string;\n}\n\n// Hex text field with a native colour picker swatch. Free text may be incomplete while typing; only valid colours are reported.\nconst ColorInput = React.forwardRef<HTMLInputElement, ColorInputProps>(\n  ({ className, value, defaultValue = '#000000', onValueChange, invalid, compact, size, disabled, pickLabel, hexLabel, name, id, onBlur, ...props }, ref) => {\n    const l = useLabels({ pickColor: pickLabel, hexColor: hexLabel });\n    const small = size === 'sm';\n    const controlled = value !== undefined;\n    const [inner, setInner] = React.useState(normalizeHexColor(defaultValue) ?? '#000000');\n    const current = controlled ? (normalizeHexColor(value) ?? '#000000') : inner;\n    const [text, setText] = React.useState(current);\n    // Keep the text in step with outside changes unless the user is mid-edit on an equivalent value.\n    React.useEffect(() => {\n      setText((prev) => (normalizeHexColor(prev) === current ? prev : current));\n    }, [current]);\n    const textInvalid = invalid || normalizeHexColor(text) === null;\n\n    const commit = (next: string) => {\n      if (!controlled) setInner(next);\n      onValueChange?.(next);\n    };\n\n    return (\n      <div className={cn('flex items-center gap-2', className)} data-color-input=\"\">\n        <span className={cn('relative inline-flex shrink-0', small ? 'h-icon-md w-icon-md' : 'h-avatar-xs w-avatar-xs')}>\n          <input\n            type=\"color\"\n            aria-label={l.pickColor}\n            disabled={disabled}\n            value={current}\n            onChange={(event) => {\n              const next = normalizeHexColor(event.target.value);\n              if (next) {\n                setText(next);\n                commit(next);\n              }\n            }}\n            className=\"peer absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed\"\n          />\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none h-full w-full rounded-full border border-solid border-border-bold peer-focus-visible:ring-focus peer-focus-visible:ring-offset-focus peer-focus-visible:ring-offset-background peer-focus-visible:ring-ring-default peer-disabled:opacity-disabled\"\n            style={{ backgroundColor: current }}\n          />\n        </span>\n        <Input\n          {...props}\n          ref={ref}\n          id={id}\n          name={name}\n          compact={compact}\n          size={small ? 'sm' : undefined}\n          disabled={disabled}\n          aria-label={l.hexColor}\n          aria-invalid={textInvalid || undefined}\n          value={text}\n          maxLength={7}\n          spellCheck={false}\n          autoComplete=\"off\"\n          inputMode=\"text\"\n          className=\"w-32 font-mono\"\n          onChange={(event) => {\n            setText(event.target.value);\n            const next = normalizeHexColor(event.target.value);\n            if (next) commit(next);\n          }}\n          onBlur={(event) => {\n            const next = normalizeHexColor(event.target.value);\n            setText(next ?? current);\n            onBlur?.(event);\n          }}\n        />\n      </div>\n    );\n  },\n);\nColorInput.displayName = 'ColorInput';\n\nexport { ColorInput, ColorSwatchPicker, projectColorOptions, swatchVariants as colorSwatchVariants };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}