frontend_nyla/src/components/UiComponents/AddressAutocomplete/AddressAutocomplete.tsx

309 lines
9.3 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import TextField, { BaseTextFieldProps } from '../TextField/TextField';
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
import styles from './AddressAutocomplete.module.css';
interface AddressAutocompleteProps extends BaseTextFieldProps {
onSelect?: (suggestion: AddressSuggestion) => void;
debounceMs?: number;
minQueryLength?: number;
maxSuggestions?: number;
}
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
value = '',
onChange,
onSelect,
placeholder,
disabled = false,
required = false,
readonly = false,
size = 'md',
error,
helperText,
label,
className = '',
type = 'text',
name,
id,
onKeyDown,
debounceMs = 300,
minQueryLength = 2,
maxSuggestions = 10,
...props
}) => {
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const [query, setQuery] = useState(value);
const [autocompleteError, setAutocompleteError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const suggestionsRef = useRef<HTMLUListElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Sync query with value prop
useEffect(() => {
setQuery(value);
}, [value]);
// Debounced search function
const performSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < minQueryLength) {
if (import.meta.env.DEV) {
console.log('🔍 [AddressAutocomplete] Query too short:', {
query: searchQuery,
length: searchQuery.length,
minLength: minQueryLength
});
}
setSuggestions([]);
setShowSuggestions(false);
setIsLoading(false);
return;
}
if (import.meta.env.DEV) {
console.log('🔍 [AddressAutocomplete] Starting search:', {
query: searchQuery,
length: searchQuery.length,
maxSuggestions: maxSuggestions
});
}
setIsLoading(true);
setAutocompleteError(null); // Clear previous errors
try {
const results = await autocompleteAddress(searchQuery, maxSuggestions);
if (import.meta.env.DEV) {
console.log('✅ [AddressAutocomplete] Search completed:', {
query: searchQuery,
resultCount: results.length,
results: results.slice(0, 3) // Log first 3
});
}
setSuggestions(results);
setShowSuggestions(results.length > 0 || true); // Show dropdown even if empty to show "no results" or error
setSelectedIndex(-1);
setAutocompleteError(null); // Clear any previous errors on success
} catch (err: any) {
console.error('❌ [AddressAutocomplete] Error in performSearch:', err);
const errorMessage = err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Adressvorschläge';
setAutocompleteError(errorMessage);
setSuggestions([]);
setShowSuggestions(true); // Show dropdown to display error
setSelectedIndex(-1);
} finally {
setIsLoading(false);
}
}, [minQueryLength, maxSuggestions]);
// Handle input change with debouncing
const handleInputChange = useCallback((newValue: string) => {
if (import.meta.env.DEV) {
console.log('⌨️ [AddressAutocomplete] Input changed:', {
newValue: newValue,
length: newValue.length,
willSearch: newValue.length >= minQueryLength
});
}
setQuery(newValue);
setAutocompleteError(null); // Clear error on new input
// Update parent component immediately
if (onChange) {
onChange(newValue);
}
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer for debounced search
debounceTimerRef.current = setTimeout(() => {
if (import.meta.env.DEV) {
console.log('⏱️ [AddressAutocomplete] Debounce timer fired, calling performSearch');
}
performSearch(newValue);
}, debounceMs);
}, [onChange, debounceMs, performSearch, minQueryLength]);
// Handle suggestion selection
const handleSelectSuggestion = useCallback((suggestion: AddressSuggestion) => {
setQuery(suggestion.value);
setShowSuggestions(false);
setSelectedIndex(-1);
if (onChange) {
onChange(suggestion.value);
}
if (onSelect) {
onSelect(suggestion);
}
}, [onChange, onSelect]);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!showSuggestions || suggestions.length === 0) {
if (onKeyDown) {
onKeyDown(e);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev =>
prev < suggestions.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
break;
case 'Enter':
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
e.preventDefault();
handleSelectSuggestion(suggestions[selectedIndex]);
} else if (onKeyDown) {
onKeyDown(e);
}
break;
case 'Escape':
e.preventDefault();
setShowSuggestions(false);
setSelectedIndex(-1);
break;
default:
if (onKeyDown) {
onKeyDown(e);
}
}
}, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]);
// Click outside handler
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
setSelectedIndex(-1);
}
};
if (showSuggestions) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showSuggestions]);
// Scroll selected item into view
useEffect(() => {
if (selectedIndex >= 0 && suggestionsRef.current) {
const selectedElement = suggestionsRef.current.children[selectedIndex] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
}
}
}, [selectedIndex]);
// Highlight matching text in suggestion
const highlightText = (text: string, query: string): React.ReactNode => {
if (!query || query.length < minQueryLength) {
return text;
}
const parts = text.split(new RegExp(`(${query})`, 'gi'));
return (
<>
{parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={index} className={styles.highlight}>{part}</mark>
) : (
<span key={index}>{part}</span>
)
)}
</>
);
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return (
<div ref={containerRef} className={`${styles.autocompleteContainer} ${className}`}>
<TextField
value={query}
onChange={handleInputChange}
placeholder={placeholder}
disabled={disabled}
required={required}
readonly={readonly}
size={size}
error={undefined}
helperText={helperText}
label={label}
type={type}
name={name}
id={id}
onKeyDown={handleKeyDown}
{...props}
/>
{showSuggestions && (
<div className={styles.suggestionsWrapper}>
<ul ref={suggestionsRef} className={styles.suggestionsList}>
{isLoading && (
<li className={styles.suggestionItem}>
<span className={styles.loadingText}>Suche Adressen...</span>
</li>
)}
{!isLoading && autocompleteError && (
<li className={styles.suggestionItem}>
<span className={styles.errorText}>{autocompleteError}</span>
</li>
)}
{!isLoading && !autocompleteError && suggestions.length === 0 && query.length >= minQueryLength && (
<li className={styles.suggestionItem}>
<span className={styles.noResultsText}>Keine Adressen gefunden</span>
</li>
)}
{!isLoading && suggestions.map((suggestion, index) => (
<li
key={`${suggestion.value}-${index}`}
className={`${styles.suggestionItem} ${
index === selectedIndex ? styles.suggestionItemSelected : ''
}`}
onClick={() => handleSelectSuggestion(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={styles.suggestionText}>
{highlightText(suggestion.label, query)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default AddressAutocomplete;