{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "type": "registry:ui",
  "title": "Tag Input",
  "description": "A chips input with paste splitting, duplicate shake, keyboard chip navigation and accessible suggestions.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/tag-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useAnimate } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface TagInputProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Controlled list of tags. */\n  value?: string[];\n  /** Initial tags when uncontrolled. */\n  defaultValue?: string[];\n  /** Called with the full list whenever a tag is added or removed. */\n  onChange?: (tags: string[]) => void;\n  /** Visible label, rendered as a real <label> for the text input. */\n  label?: React.ReactNode;\n  /** Helper text below the field (linked via aria-describedby). */\n  hint?: React.ReactNode;\n  /** Error message. Any truthy value marks the field invalid; a node is also shown and announced. */\n  error?: React.ReactNode;\n  placeholder?: string;\n  /** Maximum number of tags. 0 means no limit. */\n  maxTags?: number;\n  /** Optional suggestions, shown in an accessible listbox while typing (or on ArrowDown). */\n  suggestions?: string[];\n  /** Treat \"React\" and \"react\" as different tags. */\n  caseSensitive?: boolean;\n  /** Turn pending text into a tag when the field loses focus. */\n  addOnBlur?: boolean;\n  /** Focus colour, chip highlight and caret (any CSS colour). */\n  accent?: string;\n  disabled?: boolean;\n  /** When set, a hidden input submits the tags joined with `delimiter` in native forms. */\n  name?: string;\n  /** Separator used for the hidden form value. */\n  delimiter?: string;\n  /** id for the text input (generated when omitted). */\n  inputId?: string;\n}\n\n/** Characters that split pasted or typed text into several tags. */\nconst SPLIT = /[,\\n\\r\\t]+/;\nconst MAX_SUGGESTIONS = 8;\n\n/**\n * A chips input. Enter, comma or Tab adds the typed text; Backspace on an empty field selects\n * the last chip and a second press removes it; arrows move between chips. Pasted lists are\n * split on commas and new lines, duplicates are rejected with a shake, and optional suggestions\n * use the ARIA combobox pattern with aria-activedescendant.\n */\nexport function TagInput({\n  value,\n  defaultValue,\n  onChange,\n  label = \"Tags\",\n  hint,\n  error,\n  placeholder = \"Add a tag…\",\n  maxTags = 8,\n  suggestions,\n  caseSensitive = false,\n  addOnBlur = false,\n  accent = \"#FF7448\",\n  disabled = false,\n  name,\n  delimiter = \",\",\n  inputId: inputIdProp,\n  className = \"\",\n  style,\n  ...props\n}: TagInputProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const inputId = inputIdProp ?? `tag-${uid}`;\n  const labelId = `${inputId}-label`;\n  const hintId = `${inputId}-hint`;\n  const errorId = `${inputId}-error`;\n  const helpId = `${inputId}-help`;\n  const listboxId = `${inputId}-listbox`;\n  const optionId = (i: number) => `${inputId}-option-${i}`;\n\n  const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);\n  const tags = value ?? inner;\n  const [text, setText] = React.useState(\"\");\n  const [open, setOpen] = React.useState(false);\n  const [activeIndex, setActiveIndex] = React.useState(-1);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const [flash, setFlash] = React.useState<string | null>(null);\n\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const chipButtons = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const flashTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n\n  React.useEffect(() => () => clearTimeout(flashTimer.current), []);\n\n  const limit = maxTags > 0 ? maxTags : Infinity;\n  const atLimit = tags.length >= limit;\n  const keyOf = (t: string) => (caseSensitive ? t : t.toLowerCase());\n  const indexOfTag = (t: string, list: string[] = tags) => list.findIndex((x) => keyOf(x) === keyOf(t));\n\n  const invalid = Boolean(error);\n  const hasErrorText = invalid && error !== true;\n\n  // Suggestions are derived during render: unused entries that contain the query.\n  const query = text.trim().toLowerCase();\n  const matches = (suggestions ?? [])\n    .filter((s) => indexOfTag(s) === -1 && (!query || s.toLowerCase().includes(query)))\n    .slice(0, MAX_SUGGESTIONS);\n  const listOpen = open && !disabled && !atLimit && matches.length > 0;\n  const active = listOpen && activeIndex < matches.length ? activeIndex : -1;\n\n  /** Polite announcement; a trailing space toggles so repeated messages are re-read. */\n  const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));\n\n  const setTags = (next: string[]) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n\n  const shake = (target: Element | null) => {\n    if (!target) return;\n    if (!reduceMotion) animate(target, { x: [0, -7, 6, -4, 3, -1, 0] }, { duration: 0.4, ease: \"easeOut\" });\n  };\n\n  /** Highlights (and shakes) an existing chip, e.g. when its duplicate is rejected. */\n  const flashTag = (tag: string) => {\n    setFlash(keyOf(tag));\n    clearTimeout(flashTimer.current);\n    flashTimer.current = setTimeout(() => setFlash(null), 700);\n    shake(scope.current?.querySelector(`[data-chip-key=\"${CSS.escape(keyOf(tag))}\"]`) ?? null);\n  };\n\n  /** Adds every candidate it can. Returns true if the pending text was fully consumed. */\n  const addTags = (candidates: string[]) => {\n    const next = [...tags];\n    const added: string[] = [];\n    const duplicates: string[] = [];\n    let overLimit = 0;\n    for (const raw of candidates) {\n      const tag = raw.trim();\n      if (!tag) continue;\n      if (indexOfTag(tag, next) !== -1) {\n        duplicates.push(tag);\n        continue;\n      }\n      if (next.length >= limit) {\n        overLimit++;\n        continue;\n      }\n      next.push(tag);\n      added.push(tag);\n    }\n    if (added.length) setTags(next);\n\n    const parts: string[] = [];\n    if (added.length === 1) parts.push(`Added ${added[0]}.`);\n    else if (added.length > 1) parts.push(`Added ${added.length} tags.`);\n    if (duplicates.length) {\n      parts.push(duplicates.length === 1 ? `${duplicates[0]} is already added.` : `${duplicates.length} duplicates skipped.`);\n      flashTag(duplicates[0]);\n    }\n    if (overLimit) {\n      parts.push(`Limit of ${limit} tags reached.`);\n      shake(scope.current);\n    }\n    if (parts.length) announce(parts.join(\" \"));\n    return added.length > 0 || duplicates.length > 0;\n  };\n\n  const commitText = () => {\n    if (!text.trim()) return false;\n    const consumed = addTags([text]);\n    // Keep the text when the limit blocked it, so nothing typed is lost.\n    if (consumed) setText(\"\");\n    setActiveIndex(-1);\n    return true;\n  };\n\n  const chooseSuggestion = (index: number) => {\n    const s = matches[index];\n    if (s === undefined) return;\n    addTags([s]);\n    setText(\"\");\n    setActiveIndex(-1);\n    inputRef.current?.focus();\n  };\n\n  const removeAt = (index: number, focusAfter: \"previous\" | \"next\" | \"input\" = \"input\") => {\n    const tag = tags[index];\n    if (tag === undefined || disabled) return;\n    const next = tags.filter((_, i) => i !== index);\n    setTags(next);\n    announce(`Removed ${tag}.`);\n    // Focus moves after React commits; the chip list has already shrunk by then.\n    requestAnimationFrame(() => {\n      if (focusAfter === \"previous\" && index > 0) chipButtons.current[index - 1]?.focus();\n      else if (focusAfter === \"next\" && index < next.length) chipButtons.current[index]?.focus();\n      else inputRef.current?.focus();\n    });\n  };\n\n  const focusChip = (index: number) => {\n    if (index < 0) return;\n    if (index >= tags.length) inputRef.current?.focus();\n    else chipButtons.current[index]?.focus();\n  };\n\n  const scrollOptionIntoView = (index: number) => {\n    const list = listRef.current;\n    const option = list?.children[index] as HTMLElement | undefined;\n    if (!list || !option) return;\n    // Scroll the listbox only; scrollIntoView would also move the page.\n    if (option.offsetTop < list.scrollTop) list.scrollTop = option.offsetTop;\n    else if (option.offsetTop + option.offsetHeight > list.scrollTop + list.clientHeight)\n      list.scrollTop = option.offsetTop + option.offsetHeight - list.clientHeight;\n  };\n\n  const moveActive = (delta: number) => {\n    if (!matches.length) return;\n    const next = active === -1 ? (delta > 0 ? 0 : matches.length - 1) : (active + delta + matches.length) % matches.length;\n    setOpen(true);\n    setActiveIndex(next);\n    scrollOptionIntoView(next);\n  };\n\n  const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.nativeEvent.isComposing) return;\n    const caretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;\n    switch (event.key) {\n      case \"Enter\":\n        if (active !== -1) {\n          event.preventDefault();\n          chooseSuggestion(active);\n        } else if (text.trim()) {\n          event.preventDefault();\n          commitText();\n        }\n        break;\n      case \",\":\n        event.preventDefault();\n        commitText();\n        break;\n      case \"Tab\":\n        if (event.shiftKey) break;\n        if (active !== -1) {\n          event.preventDefault();\n          chooseSuggestion(active);\n        } else if (text.trim()) {\n          event.preventDefault();\n          commitText();\n        }\n        break;\n      case \"ArrowDown\":\n        if (matches.length) {\n          event.preventDefault();\n          moveActive(1);\n        }\n        break;\n      case \"ArrowUp\":\n        if (listOpen) {\n          event.preventDefault();\n          moveActive(-1);\n        }\n        break;\n      case \"Escape\":\n        if (listOpen) {\n          event.preventDefault();\n          setOpen(false);\n          setActiveIndex(-1);\n        } else if (text) {\n          event.preventDefault();\n          setText(\"\");\n        }\n        break;\n      case \"Backspace\":\n        // First press selects the last chip, the next press (on the chip) removes it.\n        if (!text && tags.length) {\n          event.preventDefault();\n          focusChip(tags.length - 1);\n        }\n        break;\n      case \"ArrowLeft\":\n        if (caretAtStart && tags.length) {\n          event.preventDefault();\n          focusChip(tags.length - 1);\n        }\n        break;\n      case \"Home\":\n        if (caretAtStart && tags.length) {\n          event.preventDefault();\n          focusChip(0);\n        }\n        break;\n    }\n  };\n\n  const handleChipKeyDown = (index: number, event: React.KeyboardEvent<HTMLButtonElement>) => {\n    switch (event.key) {\n      case \"Backspace\":\n        event.preventDefault();\n        removeAt(index, index > 0 ? \"previous\" : \"next\");\n        break;\n      case \"Delete\":\n        event.preventDefault();\n        removeAt(index, \"next\");\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        focusChip(Math.max(0, index - 1));\n        break;\n      case \"ArrowRight\":\n        event.preventDefault();\n        focusChip(index + 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusChip(0);\n        break;\n      case \"End\":\n      case \"Escape\":\n        event.preventDefault();\n        inputRef.current?.focus();\n        break;\n      default:\n        // Typing on a chip returns to the text field; the keystroke lands there.\n        if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) inputRef.current?.focus();\n    }\n  };\n\n  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    const raw = event.target.value;\n    // Soft keyboards often report \",\" as an unidentified key: split here as well.\n    if (SPLIT.test(raw)) {\n      const parts = raw.split(SPLIT);\n      const rest = parts.pop() ?? \"\";\n      addTags(parts);\n      setText(rest);\n    } else {\n      setText(raw);\n    }\n    setOpen(true);\n    setActiveIndex(-1);\n  };\n\n  const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {\n    const pasted = event.clipboardData.getData(\"text\");\n    if (!SPLIT.test(pasted)) return; // a single word pastes normally\n    event.preventDefault();\n    addTags([text + pasted.split(SPLIT)[0], ...pasted.split(SPLIT).slice(1)]);\n    setText(\"\");\n  };\n\n  const describedBy =\n    [helpId, hint ? hintId : null, hasErrorText ? errorId : null, props[\"aria-describedby\"]].filter(Boolean).join(\" \") || undefined;\n\n  return (\n    <div\n      className={`relative w-full ${className}`}\n      style={{ [\"--ti-accent\" as string]: accent, ...style }}\n      {...props}\n      aria-describedby={undefined}\n    >\n      <div className=\"mb-2 flex items-baseline justify-between gap-3\">\n        <label\n          id={labelId}\n          htmlFor={inputId}\n          className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\"\n        >\n          {label}\n        </label>\n        {Number.isFinite(limit) && (\n          <span aria-hidden=\"true\" className=\"font-mono text-[10px] tabular-nums tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n            {tags.length}/{limit}\n          </span>\n        )}\n      </div>\n\n      <div\n        ref={scope}\n        onMouseDown={(event) => {\n          // Clicking empty space in the field focuses the text input.\n          if (event.target === event.currentTarget) {\n            event.preventDefault();\n            inputRef.current?.focus();\n          }\n        }}\n        className={`relative flex min-h-12 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border bg-white px-2 py-1.5 transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${\n          invalid\n            ? \"border-rose-500/80 dark:border-rose-400/80\"\n            : \"border-zinc-900/15 hover:border-zinc-900/30 focus-within:border-[var(--ti-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ti-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30\"\n        } ${disabled ? \"pointer-events-none opacity-50\" : \"\"}`}\n      >\n        {/* display:contents keeps chips and the input in one wrapping row; role=\"list\" is explicit for Safari. */}\n        <ul role=\"list\" aria-label=\"Added tags\" className=\"contents\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {tags.map((tag, i) => {\n              const key = keyOf(tag);\n              return (\n                <motion.li\n                  key={key}\n                  layout={reduceMotion ? false : \"position\"}\n                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  // Exiting chips stop taking clicks so a stale remove button can't fire twice.\n                  exit={reduceMotion ? { opacity: 0, pointerEvents: \"none\" } : { opacity: 0, scale: 0.6, pointerEvents: \"none\" }}\n                  transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 32, mass: 0.6 }}\n                  className=\"flex\"\n                >\n                  <span\n                    data-chip-key={key}\n                    className={`group/chip inline-flex h-8 max-w-[16rem] items-center gap-1 rounded-lg border pl-2.5 pr-1 text-[13px] font-medium text-zinc-900 transition-[border-color,background-color] duration-150 has-[:focus-visible]:border-[var(--ti-accent)] has-[:focus-visible]:bg-[color-mix(in_srgb,var(--ti-accent)_14%,transparent)] dark:text-zinc-100 motion-reduce:transition-none ${\n                      flash === key\n                        ? \"border-[var(--ti-accent)] bg-[color-mix(in_srgb,var(--ti-accent)_18%,transparent)]\"\n                        : \"border-zinc-900/10 bg-zinc-900/[0.04] dark:border-white/10 dark:bg-white/[0.06]\"\n                    }`}\n                  >\n                    <span className=\"truncate\">{tag}</span>\n                    <button\n                      ref={(el) => {\n                        chipButtons.current[i] = el;\n                      }}\n                      type=\"button\"\n                      tabIndex={-1}\n                      disabled={disabled}\n                      aria-label={`Remove ${tag}`}\n                      onClick={() => removeAt(i)}\n                      onKeyDown={(e) => handleChipKeyDown(i, e)}\n                      className=\"grid size-6 shrink-0 place-items-center rounded-md text-zinc-500 outline-none transition-colors hover:bg-zinc-900/10 hover:text-zinc-900 focus-visible:bg-[var(--ti-accent)] focus-visible:text-white dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:text-zinc-950\"\n                    >\n                      <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n                        <path d=\"M3 3l6 6M9 3l-6 6\" />\n                      </svg>\n                    </button>\n                  </span>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </ul>\n\n        <input\n          ref={inputRef}\n          id={inputId}\n          type=\"text\"\n          role={suggestions ? \"combobox\" : undefined}\n          aria-expanded={suggestions ? listOpen : undefined}\n          aria-controls={suggestions ? listboxId : undefined}\n          aria-autocomplete={suggestions ? \"list\" : \"none\"}\n          aria-activedescendant={active !== -1 ? optionId(active) : undefined}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          autoComplete=\"off\"\n          autoCapitalize=\"off\"\n          spellCheck={false}\n          enterKeyHint=\"enter\"\n          disabled={disabled}\n          value={text}\n          placeholder={atLimit ? \"Limit reached\" : tags.length ? \"\" : placeholder}\n          onChange={handleChange}\n          onKeyDown={handleInputKeyDown}\n          onPaste={handlePaste}\n          onClick={() => setOpen(true)}\n          onBlur={(event) => {\n            setOpen(false);\n            setActiveIndex(-1);\n            // Moving to a chip in the same field is not \"leaving\" it.\n            const toChip = event.relatedTarget instanceof Node && scope.current?.contains(event.relatedTarget);\n            if (addOnBlur && !toChip) commitText();\n          }}\n          className=\"h-8 min-w-[8ch] flex-1 bg-transparent px-1.5 text-[14px] text-zinc-950 caret-[var(--ti-accent)] outline-none placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-500\"\n        />\n\n        <AnimatePresence>\n          {listOpen && (\n            <motion.ul\n              ref={listRef}\n              id={listboxId}\n              role=\"listbox\"\n              aria-label=\"Suggestions\"\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: reduceMotion ? 0 : 0.16, ease: [0.2, 0.7, 0.2, 1] }}\n              className=\"absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-60 overflow-y-auto rounded-xl border border-zinc-900/10 bg-white p-1 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.25)] dark:border-white/10 dark:bg-zinc-900\"\n            >\n              {matches.map((s, i) => {\n                const at = query ? s.toLowerCase().indexOf(query) : -1;\n                return (\n                  <li\n                    key={s}\n                    id={optionId(i)}\n                    role=\"option\"\n                    aria-selected={i === active}\n                    // Keep focus in the input while choosing with the mouse.\n                    onMouseDown={(e) => e.preventDefault()}\n                    onClick={() => chooseSuggestion(i)}\n                    onPointerMove={() => {\n                      if (i !== active) setActiveIndex(i);\n                    }}\n                    className={`flex cursor-pointer items-center justify-between rounded-lg px-2.5 py-2 text-[13px] text-zinc-700 dark:text-zinc-300 ${\n                      i === active ? \"bg-zinc-900/[0.06] text-zinc-950 dark:bg-white/[0.08] dark:text-white\" : \"\"\n                    }`}\n                  >\n                    <span className=\"truncate\">\n                      {at === -1 ? (\n                        s\n                      ) : (\n                        <>\n                          {s.slice(0, at)}\n                          <mark className=\"bg-transparent font-semibold text-zinc-950 underline decoration-[var(--ti-accent)] decoration-2 underline-offset-2 dark:text-white\">\n                            {s.slice(at, at + query.length)}\n                          </mark>\n                          {s.slice(at + query.length)}\n                        </>\n                      )}\n                    </span>\n                    {i === active && (\n                      <span aria-hidden=\"true\" className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400\">\n                        Enter\n                      </span>\n                    )}\n                  </li>\n                );\n              })}\n            </motion.ul>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <span id={helpId} className=\"sr-only\">\n        {tags.length} {tags.length === 1 ? \"tag\" : \"tags\"}\n        {Number.isFinite(limit) ? ` of ${limit}` : \"\"}. Press Enter or comma to add. Backspace or Left arrow reaches added tags; Backspace or\n        Delete removes the selected tag.\n      </span>\n\n      {hint && (\n        <p id={hintId} className=\"mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400\">\n          {hint}\n        </p>\n      )}\n\n      <div aria-live=\"polite\">\n        <AnimatePresence initial={false}>\n          {hasErrorText && (\n            <motion.p\n              key=\"error\"\n              id={errorId}\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n              animate={reduceMotion ? { opacity: 1 } : { opacity: 1, height: \"auto\", y: 0 }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n              transition={{ duration: reduceMotion ? 0 : 0.2, ease: [0.2, 0.7, 0.2, 1] }}\n              className=\"overflow-hidden text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400\"\n            >\n              <span className=\"block pt-1.5\">{error}</span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* Add/remove/duplicate announcements. */}\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n\n      {name && <input type=\"hidden\" name={name} value={tags.join(delimiter)} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/tag-input"
}
