"use client"; import { X } from "lucide-react"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { type ClipboardEvent, type KeyboardEvent, type MouseEvent, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface TagInputProps { /** Controlled tags. */ value?: string[]; /** Uncontrolled initial tags. */ defaultValue?: string[]; onChange?: (tags: string[]) => void; /** Optional suggestion pool, filtered by the typed text. Turns the input into a combobox. */ suggestions?: string[]; /** Most suggestions shown at once. Default 6. */ maxSuggestions?: number; /** Refuse new tags once this many exist. */ maxTags?: number; /** * Return `false` to reject a tag, or a string to reject it with that message. * Rejection shakes the field. */ validate?: (tag: string, tags: string[]) => boolean | string; /** Allow the same tag twice (case-insensitive). Default false. */ allowDuplicates?: boolean; placeholder?: string; label?: string; /** Helper text under the field while there is no error. */ hint?: string; disabled?: boolean; "aria-label"?: string; className?: string; } type PendingFocus = number | "input" | null; const SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] }; /** * Chips inside an input-like field. Enter, comma or Tab adds; Backspace on an * empty input arms the last chip, a second press removes it; Arrow Left/Right * walks the chips and Delete/Backspace removes the focused one. */ export function TagInput({ value: valueProp, defaultValue, onChange, suggestions, maxSuggestions = 6, maxTags, validate, allowDuplicates = false, placeholder = "Add a tag…", label, hint, disabled = false, "aria-label": ariaLabel, className, }: TagInputProps) { const uid = useId(); const inputId = `${uid}-input`; const listId = `${uid}-list`; const messageId = `${uid}-message`; const reduce = useReducedMotion(); const controlled = valueProp !== undefined; const [internal, setInternal] = useState(defaultValue ?? []); const tags = controlled ? valueProp : internal; const [query, setQuery] = useState(""); const [focused, setFocused] = useState(false); // Backspace on an empty input arms the last chip first, so one stray press // never deletes work. const [armed, setArmed] = useState(false); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); const [error, setError] = useState(null); const [flash, setFlash] = useState(null); const [announcement, setAnnouncement] = useState(""); const fieldRef = useRef(null); const inputRef = useRef(null); const chipRefs = useRef(new Map()); const pendingFocus = useRef(null); const flashTimer = useRef | null>(null); // Stable keys that survive duplicates: the nth occurrence of a tag. const keys = useMemo(() => { const seen = new Map(); return tags.map((tag) => { const n = seen.get(tag) ?? 0; seen.set(tag, n + 1); return `${tag}#${n}`; }); }, [tags]); const filtered = useMemo(() => { if (!suggestions?.length) return []; const q = query.trim().toLowerCase(); const taken = new Set(tags.map((t) => t.toLowerCase())); return suggestions .filter( (s) => (allowDuplicates || !taken.has(s.toLowerCase())) && (!q || s.toLowerCase().includes(q)), ) .slice(0, maxSuggestions); }, [suggestions, query, tags, allowDuplicates, maxSuggestions]); const listOpen = open && focused && filtered.length > 0 && !disabled; const activeIndex = listOpen && active < filtered.length ? active : -1; const atLimit = maxTags !== undefined && tags.length >= maxTags; // Focus lands after the render that re-indexes the chips. useEffect(() => { const target = pendingFocus.current; if (target === null) return; pendingFocus.current = null; if (target === "input") inputRef.current?.focus(); else chipRefs.current.get(keys[target] ?? "")?.focus(); }, [keys]); useEffect( () => () => { if (flashTimer.current) clearTimeout(flashTimer.current); }, [], ); const commit = (next: string[]) => { if (!controlled) setInternal(next); onChange?.(next); }; const reject = (message: string, existing?: string) => { setError(message); if (fieldRef.current && !reduce) { animate(fieldRef.current, SHAKE, { duration: 0.4, ease: EASE_OUT }); } if (existing) { setFlash(existing); if (flashTimer.current) clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => setFlash(null), 700); } }; /** Adds every acceptable entry in one commit; stops at the first rejection. */ const addMany = (entries: string[]) => { const next = [...tags]; let added = 0; for (const raw of entries) { const tag = raw.trim(); if (!tag) continue; if (maxTags !== undefined && next.length >= maxTags) { reject(`Up to ${maxTags} tags`); break; } if (!allowDuplicates) { const existing = next.find((t) => t.toLowerCase() === tag.toLowerCase()); if (existing) { reject(`“${tag}” is already added`, existing); break; } } const verdict = validate?.(tag, next); if (verdict === false || typeof verdict === "string") { reject(typeof verdict === "string" ? verdict : `“${tag}” isn't allowed`); break; } next.push(tag); added++; } if (added === 0) return false; commit(next); setError(null); setArmed(false); setActive(-1); setAnnouncement( added === 1 ? `Added ${next[next.length - 1]}` : `Added ${added} tags`, ); return true; }; const add = (raw: string) => { if (addMany([raw])) setQuery(""); }; const removeAt = (index: number, focusAfter: PendingFocus) => { const removed = tags[index]; if (removed === undefined) return; pendingFocus.current = focusAfter; commit(tags.filter((_, i) => i !== index)); setArmed(false); setError(null); setAnnouncement(`Removed ${removed}`); }; const focusChip = useCallback( (index: number) => { chipRefs.current.get(keys[index] ?? "")?.focus(); }, [keys], ); const onInputKeyDown = (e: KeyboardEvent) => { if (disabled || e.nativeEvent.isComposing) return; const trimmed = query.trim(); switch (e.key) { case "ArrowDown": if (!filtered.length) return; e.preventDefault(); setOpen(true); setActive((a) => (listOpen ? (a + 1) % filtered.length : 0)); return; case "ArrowUp": if (!listOpen) return; e.preventDefault(); setActive((a) => (a <= 0 ? filtered.length - 1 : a - 1)); return; case "Escape": if (listOpen) { e.preventDefault(); setOpen(false); setActive(-1); } else if (armed) { setArmed(false); } return; case "Enter": if (activeIndex >= 0) { e.preventDefault(); add(filtered[activeIndex]); } else if (trimmed) { e.preventDefault(); add(query); } return; case ",": e.preventDefault(); if (trimmed) add(query); return; case "Tab": if (e.shiftKey) return; // Tab only completes when there is something to complete; on an empty // input it moves focus on as usual. if (activeIndex >= 0) { e.preventDefault(); add(filtered[activeIndex]); } else if (trimmed) { e.preventDefault(); add(query); } return; case "Backspace": if (query !== "" || !tags.length) return; e.preventDefault(); if (armed) removeAt(tags.length - 1, "input"); else setArmed(true); return; case "ArrowLeft": { const el = e.currentTarget; if (!tags.length || el.selectionStart !== 0 || el.selectionEnd !== 0) { return; } e.preventDefault(); setArmed(false); focusChip(tags.length - 1); return; } default: if (armed) setArmed(false); } }; const onInputChange = (next: string) => { setError(null); setArmed(false); setOpen(true); setActive(-1); // Soft keyboards often report "Unidentified" for the comma key, so the // delimiter is also honoured here. if (next.includes(",")) { const parts = next.split(","); const rest = parts.pop() ?? ""; if (addMany(parts)) setQuery(rest); else setQuery(next.replace(/,/g, "")); return; } setQuery(next); }; const onPaste = (e: ClipboardEvent) => { const text = e.clipboardData.getData("text"); if (!/[,\n\t]/.test(text)) return; e.preventDefault(); if (addMany(text.split(/[,\n\t]/))) setQuery(""); }; const onChipKeyDown = (e: KeyboardEvent, index: number) => { const last = tags.length - 1; switch (e.key) { case "ArrowLeft": e.preventDefault(); focusChip(Math.max(index - 1, 0)); return; case "ArrowRight": e.preventDefault(); if (index < last) focusChip(index + 1); else inputRef.current?.focus(); return; case "Home": e.preventDefault(); focusChip(0); return; case "End": case "Escape": e.preventDefault(); inputRef.current?.focus(); return; case "Backspace": e.preventDefault(); removeAt(index, index > 0 ? index - 1 : last > 0 ? 0 : "input"); return; case "Delete": e.preventDefault(); removeAt(index, index < last ? index : "input"); return; } }; // Clicks on the field's empty space land in the input, like a native field. const onFieldMouseDown = (e: MouseEvent) => { if (disabled || e.target !== e.currentTarget) return; e.preventDefault(); inputRef.current?.focus(); }; const hasSuggestions = Boolean(suggestions?.length); const message = error ?? hint; const chipTransition = reduce ? { duration: 0.15 } : SPRING_SWAP; return (
{label ? ( ) : null} {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer convenience only — the input inside owns focus and keyboard. */}
{tags.map((tag, i) => { const key = keys[i]; const isArmed = armed && i === tags.length - 1; const isFlash = flash === tag; return ( {tag} ); })} = 0 ? `${listId}-${activeIndex}` : undefined } onChange={(e) => onInputChange(e.target.value)} onKeyDown={onInputKeyDown} onPaste={onPaste} onFocus={() => { setFocused(true); setOpen(true); }} onBlur={() => { setFocused(false); setArmed(false); setActive(-1); }} className="h-7 min-w-24 flex-1 bg-transparent px-1 text-sm text-foreground caret-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-not-allowed" />
{listOpen ? ( {filtered.map((item, i) => { const selected = i === activeIndex; return ( // Keyboard selection runs through the combobox input // (aria-activedescendant), so options stay out of the tab order. ); })} ) : null}

{message}

{maxTags !== undefined ? ( {tags.length}/{maxTags} ) : null}
{announcement}
); } function Highlight({ text, query }: { text: string; query: string }) { if (!query) return <>{text}; const at = text.toLowerCase().indexOf(query.toLowerCase()); if (at < 0) return <>{text}; return ( <> {text.slice(0, at)} {text.slice(at, at + query.length)} {text.slice(at + query.length)} ); }