"use client"; import { useState, useRef, useMemo, useEffect } from "react"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; export type ChipSuggestion = | string | { name: string; aliases?: string[]; primaryAliases?: string[] }; export function ChipInput({ values, onChange, placeholder, accent = "cyan", suggestions, }: { values: string[]; onChange: (next: string[]) => void; placeholder?: string; accent?: "cyan" | "violet"; /** Strings or { name, aliases? } — when aliases are provided, they're matched but the chip stores `name`. */ suggestions?: ChipSuggestion[]; }) { const [draft, setDraft] = useState(""); const [highlight, setHighlight] = useState(0); const [focused, setFocused] = useState(false); const inputRef = useRef(null); const blurTimerRef = useRef(null); // Cancel any pending blur-commit on unmount so we don't fire setState // (or commit a stale draft) after the component is gone. useEffect(() => () => { if (blurTimerRef.current != null) window.clearTimeout(blurTimerRef.current); }, []); const accentVar = accent === "cyan" ? "var(--color-cyan)" : "var(--color-violet)"; const lowerSelected = useMemo(() => new Set(values.map((v) => v.toLowerCase())), [values]); type Resolved = { name: string; matchedAlias?: string; rank: number }; const filtered = useMemo(() => { if (!suggestions || !draft.trim()) return []; const q = draft.trim().toLowerCase(); // rank: 0 = canonical name match, 1 = primary alias (reverse), 2 = alt alias. const out: Resolved[] = []; for (const s of suggestions) { const name = typeof s === "string" ? s : s.name; if (lowerSelected.has(name.toLowerCase())) continue; const primaryAliases = typeof s === "string" ? [] : (s.primaryAliases ?? []); const altAliases = typeof s === "string" ? [] : (s.aliases ?? []); if (name.toLowerCase().includes(q)) { out.push({ name, rank: 0 }); continue; } const primaryHit = primaryAliases.find((a) => a.toLowerCase().includes(q)); if (primaryHit) { out.push({ name, matchedAlias: primaryHit, rank: 1 }); continue; } const altHit = altAliases.find((a) => a.toLowerCase().includes(q)); if (altHit) out.push({ name, matchedAlias: altHit, rank: 2 }); } out.sort((a, b) => a.rank - b.rank); return out.slice(0, 8); }, [suggestions, draft, lowerSelected]); const showDropdown = focused && filtered.length > 0; const commitValue = (raw: string) => { const t = raw.trim(); if (!t) return; if (!lowerSelected.has(t.toLowerCase())) { onChange([...values, t]); } setDraft(""); setHighlight(0); }; const commit = () => commitValue(draft); const remove = (idx: number) => { onChange(values.filter((_, i) => i !== idx)); }; return (
inputRef.current?.focus()} > {values.map((v, i) => ( {v} ))} { setDraft(e.target.value); setHighlight(0); }} onFocus={() => { if (blurTimerRef.current) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; } setFocused(true); }} onBlur={() => { // Delay to allow mousedown on a suggestion to fire commit before we hide. blurTimerRef.current = window.setTimeout(() => { setFocused(false); commit(); }, 120); }} onKeyDown={(e) => { if (showDropdown && (e.key === "ArrowDown" || e.key === "ArrowUp")) { e.preventDefault(); setHighlight((h) => { const n = filtered.length; if (e.key === "ArrowDown") return (h + 1) % n; return (h - 1 + n) % n; }); return; } if (e.key === "Enter" || e.key === "Tab") { if (showDropdown && filtered[highlight]) { e.preventDefault(); commitValue(filtered[highlight].name); return; } if (e.key === "Enter") { e.preventDefault(); commit(); } return; } if (e.key === ",") { e.preventDefault(); commit(); } else if (e.key === "Escape" && showDropdown) { setFocused(false); } else if (e.key === "Backspace" && !draft && values.length) { remove(values.length - 1); } }} placeholder={values.length === 0 ? placeholder : ""} className={cn( "flex-1 min-w-[100px] bg-transparent text-sm outline-none placeholder:text-[var(--color-fg-muted)]", )} />
{showDropdown && (
e.preventDefault()} > {filtered.map((s, i) => ( ))}
)}
); }