re-init
This commit is contained in:
350
src/components/SearchSuggestions.tsx
Normal file
350
src/components/SearchSuggestions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user