import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { Receipt, Stethoscope, Activity as ActivityIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { SearchResult, SearchResultsGrouped } from "@/hooks/useSearch"; /** * Result row — one entity hit, clickable / Enter-able. * * The "selected" state is owned by the parent (the SearchBar wires * keyboard nav) — we just consume it via the `selected` boolean so * the row can paint its accent. We also expose `data-result-index` * so the parent's index-based keyboard nav can scroll the matching * row into view via `scrollIntoView`. */ type ResultRowProps = { result: SearchResult; selected: boolean; index: number; onSelect: (result: SearchResult) => void; onHover: (index: number) => void; }; const ICON_BY_KIND = { claim: Receipt, remittance: Stethoscope, activity: ActivityIcon, } as const; function ResultRow({ result, selected, index, onSelect, onHover, }: ResultRowProps) { const Icon = ICON_BY_KIND[result.kind]; return ( ); } /** Section header — small caps label above each group. */ function GroupHeader({ label }: { label: string }) { return (
{label}
); } /** Render the "no matches" state. */ function EmptyState({ query }: { query: string }) { return (
{query ? ( <> No matches for{" "} “{query}” ) : ( "Type to search across claims, remittances, and activity." )}
); } /** * Public ref API — the parent (SearchBar) drives navigation from * outside the list, so we expose an imperative handle with * `focusIndex(i)`. The implementation scrolls the matching row into * view; we don't move the actual DOM focus (the input owns that), * but we paint the row as selected via the parent's state. */ export interface SearchResultsHandle { focusIndex: (index: number) => void; } export interface SearchResultsProps { grouped: SearchResultsGrouped; selectedIndex: number; onSelect: (result: SearchResult) => void; onHoverIndex: (index: number) => void; } export const SearchResults = forwardRef( function SearchResults( { grouped, selectedIndex, onSelect, onHoverIndex }, ref ) { const listRef = useRef(null); /** * Scroll the row at `index` into view when it changes. We do this * imperatively (rather than relying on the browser's native focus * scroll) because the input owns focus during keyboard nav — moving * DOM focus would close the virtual keyboard on mobile and break * the "input stays focused" invariant. */ useImperativeHandle(ref, () => ({ focusIndex: (index: number) => { const list = listRef.current; if (!list) return; const el = list.querySelector( `[data-result-index="${index}"]` ); if (!el) return; el.scrollIntoView({ block: "nearest" }); }, }), []); const total = grouped.total; const isEmpty = total === 0; // Track the running flat-index so `data-result-index` is stable // and matches what the parent passes in `selectedIndex`. let runningIndex = 0; const claimNodes = grouped.claims.map((r) => { const idx = runningIndex++; return ( ); }); const remitNodes = grouped.remittances.map((r) => { const idx = runningIndex++; return ( ); }); const activityNodes = grouped.activity.map((r) => { const idx = runningIndex++; return ( ); }); // Keep the selected row visible any time the parent updates // `selectedIndex` (e.g. via ↑/↓). The `useImperativeHandle` above // does the same thing on demand; this effect picks up the cases // where the parent doesn't call into us explicitly. useEffect(() => { const list = listRef.current; if (!list) return; const el = list.querySelector( `[data-result-index="${selectedIndex}"]` ); if (!el) return; el.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); return (
{isEmpty ? ( ) : ( <> {claimNodes.length > 0 && ( <>
{claimNodes}
)} {remitNodes.length > 0 && ( <>
{remitNodes}
)} {activityNodes.length > 0 && ( <>
{activityNodes}
)} )}
); } );