{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-input","type":"registry:ui","title":"Tag Input","description":"Enter adds, backspace highlights then removes.","dependencies":["motion"],"categories":["input"],"docs":"https://www.interior.dev/docs/tag-input","files":[{"path":"registry/interior/tag-input.tsx","type":"registry:ui","target":"components/interior/tag-input.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst LEAVE = [0.4, 0, 1, 1] as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst CHIP = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\nconst EXIT = { duration: 0.18, ease: LEAVE } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst clean = (raw: string) => raw.trim().replace(/\\s+/g, \" \");\n\nconst splitter = (separators: string[]) =>\n  new RegExp(`[${separators.map((s) => s.replace(/[\\\\\\]^-]/g, \"\\\\$&\")).join(\"\")}\\\\n\\\\r\\\\t]+`);\n\nexport type TagRejection = \"duplicate\" | \"limit\" | \"invalid\";\n\nexport type UseTagInputOptions = {\n  value?: string[];\n  defaultValue?: string[];\n  onChange?: (tags: string[]) => void;\n  max?: number;\n  separators?: string[];\n  allowDuplicates?: boolean;\n  validate?: (candidate: string, tags: string[]) => boolean;\n};\n\ntype Rejection = { reason: TagRejection; tag: string; visible: boolean };\n\nexport function useTagInput({\n  value,\n  defaultValue,\n  onChange,\n  max,\n  separators = [\",\"],\n  allowDuplicates = false,\n  validate,\n}: UseTagInputOptions = {}) {\n  const [internal, setInternal] = useState<string[]>(() => defaultValue ?? []);\n  const [draft, setDraft] = useState(\"\");\n  const [armed, setArmed] = useState(-1);\n  const [rejection, setRejection] = useState<Rejection | null>(null);\n  const [flashed, setFlashed] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const controlled = value !== undefined;\n  const tags = value ?? internal;\n  const armedIndex = armed >= tags.length ? -1 : armed;\n\n  const emit = useRef(onChange);\n  emit.current = onChange;\n  const check = useRef(validate);\n  check.current = validate;\n\n  const rejectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(\n    () => () => {\n      if (rejectTimer.current) clearTimeout(rejectTimer.current);\n      if (flashTimer.current) clearTimeout(flashTimer.current);\n    },\n    [],\n  );\n\n  const dismiss = useCallback(() => {\n    if (rejectTimer.current) clearTimeout(rejectTimer.current);\n    rejectTimer.current = null;\n    setRejection((prev) => (prev && prev.visible ? { ...prev, visible: false } : prev));\n  }, []);\n\n  const refuse = useCallback(\n    (reason: TagRejection, tag: string) => {\n      if (rejectTimer.current) clearTimeout(rejectTimer.current);\n      setRejection({ reason, tag, visible: true });\n      rejectTimer.current = setTimeout(() => {\n        setRejection((prev) => (prev ? { ...prev, visible: false } : prev));\n      }, 2400);\n\n      setAnnouncement(\n        reason === \"duplicate\"\n          ? `${tag} is already in the list.`\n          : reason === \"limit\"\n            ? `That is the limit of ${max} tags.`\n            : `${tag} is not allowed here.`,\n      );\n\n      if (reason !== \"duplicate\") return;\n      if (flashTimer.current) clearTimeout(flashTimer.current);\n      setFlashed(tag);\n      flashTimer.current = setTimeout(() => setFlashed(null), 460);\n    },\n    [max],\n  );\n\n  const apply = useCallback(\n    (next: string[]) => {\n      if (!controlled) setInternal(next);\n      emit.current?.(next);\n    },\n    [controlled],\n  );\n\n  const add = useCallback(\n    (raws: string[]) => {\n      const next = [...tags];\n      let added = 0;\n      let failure: { reason: TagRejection; tag: string } | null = null;\n\n      for (const raw of raws) {\n        const candidate = clean(raw);\n        if (!candidate) continue;\n\n        if (max !== undefined && next.length >= max) {\n          failure = { reason: \"limit\", tag: candidate };\n          break;\n        }\n\n        if (!allowDuplicates) {\n          const twin = next.find((t) => t.toLowerCase() === candidate.toLowerCase());\n          if (twin) {\n            failure = { reason: \"duplicate\", tag: twin };\n            continue;\n          }\n        }\n\n        if (check.current && !check.current(candidate, next)) {\n          failure = { reason: \"invalid\", tag: candidate };\n          continue;\n        }\n\n        next.push(candidate);\n        added += 1;\n      }\n\n      if (added > 0) {\n        apply(next);\n        setDraft(\"\");\n        setArmed(-1);\n        dismiss();\n        setAnnouncement(\n          `${added === 1 ? next[next.length - 1] : `${added} tags`} added, ${next.length} total.`,\n        );\n      }\n\n      if (failure) refuse(failure.reason, failure.tag);\n      return added > 0;\n    },\n    [tags, max, allowDuplicates, apply, dismiss, refuse],\n  );\n\n  const removeAt = useCallback(\n    (index: number) => {\n      if (index < 0 || index >= tags.length) return;\n      const gone = tags[index];\n      const next = tags.filter((_, i) => i !== index);\n      apply(next);\n      setArmed(-1);\n      dismiss();\n      setAnnouncement(`${gone} removed, ${next.length} left.`);\n    },\n    [tags, apply, dismiss],\n  );\n\n  const arm = useCallback(\n    (index: number) => {\n      setArmed(index);\n      setAnnouncement(`${tags[index]} selected, press Backspace again to remove it.`);\n    },\n    [tags],\n  );\n\n  const inputProps = {\n    value: draft,\n    onChange: (e: React.ChangeEvent<HTMLInputElement>) => {\n      setDraft(e.target.value);\n      setArmed(-1);\n      dismiss();\n    },\n    onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (e.nativeEvent.isComposing) return;\n\n      if (e.key === \"Enter\" || separators.includes(e.key)) {\n        e.preventDefault();\n        add([draft]);\n        return;\n      }\n\n      if (e.key === \"Backspace\" && draft === \"\") {\n        e.preventDefault();\n        if (e.repeat) return;\n        if (armedIndex >= 0) removeAt(armedIndex);\n        else if (tags.length > 0) arm(tags.length - 1);\n        return;\n      }\n\n      if (e.key === \"Delete\" && armedIndex >= 0) {\n        e.preventDefault();\n        if (e.repeat) return;\n        removeAt(armedIndex);\n        return;\n      }\n\n      if (e.key === \"ArrowLeft\") {\n        const start = e.currentTarget.selectionStart;\n        const end = e.currentTarget.selectionEnd;\n        if (start !== 0 || end !== 0 || tags.length === 0) return;\n        e.preventDefault();\n        arm(armedIndex < 0 ? tags.length - 1 : Math.max(0, armedIndex - 1));\n        return;\n      }\n\n      if (e.key === \"ArrowRight\" && armedIndex >= 0) {\n        e.preventDefault();\n        if (armedIndex >= tags.length - 1) setArmed(-1);\n        else arm(armedIndex + 1);\n        return;\n      }\n\n      if (e.key === \"Escape\" && armedIndex >= 0) {\n        e.preventDefault();\n        setArmed(-1);\n      }\n    },\n    onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => {\n      const text = e.clipboardData.getData(\"text\");\n      const pattern = splitter(separators);\n      if (!pattern.test(text)) return;\n      e.preventDefault();\n      add(text.split(pattern));\n    },\n    onBlur: () => setArmed(-1),\n  };\n\n  return {\n    tags,\n    draft,\n    setDraft,\n    armedIndex,\n    flashed,\n    rejection,\n    announcement,\n    inputProps,\n    add,\n    removeAt,\n    max,\n  };\n}\n\nexport type TagInputProps = UseTagInputOptions & {\n  label?: string;\n  placeholder?: string;\n  hint?: string;\n  className?: string;\n};\n\nfunction CloseGlyph() {\n  return (\n    <svg\n      viewBox=\"0 0 10 10\"\n      aria-hidden\n      className=\"size-[9px]\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.6}\n      strokeLinecap=\"round\"\n    >\n      <path d=\"M2.6 2.6 7.4 7.4M7.4 2.6 2.6 7.4\" />\n    </svg>\n  );\n}\n\nexport function TagInput({\n  label,\n  placeholder = \"Add a tag\",\n  hint = \"Enter adds · Backspace removes\",\n  className = \"\",\n  ...options\n}: TagInputProps) {\n  const { tags, draft, armedIndex, flashed, rejection, announcement, inputProps, removeAt, max } =\n    useTagInput(options);\n\n  const reduced = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const uid = useId();\n  const inputId = `${uid}-tag-input`;\n  const hintId = `${uid}-tag-hint`;\n\n  const rows = useMemo(() => {\n    const seen = new Map<string, number>();\n    return tags.map((tag) => {\n      const n = seen.get(tag) ?? 0;\n      seen.set(tag, n + 1);\n      return { tag, key: n === 0 ? tag : `${tag}#${n}` };\n    });\n  }, [tags]);\n\n  const message = !rejection\n    ? \"\"\n    : rejection.reason === \"duplicate\"\n      ? `${rejection.tag} is already in the list`\n      : rejection.reason === \"limit\"\n        ? `That is the limit of ${max} tags`\n        : `${rejection.tag} is not allowed here`;\n\n  const showMessage = rejection?.visible === true;\n\n  return (\n    <div className={`w-full ${className}`}>\n      {label ? (\n        <label\n          htmlFor={inputId}\n          className=\"mb-1.5 block text-[12.5px] font-medium text-stone-700 dark:text-stone-200\"\n        >\n          {label}\n        </label>\n      ) : null}\n\n      <ul\n        onPointerDown={(e) => {\n          if (e.target !== e.currentTarget) return;\n          e.preventDefault();\n          inputRef.current?.focus();\n        }}\n        className=\"relative flex max-h-[116px] min-h-10 list-none flex-wrap items-center gap-1.5 overflow-y-auto overscroll-contain rounded-[10px] border-2 border-stone-200 bg-stone-100/70 p-[4px] shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] transition-[background-color,border-color,box-shadow] duration-150 focus-within:border-[#4568FF] focus-within:bg-white focus-within:shadow-none dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)] dark:focus-within:border-[#93B0FF] dark:focus-within:bg-[#252522]\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {rows.map(({ tag, key }, index) => {\n            const lit = armedIndex === index || flashed === tag;\n            return (\n              <motion.li\n                key={key}\n                role=\"listitem\"\n                layout=\"position\"\n                initial={reduced ? false : { opacity: 0, scale: 0.9 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={\n                  reduced\n                    ? { opacity: 0, transition: INSTANT }\n                    : { opacity: 0, scale: 0.9, transition: EXIT }\n                }\n                transition={reduced ? INSTANT : { default: CHIP, layout: CHIP }}\n                className={`flex h-6 max-w-full shrink-0 select-none items-center gap-1 rounded-[6px] border pl-2 pr-1.5 text-[12.5px] transition-[background-color,border-color,box-shadow,color] duration-150 ${\n                  lit\n                    ? \"border-stone-800 bg-stone-800 text-white shadow-[0_1px_2px_rgba(28,25,23,0.18)] dark:border-stone-100 dark:bg-stone-100 dark:text-stone-900 dark:shadow-[0_1px_2px_rgba(0,0,0,0.4)]\"\n                    : \"border-stone-200 bg-white text-stone-800 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] dark:border-white/[0.16] dark:bg-[#2A2A27] dark:text-stone-200 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)]\"\n                }`}\n              >\n                <span className=\"truncate\">{tag}</span>\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  aria-label={`Remove ${tag}`}\n                  onMouseDown={(e) => e.preventDefault()}\n                  onClick={() => {\n                    removeAt(index);\n                    inputRef.current?.focus();\n                  }}\n                  className={`-mr-0.5 grid size-[14px] shrink-0 place-items-center rounded-[5px] transition-colors duration-150 ${\n                    lit\n                      ? \"text-white/70 hover:text-white dark:text-stone-900/60 dark:hover:text-stone-900\"\n                      : \"text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100\"\n                  }`}\n                >\n                  <CloseGlyph />\n                </button>\n              </motion.li>\n            );\n          })}\n        </AnimatePresence>\n\n        <motion.li\n          layout={reduced ? false : \"position\"}\n          transition={reduced ? INSTANT : CHIP}\n          className=\"relative flex h-6 flex-1\"\n        >\n          <span\n            aria-hidden\n            className=\"pointer-events-none invisible max-w-56 overflow-hidden whitespace-pre px-1 text-[12.5px]\"\n          >\n            {draft || placeholder}\n          </span>\n          <input\n            {...inputProps}\n            ref={inputRef}\n            id={inputId}\n            type=\"text\"\n            aria-describedby={hintId}\n            aria-label={label ? undefined : \"Tags\"}\n            placeholder={placeholder}\n            autoComplete=\"off\"\n            autoCapitalize=\"off\"\n            autoCorrect=\"off\"\n            spellCheck={false}\n            enterKeyHint=\"done\"\n            className=\"absolute inset-0 h-full w-full bg-transparent px-1 text-[12.5px] text-stone-700 outline-none placeholder:text-stone-400 dark:text-stone-200 dark:placeholder:text-stone-500\"\n          />\n        </motion.li>\n      </ul>\n\n      <div className=\"mt-1.5 flex items-baseline justify-between gap-3\">\n        <div className=\"grid min-w-0 flex-1\">\n          <motion.p\n            id={hintId}\n            initial={false}\n            animate={{ opacity: showMessage ? 0 : 1 }}\n            transition={reduced ? INSTANT : CROSSFADE}\n            className=\"col-start-1 row-start-1 truncate text-[11.5px] text-stone-500 dark:text-stone-400\"\n          >\n            {hint}\n          </motion.p>\n          <motion.p\n            aria-hidden\n            initial={false}\n            animate={{ opacity: showMessage ? 1 : 0 }}\n            transition={reduced ? INSTANT : CROSSFADE}\n            className=\"col-start-1 row-start-1 truncate text-[11.5px] text-stone-700 dark:text-stone-200\"\n          >\n            {message}\n          </motion.p>\n        </div>\n\n        {max === undefined ? null : (\n          <p className=\"shrink-0 text-[11.5px] tabular-nums text-stone-500 dark:text-stone-400\">\n            <span className=\"inline-grid justify-items-end\">\n              <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n                {max}\n              </span>\n              <span className=\"col-start-1 row-start-1\">{tags.length}</span>\n            </span>\n            <span> / {max}</span>\n          </p>\n        )}\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n"}]}