{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tags-input",
  "title": "Tags Input",
  "description": "The nx-ui Tags Input component.",
  "registryDependencies": [
    "@nx-ui/utils",
    "@nx-ui/tag"
  ],
  "files": [
    {
      "path": "components/ui/tags-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Tag } from \"@/components/ui/tag\"\n\n/**\n * TagsInput — the Untitled UI \"chips-in-input\" multi-value text field.\n *\n * Committed values render as removable {@link Tag}s inside a control styled like\n * `input.tsx`. New tags are created on Enter or comma; pasting a delimited list\n * splits into multiple tags; Backspace at the caret start removes the last tag.\n * Supports `max` with graceful overflow, de-duplication and an optional\n * validator. Ported from the vendored UUI `base/input/input-tags.tsx`, swapping\n * react-aria for a plain input and the nx-ui `Tag` primitive.\n *\n * Presentational + self-contained: manages its own tag array unless `value` is\n * supplied (controlled).\n */\n\ntype TagEntry = { id: number; label: string }\n\nconst sizes = {\n  sm: { root: \"min-h-9 gap-1.5 px-2 py-1 text-sm\", gap: \"gap-1.5\" },\n  md: { root: \"min-h-10 gap-2 px-2.5 py-1.5 text-md\", gap: \"gap-2\" },\n  lg: { root: \"min-h-11 gap-2 px-3 py-2 text-md\", gap: \"gap-2\" },\n} as const\n\nfunction TagsInput({\n  className,\n  size = \"md\",\n  placeholder,\n  value,\n  defaultValue,\n  onValueChange,\n  max,\n  allowDuplicates = false,\n  disabled,\n  \"aria-invalid\": ariaInvalid,\n  validate,\n  delimiters = [\",\", \"Enter\"],\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"onChange\"> & {\n  size?: keyof typeof sizes\n  placeholder?: string\n  /** Controlled value: array of tag strings. */\n  value?: string[]\n  /** Default tags for uncontrolled mode. */\n  defaultValue?: string[]\n  /** Called whenever the committed tag array changes. */\n  onValueChange?: (tags: string[]) => void\n  /** Maximum number of tags; further input is ignored once reached. */\n  max?: number\n  /** Allow duplicate tag values (default false). */\n  allowDuplicates?: boolean\n  disabled?: boolean\n  /** Return false to reject a candidate tag. */\n  validate?: (value: string) => boolean\n  /** Keys/characters that commit the current text as a tag. */\n  delimiters?: string[]\n}) {\n  const isControlled = value !== undefined\n  const idCounter = React.useRef(0)\n  const nextId = () => idCounter.current++\n\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const [inputValue, setInputValue] = React.useState(\"\")\n\n  const [internalEntries, setInternalEntries] = React.useState<TagEntry[]>(() =>\n    (defaultValue ?? []).map((label) => ({ id: nextId(), label }))\n  )\n\n  // Keep stable ids across controlled re-renders so React keys don't shift.\n  const prevControlledValue = React.useRef<string[]>([])\n  const controlledEntries = React.useRef<TagEntry[]>([])\n\n  const entries = (() => {\n    if (!isControlled) return internalEntries\n    if (prevControlledValue.current === value) return controlledEntries.current\n\n    const oldEntries = controlledEntries.current\n    const newEntries: TagEntry[] = []\n    const usedOldIndices = new Set<number>()\n    for (const label of value) {\n      const oldIndex = oldEntries.findIndex(\n        (e, i) => e.label === label && !usedOldIndices.has(i)\n      )\n      if (oldIndex !== -1) {\n        usedOldIndices.add(oldIndex)\n        newEntries.push(oldEntries[oldIndex])\n      } else {\n        newEntries.push({ id: nextId(), label })\n      }\n    }\n    prevControlledValue.current = value\n    controlledEntries.current = newEntries\n    return newEntries\n  })()\n\n  const tags = entries.map((e) => e.label)\n\n  const commit = (nextEntries: TagEntry[]) => {\n    if (!isControlled) setInternalEntries(nextEntries)\n    onValueChange?.(nextEntries.map((e) => e.label))\n  }\n\n  const addTags = (raw: string) => {\n    const candidates = raw\n      .split(/[,\\n\\t]/)\n      .map((s) => s.trim())\n      .filter(Boolean)\n    if (candidates.length === 0) return false\n\n    let changed = false\n    const current = [...entries]\n    for (const candidate of candidates) {\n      if (max != null && current.length >= max) break\n      if (!allowDuplicates && current.some((e) => e.label === candidate)) continue\n      if (validate && !validate(candidate)) continue\n      current.push({ id: nextId(), label: candidate })\n      changed = true\n    }\n    if (changed) commit(current)\n    return changed\n  }\n\n  const removeAt = (id: number) => {\n    commit(entries.filter((e) => e.id !== id))\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    const input = event.currentTarget\n    const caretAtStart = input.selectionStart === 0 && input.selectionEnd === 0\n\n    if (delimiters.includes(event.key)) {\n      event.preventDefault()\n      if (addTags(inputValue)) setInputValue(\"\")\n      return\n    }\n    if (event.key === \"Backspace\" && caretAtStart && inputValue === \"\") {\n      if (entries.length > 0) removeAt(entries[entries.length - 1].id)\n    }\n  }\n\n  const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {\n    const text = event.clipboardData.getData(\"text\")\n    if (/[,\\n\\t]/.test(text)) {\n      event.preventDefault()\n      if (addTags(inputValue + text)) setInputValue(\"\")\n    }\n  }\n\n  const atMax = max != null && entries.length >= max\n  const isEmpty = entries.length === 0\n\n  return (\n    <div\n      data-slot=\"tags-input\"\n      aria-invalid={ariaInvalid}\n      className={cn(\n        \"group/tags-input flex w-full flex-wrap items-center rounded-lg border border-input bg-background shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/25 has-aria-invalid:border-destructive has-aria-invalid:ring-4 has-aria-invalid:ring-destructive/25 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/25\",\n        disabled && \"pointer-events-none opacity-50\",\n        sizes[size].root,\n        className\n      )}\n      onClick={() => inputRef.current?.focus()}\n      {...props}\n    >\n      {entries.map((entry) => (\n        <Tag\n          key={entry.id}\n          size={size === \"lg\" ? \"md\" : size}\n          onRemove={disabled ? undefined : () => removeAt(entry.id)}\n        >\n          {entry.label}\n        </Tag>\n      ))}\n\n      <input\n        ref={inputRef}\n        type=\"text\"\n        data-slot=\"tags-input-control\"\n        disabled={disabled}\n        value={inputValue}\n        placeholder={isEmpty ? placeholder : undefined}\n        readOnly={atMax}\n        aria-label=\"Add tag\"\n        onChange={(e) => setInputValue(e.target.value)}\n        onKeyDown={handleKeyDown}\n        onPaste={handlePaste}\n        onBlur={() => {\n          if (addTags(inputValue)) setInputValue(\"\")\n        }}\n        className=\"min-w-[6ch] flex-1 appearance-none bg-transparent px-1 text-foreground caret-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n      />\n    </div>\n  )\n}\n\nexport { TagsInput }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}