import { useState, useEffect, useRef, useCallback } from 'react'; import { searchUsers, UserSearchResult } from '../api'; import './UserAutocomplete.css'; interface UserAutocompleteProps { value: string; onChange: (username: string) => void; placeholder?: string; disabled?: boolean; autoFocus?: boolean; } export function UserAutocomplete({ value, onChange, placeholder = 'Search users...', disabled = false, autoFocus = false, }: UserAutocompleteProps) { const [query, setQuery] = useState(value); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const containerRef = useRef(null); const inputRef = useRef(null); const debounceRef = useRef>(); // Search for users with debounce const doSearch = useCallback(async (searchQuery: string) => { if (searchQuery.length < 1) { setResults([]); setIsOpen(false); return; } setLoading(true); try { const users = await searchUsers(searchQuery); setResults(users); setIsOpen(users.length > 0); setSelectedIndex(-1); } catch { setResults([]); setIsOpen(false); } finally { setLoading(false); } }, []); // Handle input change with debounce const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setQuery(newValue); onChange(newValue); // Update parent immediately for form validation // Debounce the search if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { doSearch(newValue); }, 200); }; // Handle selecting a user const handleSelect = (user: UserSearchResult) => { setQuery(user.username); onChange(user.username); setIsOpen(false); setResults([]); inputRef.current?.focus(); }; // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (!isOpen) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev)); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1)); break; case 'Enter': e.preventDefault(); if (selectedIndex >= 0 && results[selectedIndex]) { handleSelect(results[selectedIndex]); } break; case 'Escape': setIsOpen(false); break; } }; // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Sync external value changes useEffect(() => { setQuery(value); }, [value]); // Cleanup debounce on unmount useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, []); return (
query.length >= 1 && results.length > 0 && setIsOpen(true)} placeholder={placeholder} disabled={disabled} autoFocus={autoFocus} autoComplete="off" className="user-autocomplete__input" /> {loading && (
)}
{isOpen && results.length > 0 && (
    {results.map((user, index) => (
  • handleSelect(user)} onMouseEnter={() => setSelectedIndex(index)} >
    {user.username.charAt(0).toUpperCase()}
    {user.username} {user.is_admin && ( Admin )}
  • ))}
)}
); }