Files
copilotkit--copilotkit/showcase/shell-docs/src/components/search-trigger.tsx
T
2026-07-13 12:58:18 +08:00

141 lines
5.1 KiB
TypeScript

"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 && <SearchModalWrapper onClose={closeSearch} />}
</>
);
}
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<boolean | null>(null);
useEffect(() => {
const mac =
typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
setIsMac(mac);
}, []);
if (iconOnly) {
return (
<button
onClick={toggleShellSearch}
className="shell-docs-radius-control flex h-10 w-10 cursor-pointer items-center justify-center border border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-muted)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)]"
aria-label="Search"
title="Search"
>
<Search className="h-4 w-4" aria-hidden="true" />
</button>
);
}
// 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 (
<>
<button
onClick={toggleShellSearch}
aria-label="Search"
className="shell-docs-radius-control flex h-10 w-10 cursor-pointer items-center gap-2 border border-[var(--border)] bg-[var(--bg-surface)] px-2.5 text-[var(--text-muted)] shadow-[var(--shadow-control)] transition-colors hover:border-[var(--accent)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text)] lg:w-[220px] xl:w-[260px]"
>
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="hidden flex-1 text-left text-sm font-medium lg:block">
Search
</span>
<span
className="shell-docs-radius-control hidden min-w-[3.25rem] items-center justify-center gap-1 border border-[var(--border)] bg-[var(--bg-surface)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-faint)] xl:inline-flex"
// Reserve horizontal room so the button doesn't reflow when the
// shortcut hint appears after hydration.
suppressHydrationWarning
>
{isMac === null ? (
"\u00a0"
) : isMac ? (
<>
<Command className="h-3 w-3" aria-hidden="true" />K
</>
) : (
"Ctrl K"
)}
</span>
</button>
</>
);
}
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(
<div data-search-modal>
<SearchModal onClose={onClose} />
</div>,
document.body,
);
}