feat(frontend): global Cmd-K search across claims/remits/activity
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
// `data-result-index` lets the parent keyboard handler call
|
||||
// `el.scrollIntoView()` after a nav move — see SearchBar.
|
||||
data-result-index={index}
|
||||
data-result-kind={result.kind}
|
||||
data-result-id={result.id}
|
||||
data-selected={selected || undefined}
|
||||
// Row is keyboard-focusable so screen readers and keyboard-only
|
||||
// users see something focusable moving under ↑/↓. We manage the
|
||||
// focused-row state ourselves (via the `selected` prop) rather
|
||||
// than relying on the browser's focus ring because the input
|
||||
// stays focused while the palette is open.
|
||||
aria-selected={selected}
|
||||
role="option"
|
||||
onMouseDown={(e) => {
|
||||
// `mousedown` rather than `click` so the input doesn't blur
|
||||
// before the navigation fires — otherwise the user sees a
|
||||
// frame of "input lost focus" before the dialog closes.
|
||||
e.preventDefault();
|
||||
onSelect(result);
|
||||
}}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
|
||||
"focus:outline-none",
|
||||
selected
|
||||
? "bg-accent/15 ring-1 ring-inset ring-accent/50"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
|
||||
selected
|
||||
? "bg-accent/20 text-accent"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] font-medium truncate">
|
||||
{result.title}
|
||||
</span>
|
||||
<span className="block text-[11px] font-mono text-muted-foreground truncate">
|
||||
{result.subtitle}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 px-1.5 py-0.5 rounded text-[10px] font-mono uppercase tracking-[0.08em]",
|
||||
"bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{result.badge}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section header — small caps label above each group. */
|
||||
function GroupHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the "no matches" state. */
|
||||
function EmptyState({ query }: { query: string }) {
|
||||
return (
|
||||
<div className="px-4 py-10 text-center text-muted-foreground">
|
||||
<div className="text-[12px] font-mono">
|
||||
{query
|
||||
? <>No matches for <span className="text-foreground">{query}</span></>
|
||||
: "Type to search across claims, remittances, and activity."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SearchResultsHandle, SearchResultsProps>(
|
||||
function SearchResults(
|
||||
{ grouped, selectedIndex, onSelect, onHoverIndex },
|
||||
ref
|
||||
) {
|
||||
const listRef = useRef<HTMLDivElement>(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<HTMLElement>(
|
||||
`[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 (
|
||||
<ResultRow
|
||||
key={`claim-${r.id}-${idx}`}
|
||||
result={r}
|
||||
index={idx}
|
||||
selected={idx === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onHover={onHoverIndex}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const remitNodes = grouped.remittances.map((r) => {
|
||||
const idx = runningIndex++;
|
||||
return (
|
||||
<ResultRow
|
||||
key={`remit-${r.id}-${idx}`}
|
||||
result={r}
|
||||
index={idx}
|
||||
selected={idx === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onHover={onHoverIndex}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const activityNodes = grouped.activity.map((r) => {
|
||||
const idx = runningIndex++;
|
||||
return (
|
||||
<ResultRow
|
||||
key={`activity-${r.id}-${idx}`}
|
||||
result={r}
|
||||
index={idx}
|
||||
selected={idx === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onHover={onHoverIndex}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// 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<HTMLElement>(
|
||||
`[data-result-index="${selectedIndex}"]`
|
||||
);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
// `role="listbox"` for the whole results container; each row
|
||||
// is `role="option"`. This is what a screen reader expects
|
||||
// for a "combobox with listbox popup" pattern.
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
className="max-h-[60vh] overflow-y-auto py-1"
|
||||
>
|
||||
{isEmpty ? (
|
||||
<EmptyState query={grouped.query} />
|
||||
) : (
|
||||
<>
|
||||
{claimNodes.length > 0 && (
|
||||
<>
|
||||
<GroupHeader label={`Claims · ${claimNodes.length}`} />
|
||||
<div className="space-y-0.5 px-1">{claimNodes}</div>
|
||||
</>
|
||||
)}
|
||||
{remitNodes.length > 0 && (
|
||||
<>
|
||||
<GroupHeader label={`Remittances · ${remitNodes.length}`} />
|
||||
<div className="space-y-0.5 px-1">{remitNodes}</div>
|
||||
</>
|
||||
)}
|
||||
{activityNodes.length > 0 && (
|
||||
<>
|
||||
<GroupHeader label={`Activity · ${activityNodes.length}`} />
|
||||
<div className="space-y-0.5 px-1 pb-1">{activityNodes}</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user