301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
} |