search-dialog
uiCommand-palette style search dialog with keyboard navigation. Uses createSearchRoute() from @document0/core.
document0/search-dialog•v0.1.0•react, next
Preview
ESC
Type to search documentation
Installation
$npx @document0/cli add document0/search-dialog
Usage
import { SearchDialog } from "./components/document0/search-dialog";
// Example usage in your layout or page:
<SearchDialog />Source
After installation, this lives at components/document0/search-dialog/SearchDialog.tsx and you can modify it however you like.
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
interface SearchResult {
title: string;
description?: string;
url: string;
score: number;
}
/**
* Command-palette style search dialog with keyboard navigation.
*
* Usage:
* ```tsx
* import { SearchDialog } from "@/components/document0/search-dialog/SearchDialog";
*
* // In your layout/header:
* const [open, setOpen] = useState(false);
*
* // Keyboard shortcut
* useEffect(() => {
* const handler = (e: KeyboardEvent) => {
* if ((e.metaKey || e.ctrlKey) && e.key === "k") {
* e.preventDefault();
* setOpen(true);
* }
* };
* window.addEventListener("keydown", handler);
* return () => window.removeEventListener("keydown", handler);
* }, []);
*
* return <SearchDialog open={open} onClose={() => setOpen(false)} />;
* ```
*
* Requires a search API endpoint at /internal/search that accepts ?q= query param
* and returns SearchResult[]. Use createSearchRoute() from @document0/core to implement.
*/
export function SearchDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
if (open) {
setQuery("");
setResults([]);
setSelected(0);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
const fetchResults = useCallback((q: string) => {
if (!q.trim()) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
fetch(`/internal/search?q=${encodeURIComponent(q)}`)
.then((r) => r.json())
.then((data: SearchResult[]) => {
setResults(data);
setSelected(0);
})
.finally(() => setLoading(false));
}, []);
const handleChange = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchResults(value), 150);
};
const navigate = (url: string) => {
onClose();
router.push(url);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelected((s) => Math.min(s + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelected((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter" && results[selected]) {
navigate(results[selected].url);
} else if (e.key === "Escape") {
onClose();
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-[100]" onClick={onClose}>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
<div className="fixed inset-x-0 top-[15%] mx-auto w-full max-w-lg px-4">
<div
className="rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-zinc-800 px-4">
<svg
className="h-4 w-4 shrink-0 text-zinc-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search docs..."
className="flex-1 bg-transparent py-3.5 text-sm text-white placeholder:text-zinc-500 outline-none"
/>
<kbd className="hidden sm:inline-flex items-center rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
ESC
</kbd>
</div>
<div className="max-h-[320px] overflow-y-auto">
{loading && (
<div className="px-4 py-8 text-center text-sm text-zinc-500">
Searching...
</div>
)}
{!loading && query && results.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-zinc-500">
No results for “{query}”
</div>
)}
{!loading && results.length > 0 && (
<ul className="py-2">
{results.map((result, i) => (
<li key={result.url}>
<button
type="button"
onClick={() => navigate(result.url)}
onMouseEnter={() => setSelected(i)}
className={[
"w-full text-left px-4 py-2.5 flex flex-col gap-0.5 transition-colors",
selected === i
? "bg-sky-500/10"
: "hover:bg-zinc-800/60",
].join(" ")}
>
<span
className={[
"text-sm font-medium",
selected === i ? "text-sky-400" : "text-zinc-200",
].join(" ")}
>
{result.title}
</span>
{result.description && (
<span className="text-xs text-zinc-500 line-clamp-1">
{result.description}
</span>
)}
</button>
</li>
))}
</ul>
)}
{!loading && !query && (
<div className="px-4 py-8 text-center text-sm text-zinc-500">
Type to search documentation
</div>
)}
</div>
</div>
</div>
</div>
);
}
Tags
searchdialogkeyboardreact