feat(component): add TypeaheadInput component for enhanced text input with suggestion dropdown
This commit is contained in:
parent
f75db7e3bd
commit
dcac90109f
1 changed files with 280 additions and 0 deletions
280
src/components/TypeaheadInput.tsx
Normal file
280
src/components/TypeaheadInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Add table
Reference in a new issue