exocore-docs / src /components /SearchBox.tsx
ChoruYt's picture
Upload 151 files
dc2b30a verified
Raw
History Blame Contribute Delete
4.29 kB
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { search, type SearchHit } from "../lib/docs";
interface Props {
autoFocus?: boolean;
placeholder?: string;
}
export function SearchBox({ autoFocus, placeholder }: Props) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const hits: SearchHit[] = useMemo(() => search(query, 12), [query]);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
// Global "/" shortcut to focus the search box (only if the user isn't
// already typing in some other field).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "/") return;
const target = e.target as HTMLElement | null;
if (target && /input|textarea|select/i.test(target.tagName)) return;
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(a + 1, hits.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === "Enter" && hits[active]) {
e.preventDefault();
navigate(`/docs/${hits[active].doc.slug}`);
setOpen(false);
} else if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
};
return (
<div className="search-wrap">
<div className="search-shell">
<span className="search-icon" aria-hidden>🔍</span>
<input
ref={inputRef}
type="search"
className="search-input"
value={query}
placeholder={placeholder || "Search the docs (press / to focus)…"}
onChange={(e) => { setQuery(e.target.value); setActive(0); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
autoComplete="off"
spellCheck={false}
/>
{query && (
<button
type="button"
className="search-clear"
aria-label="Clear search"
onClick={() => { setQuery(""); inputRef.current?.focus(); }}
</button>
)}
</div>
{open && query.trim() && (
<div className="search-results" role="listbox">
{hits.length === 0 && (
<div className="search-empty">No matches for “{query}”.</div>
)}
{hits.map((hit, i) => (
<Link
key={hit.doc.slug || "_root"}
to={`/docs/${hit.doc.slug}`}
className={"search-result" + (i === active ? " active" : "")}
onMouseEnter={() => setActive(i)}
onClick={() => setOpen(false)}
>
<div className="search-result-head">
<span className="search-result-title">{hit.doc.title}</span>
<span className="search-result-section">
{hit.doc.breadcrumbs.join(" / ")}
</span>
</div>
<div className="search-result-snippet">{hit.snippet}</div>
</Link>
))}
</div>
)}
</div>
);
}