309 lines
9.3 KiB
TypeScript
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;
|