"use client";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Command, Search } from "lucide-react";
import { SearchModal } from "./search-modal";
const TOGGLE_SEARCH_EVENT = "shell-docs:toggle-search";
function isEditableTarget(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (target.isContentEditable) return true;
return false;
}
export function ShellSearchProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const closeSearch = useCallback(() => setOpen(false), []);
const toggleSearch = useCallback(() => setOpen((prev) => !prev), []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
// Don't hijack Cmd/Ctrl+K when the user is typing in an unrelated
// input / textarea / contenteditable — only steal the shortcut when
// focus is outside an editable element or already inside our own
// search modal.
const target = e.target as HTMLElement | null;
const insideSearchModal =
target?.closest?.("[data-search-modal]") != null;
if (isEditableTarget(target) && !insideSearchModal) return;
e.preventDefault();
e.stopPropagation();
toggleSearch();
}
if (e.key === "Escape") closeSearch();
}
document.addEventListener("keydown", onKeyDown, { capture: true });
window.addEventListener(TOGGLE_SEARCH_EVENT, toggleSearch);
return () => {
document.removeEventListener("keydown", onKeyDown, { capture: true });
window.removeEventListener(TOGGLE_SEARCH_EVENT, toggleSearch);
};
}, [closeSearch, toggleSearch]);
return (
<>
{children}
{open && }
>
);
}
function toggleShellSearch() {
window.dispatchEvent(new Event(TOGGLE_SEARCH_EVENT));
}
export function SearchTrigger({
iconOnly = false,
}: { iconOnly?: boolean } = {}) {
// Start as null so SSR output matches the initial client render; resolve
// after mount to avoid hydration mismatch flashing ⌘K → Ctrl+K on non-Mac.
const [isMac, setIsMac] = useState(null);
useEffect(() => {
const mac =
typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
setIsMac(mac);
}, []);
if (iconOnly) {
return (
);
}
// Mirrors the canonical `search-button.tsx` chrome: same height as the
// navbar's right-cluster controls, icon + label on lg+, ⌘K hint on xl+.
return (
<>
>
);
}
function SearchModalWrapper({ onClose }: { onClose: () => void }) {
// Portal to document.body so the modal's `position: fixed` resolves
// against the viewport. The trigger renders inside the navbar's right
// cluster, which uses `backdrop-blur-lg` — backdrop-filter creates a
// containing block for fixed-position descendants, which would
// otherwise clamp the overlay to the cluster's bounding rect instead
// of covering the page.
if (typeof document === "undefined") return null;
return createPortal(
,
document.body,
);
}