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 `` and * a `` 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 ( ); } 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(null); const inputRef = useRef(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) => { 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 ( <> setOpen(true)} /> { if (!o) setOpen(false); }} > {/* * 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. */}
setQuery(e.target.value)} onKeyDown={onInputKeyDown} data-testid="search-input" className={cn( "h-12 pl-10 pr-3 border-0 bg-transparent rounded-none", "text-[14px] mono tracking-tight", "shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:bg-transparent", "text-[color:var(--m-ink-primary)] placeholder:text-[color:var(--m-ink-tertiary)]" )} />
{/* 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. */}
{/* Footer hint — shows the keyboard contract. */}
navigate open Esc close
{isLoading ? ( loading… ) : ( {flatTotal} {flatTotal === 1 ? "result" : "results"} )}
); }