This commit is contained in:
Marshal
2026-07-02 19:06:22 +00:00
parent 488a135054
commit 18c158fb61
4 changed files with 51 additions and 69 deletions

View File

@@ -17,6 +17,7 @@
"@edr/ui-common": "workspace:*", "@edr/ui-common": "workspace:*",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@mantine/core": "^9.3.0", "@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0", "@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0", "@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11", "@tanstack/react-query": "^5.100.11",

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@edr/ui-common/styles.css"; import "@edr/ui-common/styles.css";
import "../index.css"; import "../index.css";
import "@edr/ui-common/theme.css"; import "@edr/ui-common/theme.css";

View File

@@ -1,14 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Autocomplete,
Box, Box,
Button, Button,
Combobox,
Group, Group,
InputBase,
Loader, Loader,
Modal, Modal,
Text, Text,
useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { Check, MapPin, Search } from "lucide-react"; import { Check, MapPin, Search } from "lucide-react";
import { import {
@@ -265,7 +263,7 @@ export interface LocationPickerProps {
/** /**
* Address + map location picker backed by Google Maps: * Address + map location picker backed by Google Maps:
* - type to search (Geocoding API forward geocoding, debounced), * - type to search (Places Autocomplete, debounced),
* - or click anywhere on the map to drop a pin (reverse geocoding). * - or click anywhere on the map to drop a pin (reverse geocoding).
* Reports the resolved address and coordinates up via `onChange`. * Reports the resolved address and coordinates up via `onChange`.
*/ */
@@ -386,7 +384,6 @@ function LocationPickerInline({
mapHeight = 260, mapHeight = 260,
withinPortal = true, withinPortal = true,
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) { }: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
const combobox = useCombobox();
const geocoder = useGeocoder(); const geocoder = useGeocoder();
const places = usePlacesSearch(); const places = usePlacesSearch();
const placesLib = useMapsLibrary("places"); const placesLib = useMapsLibrary("places");
@@ -394,7 +391,6 @@ function LocationPickerInline({
const [results, setResults] = useState<PlacePrediction[]>([]); const [results, setResults] = useState<PlacePrediction[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false); const [resolving, setResolving] = useState(false);
const searchStaleRef = useRef<{ stale: boolean } | null>(null);
const reverseStaleRef = useRef<{ stale: boolean } | null>(null); const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
// One Autocomplete session groups every keystroke of a search with the final // One Autocomplete session groups every keystroke of a search with the final
// Details fetch into a single billable unit. Reset after each pick. // Details fetch into a single billable unit. Reset after each pick.
@@ -409,9 +405,8 @@ function LocationPickerInline({
// Debounced forward search — fires only after the user stops typing // Debounced forward search — fires only after the user stops typing
// (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather // (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
// than one per keystroke. The dropdown is kept open the whole time so the // than one per keystroke. While it runs, the input shows a spinner; the
// user sees the "Searching…" state and then the live results for what they // dropdown itself only appears once there are predictions to show.
// typed.
useEffect(() => { useEffect(() => {
const q = query.trim(); const q = query.trim();
if (q.length < MIN_QUERY_LEN) { if (q.length < MIN_QUERY_LEN) {
@@ -420,12 +415,10 @@ function LocationPickerInline({
return; return;
} }
setSearching(true); setSearching(true);
combobox.openDropdown();
if (!places) return; // re-runs once the places library loads if (!places) return; // re-runs once the places library loads
// Autocomplete has no abort support, so a token marks superseded requests // Autocomplete has no abort support, so a token marks superseded requests
// and their responses are dropped instead of overwriting newer results. // and their responses are dropped instead of overwriting newer results.
const token = { stale: false }; const token = { stale: false };
searchStaleRef.current = token;
const handle = setTimeout(async () => { const handle = setTimeout(async () => {
const found = await searchPlaces( const found = await searchPlaces(
places.autocomplete, places.autocomplete,
@@ -435,13 +428,12 @@ function LocationPickerInline({
if (token.stale) return; if (token.stale) return;
setResults(found); setResults(found);
setSearching(false); setSearching(false);
combobox.openDropdown();
}, SEARCH_DEBOUNCE_MS); }, SEARCH_DEBOUNCE_MS);
return () => { return () => {
clearTimeout(handle); clearTimeout(handle);
token.stale = true; token.stale = true;
}; };
}, [query, places, combobox]); }, [query, places]);
// Drop any in-flight reverse lookup when the picker unmounts. // Drop any in-flight reverse lookup when the picker unmounts.
useEffect( useEffect(
@@ -453,7 +445,10 @@ function LocationPickerInline({
const selectResult = useCallback( const selectResult = useCallback(
async (prediction: PlacePrediction) => { async (prediction: PlacePrediction) => {
combobox.closeDropdown(); // Clear the query/results immediately so the pending debounce can't fire
// a search for the picked address and pop the dropdown back open.
setQuery("");
setResults([]);
// Predictions carry no coordinates — resolve them now via Place Details. // Predictions carry no coordinates — resolve them now via Place Details.
if (!places) return; if (!places) return;
setResolving(true); setResolving(true);
@@ -474,10 +469,8 @@ function LocationPickerInline({
lat: resolved.lat, lat: resolved.lat,
lng: resolved.lng, lng: resolved.lng,
}); });
setQuery("");
setResults([]);
}, },
[onChange, combobox, places, placesLib], [onChange, places, placesLib],
); );
const handlePin = useCallback( const handlePin = useCallback(
@@ -515,60 +508,44 @@ function LocationPickerInline({
? { lat: value.lat as number, lng: value.lng as number } ? { lat: value.lat as number, lng: value.lng as number }
: DEFAULT_CENTER; : DEFAULT_CENTER;
// Mantine Autocomplete requires unique option values; predictions are keyed
// by their display text, so de-duplicate the rare identical descriptions.
const optionsByName = useMemo(() => {
const byName = new Map<string, PlacePrediction>();
for (const r of results) {
if (!byName.has(r.displayName)) byName.set(r.displayName, r);
}
return byName;
}, [results]);
return ( return (
<Box> <Box>
<Combobox <Autocomplete
store={combobox} label={label || undefined}
withinPortal={withinPortal} placeholder={placeholder}
shadow="md" value={inputValue}
radius="md" error={error}
> radius={10}
<Combobox.Target> styles={fieldStyles}
<InputBase leftSection={<Search size={16} />}
label={label || undefined} rightSection={searching || resolving ? <Loader size={14} /> : null}
placeholder={placeholder} data={[...optionsByName.keys()]}
value={inputValue} // Predictions are already ranked by the Places API for the typed
error={error} // query; Mantine's default substring filter would hide most of them.
radius={10} filter={({ options }) => options}
styles={fieldStyles} maxDropdownHeight={240}
leftSection={<Search size={16} />} comboboxProps={{ withinPortal, shadow: "md", radius: "md" }}
rightSection={searching || resolving ? <Loader size={14} /> : null} onChange={setQuery}
onChange={(e) => { onOptionSubmit={(name) => {
setQuery(e.currentTarget.value); const prediction = optionsByName.get(name);
combobox.openDropdown(); if (prediction) void selectResult(prediction);
}} }}
onFocus={() => { renderOption={({ option }) => (
if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown(); <Text fz={13} lineClamp={2}>
}} {option.value}
/> </Text>
</Combobox.Target> )}
/>
<Combobox.Dropdown>
<Combobox.Options mah={240} style={{ overflowY: "auto" }}>
{searching ? (
<Combobox.Empty>Searching {query.trim()}</Combobox.Empty>
) : results.length === 0 ? (
<Combobox.Empty>
{query.trim().length < MIN_QUERY_LEN
? `Type at least ${MIN_QUERY_LEN} characters`
: "No matching places"}
</Combobox.Empty>
) : (
results.map((r, i) => (
<Combobox.Option
key={`${r.placeId}-${i}`}
value={String(i)}
onClick={() => void selectResult(r)}
>
<Text fz={13} lineClamp={2}>
{r.displayName}
</Text>
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Box <Box
mt={10} mt={10}

3
pnpm-lock.yaml generated
View File

@@ -217,6 +217,9 @@ importers:
'@mantine/core': '@mantine/core':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates':
specifier: ^9.3.0
version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': '@mantine/hooks':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0(react@19.2.6) version: 9.3.0(react@19.2.6)