Files
IPFS-portal/src/components/search/SearchInput.tsx
T

120 lines
3.5 KiB
TypeScript
Raw Normal View History

'use client';
/* ── SearchInput ──
*
* Controlled search input with search icon, debounced search,
* loading spinner, clear button, and keyboard navigation handler.
*/
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
import { Search, X, Loader2 } from "lucide-react";
/* ── Types ── */
export interface SearchInputHandle {
clear: () => void;
focus: () => void;
blur: () => void;
}
interface SearchInputProps {
/** Called (debounced) when the user types */
onSearch: (query: string) => void;
/** Keyboard navigation handler (from parent orchestrator) */
onKeyDown: (e: React.KeyboardEvent) => void;
/** Called when input receives focus */
onFocus?: () => void;
/** Whether a search is in progress */
loading: boolean;
/** Whether there are results to show on focus */
hasResults: boolean;
/** Placeholder text */
placeholder?: string;
/** Auto-focus on mount */
autoFocus?: boolean;
}
/* ── Component ── */
const SearchInput = forwardRef<SearchInputHandle, SearchInputProps>(function SearchInput(
{ onSearch, onKeyDown, onFocus, loading, hasResults, placeholder = "Search files, CIDs, and content…", autoFocus = false },
ref,
) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* Expose clear + focus to parent */
useImperativeHandle(ref, () => ({
clear() {
setQuery("");
},
focus() {
inputRef.current?.focus();
},
blur() {
inputRef.current?.blur();
},
}));
/* ── Debounced input ── */
function handleChange(value: string) {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => onSearch(value), 250);
}
/* ── Cleanup ── */
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [autoFocus]);
/* ── Render ── */
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 pointer-events-none" />
<input
ref={inputRef}
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => {
if (hasResults) onFocus?.();
}}
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:border-brand-500 transition-colors"
autoFocus={autoFocus}
role="combobox"
aria-expanded={hasResults}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls="search-results-listbox"
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 animate-spin" />
)}
{!loading && query && (
<button
onClick={() => {
setQuery("");
if (debounceRef.current) clearTimeout(debounceRef.current);
inputRef.current?.focus();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
>
<X className="w-4 h-4 text-surface-500" />
</button>
)}
</div>
);
});
export default SearchInput;