chrysopedia/frontend/src/components/SearchAutocomplete.tsx
jlightner 39e169b4ce feat: Split 945-line public-client.ts into 10 domain API modules with s…
- "frontend/src/api/client.ts"
- "frontend/src/api/index.ts"
- "frontend/src/api/search.ts"
- "frontend/src/api/techniques.ts"
- "frontend/src/api/creators.ts"
- "frontend/src/api/topics.ts"
- "frontend/src/api/stats.ts"
- "frontend/src/api/reports.ts"

GSD-Task: S05/T01
2026-04-03 23:04:56 +00:00

263 lines
8.6 KiB
TypeScript

/**
* 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<SearchResultItem[]>([]);
const [popularSuggestions, setPopularSuggestions] = useState<SuggestionItem[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropdownRef = useRef<HTMLDivElement>(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<string, string> = {
technique: "Technique",
topic: "Topic",
creator: "Creator",
technique_page: "Technique",
key_moment: "Key Moment",
technique_section: "Section",
};
return (
<div className={`search-container${variant === 'nav' ? ' search-container--nav' : ''}`} ref={dropdownRef}>
<form
onSubmit={handleSubmit}
className={`search-form search-form--${variant}`}
>
<input
ref={inputRef}
type="search"
className={`search-input search-input--${variant}`}
placeholder={placeholder}
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
aria-label="Search techniques"
/>
{variant !== 'nav' && (
<button type="submit" className="btn btn--search">
Search
</button>
)}
{variant === 'nav' && (
<kbd className="search-nav__shortcut">CtrlF</kbd>
)}
</form>
{showDropdown && (showPopular || showSearch) && (
<div className="typeahead-dropdown">
{showPopular && (
<>
<div className="typeahead-suggestions-header">Popular</div>
{popularSuggestions.map((item) => (
<button
key={`${item.type}-${item.text}`}
className="typeahead-suggestion"
onClick={() => handleSuggestionClick(item.text)}
type="button"
>
<span className="typeahead-suggestion__text">{item.text}</span>
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
{typeLabel[item.type] ?? item.type}
</span>
</button>
))}
</>
)}
{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 (
<Link
key={`${item.type}-${item.slug}-${item.section_anchor ?? ""}`}
to={linkTo}
className="typeahead-item"
onClick={() => setShowDropdown(false)}
>
<span className="typeahead-item__title">{item.title}</span>
{item.type === "technique_section" && item.section_heading && (
<span className="typeahead-item__section">§ {item.section_heading}</span>
)}
<span className="typeahead-item__meta">
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
{typeLabel[item.type] ?? item.type}
</span>
{item.creator_name && (
<span className="typeahead-item__creator">
{item.creator_name}
</span>
)}
</span>
</Link>
);
})}
<Link
to={`/search?q=${encodeURIComponent(query)}`}
className="typeahead-see-all"
onClick={() => setShowDropdown(false)}
>
See all results for &ldquo;{query}&rdquo;
</Link>
</>
)}
</div>
)}
</div>
);
}