feat(component): add TypeaheadInput component for enhanced text input with suggestion dropdown

This commit is contained in:
ryan 2025-06-19 15:32:51 +03:00
parent f75db7e3bd
commit dcac90109f

View file

@ -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<WikidataSuggestion[]>;
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<WikidataSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(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<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
onInput(newValue);
debouncedFetchSuggestions(newValue);
}, [onInput, debouncedFetchSuggestions]);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 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 (
<div className="relative">
<input
ref={inputRef}
type="text"
value={localValue}
onChange={handleInput}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
className={baseInputClassName}
autoComplete="off"
/>
{/* Loading indicator */}
{isLoading && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
</div>
)}
{/* Suggestions dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto"
>
{suggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const label = suggestion.label || '';
const description = suggestion.description || '';
return (
<div
key={suggestion.id}
className={`
px-3 py-2 cursor-pointer border-b border-gray-100 last:border-b-0
${isSelected
? 'bg-blue-100 text-blue-900'
: 'hover:bg-gray-50'
}
`}
onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="font-medium text-sm">{label}</div>
{description && (
<div className="text-xs text-gray-600 mt-1 truncate">
{description}
</div>
)}
<div className="text-xs text-gray-400 mt-1">
ID: {suggestion.id}
</div>
</div>
);
})}
</div>
)}
</div>
);
}