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