289 lines
9.7 KiB
JavaScript
289 lines
9.7 KiB
JavaScript
import PropTypes from 'prop-types';
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Compass, Loader2, MapPin, Navigation, X } from 'lucide-react';
|
|
import { getAddressSuggestions, geocodeAddress as serviceGeocodeAddress, standardizePlace } from '@/lib/geocodingService';
|
|
|
|
export const toPlace = (result) => standardizePlace(result);
|
|
|
|
export const geocodeAddress = async (address, options = {}) => {
|
|
return await serviceGeocodeAddress(address, options);
|
|
};
|
|
|
|
const useDebouncedCallback = (callback, delay) => {
|
|
const timeoutRef = useRef(null);
|
|
return React.useCallback(
|
|
(...args) => {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = setTimeout(() => {
|
|
callback(...args);
|
|
}, delay);
|
|
},
|
|
[callback, delay]
|
|
);
|
|
};
|
|
|
|
const AddressAutocomplete = ({
|
|
id,
|
|
label,
|
|
placeholder = 'Search address or locality…',
|
|
value,
|
|
onChange,
|
|
onPlaceSelected,
|
|
onOpenMapPin,
|
|
bias,
|
|
fullWidth = true,
|
|
disabled = false,
|
|
className = '',
|
|
inputClassName = '',
|
|
required = false
|
|
}) => {
|
|
const [inputValue, setInputValue] = useState(value || '');
|
|
const [options, setOptions] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [activeOption, setActiveOption] = useState(-1);
|
|
|
|
const wrapRef = useRef(null);
|
|
const activeRef = useRef(0);
|
|
const skipNextSearchRef = useRef(false);
|
|
const listId = `${id || 'address'}-suggestions`;
|
|
const optionId = (index) => `${listId}-option-${index}`;
|
|
|
|
useEffect(() => {
|
|
if (value !== undefined && value !== inputValue) {
|
|
setInputValue(value || '');
|
|
}
|
|
}, [value]);
|
|
|
|
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
|
|
|
|
const fetchPredictions = useDebouncedCallback((query) => {
|
|
const seq = ++activeRef.current;
|
|
getAddressSuggestions(query, { bias, limit: 6 })
|
|
.then((results) => {
|
|
if (seq !== activeRef.current) return;
|
|
setLoading(false);
|
|
setOptions(results || []);
|
|
setIsOpen((results || []).length > 0);
|
|
})
|
|
.catch(() => {
|
|
if (seq !== activeRef.current) return;
|
|
setLoading(false);
|
|
setOptions([]);
|
|
});
|
|
}, 220);
|
|
|
|
useEffect(() => {
|
|
if (skipNextSearchRef.current) {
|
|
skipNextSearchRef.current = false;
|
|
return;
|
|
}
|
|
if (!inputValue || inputValue.length < 2) {
|
|
activeRef.current++;
|
|
setOptions([]);
|
|
setLoading(false);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
fetchPredictions(inputValue);
|
|
}, [inputValue, biasKey]);
|
|
|
|
useEffect(() => {
|
|
setActiveOption(-1);
|
|
}, [options]);
|
|
|
|
useEffect(() => {
|
|
const onDocDown = (e) => {
|
|
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', onDocDown);
|
|
return () => document.removeEventListener('mousedown', onDocDown);
|
|
}, []);
|
|
|
|
const pick = (option) => {
|
|
const formatted = option.formatted_address || option.name || '';
|
|
skipNextSearchRef.current = true;
|
|
activeRef.current++;
|
|
setInputValue(formatted);
|
|
onChange?.(formatted);
|
|
onPlaceSelected?.(option);
|
|
setIsOpen(false);
|
|
setActiveOption(-1);
|
|
};
|
|
|
|
const onKeyDown = (e) => {
|
|
const listOpen = isOpen && options.length > 0;
|
|
if (e.key === 'Escape') {
|
|
setIsOpen(false);
|
|
setActiveOption(-1);
|
|
return;
|
|
}
|
|
if (!listOpen) return;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActiveOption((current) => (current + 1) % options.length);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActiveOption((current) => (current <= 0 ? options.length - 1 : current - 1));
|
|
} else if (e.key === 'Enter' && activeOption >= 0) {
|
|
e.preventDefault();
|
|
pick(options[activeOption]);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={wrapRef} className={`relative ${className}`} style={{ width: fullWidth ? '100%' : undefined }}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
{label && (
|
|
<label htmlFor={id} className="block text-xs font-semibold text-slate-800">
|
|
{label} {required && <span className="text-red-500">*</span>}
|
|
</label>
|
|
)}
|
|
{onOpenMapPin && (
|
|
<button
|
|
type="button"
|
|
onClick={onOpenMapPin}
|
|
className="text-[11px] font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1 cursor-pointer transition-colors"
|
|
title="Choose point on map"
|
|
>
|
|
<Compass className="w-3 h-3" />
|
|
<span>Pin on Map</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<input
|
|
id={id}
|
|
type="text"
|
|
placeholder={placeholder}
|
|
value={inputValue}
|
|
disabled={disabled}
|
|
role="combobox"
|
|
aria-expanded={isOpen && options.length > 0}
|
|
aria-controls={listId}
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={activeOption >= 0 ? optionId(activeOption) : undefined}
|
|
onChange={(e) => {
|
|
setInputValue(e.target.value);
|
|
onChange?.(e.target.value);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
onKeyDown(e);
|
|
if (e.key === 'Escape') setIsOpen(false);
|
|
}}
|
|
className={`w-full h-9 pl-3 pr-16 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-black/10 focus:border-black transition-colors disabled:opacity-50 disabled:bg-slate-50 ${inputClassName}`}
|
|
/>
|
|
|
|
{/* Action icons on the right */}
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
{loading ? (
|
|
<span className="text-slate-400 p-0.5">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
</span>
|
|
) : inputValue && !disabled ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setInputValue('');
|
|
onChange?.('');
|
|
setIsOpen(false);
|
|
}}
|
|
className="text-slate-400 hover:text-slate-600 p-1 rounded-full hover:bg-slate-100 cursor-pointer"
|
|
title="Clear address"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
) : null}
|
|
|
|
{onOpenMapPin && !disabled && (
|
|
<button
|
|
type="button"
|
|
onClick={onOpenMapPin}
|
|
className="text-slate-400 hover:text-black p-1 rounded-md hover:bg-slate-100 transition-colors cursor-pointer"
|
|
title="Open map to pin exact location"
|
|
>
|
|
<Navigation className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isOpen && options.length > 0 && (
|
|
<ul
|
|
id={listId}
|
|
className="absolute z-[9999] left-0 right-0 mt-1.5 bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto divide-y divide-slate-100 animate-in fade-in zoom-in-95 duration-100"
|
|
role="listbox"
|
|
aria-label="Address suggestions"
|
|
>
|
|
{options.map((option, index) => {
|
|
const isHighlight = index === activeOption;
|
|
return (
|
|
<li
|
|
key={`${option.latitude}-${option.longitude}-${index}`}
|
|
id={optionId(index)}
|
|
role="option"
|
|
aria-selected={isHighlight}
|
|
className={isHighlight ? 'bg-slate-50' : undefined}
|
|
>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
onMouseEnter={() => setActiveOption(index)}
|
|
onClick={() => pick(option)}
|
|
className="w-full text-left px-3.5 py-2.5 hover:bg-slate-50/80 flex items-start gap-2.5 transition-colors cursor-pointer group"
|
|
>
|
|
<div className="w-6 h-6 rounded-md bg-slate-100 group-hover:bg-black text-slate-500 group-hover:text-white flex items-center justify-center shrink-0 mt-0.5 transition-colors">
|
|
<MapPin className="w-3.5 h-3.5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold text-slate-900 truncate">
|
|
{option.name || option.formatted_address.split(',')[0]}
|
|
</span>
|
|
{option.suburb && (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.2 rounded bg-slate-100 text-slate-600 shrink-0">
|
|
{option.suburb}
|
|
</span>
|
|
)}
|
|
{option.postcode && (
|
|
<span className="text-[10px] font-mono text-slate-400 shrink-0">
|
|
{option.postcode}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[11px] text-slate-500 line-clamp-1 mt-0.5">
|
|
{option.formatted_address}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
AddressAutocomplete.propTypes = {
|
|
id: PropTypes.string,
|
|
label: PropTypes.string,
|
|
placeholder: PropTypes.string,
|
|
value: PropTypes.string,
|
|
onChange: PropTypes.func,
|
|
onPlaceSelected: PropTypes.func,
|
|
onOpenMapPin: PropTypes.func,
|
|
bias: PropTypes.object,
|
|
fullWidth: PropTypes.bool,
|
|
disabled: PropTypes.bool,
|
|
className: PropTypes.string,
|
|
inputClassName: PropTypes.string,
|
|
required: PropTypes.bool
|
|
};
|
|
|
|
export default AddressAutocomplete;
|