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)} />
>
);
}