/** * TagInput — WealthX Design System * * A tokenized, free-form contact-tag input: selected tags render as removable * {@link Chip}s, and an inline text field autocompletes against existing tags * with a "Create ''" affordance for new ones. Multi-select, de-duplicated * (case-insensitive), with an optional `maxTags` cap. * * Pure & controlled — the host owns the value (`value` / `onChange`) and the * autocomplete source (`suggestions`). No data fetching here. * * Used to tag a contact (ManageContactTagsDialog), tag an imported batch * (CSV import) and pick a campaign audience by tag. */ import { type ReactElement, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { Chip } from "./chip"; export interface TagInputProps { /** Currently selected tags (controlled). */ value: string[]; /** Called with the next selection whenever a tag is added or removed. */ onChange: (next: string[]) => void; /** Existing tags to autocomplete against (e.g. the broker's tag library). */ suggestions?: string[]; placeholder?: string; /** Cap the number of tags. When reached, the field stops accepting input. */ maxTags?: number; /** Allow creating a brand-new tag from the typed text. Defaults to true. */ allowCreate?: boolean; disabled?: boolean; /** Accessible label for the text field (falls back to "Add a tag"). */ "aria-label"?: string; id?: string; className?: string; } const norm = (s: string): string => s.trim().toLowerCase(); export function TagInput({ value, onChange, suggestions = [], placeholder = "Add a tag…", maxTags, allowCreate = true, disabled = false, "aria-label": ariaLabel = "Add a tag", id, className, }: TagInputProps): ReactElement { const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef(null); const controlRef = useRef(null); const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number; } | null>(null); const reactId = useId(); const listId = `${id ?? reactId}-taglist`; const atMax = maxTags != null && value.length >= maxTags; const selectedSet = useMemo(() => new Set(value.map(norm)), [value]); const filtered = useMemo( () => suggestions.filter( (s) => !selectedSet.has(norm(s)) && norm(s).includes(norm(query)), ), [suggestions, selectedSet, query], ); const trimmed = query.trim(); const canCreate = allowCreate && trimmed.length > 0 && !selectedSet.has(norm(trimmed)) && !suggestions.some((s) => norm(s) === norm(trimmed)); // Options shown in the dropdown: existing matches first, then a create row. const options: Array<{ type: "existing" | "create"; label: string }> = [ ...filtered.map((label) => ({ type: "existing" as const, label })), ...(canCreate ? [{ type: "create" as const, label: trimmed }] : []), ]; const addTag = (tag: string): void => { const clean = tag.trim(); if (!clean || disabled || atMax || selectedSet.has(norm(clean))) return; onChange([...value, clean]); setQuery(""); setActiveIndex(0); setOpen(true); inputRef.current?.focus(); }; const removeTag = (tag: string): void => { if (disabled) return; onChange(value.filter((t) => t !== tag)); }; const commitActive = (): void => { const opt = options[activeIndex]; if (opt) addTag(opt.label); else if (canCreate) addTag(trimmed); else if (filtered.length === 1) addTag(filtered[0]); }; const handleKeyDown = (e: React.KeyboardEvent): void => { switch (e.key) { case "Enter": if (options.length > 0 || trimmed.length > 0) { e.preventDefault(); commitActive(); } break; case "Backspace": if (query === "" && value.length > 0) { e.preventDefault(); removeTag(value[value.length - 1]); } break; case "ArrowDown": if (options.length > 0) { e.preventDefault(); setOpen(true); setActiveIndex((i) => (i + 1) % options.length); } break; case "ArrowUp": if (options.length > 0) { e.preventDefault(); setOpen(true); setActiveIndex((i) => (i - 1 + options.length) % options.length); } break; case "Escape": setOpen(false); break; default: break; } }; const showDropdown = open && !disabled && !atMax && options.length > 0; // The listbox is portaled to so a dialog's `overflow-y-auto` can't clip // it; keep it pinned under the control across scroll/resize while open. useLayoutEffect(() => { if (!showDropdown) return; const measure = (): void => { const el = controlRef.current; if (!el) return; const r = el.getBoundingClientRect(); setMenuPos({ top: r.bottom + 4, left: r.left, width: r.width }); }; measure(); window.addEventListener("scroll", measure, true); window.addEventListener("resize", measure); return () => { window.removeEventListener("scroll", measure, true); window.removeEventListener("resize", measure); }; }, [showDropdown, value.length, query]); useEffect(() => { if (!showDropdown) setMenuPos(null); }, [showDropdown]); return (
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
{ // Keep focus on the text field when clicking empty space in the box. if (e.target === e.currentTarget) { e.preventDefault(); inputRef.current?.focus(); } }} > {value.map((tag) => ( removeTag(tag)}> {tag} ))} { setQuery(e.target.value); setActiveIndex(0); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} onKeyDown={handleKeyDown} />
{showDropdown && menuPos && typeof document !== "undefined" ? createPortal(
    {options.map((opt, i) => (
  • { e.preventDefault(); addTag(opt.label); }} onMouseEnter={() => setActiveIndex(i)} > {opt.type === "create" ? ( Create{" "} “{opt.label}” ) : ( {opt.label} )}
  • ))}
, document.body, ) : null}
); }