{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "type": "registry:ui",
  "title": "TagInput",
  "description": "Accessible free-form tag input with React Aria tag semantics, chip removal, paste parsing, duplicate prevention, validation, repeated form values, density, and theme support.",
  "dependencies": [
    "react-aria-components@^1.21.1"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/tag-input/tag-input.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/tag-input/tag-input.tsx",
      "content": "import {\n  type ClipboardEvent,\n  type KeyboardEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefAttributes,\n  forwardRef,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  Button as AriaButton,\n  Tag as AriaTag,\n  TagGroup as AriaTagGroup,\n  TagList as AriaTagList,\n  type Key,\n} from \"react-aria-components\";\nimport { cn } from \"../../utils/cn\";\n\nexport type TagInputValue = string;\nexport type TagInputControlSize = \"sm\" | \"md\" | \"lg\";\nexport type TagInputValidationResult = ReactNode | null | undefined | false;\n\nexport interface TagInputProps {\n  \"aria-invalid\"?: boolean | \"false\" | \"true\" | \"grammar\" | \"spelling\";\n  \"data-slot\"?: string;\n  className?: string;\n  clearErrorOnInput?: boolean;\n  controlSize?: TagInputControlSize;\n  defaultInputValue?: string;\n  defaultValue?: TagInputValue[];\n  delimiters?: string[];\n  description?: ReactNode;\n  disabled?: boolean;\n  errorMessage?: ReactNode;\n  inputValue?: string;\n  invalid?: boolean;\n  label?: ReactNode;\n  maxTagLength?: number;\n  maxTags?: number;\n  name?: string;\n  normalizeTag?: (value: string) => string;\n  onInputValueChange?: (value: string) => void;\n  onValueChange?: (value: TagInputValue[]) => void;\n  placeholder?: string;\n  readOnly?: boolean;\n  removeLabel?: string;\n  required?: boolean;\n  validateTag?: (\n    value: string,\n    existingValues: TagInputValue[],\n  ) => TagInputValidationResult;\n  value?: TagInputValue[];\n}\n\ntype TagInputComponent = ((\n  props: TagInputProps & RefAttributes<HTMLDivElement>,\n) => ReactElement | null) & { displayName?: string };\n\nconst tagInputRootClasses =\n  \"grid w-full min-w-0 gap-[var(--dt-space-2)] text-foreground\";\n\nconst tagInputLabelClasses =\n  \"text-sm font-medium leading-none text-foreground data-[disabled=true]:opacity-60 data-[invalid=true]:text-destructive\";\n\nconst tagInputControlBaseClasses =\n  \"flex w-full min-w-0 flex-wrap items-center gap-[var(--dt-space-1-5)] rounded-md border border-input bg-background text-foreground shadow-sm outline-none motion-safe:transition-[background-color,border-color,box-shadow,color] motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/20 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-60 data-[invalid=true]:border-destructive data-[invalid=true]:ring-2 data-[invalid=true]:ring-destructive/15 data-[readonly=true]:bg-muted/40 data-[readonly=true]:text-muted-foreground\";\n\nconst tagInputControlSizeClasses: Record<TagInputControlSize, string> = {\n  sm: \"min-h-8 px-[var(--dt-space-2)] py-[var(--dt-space-1)] text-base sm:text-sm\",\n  md: \"min-h-density-control px-[var(--dt-space-2-5)] py-[var(--dt-space-1-5)] text-base sm:text-sm\",\n  lg: \"min-h-11 px-[var(--dt-space-3)] py-[var(--dt-space-2)] text-base\",\n};\n\nconst tagInputListClasses =\n  \"contents outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring\";\n\nconst tagInputTagClasses =\n  \"inline-flex min-w-0 max-w-full items-center gap-[var(--dt-space-1)] motion-safe:animate-feedback-in rounded-full border border-border bg-muted px-[var(--dt-space-2)] py-[var(--dt-space-0-5)] text-xs font-medium text-foreground outline-none motion-safe:transition-[background-color,border-color,color,box-shadow] motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control data-[disabled]:opacity-60 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[hovered]:bg-muted/80\";\n\nconst tagInputTagTextClasses = \"min-w-0 truncate\";\n\nconst tagInputRemoveClasses =\n  \"ms-[var(--dt-space-0-5)] inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none motion-safe:transition-[background-color,color] motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\";\n\nconst tagInputInputClasses =\n  \"min-w-24 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60 read-only:text-muted-foreground\";\n\nconst tagInputHelpClasses = \"text-xs leading-5 text-muted-foreground\";\n\nconst tagInputErrorClasses = \"text-xs font-medium leading-5 text-destructive\";\n\nconst defaultDelimiters = [\",\"];\n\nfunction isAriaInvalid(value: TagInputProps[\"aria-invalid\"]) {\n  return (\n    value === true ||\n    value === \"true\" ||\n    value === \"grammar\" ||\n    value === \"spelling\"\n  );\n}\n\nfunction defaultNormalizeTag(value: string) {\n  return value.trim().replace(/\\s+/g, \" \");\n}\n\nfunction escapeRegExp(value: string) {\n  return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nfunction splitInputValue(value: string, delimiters: string[]) {\n  const delimiterPattern = [\n    ...delimiters.filter(Boolean).map(escapeRegExp),\n    \"\\\\r?\\\\n\",\n  ].join(\"|\");\n\n  return value.split(new RegExp(delimiterPattern, \"g\"));\n}\n\nfunction normalizeInitialTags(\n  values: TagInputValue[] | undefined,\n  normalizeTag: (value: string) => string,\n) {\n  const seen = new Set<string>();\n  const normalizedValues: TagInputValue[] = [];\n\n  for (const value of values ?? []) {\n    const normalizedValue = normalizeTag(value);\n    const duplicateKey = normalizedValue.toLocaleLowerCase();\n\n    if (normalizedValue && !seen.has(duplicateKey)) {\n      seen.add(duplicateKey);\n      normalizedValues.push(normalizedValue);\n    }\n  }\n\n  return normalizedValues;\n}\n\nfunction validationMessageToNode(result: TagInputValidationResult) {\n  return result === false || result === null || result === undefined\n    ? null\n    : result;\n}\n\nfunction XIcon() {\n  return (\n    <svg aria-hidden=\"true\" className=\"size-3\" fill=\"none\" viewBox=\"0 0 16 16\">\n      <path\n        d=\"m4.5 4.5 7 7m0-7-7 7\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"1.75\"\n      />\n    </svg>\n  );\n}\n\nexport function tagInputClassNames({\n  className,\n}: Pick<TagInputProps, \"className\"> = {}) {\n  return cn(tagInputRootClasses, className);\n}\n\nfunction TagInputRoot(\n  {\n    \"aria-invalid\": ariaInvalid,\n    \"data-slot\": dataSlot,\n    className,\n    clearErrorOnInput = true,\n    controlSize = \"md\",\n    defaultInputValue = \"\",\n    defaultValue,\n    delimiters = defaultDelimiters,\n    description,\n    disabled = false,\n    errorMessage,\n    inputValue,\n    invalid = false,\n    label,\n    maxTagLength,\n    maxTags,\n    name,\n    normalizeTag = defaultNormalizeTag,\n    onInputValueChange,\n    onValueChange,\n    placeholder = \"Add tag\",\n    readOnly = false,\n    removeLabel = \"Remove\",\n    required = false,\n    validateTag,\n    value,\n  }: TagInputProps,\n  ref: React.ForwardedRef<HTMLDivElement>,\n) {\n  const labelId = useId();\n  const descriptionId = useId();\n  const errorId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const valueControlled = value !== undefined;\n  const inputControlled = inputValue !== undefined;\n  const [uncontrolledValue, setUncontrolledValue] = useState(() =>\n    normalizeInitialTags(defaultValue, normalizeTag),\n  );\n  const [uncontrolledInputValue, setUncontrolledInputValue] =\n    useState(defaultInputValue);\n  const [entryError, setEntryError] = useState<ReactNode>(null);\n  const resolvedValue = valueControlled\n    ? normalizeInitialTags(value, normalizeTag)\n    : uncontrolledValue;\n  const resolvedInputValue = inputValue ?? uncontrolledInputValue;\n  const resolvedInvalid =\n    invalid || Boolean(entryError) || isAriaInvalid(ariaInvalid);\n  const visibleError =\n    entryError ?? (resolvedInvalid && errorMessage ? errorMessage : null);\n  const disabledOrReadOnly = disabled || readOnly;\n  const describedBy =\n    [description ? descriptionId : null, visibleError ? errorId : null]\n      .filter(Boolean)\n      .join(\" \") || undefined;\n\n  const setInputValue = (nextValue: string) => {\n    if (!inputControlled) {\n      setUncontrolledInputValue(nextValue);\n    }\n\n    if (clearErrorOnInput) {\n      setEntryError(null);\n    }\n\n    onInputValueChange?.(nextValue);\n  };\n\n  const updateValue = (nextValue: TagInputValue[]) => {\n    if (!valueControlled) {\n      setUncontrolledValue(nextValue);\n    }\n\n    onValueChange?.(nextValue);\n  };\n\n  const addTags = (rawValues: string[]) => {\n    if (disabledOrReadOnly) {\n      return false;\n    }\n\n    let nextValue = [...resolvedValue];\n    const seen = new Set(nextValue.map((tag) => tag.toLocaleLowerCase()));\n\n    for (const rawValue of rawValues) {\n      const normalizedValue = normalizeTag(rawValue);\n\n      if (!normalizedValue) {\n        continue;\n      }\n\n      if (maxTags !== undefined && nextValue.length >= maxTags) {\n        setEntryError(`Add no more than ${maxTags} tags.`);\n        return false;\n      }\n\n      if (maxTagLength !== undefined && normalizedValue.length > maxTagLength) {\n        setEntryError(`Keep tags to ${maxTagLength} characters or fewer.`);\n        return false;\n      }\n\n      const duplicateKey = normalizedValue.toLocaleLowerCase();\n\n      if (seen.has(duplicateKey)) {\n        setEntryError(`${normalizedValue} is already added.`);\n        return false;\n      }\n\n      const validationMessage = validationMessageToNode(\n        validateTag?.(normalizedValue, nextValue),\n      );\n\n      if (validationMessage) {\n        setEntryError(validationMessage);\n        return false;\n      }\n\n      seen.add(duplicateKey);\n      nextValue = [...nextValue, normalizedValue];\n    }\n\n    if (nextValue.length === resolvedValue.length) {\n      return false;\n    }\n\n    setEntryError(null);\n    updateValue(nextValue);\n    return true;\n  };\n\n  const commitInputValue = () => {\n    const nextTags = splitInputValue(resolvedInputValue, delimiters);\n\n    if (addTags(nextTags)) {\n      setInputValue(\"\");\n      return true;\n    }\n\n    return false;\n  };\n\n  const removeTags = (keys: Set<Key>) => {\n    if (disabledOrReadOnly) {\n      return;\n    }\n\n    const removeKeys = new Set(Array.from(keys, String));\n    updateValue(resolvedValue.filter((tag) => !removeKeys.has(tag)));\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (disabledOrReadOnly) {\n      return;\n    }\n\n    if (\n      event.key === \"Enter\" ||\n      event.key === \"Tab\" ||\n      delimiters.includes(event.key)\n    ) {\n      if (resolvedInputValue.trim()) {\n        event.preventDefault();\n        commitInputValue();\n      }\n\n      return;\n    }\n\n    if (\n      event.key === \"Backspace\" &&\n      !resolvedInputValue &&\n      resolvedValue.length > 0\n    ) {\n      event.preventDefault();\n      updateValue(resolvedValue.slice(0, -1));\n    }\n  };\n\n  const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {\n    if (disabledOrReadOnly) {\n      return;\n    }\n\n    const pastedText = event.clipboardData.getData(\"text\");\n    const pastedTags = splitInputValue(pastedText, delimiters);\n\n    if (pastedTags.length <= 1) {\n      return;\n    }\n\n    event.preventDefault();\n    addTags(pastedTags);\n    setInputValue(\"\");\n  };\n\n  const tagItems = resolvedValue.map((tag) => ({\n    id: tag,\n    value: tag,\n  }));\n\n  return (\n    <AriaTagGroup\n      ref={ref}\n      aria-describedby={describedBy}\n      aria-invalid={resolvedInvalid ? true : ariaInvalid}\n      aria-labelledby={label ? labelId : undefined}\n      data-slot={dataSlot ?? \"tag-input\"}\n      data-size={controlSize}\n      data-disabled={disabled ? \"true\" : undefined}\n      data-invalid={resolvedInvalid ? \"true\" : undefined}\n      data-readonly={readOnly ? \"true\" : undefined}\n      data-required={required ? \"true\" : undefined}\n      onRemove={removeTags}\n      className={tagInputClassNames({ className })}\n    >\n      {label ? (\n        <span\n          id={labelId}\n          data-slot=\"tag-input-label\"\n          data-disabled={disabled ? \"true\" : undefined}\n          data-invalid={resolvedInvalid ? \"true\" : undefined}\n          data-required={required ? \"true\" : undefined}\n          className={tagInputLabelClasses}\n        >\n          {label}\n          {required ? <span aria-hidden=\"true\"> *</span> : null}\n        </span>\n      ) : null}\n      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Clicking the surrounding field focuses its native input, which already supports keyboard entry. */}\n      <div\n        aria-invalid={resolvedInvalid ? true : ariaInvalid}\n        data-slot=\"tag-input-control\"\n        data-size={controlSize}\n        data-disabled={disabled ? \"true\" : undefined}\n        data-invalid={resolvedInvalid ? \"true\" : undefined}\n        data-readonly={readOnly ? \"true\" : undefined}\n        data-required={required ? \"true\" : undefined}\n        className={cn(\n          tagInputControlBaseClasses,\n          tagInputControlSizeClasses[controlSize],\n        )}\n        onClick={() => {\n          inputRef.current?.focus();\n        }}\n      >\n        <AriaTagList\n          items={tagItems}\n          data-slot=\"tag-input-list\"\n          className={tagInputListClasses}\n        >\n          {(item) => (\n            <AriaTag\n              id={item.id}\n              textValue={item.value}\n              data-slot=\"tag-input-tag\"\n              isDisabled={disabled}\n              className={tagInputTagClasses}\n            >\n              <span\n                data-slot=\"tag-input-tag-text\"\n                className={tagInputTagTextClasses}\n              >\n                {item.value}\n              </span>\n              <AriaButton\n                aria-label={removeLabel}\n                data-slot=\"tag-input-tag-remove\"\n                slot=\"remove\"\n                className={tagInputRemoveClasses}\n                isDisabled={disabledOrReadOnly}\n              >\n                <XIcon />\n              </AriaButton>\n            </AriaTag>\n          )}\n        </AriaTagList>\n        <input\n          ref={inputRef}\n          aria-describedby={describedBy}\n          aria-invalid={resolvedInvalid ? true : ariaInvalid}\n          aria-labelledby={label ? labelId : undefined}\n          aria-required={required || undefined}\n          data-slot=\"tag-input-field\"\n          data-size={controlSize}\n          disabled={disabled}\n          onChange={(event) => {\n            setInputValue(event.currentTarget.value);\n          }}\n          onKeyDown={handleKeyDown}\n          onPaste={handlePaste}\n          placeholder={resolvedValue.length > 0 ? undefined : placeholder}\n          readOnly={readOnly}\n          type=\"text\"\n          value={resolvedInputValue}\n          className={tagInputInputClasses}\n        />\n      </div>\n      {name && !disabled\n        ? resolvedValue.map((tag) => (\n            <input key={tag} name={name} type=\"hidden\" value={tag} />\n          ))\n        : null}\n      {description ? (\n        <p\n          id={descriptionId}\n          data-slot=\"tag-input-description\"\n          data-disabled={disabled ? \"true\" : undefined}\n          className={tagInputHelpClasses}\n        >\n          {description}\n        </p>\n      ) : null}\n      {visibleError ? (\n        <p\n          id={errorId}\n          role=\"alert\"\n          data-slot=\"tag-input-error\"\n          className={tagInputErrorClasses}\n        >\n          {visibleError}\n        </p>\n      ) : null}\n    </AriaTagGroup>\n  );\n}\n\nexport const TagInput = forwardRef(TagInputRoot) as TagInputComponent;\n\nTagInput.displayName = \"TagInput\";\n"
    },
    {
      "path": "packages/components/src/components/tag-input/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/tag-input/index.ts",
      "content": "export {\n  TagInput,\n  tagInputClassNames,\n  type TagInputControlSize,\n  type TagInputProps,\n  type TagInputValidationResult,\n  type TagInputValue,\n} from \"./tag-input\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
