"use client"; // Shared symbol autocomplete (M22 Symbol Search Index). // // Debounced local-first search against the symbol index (ADR-0009 — no live vendor // call on the request path). Renders a dropdown of matches; Enter picks the top // (or sole) result. Emits: // onPick(symbol) — a known-index symbol was chosen // onSubmitRaw(raw) — the user submitted text that did NOT match any known // symbol (caller owns the hybrid soft-block flow) import { useCallback, useEffect, useRef, useState } from "react"; import { api, type SymbolSearchResult } from "@/lib/trpc"; export interface SymbolAutocompleteProps { placeholder?: string; /** Called with an exact index symbol match (already validated against the index). */ onPick: (symbol: string) => void; /** Called when the user submits text that has no exact index match. */ onSubmitRaw: (raw: string) => void; /** Called when a search query is in flight / completes (for busy indicators). */ onBusyChange?: (busy: boolean) => void; /** Externally sync the current query value (for parent filtering). */ onChange?: (query: string) => void; /** Controlled value — when provided, the input is externally managed. */ value?: string; autoFocus?: boolean; className?: string; inputClassName?: string; id?: string; ariaLabel?: string; disabled?: boolean; } export function SymbolAutocomplete({ placeholder = "Add symbol…", onPick, onSubmitRaw, onBusyChange, onChange: externalOnChange, value: controlledValue, autoFocus, className = "", inputClassName = "", id, ariaLabel = "Add symbol", disabled, }: SymbolAutocompleteProps) { const [internalQuery, setInternalQuery] = useState(""); const query = controlledValue ?? internalQuery; const [results, setResults] = useState([]); const [active, setActive] = useState(0); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const boxRef = useRef(null); const timerRef = useRef | null>(null); const lastQueryRef = useRef(""); const runSearch = useCallback((q: string) => { if (!q.trim()) { setResults([]); setOpen(false); setBusy(false); onBusyChange?.(false); return; } setBusy(true); onBusyChange?.(true); api.symbols .search({ q, limit: 8 }) .then((r) => { setResults(r.results); setActive(0); setOpen(r.results.length > 0); }) .catch(() => { setResults([]); setOpen(false); }) .finally(() => { setBusy(false); onBusyChange?.(false); }); }, [onBusyChange]); const onChange = useCallback((v: string) => { setInternalQuery(v); externalOnChange?.(v); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => runSearch(v), 150); }, [runSearch, externalOnChange]); // Click-outside closes the dropdown. useEffect(() => { const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, []); useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []); const submitCurrent = useCallback(() => { const q = query.trim(); if (!q) return; lastQueryRef.current = q; // Prefer the exact symbol match; else the top result; else raw submit. const exact = results.find((r) => r.symbol === q.toUpperCase()); if (exact) { setOpen(false); setInternalQuery(""); setResults([]); onPick(exact.symbol); return; } if (results.length === 1) { setOpen(false); setInternalQuery(""); setResults([]); onPick(results[0].symbol); return; } if (results.length > 1 && active >= 0 && active < results.length) { setOpen(false); setInternalQuery(""); setResults([]); onPick(results[active].symbol); return; } // No known match — hand the raw text to the caller (hybrid soft-block). setOpen(false); setInternalQuery(""); setResults([]); onSubmitRaw(q); }, [query, results, active, onPick, onSubmitRaw]); return (
onChange(e.target.value)} onFocus={() => { if (results.length) setOpen(true); }} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, results.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); submitCurrent(); } else if (e.key === "Escape") { setOpen(false); } }} placeholder={placeholder} autoFocus={autoFocus} disabled={disabled} aria-label={ariaLabel} aria-expanded={open} className={inputClassName || "w-full bg-surface border border-line rounded-md px-3 py-2.5 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"} /> {busy && ( … )} {open && results.length > 0 && (
    {results.map((r, i) => (
  • ))}
)}
); }