feat(frontend): global Cmd-K search across claims/remits/activity

This commit is contained in:
Tyler
2026-06-20 17:25:40 -06:00
parent 4cd52c3084
commit a79668192d
7 changed files with 2159 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
destinationFor,
flattenResults,
useSearch,
type SearchResult,
} from "@/hooks/useSearch";
import { SearchResults, type SearchResultsHandle } from "./SearchResults";
/**
* Global Cmd-K / Ctrl-K search bar.
*
* Two pieces:
*
* 1. **Top-bar button** — always-visible chip in the Layout's top
* bar. Clicking opens the palette; pressing ⌘K / Ctrl-K also
* opens it from anywhere on the page.
*
* 2. **Command palette dialog** — centered modal built on the
* project's `Dialog` primitive (Radix underneath, free focus
* management + ESC-to-close). Hosts an autofocused `<input>` and
* a `<SearchResults>` listbox with ↑/↓/Enter keyboard nav.
*
* Keyboard contract:
*
* - ⌘K / Ctrl+K → toggle open (from anywhere)
* - ↑ / k → move selection up
* - ↓ / j → move selection down
* - Enter → open the selected result
* - Esc → close the palette (handled by Radix's Dialog)
* - Backspace / typing in the input → updates the query
*
* When the dialog is closed, keyboard input flows normally. When
* the dialog is open, the ↑/↓/Enter listeners run while the input
* is focused — they're owned by the palette, not the page.
*/
/**
* True when running on a Mac-class platform. Computed per call rather
* than at module load — tests mutate `navigator.platform` and we want
* the chord-detection to honor the current value.
*/
function isMac(): boolean {
return (
typeof navigator !== "undefined" &&
/Mac|iPod|iPhone|iPad/.test(navigator.platform)
);
}
/** Pretty label for the modifier key in the top-bar trigger. */
const MOD_LABEL = isMac() ? "⌘" : "Ctrl";
/**
* Render the trigger button. Lives in the top bar; opens the
* palette on click. Pure presentation — the parent passes `onOpen`.
*/
function SearchTrigger({ onOpen }: { onOpen: () => void }) {
return (
<button
type="button"
onClick={onOpen}
data-testid="search-trigger"
aria-label="Open global search (Cmd or Ctrl + K)"
className={cn(
"group inline-flex items-center gap-2.5 h-8 pl-2.5 pr-2 rounded-md",
"border border-border/60 bg-background/40 hover:bg-background/70",
"text-[12px] text-muted-foreground hover:text-foreground",
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
)}
>
<Search className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
<span className="font-medium tracking-tight">Search</span>
<span className="ml-2 inline-flex items-center gap-0.5">
<kbd className="kbd">{MOD_LABEL}</kbd>
<kbd className="kbd">K</kbd>
</span>
</button>
);
}
export function SearchBar() {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const { query, setQuery, results, isLoading } = useSearch();
const [selectedIndex, setSelectedIndex] = useState(0);
const resultsRef = useRef<SearchResultsHandle>(null);
const inputRef = useRef<HTMLInputElement>(null);
/**
* Global ⌘K / Ctrl+K listener. Mounted once at the SearchBar level
* (which lives in the Layout — always present). Ignored when the
* palette is already open so a second chord doesn't reopen it
* while the user is typing.
*/
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = isMac() ? e.metaKey : e.ctrlKey;
if (!mod) return;
if (e.key.toLowerCase() !== "k") return;
e.preventDefault();
setOpen((prev) => !prev);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
/**
* Reset state when the palette closes — clear the query, drop the
* selection. Without this, reopening the palette shows the stale
* query from the last session.
*/
useEffect(() => {
if (open) return;
setQuery("");
setSelectedIndex(0);
}, [open, setQuery]);
/**
* Whenever the result list changes, snap the selected index back
* to 0 so the user always sees a selection at the top of the new
* list (rather than carrying a stale index from the previous
* query that may now point past the end).
*/
useEffect(() => {
setSelectedIndex(0);
}, [results.query]);
/** Flat ordered list — what ↑/↓ step through. */
const flat = useMemo(() => flattenResults(results), [results]);
const flatTotal = flat.length;
const moveNext = useCallback(() => {
if (flatTotal === 0) return;
setSelectedIndex((i) => (i + 1) % flatTotal);
}, [flatTotal]);
const movePrev = useCallback(() => {
if (flatTotal === 0) return;
// `+ flatTotal` keeps the modulo positive when `i === 0`.
setSelectedIndex((i) => (i - 1 + flatTotal) % flatTotal);
}, [flatTotal]);
const openResult = useCallback(
(result: SearchResult) => {
const dest = destinationFor(result);
setOpen(false);
// Push the destination via react-router. The page-level
// effects (e.g. Claims reading `?claim=` to open its drawer)
// react to the new URL on the next render.
navigate(dest);
},
[navigate]
);
/**
* Input keydown — own ↑/↓/Enter here so the keys are intercepted
* BEFORE the browser handles them (arrows would otherwise move
* the text caret, which is wrong inside a listbox picker).
* Esc is owned by Radix and bubbles via `onOpenChange`.
*/
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
moveNext();
break;
case "ArrowUp":
e.preventDefault();
movePrev();
break;
case "Enter": {
e.preventDefault();
const r = flat[selectedIndex];
if (r) openResult(r);
break;
}
// j/k — vim-style. We don't preempt ⌘K here because that
// would conflict with the chord-detection in the global listener.
case "j":
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault();
moveNext();
}
break;
case "k":
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault();
movePrev();
}
break;
}
};
return (
<>
<SearchTrigger onOpen={() => setOpen(true)} />
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
>
<DialogContent
// Slightly wider than the default `max-w-lg` to give the
// listbox room for two-line rows without truncation.
className={cn(
"max-w-2xl p-0 gap-0 overflow-hidden",
"bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]",
"border border-[color:var(--m-border-heavy)]/20"
)}
aria-describedby={undefined}
data-testid="search-dialog"
>
{/*
* Search input row. Autofocuses on mount via Radix's
* focus management (the Dialog primitive focuses the first
* tabbable element on open). `pl-9` makes room for the
* magnifier icon on the left.
*/}
<div className="relative border-b border-border/40">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
strokeWidth={1.75}
aria-hidden
/>
<Input
ref={inputRef}
type="text"
autoComplete="off"
spellCheck={false}
placeholder="Search claims, remittances, activity…"
role="combobox"
aria-label="Search claims, remittances, and activity"
aria-expanded={open}
aria-controls="search-results"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKeyDown}
data-testid="search-input"
className={cn(
"h-12 pl-9 pr-3 border-0 bg-transparent rounded-none",
"text-[14px] font-mono tracking-tight",
"shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
)}
/>
</div>
{/* Results listbox. `id="search-results"` is the value of the
input's `aria-controls` attribute — links the two for
screen readers per the combobox pattern. */}
<div id="search-results">
<SearchResults
ref={resultsRef}
grouped={results}
selectedIndex={selectedIndex}
onSelect={openResult}
onHoverIndex={setSelectedIndex}
/>
</div>
{/* Footer hint — shows the keyboard contract. */}
<div className="flex items-center justify-between gap-3 px-3 py-2 border-t border-border/40 text-[10.5px] font-mono uppercase tracking-[0.12em] text-muted-foreground">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1">
<kbd className="kbd"></kbd>
<kbd className="kbd"></kbd>
navigate
</span>
<span className="inline-flex items-center gap-1">
<kbd className="kbd"></kbd>
open
</span>
<span className="inline-flex items-center gap-1">
<kbd className="kbd">Esc</kbd>
close
</span>
</div>
{isLoading ? (
<span className="text-muted-foreground/70">loading</span>
) : (
<span className="text-muted-foreground/70">
{flatTotal} {flatTotal === 1 ? "result" : "results"}
</span>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}