import { useState, useRef, useEffect, ReactNode } from "react"; import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core"; import { useDebouncedCallback } from "@mantine/hooks"; export interface TypeaheadOption { id: string; data: T; } export interface TypeaheadProps { onSelect: (option: TypeaheadOption) => void; searchFn: (query: string) => Promise[]>; renderOption: (option: TypeaheadOption, isSelected?: boolean) => ReactNode; format?: (option: TypeaheadOption) => string; placeholder?: string; debounceMs?: number; disabled?: boolean; initialValue?: string; } const Typeahead = ({ onSelect, searchFn, renderOption, format, placeholder = "Search...", debounceMs = 300, disabled = false, initialValue = "" }: TypeaheadProps) => { const [searchQuery, setSearchQuery] = useState(initialValue); const [searchResults, setSearchResults] = useState[]>([]); const [isLoading, setIsLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const containerRef = useRef(null); const inputRef = useRef(null); const debouncedSearch = useDebouncedCallback(async (query: string) => { if (!query.trim()) { setSearchResults([]); setIsOpen(false); return; } setIsLoading(true); try { const results = await searchFn(query); setSearchResults(results); setIsOpen(results.length > 0); setSelectedIndex(-1); } catch (error) { console.error('Search failed:', error); setSearchResults([]); setIsOpen(false); } finally { setIsLoading(false); } }, debounceMs); const handleSearchChange = (value: string) => { setSearchQuery(value); debouncedSearch(value); }; const handleOptionSelect = (option: TypeaheadOption) => { onSelect(option); const displayValue = format ? format(option) : String(option.data); setSearchQuery(displayValue); setIsOpen(false); setSelectedIndex(-1); }; useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleKeyDown = (e: React.KeyboardEvent) => { if (!isOpen || searchResults.length === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev)); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev)); break; case 'Enter': e.preventDefault(); if (selectedIndex >= 0 && searchResults[selectedIndex]) { handleOptionSelect(searchResults[selectedIndex]); } break; case 'Escape': setIsOpen(false); setSelectedIndex(-1); break; } }; return ( handleSearchChange(event.currentTarget.value)} onKeyDown={handleKeyDown} onFocus={() => { if (searchResults.length > 0) setIsOpen(true); }} placeholder={placeholder} rightSection={isLoading ? : null} disabled={disabled} /> {isOpen && ( e.stopPropagation()} > {searchResults.length > 0 ? ( {searchResults.map((option, index) => ( handleOptionSelect(option)} onMouseEnter={() => setSelectedIndex(index)} > {renderOption(option, selectedIndex === index)} ))} ) : ( {searchQuery.trim() ? 'No results found' : 'Start typing to search...'} )} )} ); }; export default Typeahead;