183 lines
6.5 KiB
TypeScript
183 lines
6.5 KiB
TypeScript
"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<SymbolSearchResult[]>([]);
|
||
|
|
const [active, setActive] = useState(0);
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
||
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||
|
|
<div ref={boxRef} className={`relative ${className}`}>
|
||
|
|
<input
|
||
|
|
id={id}
|
||
|
|
value={query}
|
||
|
|
onChange={(e) => 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 && (
|
||
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-fg-muted animate-pulse">…</span>
|
||
|
|
)}
|
||
|
|
{open && results.length > 0 && (
|
||
|
|
<ul className="absolute left-0 right-0 z-30 mt-1 max-h-64 overflow-y-auto rounded-md border border-line bg-surface-raised shadow-lg">
|
||
|
|
{results.map((r, i) => (
|
||
|
|
<li key={r.symbol}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
data-idx={i}
|
||
|
|
onMouseEnter={() => setActive(i)}
|
||
|
|
onClick={() => { setInternalQuery(r.symbol); setOpen(false); onPick(r.symbol); }}
|
||
|
|
className={`w-full text-left px-3 py-2 flex items-baseline gap-2 ${i === active ? "bg-surface-sunken" : ""}`}
|
||
|
|
>
|
||
|
|
<span className="text-sm font-semibold tnum text-fg">{r.symbol}</span>
|
||
|
|
<span className="flex-1 truncate text-xs text-fg-muted">{r.name ?? ""}</span>
|
||
|
|
{r.ticker_kind && (
|
||
|
|
<span className="text-[10px] uppercase tracking-wide text-fg-faint border border-line rounded px-1">{r.ticker_kind}</span>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|