351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
"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 <input> element */
|
|
inputClassName?: string;
|
|
/** Additional CSS classes for the outer wrapper div */
|
|
wrapperClassName?: string;
|
|
/** Additional CSS classes for the <form> 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 <mark> 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 (
|
|
<mark
|
|
key={i}
|
|
className="bg-leaf-green-200 dark:bg-leaf-green-700 text-leaf-green-900 dark:text-leaf-green-100 rounded px-0.5"
|
|
>
|
|
{part}
|
|
</mark>
|
|
);
|
|
}
|
|
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<Suggestion[]>([]);
|
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className={`relative ${wrapperClassName}`}>
|
|
<form
|
|
className={formClassName}
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
submitQuery();
|
|
}}
|
|
role="search"
|
|
>
|
|
<div className="relative">
|
|
<label htmlFor={inputId} className="sr-only">
|
|
{placeholder}
|
|
</label>
|
|
|
|
<input
|
|
ref={inputRef}
|
|
id={inputId}
|
|
type="search"
|
|
placeholder={placeholder}
|
|
value={query}
|
|
onChange={handleInputChange}
|
|
onFocus={() => {
|
|
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 && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2" aria-hidden="true">
|
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-300 border-t-leaf-green-600 dark:border-zinc-600 dark:border-t-leaf-green-400" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Suggestions dropdown */}
|
|
{showDropdown && suggestions.length > 0 && (
|
|
<div
|
|
ref={dropdownRef}
|
|
id={`${inputId}-listbox`}
|
|
role="listbox"
|
|
aria-label="Search suggestions"
|
|
className="absolute z-50 mt-1 w-full rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-lg overflow-hidden"
|
|
>
|
|
{suggestions.map((suggestion, index) => (
|
|
<button
|
|
key={`${suggestion.type}-${suggestion.id}`}
|
|
id={`${inputId}-option-${index}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={index === activeIndex}
|
|
onMouseDown={handleSuggestionClick(suggestion.href)}
|
|
className={`w-full flex items-center gap-3 px-3.5 py-2.5 text-left transition-colors ${
|
|
index === activeIndex
|
|
? "bg-leaf-green-50 dark:bg-leaf-green-900/30"
|
|
: "hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
|
}`}
|
|
>
|
|
{/* Emoji */}
|
|
<span className="text-xl shrink-0" aria-hidden="true">
|
|
{suggestion.emoji}
|
|
</span>
|
|
|
|
{/* Text */}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
|
|
{highlightMatch(suggestion.label, query)}
|
|
</div>
|
|
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate mt-0.5">
|
|
{highlightMatch(suggestion.subtitle, query)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Type badge */}
|
|
<span className="text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-500 shrink-0 ml-1">
|
|
{suggestion.type === "plant" ? "Plant" : "Disease"}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|