import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; import { Loader2, MapPin, X } from 'lucide-react'; const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; 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 buildAddressComponents = (addr = {}) => { const components = []; const push = (longName, types) => { if (longName) components.push({ long_name: longName, short_name: longName, types }); }; push(addr.house_number, ['street_number']); push(addr.road || addr.pedestrian, ['route']); push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']); push(addr.city_district || addr.county, ['administrative_area_level_3']); push(addr.city || addr.town || addr.village, ['locality']); push(addr.state, ['administrative_area_level_1']); push(addr.country, ['country']); push(addr.postcode, ['postal_code']); return components; }; export const toPlace = (result) => ({ formatted_address: result.display_name || '', name: result.display_name?.split(',')[0] || '', geometry: { location: { lat: () => parseFloat(result.lat), lng: () => parseFloat(result.lon) } }, address_components: buildAddressComponents(result.address) }); const buildParams = (query, bias) => { const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' }); if (bias?.lat && bias?.lng) { const d = 0.5; // ~55km soft bias bounding box params.set('viewbox', `${bias.lng - d},${bias.lat + d},${bias.lng + d},${bias.lat - d}`); } return params; }; export async function geocodeAddress(address, { bias } = {}) { if (!address || !address.trim()) return null; try { const params = buildParams(address.trim(), bias); params.set('limit', '1'); const res = await fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' } }); if (!res.ok) return null; const results = await res.json(); if (!results?.length) return null; return toPlace(results[0]); } catch (err) { console.error('geocodeAddress error:', err); return null; } } const AddressAutocomplete = ({ id, label, placeholder = 'Search address…', value, onChange, onPlaceSelected, 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); /* Which suggestion the arrow keys are on. -1 is "none highlighted", the state the list opens in, so Enter never picks a result the operator hasn't moved to. */ const [activeOption, setActiveOption] = useState(-1); const wrapRef = useRef(null); const activeRef = useRef(0); // Set by pick(); consumed by the search effect. See the comment there. 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; const params = buildParams(query, bias); fetch(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json', 'Accept-Language': 'en-GB,en;q=0.9' } }) .then((res) => (res.ok ? res.json() : [])) .then((results) => { if (seq !== activeRef.current) return; setLoading(false); setOptions(results || []); setIsOpen((results || []).length > 0); }) .catch(() => { if (seq !== activeRef.current) return; setLoading(false); setOptions([]); }); }, 450); useEffect(() => { /* Picking a suggestion writes the resolved address back into inputValue, which used to re-trigger this search: the list reopened over the form the instant it was dismissed, and every selection cost a second Nominatim request against a service that asks for one per second. The pick is not a new query, so skip exactly one run. */ if (skipNextSearchRef.current) { skipNextSearchRef.current = false; return; } if (!inputValue || inputValue.length < 3) { activeRef.current++; setOptions([]); setLoading(false); setIsOpen(false); return; } setLoading(true); fetchPredictions(inputValue); }, [inputValue, biasKey]); /* A fresh result set invalidates the highlight — otherwise index 2 of the old list silently becomes index 2 of the new one. */ 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.display_name || ''; skipNextSearchRef.current = true; activeRef.current++; // drop any in-flight response that would reopen the list setInputValue(formatted); onChange?.(formatted); onPlaceSelected?.(toPlace(option)); setIsOpen(false); setActiveOption(-1); }; /** * Arrow keys, Enter and Escape over the suggestion list. * * Not a nicety: an order cannot be created from typed text — the submit gate * requires the coordinates that only picking a suggestion supplies — so * without this the whole create-order flow needed a mouse. */ 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) { // Only swallow Enter when a suggestion is actually highlighted, so Enter // on a bare query still submits the surrounding form. e.preventDefault(); pick(options[activeOption]); } }; return (
{label && ( )}
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-8 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}`} /> {loading ? ( ) : inputValue && !disabled ? ( ) : null}
{isOpen && options.length > 0 && ( )}
); }; AddressAutocomplete.propTypes = { id: PropTypes.string, label: PropTypes.string, placeholder: PropTypes.string, value: PropTypes.string, onChange: PropTypes.func, onPlaceSelected: PropTypes.func, bias: PropTypes.object, fullWidth: PropTypes.bool, disabled: PropTypes.bool, className: PropTypes.string, inputClassName: PropTypes.string, required: PropTypes.bool }; export default AddressAutocomplete;