"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { Search as SearchIcon, FileText, CornerDownLeft, Hash, AlignLeft, Command, Option, ChevronUp, ArrowBigUp, CircleArrowOutUpLeft, Space, Delete, ArrowRightToLine, ChevronDown, ChevronLeft, ChevronRight, } from "lucide-react"; import { Modal, useModal } from "@docubook/ui-react/modal"; import { Kbd, FnKey } from "@docubook/ui-react/kbd"; FnKey.configure({ Command, Option, ChevronUp, ArrowBigUp, CircleArrowOutUpLeft, Space, Delete, ArrowRightToLine, ChevronDown, ChevronLeft, ChevronRight, }); import { cn } from "../node/utils"; import { search, type SearchResult } from "../node/search"; import type { SearchRecord } from "../node/search-indexer"; interface SearchProps { className?: string; } let indexCache: SearchRecord[] | null = null; let cmdKRegistered = false; async function loadIndex(): Promise { if (indexCache) return indexCache; const res = await fetch("/assets/search-index.json"); indexCache = await res.json(); return indexCache!; } export default function Search({ className }: SearchProps) { const { ref: modalRef, open, close } = useModal(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [indexLoaded, setIndexLoaded] = useState(false); const inputRef = useRef(null); const itemRefs = useRef<(HTMLAnchorElement | null)[]>([]); useEffect(() => { if (cmdKRegistered) return; cmdKRegistered = true; const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); const dialog = modalRef.current; if (dialog?.open) { close(); } else { open(); } } }; window.addEventListener("keydown", handler); return () => { window.removeEventListener("keydown", handler); cmdKRegistered = false; }; }, [open, close, modalRef]); useEffect(() => { const dialog = modalRef.current; if (!dialog) return; const observer = new MutationObserver(() => { if (dialog.open) { setTimeout(() => inputRef.current?.focus(), 50); if (!indexLoaded) { loadIndex().then(() => setIndexLoaded(true)); } } else { setQuery(""); setResults([]); setSelectedIndex(0); } }); observer.observe(dialog, { attributes: true, attributeFilter: ["open"] }); return () => observer.disconnect(); }, [modalRef, indexLoaded]); const handleQueryChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; setQuery(value); if (!indexLoaded || value.trim().length < 2) { setResults([]); setSelectedIndex(0); return; } setResults(search(value, indexCache!)); setSelectedIndex(0); }, [indexLoaded] ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (results.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => (i + 1) % results.length); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => (i - 1 + results.length) % results.length); } else if (e.key === "Enter") { e.preventDefault(); const item = results[selectedIndex]; if (item) { window.location.href = item.url; close(); } } }, [results, selectedIndex, close] ); useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); return (
{query && ( )}
{query.trim().length >= 2 && results.length === 0 && (

No results for “{query}

)} {query.trim().length >= 2 && results.length > 0 && ( )} {query.trim().length < 2 && (

Type to search documentation

)}
navigate
select
esc close
); } function SearchTrigger({ onClick }: { onClick: () => void }) { return ( ); } function GroupedResults({ results, selectedIndex, itemRefs, onSelect, }: { results: SearchResult[]; selectedIndex: number; itemRefs: React.MutableRefObject<(HTMLAnchorElement | null)[]>; onSelect: () => void; }) { // Group results by lvl0 const groups: { section: string; items: { result: SearchResult; globalIndex: number }[] }[] = []; let currentSection = ""; results.forEach((result, index) => { const section = result.hierarchy.lvl0; if (section !== currentSection) { groups.push({ section, items: [] }); currentSection = section; } groups[groups.length - 1].items.push({ result, globalIndex: index }); }); return (
{groups.map((group) => (
{group.section}
{group.items.map(({ result, globalIndex }) => { const isActive = globalIndex === selectedIndex; const isPage = result.type === "lvl1"; const isContent = result.type === "content"; const isHeading = !isPage && !isContent; const parentLabel = isPage ? null : result.hierarchy.lvl1; return ( { itemRefs.current[globalIndex] = el; }} href={result.url} onClick={onSelect} className={cn( "flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors", isActive ? "bg-primary/10 text-primary shadow-sm" : "hover:bg-base-200 text-base-content" )} > {/* Icon */}
{isPage && } {isHeading && } {isContent && }
{/* Text */}
{isContent ? result.content : result.title}
{parentLabel && parentLabel !== result.title && (
{parentLabel}
)}
{/* Return icon on active */} {isActive && }
); })}
))}
); }