SPB Git
4.8 KB · 58 lines tsx
1"use client";2import { useEffect, useRef, useState, useId } from "react";3import { useRouter } from "next/navigation";4import { Search, CornerDownLeft } from "lucide-react";5type Hit = { slug: string; name: string; summary: string; kind: "tool" | "category" };67export function SearchBox({ size = "lg", autoFocus = false, defaultValue = "", ariaLabel }: { size?: "sm" | "lg"; autoFocus?: boolean; defaultValue?: string; ariaLabel?: string }) {8  const router = useRouter(); const id = useId();9  const [q, setQ] = useState(defaultValue); const [hits, setHits] = useState<Hit[]>([]); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1);10  const box = useRef<HTMLDivElement>(null); const input = useRef<HTMLInputElement>(null); const timer = useRef<ReturnType<typeof setTimeout> | null>(null);11  useEffect(() => {12    const onKey = (e: KeyboardEvent) => { if (e.key === "/" && !/input|textarea/i.test((e.target as HTMLElement).tagName)) { e.preventDefault(); input.current?.focus(); } };13    const onClick = (e: MouseEvent) => { if (!box.current?.contains(e.target as Node)) setOpen(false); };14    window.addEventListener("keydown", onKey); window.addEventListener("click", onClick);15    return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("click", onClick); };16  }, []);17  useEffect(() => {18    if (timer.current) clearTimeout(timer.current);19    if (q.trim().length < 2) { setHits([]); return; }20    timer.current = setTimeout(async () => {21      try { const r = await fetch(`/api/suggest?q=${encodeURIComponent(q.trim())}`); if (r.ok) { setHits(await r.json()); setOpen(true); setActive(-1); } } catch {}22    }, 120);23  }, [q]);24  const go = (h?: Hit) => { setOpen(false); if (h) router.push(h.kind === "tool" ? `/tools/${h.slug}` : `/categories/${h.slug}`); else if (q.trim()) router.push(`/tools?q=${encodeURIComponent(q.trim())}`); };25  const onKey = (e: React.KeyboardEvent) => {26    if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setActive((a) => Math.min(hits.length - 1, a + 1)); }27    else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(-1, a - 1)); }28    else if (e.key === "Enter") { e.preventDefault(); go(active >= 0 ? hits[active] : undefined); }29    else if (e.key === "Escape") { setOpen(false); input.current?.blur(); }30  };31  const lg = size === "lg";32  return (33    <div ref={box} className={`relative w-full ${lg ? "max-w-2xl" : "max-w-md"}`} role="search" aria-label={ariaLabel ?? (lg ? "Search the encyclopedia" : "Site search")}>34      <form onSubmit={(e) => { e.preventDefault(); go(); }} className="relative">35        <Search className={`pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-fg-3 ${lg ? "h-5 w-5" : "h-4 w-4"}`} aria-hidden />36        <input ref={input} id={id} value={q} onChange={(e) => setQ(e.target.value)} onFocus={() => hits.length && setOpen(true)} onKeyDown={onKey} autoFocus={autoFocus}37          type="search" name="q" autoComplete="off" spellCheck={false} placeholder={lg ? "Search tools, e.g. vector database, LaTeX, intraday data…" : "Search tools…"} aria-label="Search tools"38          role="combobox" aria-expanded={open} aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={active >= 0 ? `${id}-opt-${active}` : undefined}39          className={`input ${lg ? "h-14 rounded-2xl pl-12 pr-24 text-base shadow-soft" : "h-9 rounded-xl pl-9 pr-12 text-sm"}`} />40        <span className={`pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 sm:flex`}>{lg ? <><span className="kbd">/</span><span className="text-xs text-fg-3">to focus</span></> : <span className="kbd">/</span>}</span>41      </form>42      {open && hits.length > 0 && (43        <ul id={`${id}-list`} role="listbox" className="absolute z-50 mt-2 w-full overflow-hidden rounded-2xl border border-line bg-bg-2 p-1.5 shadow-lift">44          {hits.map((h, i) => (45            <li key={`${h.kind}-${h.slug}`} id={`${id}-opt-${i}`} role="option" aria-selected={i === active} onMouseEnter={() => setActive(i)} onMouseDown={(e) => { e.preventDefault(); go(h); }}46              className={`flex cursor-pointer items-start gap-3 rounded-xl px-3 py-2 ${i === active ? "bg-bg-3" : ""}`}>47              <span className={`mt-0.5 chip ${h.kind === "category" ? "chip-accent" : ""}`}>{h.kind}</span>48              <span className="min-w-0"><span className="block truncate text-sm font-medium">{h.name}</span><span className="block truncate text-xs text-fg-3">{h.summary}</span></span>49              {i === active && <CornerDownLeft className="ml-auto mt-1 h-3.5 w-3.5 shrink-0 text-fg-3" aria-hidden />}50            </li>51          ))}52          <li className="px-3 pt-1 pb-0.5 text-[11px] text-fg-3" aria-hidden>Enter to search all results · ↑↓ to navigate</li>53        </ul>54      )}55    </div>56  );57}58
Files · laptop-initial-20261102
Menu

Theme