{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tags-input",
  "title": "Tags Input",
  "description": "A free-form multi-value input with removable tags, keyboard entry, and paste handling.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/rivetui/components/tags-input/tags-input.tsx",
      "content": "\"use client\";\n\nimport { X } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype TagsInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  \"className\" | \"defaultValue\" | \"onChange\" | \"type\" | \"value\"\n> & {\n  addOnBlur?: boolean;\n  className?: string;\n  defaultValue?: readonly string[];\n  inputClassName?: string;\n  maxTags?: number;\n  onValueChange?: (value: string[]) => void;\n  removeTagLabel?: (tag: string) => string;\n  validateTag?: (tag: string) => boolean;\n  value?: readonly string[];\n};\n\nconst TagsInput = React.forwardRef<HTMLInputElement, TagsInputProps>(function TagsInput(\n  {\n    \"aria-invalid\": ariaInvalid,\n    addOnBlur = true,\n    className,\n    defaultValue = [],\n    disabled = false,\n    form,\n    inputClassName,\n    maxTags = Number.POSITIVE_INFINITY,\n    name,\n    onBlur,\n    onKeyDown,\n    onPaste,\n    onValueChange,\n    readOnly = false,\n    removeTagLabel = (tag) => `Remove ${tag}`,\n    required,\n    validateTag,\n    value: valueProp,\n    ...props\n  },\n  forwardedRef,\n) {\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const [uncontrolledValue, setUncontrolledValue] = React.useState<string[]>([...defaultValue]);\n  const [inputValue, setInputValue] = React.useState(\"\");\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const value = valueProp ?? uncontrolledValue;\n  const resolvedMaxTags = Math.max(1, maxTags);\n  const atLimit = value.length >= resolvedMaxTags;\n  const interactionDisabled = disabled || readOnly;\n  const invalid = ariaInvalid !== undefined && ariaInvalid !== false && ariaInvalid !== \"false\";\n\n  function updateValue(nextValue: string[]) {\n    if (valueProp === undefined) {\n      setUncontrolledValue(nextValue);\n    }\n\n    onValueChange?.(nextValue);\n  }\n\n  function addTags(candidates: readonly string[]) {\n    if (interactionDisabled) {\n      return 0;\n    }\n\n    const nextValue = [...value];\n    const knownTags = new Set(nextValue.map(normalizeTag));\n    let added = 0;\n\n    for (const candidate of candidates) {\n      const tag = candidate.trim();\n\n      if (!tag) {\n        continue;\n      }\n\n      if (nextValue.length >= resolvedMaxTags) {\n        setAnnouncement(`Maximum of ${resolvedMaxTags} tags reached.`);\n        break;\n      }\n\n      if (validateTag && !validateTag(tag)) {\n        setAnnouncement(`${tag} is not a valid tag.`);\n        continue;\n      }\n\n      const normalizedTag = normalizeTag(tag);\n      if (knownTags.has(normalizedTag)) {\n        setAnnouncement(`${tag} is already added.`);\n        continue;\n      }\n\n      nextValue.push(tag);\n      knownTags.add(normalizedTag);\n      added += 1;\n    }\n\n    if (added > 0) {\n      updateValue(nextValue);\n      setAnnouncement(added === 1 ? `${nextValue.at(-1)} added.` : `${added} tags added.`);\n    }\n\n    return added;\n  }\n\n  function commitInput() {\n    const candidate = inputValue.trim();\n    if (!candidate) {\n      return;\n    }\n\n    const added = addTags([candidate]);\n    const duplicate = value.some((tag) => normalizeTag(tag) === normalizeTag(candidate));\n\n    if (added > 0 || duplicate) {\n      setInputValue(\"\");\n    }\n  }\n\n  function removeTag(tag: string) {\n    if (interactionDisabled) {\n      return;\n    }\n\n    updateValue(value.filter((candidate) => candidate !== tag));\n    setAnnouncement(`${tag} removed.`);\n    inputRef.current?.focus();\n  }\n\n  return (\n    <div\n      data-slot=\"tags-input\"\n      data-disabled={disabled ? \"\" : undefined}\n      data-invalid={invalid ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      className={cn(\n        \"flex min-h-10 w-full min-w-0 flex-wrap items-center gap-1.5 border border-input bg-background p-1.5 text-sm outline-none transition-[background-color,border-color,box-shadow,color,opacity] duration-100 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring/40 data-invalid:border-destructive data-invalid:has-[input:focus-visible]:border-destructive data-invalid:has-[input:focus-visible]:ring-destructive/25 data-disabled:cursor-not-allowed data-disabled:bg-muted data-disabled:text-muted-foreground data-disabled:opacity-60 motion-reduce:transition-none\",\n        className,\n      )}\n    >\n      {value.map((tag) => (\n        <span\n          key={tag}\n          data-slot=\"tags-input-tag\"\n          className=\"inline-flex h-7 max-w-full items-center border border-border bg-muted/50 pl-2.5 text-xs font-medium text-foreground\"\n        >\n          <span className=\"max-w-48 truncate\">{tag}</span>\n          <button\n            type=\"button\"\n            data-slot=\"tags-input-remove\"\n            aria-label={removeTagLabel(tag)}\n            disabled={interactionDisabled}\n            className=\"ml-2 grid h-full w-7 shrink-0 cursor-pointer place-items-center border-l border-border text-muted-foreground outline-none transition-[background-color,box-shadow,color,opacity] duration-100 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none\"\n            onClick={() => removeTag(tag)}\n          >\n            <X className=\"size-3\" aria-hidden=\"true\" />\n          </button>\n        </span>\n      ))}\n\n      <input\n        {...props}\n        ref={(node) => {\n          inputRef.current = node;\n          setForwardedRef(forwardedRef, node);\n        }}\n        type=\"text\"\n        value={inputValue}\n        aria-invalid={ariaInvalid}\n        disabled={disabled}\n        readOnly={readOnly || atLimit}\n        required={required && value.length === 0}\n        form={form}\n        data-slot=\"tags-input-input\"\n        className={cn(\n          \"h-7 min-w-24 flex-[1_0_7rem] bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed read-only:cursor-default\",\n          inputClassName,\n        )}\n        onChange={(event) => setInputValue(event.currentTarget.value)}\n        onBlur={(event) => {\n          onBlur?.(event);\n          if (addOnBlur) {\n            commitInput();\n          }\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n\n          if (event.defaultPrevented || interactionDisabled) {\n            return;\n          }\n\n          if ((event.key === \"Enter\" || event.key === \",\") && inputValue.trim()) {\n            event.preventDefault();\n            commitInput();\n          } else if (event.key === \"Backspace\" && !inputValue && value.length > 0) {\n            event.preventDefault();\n            removeTag(value[value.length - 1]);\n          } else if (event.key === \"Escape\" && inputValue) {\n            event.preventDefault();\n            setInputValue(\"\");\n          }\n        }}\n        onPaste={(event) => {\n          onPaste?.(event);\n\n          if (event.defaultPrevented || interactionDisabled) {\n            return;\n          }\n\n          const pastedText = event.clipboardData.getData(\"text\");\n          if (!/[\\n\\r,]/.test(pastedText)) {\n            return;\n          }\n\n          event.preventDefault();\n          addTags(pastedText.split(/[\\n\\r,]+/));\n        }}\n      />\n\n      {name\n        ? value.map((tag) => (\n            <input\n              key={`${name}-${tag}`}\n              type=\"hidden\"\n              name={name}\n              value={tag}\n              form={form}\n              disabled={disabled}\n            />\n          ))\n        : null}\n\n      <span role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n});\n\nfunction TagsInputLabel({ children, className, htmlFor, ...props }: React.ComponentProps<\"label\">) {\n  return (\n    <label\n      htmlFor={htmlFor}\n      data-slot=\"tags-input-label\"\n      className={cn(\n        \"w-fit font-mono text-[0.6875rem] font-medium uppercase leading-none tracking-[0.14em] text-foreground\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </label>\n  );\n}\n\nfunction normalizeTag(tag: string) {\n  return tag.toLocaleLowerCase();\n}\n\nfunction setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {\n  if (typeof ref === \"function\") {\n    ref(value);\n  } else if (ref) {\n    ref.current = value;\n  }\n}\n\nexport type { TagsInputProps };\nexport { TagsInput, TagsInputLabel };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}