({
search,
focusSymbol,
onError,
debounceMs = 180,
}: {
search: (params: { q?: string; limit?: number; offset?: number }) => Promise<{ results: GraphNode[] }>;
focusSymbol: (node: Pick<GraphNode, "id" | "name">) => void;
onError: (message: string) => void;
debounceMs?: number;
})
| 4 | import type { GraphNode } from "./types"; |
| 5 | |
| 6 | export function useGraphSearch({ |
| 7 | search, |
| 8 | focusSymbol, |
| 9 | onError, |
| 10 | debounceMs = 180, |
| 11 | }: { |
| 12 | search: (params: { q?: string; limit?: number; offset?: number }) => Promise<{ results: GraphNode[] }>; |
| 13 | focusSymbol: (node: Pick<GraphNode, "id" | "name">) => void; |
| 14 | onError: (message: string) => void; |
| 15 | debounceMs?: number; |
| 16 | }) { |
| 17 | const [query, setQuery] = useState(""); |
| 18 | const [results, setResults] = useState<GraphNode[]>([]); |
| 19 | const [searchOpen, setSearchOpen] = useState(false); |
| 20 | const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 21 | const searchSeq = useRef(makeSequence()).current; |
| 22 | const searchBoxRef = useRef<HTMLDivElement | null>(null); |
| 23 | |
| 24 | const onQueryChange = useCallback((value: string) => { |
| 25 | setQuery(value); |
| 26 | if (searchTimer.current) clearTimeout(searchTimer.current); |
| 27 | if (!value.trim()) { |
| 28 | searchSeq.next(); |
| 29 | setResults([]); |
| 30 | setSearchOpen(false); |
| 31 | return; |
| 32 | } |
| 33 | setSearchOpen(true); |
| 34 | searchTimer.current = setTimeout(() => { |
| 35 | const ticket = searchSeq.next(); |
| 36 | search({ q: value, limit: 20 }) |
| 37 | .then((payload) => { |
| 38 | if (searchSeq.isCurrent(ticket)) setResults(payload.results); |
| 39 | }) |
| 40 | .catch((err) => { |
| 41 | if (searchSeq.isCurrent(ticket)) { |
| 42 | onError(err instanceof Error ? err.message : String(err)); |
| 43 | } |
| 44 | }); |
| 45 | }, debounceMs); |
| 46 | }, [debounceMs, onError, search, searchSeq]); |
| 47 | |
| 48 | const onSearchKeyDown = useCallback( |
| 49 | (event: KeyboardEvent<HTMLElement>) => { |
| 50 | const box = searchBoxRef.current; |
| 51 | if (!box) return; |
| 52 | if (event.key === "Escape") { |
| 53 | event.preventDefault(); |
| 54 | setSearchOpen(false); |
| 55 | box.querySelector<HTMLInputElement>("input")?.focus(); |
| 56 | return; |
| 57 | } |
| 58 | if (!searchOpen || results.length === 0) return; |
| 59 | const buttons: HTMLButtonElement[] = Array.from( |
| 60 | box.querySelectorAll<HTMLButtonElement>(".tsg-search-pop button"), |
| 61 | ); |
| 62 | const active = document.activeElement as HTMLElement | null; |
| 63 | const index = buttons.indexOf(active as HTMLButtonElement); |
no test coverage detected