This commit is contained in:
2026-06-08 16:42:04 -04:00
commit 8bda14ab63
179 changed files with 48104 additions and 0 deletions

View File

@@ -0,0 +1,350 @@
"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>
);
}