{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "type": "registry:ui",
  "title": "Tag Input",
  "description": "Input that converts entries into removable pill tags on Enter / comma. Controlled or uncontrolled.",
  "dependencies": [
    "@saasflare/ui"
  ],
  "files": [
    {
      "path": "components/ui/tag-input.tsx",
      "type": "registry:ui",
      "content": "// @draft\n\"use client\"\n\n/**\n * @fileoverview Saasflare TagInput — input that converts entries into\n * removable pills on Enter / comma. Controlled or uncontrolled.\n * @author Saasflare™\n *\n * Used for keyword fields, recipient lists, tag editors. Self-contained\n * (no `react-tag-input`). Pills render as Saasflare-styled chips with an\n * inline remove (\"×\") affordance.\n *\n * @module packages/ui/components/ui/tag-input\n * @package ui\n * @layer core\n *\n * @example\n * const [tags, setTags] = useState<string[]>([]);\n * <TagInput value={tags} onChange={setTags} placeholder=\"Add a tag…\" />\n */\n\nimport {\n    useCallback,\n    useEffect,\n    useRef,\n    useState,\n    type ChangeEvent,\n    type KeyboardEvent,\n    type ReactNode,\n} from \"react\"\nimport { cn } from \"@saasflare/ui\"\nimport { XIcon } from \"@saasflare/ui\"\nimport { useSaasflareProps, type SaasflareComponentProps } from \"@saasflare/ui\"\n\n/** Props for the TagInput component. */\nexport interface TagInputProps extends SaasflareComponentProps {\n    /** Controlled list of tags. */\n    value?: string[]\n    /** Uncontrolled initial tags. */\n    defaultValue?: string[]\n    /** Called whenever the tag list changes. */\n    onChange?: (tags: string[]) => void\n    /** Placeholder shown in the input. */\n    placeholder?: string\n    /** Maximum number of tags accepted. */\n    maxTags?: number\n    /** Characters that commit the current input as a tag. Default: `[\",\", \"Enter\"]`. */\n    separators?: string[]\n    /** Reject duplicate tags. Default: `true`. */\n    unique?: boolean\n    /** Disable the input. */\n    disabled?: boolean\n    /** Custom renderer for each pill. Falls back to the default pill UI. */\n    renderTag?: (tag: string, onRemove: () => void) => ReactNode\n    /** Additional class names on the outer wrapper. */\n    className?: string\n    /** Accessible label. */\n    \"aria-label\"?: string\n}\n\n/**\n * Input field that converts text into removable tag pills.\n *\n * @component\n * @layer core\n */\nexport function TagInput({\n    value,\n    defaultValue,\n    onChange,\n    placeholder,\n    maxTags,\n    separators = [\",\", \"Enter\"],\n    unique = true,\n    disabled = false,\n    renderTag,\n    className,\n    surface,\n    radius,\n    animated,\n    iconWeight,\n    \"aria-label\": ariaLabel,\n}: TagInputProps) {\n    const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n    const isControlled = value !== undefined\n    const [internal, setInternal] = useState<string[]>(defaultValue ?? [])\n    const tags = isControlled ? value : internal\n    const [draft, setDraft] = useState(\"\")\n\n    // Stable identity per pill, decoupled from string content + index, so\n    // duplicate tags (unique=false) survive removals without React reusing the\n    // wrong DOM node (which would drop focus / animation state).\n    const idCounter = useRef(0)\n    const [ids, setIds] = useState<number[]>(() =>\n        (isControlled ? (value ?? []) : (defaultValue ?? [])).map(() => idCounter.current++),\n    )\n\n    // Safety net for EXTERNAL/controlled value resyncs only. Internal add/remove\n    // keep ids index-aligned in lockstep (see commit/removeAt), so this effect is\n    // a no-op for them; it only reconciles when a parent swaps `value` wholesale.\n    useEffect(() => {\n        setIds((prev) => {\n            if (prev.length === tags.length) return prev\n            if (prev.length < tags.length) {\n                const grown = prev.slice()\n                while (grown.length < tags.length) grown.push(idCounter.current++)\n                return grown\n            }\n            return prev.slice(0, tags.length)\n        })\n    }, [tags.length])\n\n    /* Batched commit: a multi-separator paste (\"a, b, c\") must land in ONE\n     * state update — looping a single-tag commit would rebuild `[...tags, x]`\n     * from the same stale snapshot each iteration and keep only the last tag. */\n    const commitMany = useCallback(\n        (parts: string[]) => {\n            let next = tags\n            let added = 0\n            for (const raw of parts) {\n                const trimmed = raw.trim()\n                if (!trimmed) continue\n                if (unique && next.includes(trimmed)) continue\n                if (typeof maxTags === \"number\" && next.length >= maxTags) break\n                next = [...next, trimmed]\n                added++\n            }\n            if (added === 0) return\n            setIds((prev) => {\n                const grown = prev.slice()\n                for (let i = 0; i < added; i++) grown.push(idCounter.current++)\n                return grown\n            })\n            if (!isControlled) setInternal(next)\n            onChange?.(next)\n            setDraft(\"\")\n        },\n        [isControlled, maxTags, onChange, tags, unique],\n    )\n\n    const commit = useCallback((raw: string) => commitMany([raw]), [commitMany])\n\n    const removeAt = useCallback(\n        (index: number) => {\n            const next = tags.filter((_, i) => i !== index)\n            setIds((prev) => prev.filter((_, i) => i !== index))\n            if (!isControlled) setInternal(next)\n            onChange?.(next)\n        },\n        [isControlled, onChange, tags],\n    )\n\n    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n        const v = e.target.value\n        // Allow paste of \"a, b, c\" — split on the first separator if it's not Enter.\n        const sep = separators.find((s) => s !== \"Enter\" && v.includes(s))\n        if (sep) {\n            const parts = v.split(sep)\n            commitMany(parts.slice(0, -1))\n            setDraft(parts[parts.length - 1] ?? \"\")\n        } else {\n            setDraft(v)\n        }\n    }\n\n    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n        if (separators.includes(e.key)) {\n            e.preventDefault()\n            commit(draft)\n            return\n        }\n        if (e.key === \"Backspace\" && draft === \"\" && tags.length > 0) {\n            e.preventDefault()\n            removeAt(tags.length - 1)\n        }\n    }\n\n    return (\n        <div\n            data-slot=\"tag-input\"\n            data-surface={sf.surface}\n            data-radius={sf.radius}\n            data-animated={String(sf.animated)}\n            data-disabled={String(disabled)}\n            className={cn(\n                \"flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent px-2 py-1.5 text-sm shadow-xs\",\n                \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n                \"data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50\",\n                className,\n            )}\n        >\n            {tags.map((tag, i) => {\n                const onRemove = () => removeAt(i)\n                const key = ids[i] ?? `_${i}`\n                if (renderTag) return <span key={key}>{renderTag(tag, onRemove)}</span>\n                return (\n                    <span\n                        key={key}\n                        data-slot=\"tag-input-tag\"\n                        className=\"inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-foreground\"\n                    >\n                        <span className=\"truncate\">{tag}</span>\n                        <button\n                            type=\"button\"\n                            data-slot=\"tag-input-remove\"\n                            onClick={onRemove}\n                            disabled={disabled}\n                            aria-label={`Remove ${tag}`}\n                            className=\"inline-flex size-3.5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground\"\n                        >\n                            <XIcon weight={sf.iconWeight} aria-hidden=\"true\" className=\"size-2.5\" />\n                        </button>\n                    </span>\n                )\n            })}\n            <input\n                data-slot=\"tag-input-field\"\n                type=\"text\"\n                value={draft}\n                onChange={handleChange}\n                onKeyDown={handleKeyDown}\n                onBlur={() => commit(draft)}\n                disabled={disabled}\n                placeholder={tags.length === 0 ? placeholder : undefined}\n                aria-label={ariaLabel ?? \"Add tag\"}\n                className=\"flex-1 min-w-24 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n            />\n        </div>\n    )\n}\n",
      "target": "components/ui/tag-input.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/tag-input.json"
  }
}
