/** * Shared search autocomplete component. * * - On focus with empty input: shows popular suggestions from /api/v1/search/suggestions * - On 2+ chars: shows debounced typeahead search results (top 5) * - Escape / outside-click closes dropdown * - Enter submits via onSearch callback */ import { useCallback, useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { searchApi, fetchSuggestions, type SearchResultItem, type SuggestionItem, } from "../api"; interface SearchAutocompleteProps { onSearch: (query: string) => void; placeholder?: string; /** @deprecated Use variant="hero" instead */ heroSize?: boolean; variant?: 'hero' | 'inline' | 'nav'; initialQuery?: string; autoFocus?: boolean; /** When true, Ctrl+Shift+F focuses the input globally */ globalShortcut?: boolean; } export default function SearchAutocomplete({ onSearch, placeholder = "Search techniques…", heroSize = false, variant: variantProp, initialQuery = "", autoFocus = false, globalShortcut = false, }: SearchAutocompleteProps) { // Resolve variant: explicit prop wins, then legacy heroSize, then default 'inline' const variant = variantProp ?? (heroSize ? 'hero' : 'inline'); const [query, setQuery] = useState(initialQuery); const [searchResults, setSearchResults] = useState([]); const [popularSuggestions, setPopularSuggestions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const inputRef = useRef(null); const debounceRef = useRef | null>(null); const dropdownRef = useRef(null); const suggestionsLoadedRef = useRef(false); // Sync initialQuery when URL changes (e.g. SearchResults page) useEffect(() => { setQuery(initialQuery); }, [initialQuery]); // Fetch popular suggestions once on mount useEffect(() => { if (suggestionsLoadedRef.current) return; suggestionsLoadedRef.current = true; void (async () => { try { const res = await fetchSuggestions(); setPopularSuggestions(res.suggestions); } catch { // Non-critical — autocomplete still works without popular suggestions } })(); }, []); // Auto-focus useEffect(() => { if (autoFocus) inputRef.current?.focus(); }, [autoFocus]); // Global Ctrl+Shift+F shortcut to focus input useEffect(() => { if (!globalShortcut) return; function handleGlobalKey(e: KeyboardEvent) { if (e.key === "F" && e.ctrlKey && e.shiftKey) { e.preventDefault(); inputRef.current?.focus(); } } document.addEventListener("keydown", handleGlobalKey); return () => document.removeEventListener("keydown", handleGlobalKey); }, [globalShortcut]); // Close dropdown on outside click useEffect(() => { function handleClick(e: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setShowDropdown(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); // Debounced typeahead search const handleInputChange = useCallback((value: string) => { setQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); if (value.length < 2) { setSearchResults([]); // Show popular suggestions if input is empty and we have them if (value.length === 0 && popularSuggestions.length > 0) { setShowDropdown(true); } else { setShowDropdown(false); } return; } debounceRef.current = setTimeout(() => { void (async () => { try { const res = await searchApi(value, undefined, 5); setSearchResults(res.items); setShowDropdown(res.items.length > 0); } catch { setSearchResults([]); setShowDropdown(false); } })(); }, 300); }, [popularSuggestions.length]); function handleFocus() { if (query.length === 0 && popularSuggestions.length > 0) { setShowDropdown(true); } else if (query.length >= 2 && searchResults.length > 0) { setShowDropdown(true); } } function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (query.trim()) { setShowDropdown(false); onSearch(query.trim()); } } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Escape") { setShowDropdown(false); } } function handleSuggestionClick(text: string) { setShowDropdown(false); setQuery(text); onSearch(text); } const showPopular = query.length < 2 && popularSuggestions.length > 0; const showSearch = query.length >= 2 && searchResults.length > 0; const typeLabel: Record = { technique: "Technique", topic: "Topic", creator: "Creator", technique_page: "Technique", key_moment: "Key Moment", technique_section: "Section", }; return (
handleInputChange(e.target.value)} onFocus={handleFocus} onKeyDown={handleKeyDown} aria-label="Search techniques" /> {variant !== 'nav' && ( )} {variant === 'nav' && ( Ctrl⇧F )}
{showDropdown && (showPopular || showSearch) && (
{showPopular && ( <>
Popular
{popularSuggestions.map((item) => ( ))} )} {showSearch && ( <> {searchResults.map((item) => { let linkTo = `/techniques/${item.slug}`; if (item.type === "technique_section" && item.technique_page_slug) { linkTo = `/techniques/${item.technique_page_slug}${item.section_anchor ? `#${item.section_anchor}` : ""}`; } else if (item.type === "key_moment" && item.technique_page_slug) { linkTo = `/techniques/${item.technique_page_slug}#km-${item.slug || item.title}`; } return ( setShowDropdown(false)} > {item.title} {item.type === "technique_section" && item.section_heading && ( § {item.section_heading} )} {typeLabel[item.type] ?? item.type} {item.creator_name && ( {item.creator_name} )} ); })} setShowDropdown(false)} > See all results for “{query}” )}
)}
); }