Files
rentall-app/frontend/src/components/AddressAutocomplete.tsx
2025-09-09 22:49:55 -04:00

319 lines
9.3 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { placesService, AutocompletePrediction, PlaceDetails } from '../services/placesService';
export interface AddressAutocompleteProps {
value: string;
onChange: (value: string) => void;
onPlaceSelect?: (place: PlaceDetails) => void;
onError?: (error: string) => void;
placeholder?: string;
className?: string;
id?: string;
name?: string;
required?: boolean;
countryRestriction?: string;
types?: string[];
disabled?: boolean;
}
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
value,
onChange,
onPlaceSelect,
onError,
placeholder = "Enter address",
className = "form-control",
id,
name,
required = false,
countryRestriction,
types = ['address'],
disabled = false
}) => {
const [predictions, setPredictions] = useState<AutocompletePrediction[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isSelectingPlace, setIsSelectingPlace] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceTimer = useRef<number | undefined>(undefined);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setShowDropdown(false);
setSelectedIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, []);
// Fetch autocomplete predictions
const fetchPredictions = useCallback(async (query: string) => {
if (query.trim().length < 2 || isSelectingPlace) {
setPredictions([]);
return;
}
setLoading(true);
setError(null);
try {
const options = {
types,
componentRestrictions: countryRestriction ? { country: countryRestriction } : undefined
};
const results = await placesService.getAutocompletePredictions(query, options);
setPredictions(results);
} catch (err) {
console.error('Error fetching place predictions:', err);
const errorMessage = 'Failed to load suggestions';
setError(errorMessage);
setPredictions([]);
// Notify parent component of error
if (onError) {
onError(errorMessage);
}
} finally {
setLoading(false);
}
}, [types, countryRestriction, isSelectingPlace, onError]);
// Handle input change with debouncing
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
setShowDropdown(true);
setSelectedIndex(-1);
setIsSelectingPlace(false);
// Clear previous timer
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
// Debounce API calls
debounceTimer.current = window.setTimeout(() => {
fetchPredictions(newValue);
}, 300);
};
// Handle place selection
const handlePlaceSelect = async (prediction: AutocompletePrediction) => {
setIsSelectingPlace(true);
setLoading(true);
setShowDropdown(false);
setSelectedIndex(-1);
try {
const placeDetails = await placesService.getPlaceDetails(prediction.placeId);
onChange(placeDetails.formattedAddress);
if (onPlaceSelect) {
onPlaceSelect(placeDetails);
}
} catch (err) {
console.error('Error fetching place details:', err);
const errorMessage = 'Failed to load place details';
setError(errorMessage);
// Keep the description as fallback
onChange(prediction.description);
// Notify parent component of error
if (onError) {
onError(errorMessage);
}
} finally {
setLoading(false);
setIsSelectingPlace(false);
}
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showDropdown || predictions.length === 0) {
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev =>
prev < predictions.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < predictions.length) {
handlePlaceSelect(predictions[selectedIndex]);
}
break;
case 'Escape':
setShowDropdown(false);
setSelectedIndex(-1);
inputRef.current?.blur();
break;
default:
break;
}
};
// Handle input focus
const handleFocus = () => {
if (predictions.length > 0) {
setShowDropdown(true);
}
};
return (
<div ref={wrapperRef} className="position-relative">
<input
ref={inputRef}
type="text"
className={`${className} ${loading ? 'pe-5' : ''}`}
id={id}
name={name}
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
required={required}
disabled={disabled}
autoComplete="off"
aria-expanded={showDropdown}
aria-haspopup="listbox"
aria-owns={showDropdown ? `${id}-dropdown` : undefined}
aria-activedescendant={
selectedIndex >= 0 ? `${id}-option-${selectedIndex}` : undefined
}
/>
{/* Loading spinner */}
{loading && (
<div
className="position-absolute top-50 end-0 translate-middle-y me-3"
style={{ pointerEvents: 'none' }}
>
<div
className="spinner-border spinner-border-sm text-muted"
role="status"
style={{ width: '1rem', height: '1rem' }}
>
<span className="visually-hidden">Loading...</span>
</div>
</div>
)}
{/* Dropdown with predictions */}
{showDropdown && (predictions.length > 0 || error || loading) && (
<div
ref={dropdownRef}
id={`${id}-dropdown`}
className="position-absolute w-100 bg-white border rounded-bottom shadow-sm"
style={{
top: '100%',
zIndex: 1050,
maxHeight: '300px',
overflowY: 'auto',
borderTop: 'none',
borderTopLeftRadius: 0,
borderTopRightRadius: 0
}}
role="listbox"
>
{error && (
<div className="p-3 text-center text-danger">
<small>
<i className="bi bi-exclamation-triangle me-1"></i>
{error}
</small>
</div>
)}
{loading && predictions.length === 0 && !error && (
<div className="p-3 text-center text-muted">
<small>
<i className="bi bi-search me-1"></i>
Searching addresses...
</small>
</div>
)}
{predictions.map((prediction, index) => (
<div
key={prediction.placeId}
id={`${id}-option-${index}`}
className={`p-3 border-bottom cursor-pointer ${
index === selectedIndex ? 'bg-light' : ''
}`}
style={{ cursor: 'pointer' }}
onClick={() => handlePlaceSelect(prediction)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseLeave={() => setSelectedIndex(-1)}
role="option"
aria-selected={index === selectedIndex}
>
<div className="d-flex align-items-start">
<i className="bi bi-geo-alt text-muted me-2 mt-1" style={{ fontSize: '0.875rem' }}></i>
<div className="flex-grow-1 min-width-0">
<div className="text-truncate fw-medium">
{prediction.mainText}
</div>
{prediction.secondaryText && (
<div className="text-muted small text-truncate">
{prediction.secondaryText}
</div>
)}
</div>
</div>
</div>
))}
{predictions.length > 0 && (
<div className="p-2 text-center border-top bg-light">
<small className="text-muted d-flex align-items-center justify-content-center">
<img
src="https://developers.google.com/maps/documentation/places/web-service/images/powered_by_google_on_white.png"
alt="Powered by Google"
style={{ height: '12px' }}
/>
</small>
</div>
)}
</div>
)}
</div>
);
};
export default AddressAutocomplete;