{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-input-clean","type":"registry:ui","title":"Tag Input (Clean)","description":"Several values in one field as chips – paste a whole list, a check per value, duplicates flash, Backspace and arrow keys to edit","dependencies":[],"files":[{"path":"registry/tag-input-clean/tag-input-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useId, useRef, useState, type ClipboardEvent, type KeyboardEvent, type ReactNode, type Ref } from \"react\";\n\n/*\n * Tag Input – a single self-contained file, clean style.\n * Several values in one field, as chips: Enter, comma or Tab adds what you typed, a pasted list becomes many chips at\n * once, a value that doesn't pass the check stays in the field with a message, and a duplicate flashes the chip it\n * repeats. Backspace on an empty field takes the last chip back into the text; ← and → walk the chips.\n * The field has the Text Field's look. The styles are injected below, so no extra files are needed.\n */\n\nexport type TagInputProps = {\n  /** The chips – makes it controlled */\n  value?: string[];\n  defaultValue?: string[];\n  onChange?: (tags: string[]) => void;\n  /** Shown above the field and tied to it */\n  label?: ReactNode;\n  /** A quiet line under the field – a message takes its place while there is one */\n  hint?: ReactNode;\n  /** Marks the field invalid and shows this under it */\n  error?: ReactNode;\n  /** \"Optional\" next to the label */\n  optional?: boolean;\n  placeholder?: string;\n  /** Checks a value before it becomes a chip: true to accept, or a message that says what's wrong */\n  validate?: (tag: string) => true | string;\n  /** Cleans a value before it's checked, e.g. trims and lowercases an email (default: trim) */\n  normalize?: (tag: string) => string;\n  /** Also split on spaces – right for emails and single words, wrong for names with spaces */\n  splitOnSpace?: boolean;\n  /** At most this many chips */\n  max?: number;\n  /** A small circle with the first letter on every chip, e.g. for people */\n  avatar?: boolean;\n  disabled?: boolean;\n  /** Sends the chips with a form, as one hidden input per chip */\n  name?: string;\n  id?: string;\n  className?: string;\n  \"aria-label\"?: string;\n  ref?: Ref<HTMLInputElement>;\n};\n\nexport function TagInput({\n  value,\n  defaultValue = [],\n  onChange,\n  label,\n  hint,\n  error,\n  optional = false,\n  placeholder = \"Add…\",\n  validate,\n  normalize = (t) => t.trim(),\n  splitOnSpace = false,\n  max,\n  avatar = false,\n  disabled = false,\n  name,\n  id,\n  className,\n  \"aria-label\": ariaLabel,\n  ref,\n}: TagInputProps) {\n  const autoId = useId();\n  const inputId = id ?? `${autoId}-input`;\n  const labelId = `${autoId}-label`;\n  const messageId = `${autoId}-message`;\n  const liveId = `${autoId}-live`;\n\n  const [own, setOwn] = useState<string[]>(defaultValue);\n  const tags = value ?? own;\n  const [draft, setDraft] = useState(\"\");\n  const [problem, setProblem] = useState(\"\");\n  const [armed, setArmed] = useState(false);\n  const [flash, setFlash] = useState<{ tag: string; n: number } | null>(null);\n  const [said, setSaid] = useState(\"\");\n  const input = useRef<HTMLInputElement | null>(null);\n  const box = useRef<HTMLDivElement | null>(null);\n\n  const full = max != null && tags.length >= max;\n  const shownError = error != null && typeof error !== \"boolean\" && error !== \"\" ? error : problem || null;\n  const invalid = Boolean(error) || Boolean(problem);\n  const message = shownError ?? (hint != null && typeof hint !== \"boolean\" && hint !== \"\" ? hint : null);\n  const split = splitOnSpace ? /[\\s,;]+/ : /[,;\\n]+/;\n\n  const set = (next: string[]) => {\n    if (value === undefined) setOwn(next);\n    onChange?.(next);\n  };\n\n  // Adds every good value in the text; whatever doesn't pass stays in the field with a message, ahead of `rest`\n  const commit = (text: string, rest = \"\") => {\n    const parts = text.split(split).map(normalize).filter(Boolean);\n    if (!parts.length) {\n      setDraft(rest);\n      return;\n    }\n    const added: string[] = [];\n    const left: string[] = [];\n    let why = \"\";\n    for (const p of parts) {\n      const check = validate ? validate(p) : true;\n      if (check !== true) {\n        left.push(p);\n        why ||= check;\n      } else if (tags.includes(p) || added.includes(p)) {\n        setFlash({ tag: p, n: Date.now() });\n      } else if (max != null && tags.length + added.length >= max) {\n        left.push(p);\n        why ||= `Up to ${max} at a time.`;\n      } else added.push(p);\n    }\n    if (added.length) {\n      set([...tags, ...added]);\n      setSaid(added.length === 1 ? `${added[0]} added` : `${added.length} added`);\n    }\n    setDraft([left.join(splitOnSpace ? \" \" : \", \"), rest].filter(Boolean).join(splitOnSpace ? \" \" : \", \"));\n    setProblem(why);\n  };\n\n  const remove = (tag: string, focusInput = true) => {\n    set(tags.filter((t) => t !== tag));\n    setSaid(`${tag} removed`);\n    if (focusInput) input.current?.focus();\n  };\n\n  const chips = () => [...(box.current?.querySelectorAll<HTMLElement>(\".ui-tagc__chip\") ?? [])];\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (e.nativeEvent.isComposing) return;\n    const sep = e.key === \",\" || e.key === \";\" || (splitOnSpace && e.key === \" \");\n    if ((e.key === \"Enter\" || e.key === \"Tab\" || sep) && draft.trim()) {\n      e.preventDefault();\n      commit(draft);\n      return;\n    }\n    if (sep) {\n      e.preventDefault();\n      return;\n    }\n    if (e.key === \"Backspace\" && !draft && tags.length) {\n      // First press marks the last chip, the second takes it out\n      e.preventDefault();\n      if (armed) {\n        remove(tags[tags.length - 1]);\n        setArmed(false);\n      } else setArmed(true);\n      return;\n    }\n    if (e.key === \"ArrowLeft\" && e.currentTarget.selectionStart === 0 && tags.length) {\n      e.preventDefault();\n      chips().at(-1)?.focus();\n      return;\n    }\n    if (e.key === \"Escape\" && draft) {\n      e.preventDefault();\n      setDraft(\"\");\n      setProblem(\"\");\n    }\n    setArmed(false);\n  };\n\n  const onChipKey = (e: KeyboardEvent<HTMLElement>, i: number) => {\n    const list = chips();\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      list[Math.max(0, i - 1)]?.focus();\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      if (i + 1 < list.length) list[i + 1].focus();\n      else input.current?.focus();\n    } else if (e.key === \"Backspace\" || e.key === \"Delete\") {\n      e.preventDefault();\n      const next = list[i + 1] ?? null;\n      remove(tags[i], false);\n      // Focus moves on to the next chip, or back to the field\n      window.setTimeout(() => (next && next.isConnected ? next.focus() : input.current?.focus()));\n    }\n  };\n\n  const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {\n    const text = e.clipboardData.getData(\"text\");\n    if (!split.test(text.trim())) return;\n    e.preventDefault();\n    commit(draft + (draft ? (splitOnSpace ? \" \" : \",\") : \"\") + text);\n  };\n\n  return (\n    <div className={[\"ui-tagc\", className].filter(Boolean).join(\" \")} data-invalid={invalid || undefined} data-disabled={disabled || undefined}>\n      {/* React 19 hoists this into <head> once, no matter how many fields render */}\n      <style href=\"ui-tag-input-clean\" precedence=\"default\">\n        {css}\n      </style>\n      {label != null && (\n        <div className=\"ui-tagc__head\">\n          <label className=\"ui-tagc__label\" id={labelId} htmlFor={inputId}>\n            {label}\n          </label>\n          {optional && <span className=\"ui-tagc__optional\">Optional</span>}\n        </div>\n      )}\n      {/* A click on the box's empty space puts the caret in the field */}\n      <div\n        ref={box}\n        className=\"ui-tagc__box\"\n        onPointerDown={(e) => {\n          if (e.target === e.currentTarget) {\n            e.preventDefault();\n            input.current?.focus();\n          }\n        }}\n      >\n        {tags.map((t, i) => (\n          <span\n            key={t}\n            className=\"ui-tagc__chip\"\n            tabIndex={-1}\n            title={t}\n            data-armed={(armed && i === tags.length - 1) || undefined}\n            data-flash={flash?.tag === t ? flash.n : undefined}\n            onKeyDown={(e) => onChipKey(e, i)}\n            onAnimationEnd={(e) => e.animationName === \"ui-tagc-flash\" && setFlash(null)}\n          >\n            {avatar && (\n              <span className=\"ui-tagc__avatar\" aria-hidden=\"true\">\n                {(Array.from(t)[0] ?? \"\").toUpperCase()}\n              </span>\n            )}\n            <span className=\"ui-tagc__text\">{t}</span>\n            {!disabled && (\n              <button\n                type=\"button\"\n                className=\"ui-tagc__remove\"\n                tabIndex={-1}\n                aria-label={`Remove ${t}`}\n                onPointerDown={(e) => e.preventDefault()}\n                onClick={() => remove(t)}\n              >\n                <svg viewBox=\"0 0 12 12\" width=\"10\" height=\"10\" aria-hidden=\"true\">\n                  <path d=\"M3 3l6 6M9 3l-6 6\" />\n                </svg>\n              </button>\n            )}\n          </span>\n        ))}\n        <input\n          ref={(el) => {\n            input.current = el;\n            if (typeof ref === \"function\") ref(el);\n            else if (ref) ref.current = el;\n          }}\n          id={inputId}\n          className=\"ui-tagc__input\"\n          value={draft}\n          placeholder={full ? \"\" : tags.length ? \"Add more…\" : placeholder}\n          disabled={disabled || full}\n          dir=\"auto\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          aria-label={ariaLabel}\n          aria-invalid={invalid || undefined}\n          aria-describedby={[message ? messageId : null, liveId].filter(Boolean).join(\" \")}\n          onChange={(e) => {\n            const v = e.target.value;\n            setArmed(false);\n            if (problem) setProblem(\"\");\n            // A separator typed anywhere – also by phone keyboards that don't report the key – turns what's before it into chips\n            const parts = v.split(split);\n            if (parts.length > 1) commit(parts.slice(0, -1).join(\",\"), parts[parts.length - 1]);\n            else setDraft(v);\n          }}\n          onKeyDown={onKeyDown}\n          onPaste={onPaste}\n          onBlur={() => {\n            setArmed(false);\n            if (draft.trim()) commit(draft);\n          }}\n        />\n      </div>\n      {name && tags.map((t) => <input key={t} type=\"hidden\" name={name} value={t} />)}\n      {(message || max != null) && (\n        <div className=\"ui-tagc__foot\">\n          {message && (\n            <p className=\"ui-tagc__message\" id={messageId} key={shownError ? \"error\" : \"hint\"} aria-live={shownError ? \"polite\" : undefined}>\n              {message}\n            </p>\n          )}\n          {max != null && (\n            <span className=\"ui-tagc__count\" data-full={full || undefined}>\n              {tags.length}/{max}\n            </span>\n          )}\n        </div>\n      )}\n      <span className=\"ui-tagc__sr\" id={liveId} role=\"status\">\n        {said}\n      </span>\n    </div>\n  );\n}\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-tagc (see Theming in the docs) */\n.ui-tagc {\n  --ui-tagc-title: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-tagc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-tagc-muted: var(--aui-muted, rgba(0, 0, 0, 0.61));\n  --ui-tagc-subtle: var(--aui-muted, rgba(0, 0, 0, 0.45));\n  --ui-tagc-faint: var(--aui-placeholder, rgba(0, 0, 0, 0.3));\n  --ui-tagc-surface: var(--aui-surface, #ffffff);\n  --ui-tagc-chip: rgba(0, 0, 0, 0.045);\n  --ui-tagc-chip-edge: rgba(0, 0, 0, 0.06);\n  --ui-tagc-chip-armed: rgba(0, 0, 0, 0.1);\n  --ui-tagc-avatar: rgba(0, 0, 0, 0.08);\n  --ui-tagc-flash: rgba(0, 96, 226, 0.14);\n  --ui-tagc-shadow: 0 0 0 0.5px var(--aui-border, rgba(0, 0, 0, 0.08)), 0 1px 1px -1px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  --ui-tagc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(0, 0, 0, 0.13)), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 1px 2px 0 rgba(0, 0, 0, 0.06);\n  --ui-tagc-shadow-focus: 0 0 0 1px var(--aui-border-strong, rgba(0, 0, 0, 0.28)), 0 1px 2px 0 rgba(0, 0, 0, 0.06);\n  --ui-tagc-danger: var(--aui-danger, #c4320a);\n  --ui-tagc-danger-edge: color-mix(in srgb, var(--aui-red, #f04438) 55%, transparent);\n  --ui-tagc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-tagc-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n  --fs: 13px;\n\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  min-width: 0;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13px;\n  line-height: 20px;\n  color: var(--ui-tagc-ink);\n  -webkit-font-smoothing: antialiased;\n}\n@media (pointer: coarse) {\n  .ui-tagc { --fs: 16px; }\n}\n\n.ui-tagc__head { display: flex; align-items: baseline; gap: 6px; min-width: 0; }\n.ui-tagc__label { min-width: 0; font-weight: 500; color: var(--ui-tagc-title); overflow-wrap: anywhere; cursor: default; }\n.ui-tagc__optional { flex: none; font-size: 12px; line-height: 16px; color: var(--ui-tagc-subtle); }\n\n/* The box: the Text Field's look; chips wrap onto new lines and it grows with them */\n.ui-tagc__box {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 4px;\n  box-sizing: border-box;\n  min-height: 32px;\n  min-width: 0;\n  max-height: 156px;\n  padding: 3px 4px;\n  border-radius: var(--aui-radius, 7px);\n  background: var(--ui-tagc-surface);\n  box-shadow: var(--ui-tagc-shadow);\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  scrollbar-width: thin;\n  cursor: text;\n  transition: box-shadow 140ms ease;\n}\n.ui-tagc__box:hover { box-shadow: var(--ui-tagc-shadow-hover); }\n.ui-tagc__box:focus-within { box-shadow: var(--ui-tagc-shadow-focus); }\n.ui-tagc[data-invalid] .ui-tagc__box { box-shadow: 0 0 0 1px var(--ui-tagc-danger-edge), 0 1px 2px 0 rgba(0, 0, 0, 0.05); }\n.ui-tagc[data-disabled] { opacity: 0.55; }\n.ui-tagc[data-disabled] .ui-tagc__box { cursor: not-allowed; }\n\n/* A chip: a soft gray pill with the text and a small × */\n.ui-tagc__chip {\n  display: inline-flex;\n  align-items: center;\n  gap: 5px;\n  max-width: 100%;\n  height: 24px;\n  box-sizing: border-box;\n  padding: 0 3px 0 8px;\n  border-radius: 5px;\n  background: var(--ui-tagc-chip);\n  box-shadow: inset 0 0 0 0.5px var(--ui-tagc-chip-edge);\n  font-size: 12.5px;\n  color: var(--ui-tagc-ink);\n  outline: none;\n  animation: ui-tagc-in 220ms var(--ui-tagc-ease) both;\n  transition: background-color 140ms ease;\n}\n.ui-tagc__chip:has(.ui-tagc__avatar) { padding-left: 3px; }\n.ui-tagc__chip:focus-visible { outline: 2px solid var(--ui-tagc-focus); outline-offset: 2px; }\n.ui-tagc__chip[data-armed] { background: var(--ui-tagc-chip-armed); }\n.ui-tagc__chip[data-flash] { animation: ui-tagc-flash 700ms ease; }\n.ui-tagc__avatar { display: grid; flex: none; place-items: center; width: 18px; height: 18px; border-radius: 50%; background: var(--ui-tagc-avatar); font-size: 10px; font-weight: 500; color: var(--ui-tagc-muted); }\n.ui-tagc__text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n.ui-tagc__remove {\n  position: relative;\n  display: grid;\n  flex: none;\n  place-items: center;\n  width: 18px;\n  height: 18px;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: 4px;\n  background: transparent;\n  color: var(--ui-tagc-subtle);\n  cursor: pointer;\n  transition: background-color 140ms ease, color 140ms ease;\n}\n.ui-tagc__remove::before { content: \"\"; position: absolute; inset: -4px; }\n.ui-tagc__remove:hover { background: var(--ui-tagc-chip-armed); color: var(--ui-tagc-ink); }\n.ui-tagc__remove svg { display: block; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }\n\n.ui-tagc__input {\n  flex: 1 0 80px;\n  min-width: 80px;\n  height: 24px;\n  margin: 0;\n  padding: 0 6px;\n  border: 0;\n  outline: 0;\n  background: transparent;\n  font: inherit;\n  font-size: var(--fs);\n  color: var(--ui-tagc-ink);\n}\n.ui-tagc__input::placeholder { color: var(--ui-tagc-faint); opacity: 1; }\n.ui-tagc__input:disabled { cursor: not-allowed; }\n\n.ui-tagc__foot { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }\n.ui-tagc__message { flex: 1; min-width: 0; margin: 0; font-size: 12px; line-height: 16px; color: var(--ui-tagc-subtle); overflow-wrap: anywhere; animation: ui-tagc-message 260ms var(--ui-tagc-ease); }\n.ui-tagc[data-invalid] .ui-tagc__message { color: var(--ui-tagc-danger); }\n.ui-tagc__count { flex: none; margin-left: auto; font-size: 12px; line-height: 16px; font-variant-numeric: tabular-nums; color: var(--ui-tagc-faint); }\n.ui-tagc__count[data-full] { color: var(--ui-tagc-muted); }\n.ui-tagc__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n\n/* Touch screens: taller chips and a bigger × */\n@media (pointer: coarse) {\n  .ui-tagc__box { min-height: 40px; }\n  .ui-tagc__chip { height: 30px; font-size: 14px; }\n  .ui-tagc__remove { width: 24px; height: 24px; }\n  .ui-tagc__input { height: 30px; }\n}\n\n@keyframes ui-tagc-in {\n  from { opacity: 0; transform: scale(0.85); filter: blur(2px); }\n}\n@keyframes ui-tagc-flash {\n  20%, 60% { background: var(--ui-tagc-flash); }\n}\n@keyframes ui-tagc-message {\n  from { opacity: 0; transform: translateY(-3px); filter: blur(2px); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-tagc * { animation-duration: 1ms !important; transition-duration: 1ms !important; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-tagc {\n  --ui-tagc-title: var(--aui-ink, rgba(255, 255, 255, 0.92));\n  --ui-tagc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-tagc-muted: var(--aui-muted, rgba(255, 255, 255, 0.62));\n  --ui-tagc-subtle: var(--aui-muted, rgba(255, 255, 255, 0.46));\n  --ui-tagc-faint: var(--aui-placeholder, rgba(255, 255, 255, 0.3));\n  --ui-tagc-surface: var(--aui-surface, #1c1c1e);\n  --ui-tagc-chip: rgba(255, 255, 255, 0.07);\n  --ui-tagc-chip-edge: rgba(255, 255, 255, 0.08);\n  --ui-tagc-chip-armed: rgba(255, 255, 255, 0.14);\n  --ui-tagc-avatar: rgba(255, 255, 255, 0.12);\n  --ui-tagc-flash: rgba(77, 141, 255, 0.28);\n  --ui-tagc-shadow: 0 0 0 0.5px var(--aui-border, rgba(255, 255, 255, 0.14)), 0 1px 2px 0 rgba(0, 0, 0, 0.4);\n  --ui-tagc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(255, 255, 255, 0.2)), 0 1px 2px 0 rgba(0, 0, 0, 0.4);\n  --ui-tagc-shadow-focus: 0 0 0 1px var(--aui-border-strong, rgba(255, 255, 255, 0.3)), 0 1px 2px 0 rgba(0, 0, 0, 0.4);\n  --ui-tagc-danger: var(--aui-danger, #ff8a7a);\n  --ui-tagc-danger-edge: color-mix(in srgb, var(--aui-red, #ff6b5f) 50%, transparent);\n  --ui-tagc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}