initial commit
This commit is contained in:
@@ -1,121 +1,219 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
const useDebouncedCallback = (callback, delay) => { const timeoutRef = React.useRef(null); return React.useCallback((...args) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { callback(...args); }, delay); }, [callback, delay]); };
|
||||
import { Loader2, MapPin, X } from 'lucide-react';
|
||||
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
|
||||
const toPlace = (nom) => {
|
||||
const c = nom.address || {};
|
||||
const addr = [
|
||||
{ long_name: c.house_number || '', short_name: c.house_number || '', types: ['street_number'] },
|
||||
{ long_name: c.road || '', short_name: c.road || '', types: ['route'] },
|
||||
{ long_name: c.city || c.town || c.village || '', short_name: c.city || c.town || c.village || '', types: ['locality', 'political'] },
|
||||
{ long_name: c.county || c.state_district || '', short_name: c.county || c.state_district || '', types: ['administrative_area_level_2', 'political'] },
|
||||
{ long_name: c.state || '', short_name: c.state || '', types: ['administrative_area_level_1', 'political'] },
|
||||
{ long_name: c.country || '', short_name: c.country_code?.toUpperCase() || '', types: ['country', 'political'] },
|
||||
{ long_name: c.postcode || '', short_name: c.postcode || '', types: ['postal_code'] }
|
||||
].filter((p) => p.long_name);
|
||||
|
||||
return {
|
||||
address_components: addr,
|
||||
formatted_address: nom.display_name,
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => Number(nom.lat),
|
||||
lng: () => Number(nom.lon)
|
||||
}
|
||||
}
|
||||
};
|
||||
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,
|
||||
placeholder = 'Search address…',
|
||||
value,
|
||||
onChange,
|
||||
onPlaceSelected,
|
||||
bias,
|
||||
fullWidth = true,
|
||||
disabled = false,
|
||||
status
|
||||
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 abortCtrl = 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(() => {
|
||||
setInputValue(value || '');
|
||||
if (value !== undefined && value !== inputValue) {
|
||||
setInputValue(value || '');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const biasKey = bias ? `${bias.lat},${bias.lng}` : '';
|
||||
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
|
||||
|
||||
const runSearch = useDebouncedCallback(async (query) => {
|
||||
if (!query || query.length < 3) {
|
||||
setOptions([]);
|
||||
setIsOpen(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortCtrl.current) abortCtrl.current.abort();
|
||||
abortCtrl.current = new AbortController();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = new URL(NOMINATIM_SEARCH_URL);
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('addressdetails', '1');
|
||||
url.searchParams.set('limit', '5');
|
||||
|
||||
if (bias && bias.lat && bias.lng) {
|
||||
url.searchParams.set('lat', bias.lat);
|
||||
url.searchParams.set('lon', bias.lng);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'Accept-Language': 'en-GB,en;q=0.9' },
|
||||
signal: abortCtrl.current.signal
|
||||
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([]);
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const data = await res.json();
|
||||
setOptions(data || []);
|
||||
setIsOpen((data || []).length > 0);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('Nominatim search error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 500);
|
||||
}, 450);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue && inputValue !== value) runSearch(inputValue);
|
||||
/* 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);
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocDown);
|
||||
return () => document.removeEventListener('mousedown', onDocDown);
|
||||
}, []);
|
||||
|
||||
const pick = (option) => {
|
||||
setInputValue(option.display_name || '');
|
||||
onChange?.(option.display_name || '');
|
||||
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 (
|
||||
<div ref={wrapRef} className="relative" style={{ width: fullWidth ? '100%' : undefined }}>
|
||||
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||
<div ref={wrapRef} className={`relative ${className}`} style={{ width: fullWidth ? '100%' : undefined }}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="block text-xs font-semibold text-slate-800 mb-1">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
@@ -123,32 +221,76 @@ const AddressAutocomplete = ({
|
||||
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="flex h-10 w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
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 && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span className="w-4 h-4 rounded-full border-2 border-t-2 border-gray-200 border-t-blue-500 animate-spin"></span>
|
||||
|
||||
{loading ? (
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
) : inputValue && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputValue('');
|
||||
onChange?.('');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 p-0.5 rounded-full hover:bg-slate-100"
|
||||
title="Clear address"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isOpen && options.length > 0 && (
|
||||
<ul className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto" role="listbox">
|
||||
{options.map((option) => (
|
||||
<li key={option.place_id}>
|
||||
<ul
|
||||
id={listId}
|
||||
className="absolute z-[9999] left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-xl max-h-60 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) => (
|
||||
/* The option role sits on the <li>, which is what aria-activedescendant
|
||||
points at; the button inside stays for the mouse. Without this the
|
||||
popup announced as an empty listbox. */
|
||||
<li
|
||||
key={option.place_id}
|
||||
id={optionId(index)}
|
||||
role="option"
|
||||
aria-selected={index === activeOption}
|
||||
className={index === activeOption ? 'bg-slate-100' : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => setActiveOption(index)}
|
||||
onClick={() => pick(option)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 flex items-start gap-2.5 transition-colors cursor-pointer group"
|
||||
>
|
||||
<span className="block truncate">{option.display_name}</span>
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 group-hover:text-black mt-0.5 shrink-0 transition-colors" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-slate-800 truncate">
|
||||
{option.display_name?.split(',')[0]}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 line-clamp-1">
|
||||
{option.display_name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -168,30 +310,9 @@ AddressAutocomplete.propTypes = {
|
||||
bias: PropTypes.object,
|
||||
fullWidth: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
status: PropTypes.object
|
||||
className: PropTypes.string,
|
||||
inputClassName: PropTypes.string,
|
||||
required: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AddressAutocomplete;
|
||||
|
||||
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
const url = new URL(NOMINATIM_SEARCH_URL);
|
||||
url.searchParams.set('q', address);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('addressdetails', '1');
|
||||
url.searchParams.set('limit', '1');
|
||||
if (bias && bias.lat && bias.lng) {
|
||||
url.searchParams.set('lat', bias.lat);
|
||||
url.searchParams.set('lon', bias.lng);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url.toString(), { headers: { 'Accept-Language': 'en-GB,en;q=0.9' } });
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) return toPlace(data[0]);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user