"use client" import { useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import { useRouter } from "next/navigation" import { Search, X } from "lucide-react" import { cn } from "@/lib/utils" declare global { interface Window { PagefindUI?: new (options: Record) => unknown } } export function SearchDialog() { const [isOpen, setIsOpen] = useState(false) const [isReady, setIsReady] = useState(false) const [loadError, setLoadError] = useState(false) // Track when the component has hydrated so we know it's safe to use document.body // for the portal target — avoids React hydration mismatch warnings. const [mounted, setMounted] = useState(false) const containerRef = useRef(null) const router = useRouter() useEffect(() => { setMounted(true) }, []) useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault() setIsOpen((v) => !v) } if (e.key === "Escape" && isOpen) { setIsOpen(false) } } window.addEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler) }, [isOpen]) useEffect(() => { if (!isOpen) { // Reset readiness on close so the next open shows the loading state // until Pagefind UI mounts again into the new container ref. setIsReady(false) return } // Inject Pagefind UI CSS once across the page lifetime. const cssId = "pagefind-ui-css" if (!document.getElementById(cssId)) { const link = document.createElement("link") link.id = cssId link.rel = "stylesheet" link.href = "/pagefind/pagefind-ui.css" document.head.appendChild(link) } const init = () => { if (!containerRef.current) return if (typeof window.PagefindUI !== "function") { setLoadError(true) return } // Wipe any prior UI instance — the container is a fresh DOM node every open, // but this also handles the unlikely case of a partial mount. containerRef.current.innerHTML = "" new window.PagefindUI({ element: containerRef.current, showSubResults: true, showImages: false, resetStyles: false, autofocus: true, // Append ?pagefind-search= to result URLs so the destination page can highlight // the matched terms via /pagefind/pagefind-highlight.js (loaded in the root layout). highlightParam: "pagefind-search", // Note: we intentionally do NOT use Pagefind's `processResult` to rewrite URLs. // Pagefind UI versions differ in which field they bind to the rendered // (some use `meta.url`, some `url`, some keep raw_url internally), and mutating // the result object can also break sub-result rendering. Instead, we intercept // the click event below and clean the URL at click time — see onClickCapture // on the result container. translations: { placeholder: "Search documentation…", clear_search: "Clear", load_more: "Load more results", search_label: "Search this site", filters_label: "Filters", zero_results: "No results for [SEARCH_TERM]", many_results: "[COUNT] results for [SEARCH_TERM]", one_result: "[COUNT] result for [SEARCH_TERM]", alt_search: "No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM] instead", search_suggestion: "No results for [SEARCH_TERM]. Try one of the following searches:", searching: "Searching for [SEARCH_TERM]…", }, }) setIsReady(true) } // If Pagefind UI is already loaded (we've opened the dialog before in this session), // re-init directly into the new container ref. if (typeof window.PagefindUI === "function") { init() return } // First time: load the script and init on load. const scriptId = "pagefind-ui-js" let script = document.getElementById(scriptId) as HTMLScriptElement | null if (!script) { script = document.createElement("script") script.id = scriptId script.src = "/pagefind/pagefind-ui.js" script.defer = true script.onerror = () => setLoadError(true) document.head.appendChild(script) } script.addEventListener("load", init, { once: true }) }, [isOpen]) return ( <> {/* Trigger button — icon only on mobile/tablet, full button with text + kbd on lg+ */} {/* Render the modal in a portal to document.body so it escapes the Navbar's fixed/z-50 stacking context. Otherwise z-[1000] is bounded by the parent context and the mobile "Documentation" bar (also z-50, later in the DOM) paints on top, hiding the close button. */} {mounted && isOpen && createPortal(
{ if (e.target === e.currentTarget) setIsOpen(false) }} role="dialog" aria-modal="true" aria-label="Search" >
{/* Header bar — close button. Esc hint is desktop-only (no keyboard on mobile). */}
Press Esc to close {/* Spacer so the X stays right-aligned even when the Esc hint is hidden */}
{loadError ? (

Search index not available

Search is generated during the production build. Run{" "} npm run build to generate the index locally, or wait for the next deploy.

) : ( <> {!isReady && (
Loading search…
)} {/* Click interception: Pagefind indexes the static export (.html files), so result links carry hrefs like "/docs/foo.html?pagefind-search=term". In dev mode and on hosts that don't serve .html, those URLs 404. We intercept the click here, strip .html / /index.html, and route via Next.js for SPA nav. Capture phase runs before any Pagefind handlers; modifier-key clicks fall through to the browser so cmd/ctrl-click still opens in a new tab. */}
{ const target = e.target as HTMLElement const anchor = target.closest("a") as HTMLAnchorElement | null if (!anchor) return if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return const raw = anchor.getAttribute("href") if (!raw || /^(https?:)?\/\//i.test(raw) || raw.startsWith("mailto:")) return const cleaned = raw .replace(/\/index\.html(?=[?#]|$)/g, "/") .replace(/\.html(?=[?#]|$)/g, "") e.preventDefault() setIsOpen(false) router.push(cleaned) }} /> )}
, document.body, )} ) }