src/components/header-search.tsx
Linked repository file from this build.
"use client";
import { Suspense, useEffect, useRef, useState } from "react";
import { NavigationLink as Link } from "@/components/navigation-link";
import { usePathname, useSearchParams } from "next/navigation";
import { ListFilter, Search } from "lucide-react";
import { useReviewNavigation } from "@/features/reviews/provider";
import { useSession } from "@/lib/auth/client";
import { workflowLabels } from "@/features/reviews/types";
import { examinerLabel } from "@/features/reports/report-data";
import type { InboxReview } from "@/features/inbox/types";
import type { SearchResponse } from "@/lib/db/search-store";
import { fetchJson } from "@/lib/fetch-json";
import { useRemoteLoad } from "@/lib/use-remote-load";
import { Input } from "@/components/ui/input";
import {
actionClass,
committedFilters,
controlClass,
defaultFilter,
parseSearchFilters,
searchHref,
type SearchFilter,
} from "@/features/reviews/filter-builder";
import { parseSearchSorts } from "@/features/reviews/search-filters";
import { searchFields } from "@/features/reviews/search-fields";
function SearchControl() {
const { actor } = useSession();
const { navigate } = useReviewNavigation();
const pathname = usePathname();
const params = useSearchParams();
const appliedQuery = pathname === "/search" ? (params.get("q") ?? "") : "";
const appliedFiltersKey = pathname === "/search" ? (params.get("filters") ?? "") : "";
const scope = `${actor.role}:${actor.staffId}:${actor.program}:${(actor.subordinateStaffIds ?? []).join(",")}`;
const [query, setQuery] = useState(appliedQuery);
const [open, setOpen] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [quickFilters, setQuickFilters] = useState<SearchFilter[]>(() =>
parseSearchFilters(appliedFiltersKey),
);
const [rows, setRows] = useState<InboxReview[]>([]);
const [total, setTotal] = useState(0);
const root = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const hasQuery = !!query.trim();
const onResultsPage = pathname === "/search";
const showPanel = !onResultsPage && open && hasQuery && !filtersOpen;
// A new URL or account resets the box during render, so no stale frame shows.
const syncKey = [appliedQuery, appliedFiltersKey, pathname, scope].join("\n");
const [syncedKey, setSyncedKey] = useState(syncKey);
if (syncedKey !== syncKey) {
setSyncedKey(syncKey);
setQuery(appliedQuery);
setQuickFilters(parseSearchFilters(appliedFiltersKey));
setOpen(false);
setFiltersOpen(false);
}
useEffect(() => {
const outside = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) {
setOpen(false);
setFiltersOpen(false);
}
};
document.addEventListener("pointerdown", outside);
return () => document.removeEventListener("pointerdown", outside);
}, []);
const { loading, error, setError } = useRemoteLoad(
async (signal) => {
const search = new URLSearchParams({ q: query.trim(), limit: "5", quick: "true" });
const result = await fetchJson<SearchResponse>(`/api/search?${search}`, {
signal,
cache: "no-store",
fallback: "Search is unavailable. Please try again.",
});
if (signal.aborted) return;
setRows(result.reviews);
setTotal(result.total);
},
[query, scope, showPanel, hasQuery],
{
enabled: showPanel && hasQuery,
delayMs: 150,
reset: () => {
setRows([]);
setTotal(0);
},
},
);
async function go(href: string) {
if (!(await navigate(href))) {
setError("Save this review before searching.");
return;
}
if (href.startsWith("/search?")) window.dispatchEvent(new Event("qc:search-refresh"));
setOpen(false);
}
function activeHref(filters: SearchFilter[]) {
return searchHref(
query,
committedFilters(filters),
0,
"relevance",
"desc",
pathname === "/search"
? parseSearchSorts(
params.get("sorts"),
params.get("sort") || "relevance",
params.get("direction") || "desc",
)
: [],
);
}
function openFilters() {
setFiltersOpen(!filtersOpen);
setOpen(false);
}
async function submitQuickSearch() {
await go(activeHref(quickFilters));
setFiltersOpen(false);
}
function quickFilter(field: string) {
return quickFilters.find((filter) => filter.field === field) ?? defaultFilter(field);
}
function changeQuickFilter(filter: SearchFilter) {
setQuickFilters((current) => [
...current.filter((entry) => entry.field !== filter.field),
...(filter.term ? [filter] : []),
]);
}
return (
<div
ref={root}
className="relative mx-auto w-full min-w-0 max-w-[44rem]"
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false);
setFiltersOpen(false);
input.current?.focus();
}
}}
>
<form
onSubmit={(event) => {
event.preventDefault();
void submitQuickSearch();
}}
>
<div className="flex min-w-0 items-center overflow-hidden rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
<input
ref={input}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
root.current?.querySelector<HTMLAnchorElement>("[data-search-suggestion]")?.focus();
}
}}
aria-label="Search reviews"
aria-autocomplete={onResultsPage ? "none" : "list"}
aria-controls={showPanel ? "header-search-panel" : undefined}
placeholder="Search reviews, clients, cases…"
className="h-10 min-w-0 flex-1 bg-transparent px-2 text-sm outline-none"
/>
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
aria-label="Quick filters"
aria-expanded={filtersOpen}
aria-controls="header-quick-filters"
onClick={openFilters}
>
<ListFilter className="size-4" aria-hidden />
</button>
<button
type="submit"
aria-label="Submit search"
className="flex size-10 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent"
>
<Search className="size-4" aria-hidden />
</button>
</div>
{filtersOpen && (
<div
id="header-quick-filters"
role="group"
aria-label="Quick filters"
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl"
>
<div className="grid gap-3 sm:grid-cols-2">
{["sampleMonth", "examiner", "state", "outcome"].map((key) => {
const field = searchFields.find((option) => option.key === key)!;
const filter = quickFilter(key);
return (
<label key={key} className="grid gap-1 text-xs font-medium">
{field.label}
{key === "sampleMonth" ? (
<Input
type="month"
aria-label="Quick sample month"
value={filter.term || filter.from || ""}
onChange={(event) =>
changeQuickFilter({ field: key, mode: "exact", term: event.target.value })
}
/>
) : (
<select
className={controlClass}
value={filter.term || ""}
onChange={(event) =>
changeQuickFilter({ field: key, mode: "exact", term: event.target.value })
}
>
<option value="">Any</option>
{field.choices
?.filter((choice) => choice.value)
.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
)}
</label>
);
})}
</div>
<div className="mt-4 flex justify-end gap-2 border-t pt-3">
<button type="button" className={actionClass} onClick={() => setQuickFilters([])}>
Clear filters
</button>
<button
type="button"
className="h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:opacity-90"
onClick={() => {
void go(`${activeHref(quickFilters)}#search-filters`);
setFiltersOpen(false);
}}
>
Advanced search
</button>
</div>
</div>
)}
{showPanel && (
<div
id="header-search-panel"
className="absolute left-0 right-0 top-full z-50 mt-2 max-h-[75dvh] overflow-auto rounded-lg border bg-popover p-3 text-popover-foreground shadow-xl"
>
{hasQuery && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">Quick matches</p>
{loading ? (
<p role="status" className="p-2 text-sm text-muted-foreground">
Searching…
</p>
) : error ? (
<p role="alert" className="p-2 text-sm text-destructive">
{error}
</p>
) : !rows.length ? (
<p role="status" className="p-2 text-sm text-muted-foreground">
No matching reviews.
</p>
) : null}
<ul aria-label="Suggested reviews" className="space-y-1">
{rows.slice(0, 5).map((row) => (
<li key={row.reviewKey}>
<Link
data-search-suggestion
href={`/reviews/${row.reviewKey}`}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
return;
event.preventDefault();
void go(`/reviews/${row.reviewKey}`);
}}
onKeyDown={(event) => {
const links = [
...root.current!.querySelectorAll<HTMLAnchorElement>(
"[data-search-suggestion]",
),
];
const index = links.indexOf(event.currentTarget);
if (event.key === "ArrowDown") {
event.preventDefault();
links[index + 1]?.focus();
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (index) links[index - 1].focus();
else input.current?.focus();
}
}}
className="block rounded-md px-2 py-1 text-sm hover:bg-muted focus-visible:bg-muted focus-visible:outline-ring"
>
<span className="font-medium">
{row.clientName || "Unnamed client"} · {row.reviewNumber} · FFY{" "}
{row.fiscalYear}
</span>
<span className="block truncate text-xs text-muted-foreground">
Case {row.caseNumber} · {examinerLabel(row.examiner)} ·{" "}
{workflowLabels[row.state]}
</span>
</Link>
</li>
))}
</ul>
<Link
href={searchHref(query, [])}
aria-label={
loading || error
? "See all results"
: `See all ${total} result${total === 1 ? "" : "s"}`
}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
void go(searchHref(query, []));
}}
className="mt-2 flex min-h-8 w-full items-center justify-between gap-2 rounded-md border px-2 text-left text-xs font-medium hover:bg-muted"
>
<span>
{loading || error
? "See all results"
: `See all ${total} result${total === 1 ? "" : "s"}`}
</span>
{!loading && !error && total > rows.length && (
<span aria-hidden className="rounded bg-muted px-1.5 text-muted-foreground">
+{total - rows.length} more
</span>
)}
</Link>
<p className="mt-2 text-xs text-muted-foreground">
Press Enter to search all matches.
</p>
</div>
)}
</div>
)}
</form>
{error && !showPanel && (
<p role="alert" className="mt-2 text-sm text-destructive">
{error}
</p>
)}
</div>
);
}
export function HeaderSearch() {
return (
<Suspense
fallback={
<div className="h-10 w-full rounded-md border bg-muted/30" aria-label="Loading search" />
}
>
<SearchControl />
</Suspense>
);
}