{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "type": "registry:ui",
  "title": "Tag Input",
  "description": "Chips from Enter, comma or paste; duplicates flash the one already there.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/tag-input.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type TagInputProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (tags: string[]) => void;\n  label?: React.ReactNode;\n  /** One line under the field. Replaced by a validation message while there is one. */\n  description?: React.ReactNode;\n  /** An error from outside (for example, from the server). */\n  error?: React.ReactNode;\n  placeholder?: string;\n  /** Most tags allowed. The count appears once you're within two of it. */\n  max?: number;\n  /** Clean up what was typed before it becomes a tag. Defaults to trimming. */\n  normalize?: (raw: string) => string;\n  /** Return a message to reject a tag and keep the text for fixing. */\n  validate?: (tag: string) => string | null | undefined;\n  /** Treat \"Design\" and \"design\" as the same tag. */\n  caseSensitive?: boolean;\n  /** Turn what's left in the field into a tag when focus leaves. */\n  addOnBlur?: boolean;\n  disabled?: boolean;\n  readOnly?: boolean;\n  /** Submits the tags as one comma-separated value under this name. */\n  name?: string;\n  inputProps?: Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\">;\n};\n\nconst SPLIT = /[,\\n\\t;]+/;\n\nexport function TagInput({\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  label,\n  description,\n  error: errorProp,\n  placeholder = \"Add a tag\",\n  max = Infinity,\n  normalize = (s) => s.trim(),\n  validate,\n  caseSensitive = false,\n  addOnBlur = true,\n  disabled,\n  readOnly,\n  name,\n  inputProps,\n  className,\n  ...rest\n}: TagInputProps) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  const inputId = inputProps?.id ?? `${id}-input`;\n  const descId = `${id}-desc`;\n  const [tags, setTags] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [draft, setDraft] = useState(\"\");\n  const [problem, setProblem] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const counterRef = useRef<HTMLSpanElement>(null);\n\n  const key = (t: string) => (caseSensitive ? t : t.toLocaleLowerCase());\n  const error = problem ?? errorProp;\n  const message = error ?? description;\n  const locked = disabled || readOnly;\n\n  // A duplicate doesn't get added; the one that already exists answers instead.\n  const flash = (el: Element | null | undefined) => {\n    if (!(el instanceof HTMLElement)) return;\n    el.dataset.flash = \"\";\n    window.setTimeout(() => delete el.dataset.flash, 650);\n    if (!reduce) animate(el, { scale: [1, 1.1, 1] }, { duration: 0.36, ease: [0.16, 1, 0.3, 1] });\n  };\n\n  /** Adds each candidate in order; returns what couldn't be added so it can stay in the field. */\n  const add = (candidates: string[]) => {\n    let next = [...tags];\n    const added: string[] = [];\n    let rejected = \"\";\n    for (const raw of candidates) {\n      const tag = normalize(raw);\n      if (!tag) continue;\n      const existing = next.findIndex((t) => key(t) === key(tag));\n      if (existing >= 0) {\n        flash(rootRef.current?.querySelector(`[data-tag-index=\"${existing}\"]`));\n        setAnnouncement(`${tag} is already added`);\n        continue;\n      }\n      if (next.length >= max) {\n        flash(counterRef.current);\n        setProblem(`You can add up to ${max}. Remove one to add another.`);\n        rejected = rejected ? `${rejected}, ${raw.trim()}` : raw.trim();\n        continue;\n      }\n      const invalid = validate?.(tag);\n      if (invalid) {\n        setProblem(invalid);\n        rejected = rejected ? `${rejected}, ${raw.trim()}` : raw.trim();\n        continue;\n      }\n      next = [...next, tag];\n      added.push(tag);\n    }\n    if (added.length) {\n      setTags(next);\n      setAnnouncement(added.length === 1 ? `Added ${added[0]}` : `Added ${added.length} tags`);\n    }\n    return rejected;\n  };\n\n  const commit = () => {\n    if (!draft.trim()) return;\n    setDraft(add([draft]));\n  };\n\n  const remove = (index: number, via: \"keyboard\" | \"pointer\") => {\n    const tag = tags[index];\n    setTags(tags.filter((_, i) => i !== index));\n    setProblem(null);\n    setAnnouncement(`Removed ${tag}`);\n    // Removing the last chip hands focus back to the field, so a held Backspace can't\n    // run through every tag. From the middle, focus moves to the chip that took its place.\n    if (via === \"keyboard\" && index < tags.length - 1) {\n      requestAnimationFrame(() => chipButton(index)?.focus());\n    } else inputRef.current?.focus();\n  };\n\n  const chipButton = (i: number) => rootRef.current?.querySelector<HTMLButtonElement>(`[data-tag-index=\"${i}\"] button`);\n\n  const onChipKey = (e: React.KeyboardEvent, i: number) => {\n    if (e.key === \"Backspace\" || e.key === \"Delete\") {\n      e.preventDefault();\n      remove(i, \"keyboard\");\n    } else if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      chipButton(Math.max(0, i - 1))?.focus();\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      if (i + 1 < tags.length) chipButton(i + 1)?.focus();\n      else inputRef.current?.focus();\n    } else if (e.key === \"Escape\" || e.key === \"End\") {\n      e.preventDefault();\n      inputRef.current?.focus();\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      chipButton(0)?.focus();\n    } else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {\n      // Typing on a chip goes to the field, the way it would if focus had never left.\n      inputRef.current?.focus();\n    }\n  };\n\n  const showCount = Number.isFinite(max) && tags.length >= max - 2;\n\n  return (\n    <div\n      data-disabled={disabled || undefined}\n      data-invalid={error ? \"\" : undefined}\n      className={cn(\"group/tags flex w-full min-w-0 flex-col gap-1.5\", className)}\n      {...rest}\n    >\n      {label && (\n        <label htmlFor={inputId} className=\"w-fit text-[12.5px] font-medium leading-4 text-fg group-data-[disabled]/tags:text-fg-3\">\n          {label}\n        </label>\n      )}\n      <div\n        ref={rootRef}\n        onMouseDown={(e) => {\n          // A press on the field's padding focuses the text box, like any input.\n          if (e.target === e.currentTarget) {\n            e.preventDefault();\n            inputRef.current?.focus();\n          }\n        }}\n        className={cn(\n          \"relative flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-lg border border-line-2 bg-raised p-[3px]\",\n          \"transition-[border-color,box-shadow] duration-150 ease-out\",\n          \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 focus-within:hover:border-fg-3\",\n          \"group-data-[invalid]/tags:border-danger/60 group-data-[invalid]/tags:focus-within:border-danger/80 group-data-[invalid]/tags:focus-within:ring-danger/15\",\n          \"group-data-[disabled]/tags:pointer-events-none group-data-[disabled]/tags:opacity-50\",\n          readOnly && \"bg-hover\",\n          showCount && \"pr-11\",\n        )}\n      >\n        <ul role=\"list\" aria-label={typeof label === \"string\" ? label : \"Tags\"} className=\"contents\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {tags.map((tag, i) => (\n              <motion.li\n                key={key(tag)}\n                layout={reduce ? false : \"position\"}\n                data-tag-index={i}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0, transition: { duration: 0.08 } } : { opacity: 0, scale: 0.8, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                transition={reduce ? { duration: 0.12 } : { ...spring.pop, layout: spring.snappy }}\n                className={cn(\n                  \"group/chip flex h-6 max-w-full min-w-0 items-center rounded-md bg-fg/8 pl-2 text-[12.5px] text-fg\",\n                  \"transition-[background-color,color] duration-150\",\n                  // Selected by keyboard: the chip inverts, so Backspace clearly has a target.\n                  \"has-[button:focus-visible]:bg-fg has-[button:focus-visible]:text-frame\",\n                  \"data-[flash]:bg-warning-soft data-[flash]:text-warning\",\n                  locked ? \"pr-2\" : \"pr-0.5\",\n                )}\n              >\n                <span className=\"truncate\">{tag}</span>\n                {!locked && (\n                  <button\n                    type=\"button\"\n                    tabIndex={-1}\n                    aria-label={`Remove ${tag}`}\n                    onKeyDown={(e) => onChipKey(e, i)}\n                    onClick={(e) => remove(i, e.detail === 0 ? \"keyboard\" : \"pointer\")}\n                    className={cn(\n                      \"relative ml-0.5 grid size-5 shrink-0 place-items-center rounded-[5px] text-fg-3 outline-none\",\n                      \"transition-[background-color,color,scale] duration-150 hover:bg-fg/10 hover:text-fg active:scale-[0.88]\",\n                      \"group-has-[button:focus-visible]/chip:text-frame/70\",\n                      \"after:absolute after:-inset-1.5 after:content-['']\",\n                    )}\n                  >\n                    <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" aria-hidden>\n                      <path d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" />\n                    </svg>\n                  </button>\n                )}\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n\n        <motion.input\n          layout={reduce ? false : \"position\"}\n          transition={spring.snappy}\n          {...(inputProps as object)}\n          ref={inputRef}\n          id={inputId}\n          value={draft}\n          disabled={disabled}\n          readOnly={readOnly}\n          placeholder={tags.length ? undefined : placeholder}\n          aria-describedby={message ? descId : undefined}\n          aria-invalid={error ? true : undefined}\n          autoComplete=\"off\"\n          autoCapitalize=\"none\"\n          spellCheck={false}\n          enterKeyHint=\"enter\"\n          onChange={(e) => {\n            const v = e.target.value;\n            setProblem(null);\n            // Typing a comma (or pasting on phones that don't fire paste) commits what came before it.\n            if (SPLIT.test(v)) {\n              const parts = v.split(SPLIT);\n              const tail = parts.pop() ?? \"\";\n              const left = add(parts);\n              setDraft(left ? `${left}, ${tail}` : tail);\n            } else setDraft(v);\n          }}\n          onPaste={(e) => {\n            const text = e.clipboardData.getData(\"text\");\n            if (!SPLIT.test(text)) return;\n            e.preventDefault();\n            const left = add(text.split(SPLIT));\n            setDraft(left);\n          }}\n          onKeyDown={(e) => {\n            inputProps?.onKeyDown?.(e);\n            if (e.defaultPrevented || e.nativeEvent.isComposing) return;\n            const el = e.currentTarget;\n            const atStart = el.selectionStart === 0 && el.selectionEnd === 0;\n            if (e.key === \"Enter\") {\n              if (!draft.trim()) return;\n              e.preventDefault();\n              commit();\n            } else if ((e.key === \"Backspace\" || e.key === \"ArrowLeft\") && atStart && tags.length) {\n              e.preventDefault();\n              chipButton(tags.length - 1)?.focus();\n            } else if (e.key === \"Escape\" && draft) {\n              e.preventDefault();\n              setDraft(\"\");\n              setProblem(null);\n            }\n          }}\n          onBlur={(e) => {\n            inputProps?.onBlur?.(e);\n            if (addOnBlur && !rootRef.current?.contains(e.relatedTarget as Node)) commit();\n          }}\n          className={cn(\n            \"h-6 min-w-[8ch] flex-1 bg-transparent px-1.5 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\",\n            \"disabled:cursor-not-allowed\",\n          )}\n        />\n\n        {showCount && (\n          <span\n            ref={counterRef}\n            aria-hidden\n            className=\"pointer-events-none absolute bottom-[7px] right-2 font-mono text-[11px] leading-4 tabular text-fg-4 transition-colors duration-150 data-[flash]:text-danger\"\n          >\n            {tags.length}/{max}\n          </span>\n        )}\n      </div>\n\n      {message && (\n        <p id={descId} className={cn(\"flex items-start gap-1.5 text-[12px] leading-4\", error ? \"text-danger\" : \"text-fg-3\")}>\n          {error && <Alert size={14} className=\"mt-px shrink-0\" />}\n          <span className=\"min-w-0\">{message}</span>\n        </p>\n      )}\n      {name && <input type=\"hidden\" name={name} value={tags.join(\",\")} />}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/tag-input"
}
