search: return all matches (not just 100) + add typeahead autocomplete
- searchLegacyArticles() default limit: 50 → 5000. /search page
explicitly passes 5000 too. ilike on title/excerpt/author is fast
enough on the 29k-row archive that returning everything is fine.
- New API route /api/search/suggest?q=...&limit=8 returns the top
matching article titles + category + slug, with a 30-second CDN
cache + 60-second stale-while-revalidate.
- The search input in the Browse bar now debounces (150ms) and
fires that API per keystroke once 2+ chars are typed. Matches
appear in a styled dropdown below the input — click to navigate
directly to /news/<slug>, or press Enter to fall through to the
full /search page. "See all results for …" link in the dropdown
footer.
- Keyboard: Escape closes the dropdown; aria-autocomplete + role
listbox/option for accessibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,13 +37,35 @@ export default function Header() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<Array<{ slug: string; title: string; category?: string; href: string }>>([]);
|
||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||
|
||||
const submitSearch = () => {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
setMobileOpen(false);
|
||||
setSuggestOpen(false);
|
||||
router.push(`/search?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
// Autocomplete: debounced fetch to /api/search/suggest while the user types.
|
||||
useEffect(() => {
|
||||
const q = searchQuery.trim();
|
||||
if (q.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=8`, { cache: "no-store" })
|
||||
.then((r) => r.ok ? r.json() : { items: [] })
|
||||
.then((d) => {
|
||||
setSuggestions(d.items || []);
|
||||
setSuggestOpen(true);
|
||||
})
|
||||
.catch(() => setSuggestions([]));
|
||||
}, 150);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchQuery]);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [currentUserEmail, setCurrentUserEmail] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
@@ -483,33 +505,69 @@ export default function Header() {
|
||||
<div className="bb-browse-bar hidden md:block" role="navigation" aria-label="Browse sections">
|
||||
<div className="max-w-container mx-auto px-4">
|
||||
<div className="bb-browse-row">
|
||||
{/* Search (left) */}
|
||||
<div className="bb-browse-search">
|
||||
<SearchIcon size={14} strokeWidth={1.75} className="bb-browse-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
placeholder="Search broadcast news…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e?.target?.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitSearch();
|
||||
}
|
||||
}}
|
||||
aria-label="Search articles"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchQuery(""); searchRef.current?.focus(); }}
|
||||
aria-label="Clear search"
|
||||
className="bb-browse-search-clear">
|
||||
<CloseIcon size={11} />
|
||||
</button>
|
||||
{/* Search (left) — with autocomplete dropdown */}
|
||||
<div className="bb-browse-search-wrap">
|
||||
<div className="bb-browse-search">
|
||||
<SearchIcon size={14} strokeWidth={1.75} className="bb-browse-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
placeholder="Search broadcast news…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e?.target?.value)}
|
||||
onFocus={() => { setSearchFocused(true); if (suggestions.length > 0) setSuggestOpen(true); }}
|
||||
onBlur={() => { setSearchFocused(false); setTimeout(() => setSuggestOpen(false), 150); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitSearch();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setSuggestOpen(false);
|
||||
}
|
||||
}}
|
||||
aria-label="Search articles"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={suggestOpen}
|
||||
aria-controls="bb-search-suggest"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchQuery(""); setSuggestions([]); searchRef.current?.focus(); }}
|
||||
aria-label="Clear search"
|
||||
className="bb-browse-search-clear">
|
||||
<CloseIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{suggestOpen && suggestions.length > 0 && (
|
||||
<ul
|
||||
id="bb-search-suggest"
|
||||
role="listbox"
|
||||
className="bb-browse-search-suggest">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.slug} role="option">
|
||||
<Link
|
||||
href={s.href}
|
||||
className="bb-browse-search-suggest-item"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}>
|
||||
<span className="bb-browse-search-suggest-title">{s.title}</span>
|
||||
{s.category && <span className="bb-browse-search-suggest-cat">{s.category}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li role="option" className="bb-browse-search-suggest-all">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={submitSearch}>
|
||||
See all results for "{searchQuery}" →
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user