"use client"; import React, { useState, useCallback, useEffect, useRef, useId } from "react"; import { useRouter } from "next/navigation"; // ─── Types ─────────────────────────────────────────────────────────────────── interface Suggestion { type: "plant" | "disease"; id: string; label: string; subtitle: string; emoji: string; href: string; } export interface SearchSuggestionsProps { /** Placeholder text for the search input */ placeholder?: string; /** Additional CSS classes for the search element */ inputClassName?: string; /** Additional CSS classes for the outer wrapper div */ wrapperClassName?: string; /** Additional CSS classes for the
element */ formClassName?: string; /** Called after a suggestion is clicked or the search is submitted (e.g., to close a mobile drawer) */ onNavigate?: () => void; } // ─── Highlight helper ──────────────────────────────────────────────────────── /** * Splits `text` on case-insensitive occurrences of `query` and wraps each match * in a element so the user can see what part of the suggestion matched * their typed input. */ function highlightMatch(text: string, query: string): React.ReactNode { if (!query.trim()) return text; const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`(${escaped})`, "gi"); const parts = text.split(regex); const lowerQuery = query.toLowerCase(); return parts.map((part, i) => { if (part.toLowerCase() === lowerQuery) { return ( {part} ); } return part; }); } // ─── Component ─────────────────────────────────────────────────────────────── /** * Search-as-you-type input with a debounced suggestions dropdown. * * - Fetches suggestions from `/api/plants/suggestions?q=...` as the user types * - Displays results in a dropdown with keyboard navigation (↑↓ Enter Escape) * - Clicking a suggestion navigates directly to the plant or disease page * - Pressing Enter (when no suggestion is highlighted) navigates to the browse * page with the query as a search parameter */ export default function SearchSuggestions({ placeholder = "Search plants...", inputClassName = "", wrapperClassName = "", formClassName = "", onNavigate, }: SearchSuggestionsProps) { const router = useRouter(); const inputId = useId(); const [query, setQuery] = useState(""); const [suggestions, setSuggestions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); const inputRef = useRef(null); const dropdownRef = useRef(null); const debounceRef = useRef | null>(null); // ─── Fetch suggestions with debounce ───────────────────────────────────── useEffect(() => { const trimmed = query.trim(); // Empty query: don't fetch (the empty-input reset is handled in onChange). if (trimmed.length < 1) return; // Cancel any pending debounced fetch so we only fire the latest one. if (debounceRef.current) { clearTimeout(debounceRef.current); } // Track whether this particular effect cycle is still active, so stale // async responses don't overwrite later (or cleared) state. let cancelled = false; debounceRef.current = setTimeout(async () => { setIsLoading(true); try { const response = await fetch(`/api/plants/suggestions?q=${encodeURIComponent(trimmed)}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); const items: Suggestion[] = data.suggestions ?? []; if (!cancelled) { setSuggestions(items); setShowDropdown(items.length > 0); setActiveIndex(-1); } } catch { if (!cancelled) { setSuggestions([]); setShowDropdown(false); } } finally { if (!cancelled) { setIsLoading(false); } } }, 200); return () => { cancelled = true; if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, [query]); // ─── Close dropdown on outside click ───────────────────────────────────── useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( dropdownRef.current && inputRef.current && !dropdownRef.current.contains(e.target as Node) && !inputRef.current.contains(e.target as Node) ) { setShowDropdown(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // ─── Navigation helpers ────────────────────────────────────────────────── const navigate = useCallback( (href: string) => { setShowDropdown(false); setQuery(""); setSuggestions([]); setActiveIndex(-1); router.push(href); onNavigate?.(); }, [router, onNavigate], ); const submitQuery = useCallback(() => { const trimmed = query.trim(); if (trimmed) { navigate(`/browse?search=${encodeURIComponent(trimmed)}`); } else { navigate("/browse"); } }, [query, navigate]); // ─── Keyboard navigation ───────────────────────────────────────────────── const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!showDropdown || suggestions.length === 0) { if (e.key === "Enter") { e.preventDefault(); submitQuery(); } return; } switch (e.key) { case "ArrowDown": e.preventDefault(); setActiveIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0)); break; case "ArrowUp": e.preventDefault(); setActiveIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1)); break; case "Enter": e.preventDefault(); if (activeIndex >= 0 && activeIndex < suggestions.length) { navigate(suggestions[activeIndex].href); } else { submitQuery(); } break; case "Escape": e.preventDefault(); setShowDropdown(false); setActiveIndex(-1); inputRef.current?.blur(); break; } }, [showDropdown, suggestions, activeIndex, submitQuery, navigate], ); // ─── Suggestion click (uses mousedown so it fires before blur) ─────────── const handleSuggestionClick = useCallback( (href: string) => (e: React.MouseEvent) => { e.preventDefault(); navigate(href); }, [navigate], ); // ─── Input change handler: syncs query state AND resets suggestions // when the user clears the input (avoids doing setState in the effect). // ─────────────────────────────────────────────────────────────────────────── const handleInputChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setQuery(value); // When the input is cleared, immediately reset the suggestion state // instead of doing it inside the effect (which would trigger a // cascading-render warning). if (!value.trim()) { setSuggestions([]); setShowDropdown(false); setActiveIndex(-1); setIsLoading(false); } }, []); // ─── Render ────────────────────────────────────────────────────────────── return (
{ e.preventDefault(); submitQuery(); }} role="search" >
{ if (suggestions.length > 0) setShowDropdown(true); }} onKeyDown={handleKeyDown} className={inputClassName} autoComplete="off" aria-expanded={showDropdown} aria-haspopup="listbox" aria-autocomplete="list" aria-controls={showDropdown ? `${inputId}-listbox` : undefined} aria-activedescendant={ activeIndex >= 0 ? `${inputId}-option-${activeIndex}` : undefined } /> {/* Loading spinner */} {isLoading && ( {/* Suggestions dropdown */} {showDropdown && suggestions.length > 0 && (
{suggestions.map((suggestion, index) => ( ))}
)}
); }