From dcac90109f9a3f56a7625aca48e4d989d52f33e8 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 19 Jun 2025 15:32:51 +0300 Subject: [PATCH] feat(component): add TypeaheadInput component for enhanced text input with suggestion dropdown --- src/components/TypeaheadInput.tsx | 280 ++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 src/components/TypeaheadInput.tsx diff --git a/src/components/TypeaheadInput.tsx b/src/components/TypeaheadInput.tsx new file mode 100644 index 0000000..35e4a94 --- /dev/null +++ b/src/components/TypeaheadInput.tsx @@ -0,0 +1,280 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { WikidataSuggestion } from '@/types/database'; + +interface TypeaheadInputProps { + value: string; + onInput: (value: string) => void; + onSelect: (suggestion: WikidataSuggestion) => void; + fetchSuggestions: (query: string) => Promise; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + className?: string; +} + +export function TypeaheadInput({ + value, + onInput, + onSelect, + fetchSuggestions, + onFocus, + onBlur, + placeholder = '', + className = '' +}: TypeaheadInputProps) { + const [localValue, setLocalValue] = useState(value); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + const inputRef = useRef(null); + const suggestionsRef = useRef(null); + const timeoutRef = useRef(undefined); + + // Update local value when prop changes + useEffect(() => { + setLocalValue(value); + }, [value]); + + // Debounced suggestion fetching + const debouncedFetchSuggestions = useCallback(async (query: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(async () => { + if (query.trim().length < 2) { + setSuggestions([]); + setShowSuggestions(false); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + console.log('Fetching suggestions for:', query); + const results = await fetchSuggestions(query); + setSuggestions(results); + setShowSuggestions(results.length > 0); + setSelectedIndex(-1); + } catch (error) { + console.error('Error fetching suggestions:', error); + setSuggestions([]); + setShowSuggestions(false); + } finally { + setIsLoading(false); + } + }, 300); // 300ms debounce + }, [fetchSuggestions]); + + // Handle input changes + const handleInput = useCallback((e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalValue(newValue); + onInput(newValue); + debouncedFetchSuggestions(newValue); + }, [onInput, debouncedFetchSuggestions]); + + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!showSuggestions || suggestions.length === 0) { + if (e.key === 'Enter') { + e.preventDefault(); + // Commit the current value + onInput(localValue); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < suggestions.length - 1 ? prev + 1 : 0 + ); + break; + + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => + prev > 0 ? prev - 1 : suggestions.length - 1 + ); + break; + + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + const selectedSuggestion = suggestions[selectedIndex]; + handleSelect(selectedSuggestion); + } else { + // No suggestion selected, just commit the current value + onInput(localValue); + setShowSuggestions(false); + } + break; + + case 'Escape': + e.preventDefault(); + setShowSuggestions(false); + setSelectedIndex(-1); + inputRef.current?.blur(); + break; + + case 'Tab': + // Allow tab to close suggestions + setShowSuggestions(false); + setSelectedIndex(-1); + break; + } + }, [showSuggestions, suggestions, selectedIndex, localValue, onInput]); + + // Handle suggestion selection + const handleSelect = useCallback((suggestion: WikidataSuggestion) => { + console.log('Suggestion selected:', suggestion); + + // Update local value with the selected suggestion + const newValue = suggestion.label || ''; + setLocalValue(newValue); + onInput(newValue); + + // Call the selection handler + onSelect(suggestion); + + // Hide suggestions + setShowSuggestions(false); + setSelectedIndex(-1); + + // Keep focus on input + inputRef.current?.focus(); + }, [onInput, onSelect]); + + // Handle focus + const handleFocus = useCallback(() => { + console.log('TypeaheadInput focused'); + onFocus?.(); + + // Show suggestions if we have them and there's a value + if (suggestions.length > 0 && localValue.trim().length >= 2) { + setShowSuggestions(true); + } + }, [onFocus, suggestions.length, localValue]); + + // Handle blur + const handleBlur = useCallback((e: React.FocusEvent) => { + // Delay hiding suggestions to allow for clicks on suggestions + setTimeout(() => { + // Check if the new focus target is within our suggestions + const relatedTarget = e.relatedTarget as HTMLElement; + if (!suggestionsRef.current?.contains(relatedTarget)) { + setShowSuggestions(false); + setSelectedIndex(-1); + onBlur?.(); + } + }, 150); + }, [onBlur]); + + // Handle clicking on suggestions + const handleSuggestionClick = useCallback((suggestion: WikidataSuggestion) => { + handleSelect(suggestion); + }, [handleSelect]); + + // Handle clicking outside to close suggestions + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if ( + inputRef.current && + !inputRef.current.contains(target) && + suggestionsRef.current && + !suggestionsRef.current.contains(target) + ) { + setShowSuggestions(false); + setSelectedIndex(-1); + } + }; + + if (showSuggestions) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showSuggestions]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const baseInputClassName = ` + w-full px-2 py-1 border border-gray-300 rounded + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + ${className} + `.trim(); + + return ( +
+ + + {/* Loading indicator */} + {isLoading && ( +
+
+
+ )} + + {/* Suggestions dropdown */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => { + const isSelected = index === selectedIndex; + const label = suggestion.label || ''; + const description = suggestion.description || ''; + + return ( +
handleSuggestionClick(suggestion)} + onMouseEnter={() => setSelectedIndex(index)} + > +
{label}
+ {description && ( +
+ {description} +
+ )} +
+ ID: {suggestion.id} +
+
+ ); + })} +
+ )} +
+ ); +} \ No newline at end of file