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:*",
"@hello-pangea/dnd": "^18.0.1",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0",
"@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 { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@edr/ui-common/styles.css";
import "../index.css";
import "@edr/ui-common/theme.css";

View File

@@ -1,14 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
Box,
Button,
Combobox,
Group,
InputBase,
Loader,
Modal,
Text,
useCombobox,
} from "@mantine/core";
import { Check, MapPin, Search } from "lucide-react";
import {
@@ -265,7 +263,7 @@ export interface LocationPickerProps {
/**
* 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).
* Reports the resolved address and coordinates up via `onChange`.
*/
@@ -386,7 +384,6 @@ function LocationPickerInline({
mapHeight = 260,
withinPortal = true,
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
const combobox = useCombobox();
const geocoder = useGeocoder();
const places = usePlacesSearch();
const placesLib = useMapsLibrary("places");
@@ -394,7 +391,6 @@ function LocationPickerInline({
const [results, setResults] = useState<PlacePrediction[]>([]);
const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false);
const searchStaleRef = useRef<{ stale: boolean } | null>(null);
const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
// One Autocomplete session groups every keystroke of a search with the final
// 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
// (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
// user sees the "Searching…" state and then the live results for what they
// typed.
// than one per keystroke. While it runs, the input shows a spinner; the
// dropdown itself only appears once there are predictions to show.
useEffect(() => {
const q = query.trim();
if (q.length < MIN_QUERY_LEN) {
@@ -420,12 +415,10 @@ function LocationPickerInline({
return;
}
setSearching(true);
combobox.openDropdown();
if (!places) return; // re-runs once the places library loads
// Autocomplete has no abort support, so a token marks superseded requests
// and their responses are dropped instead of overwriting newer results.
const token = { stale: false };
searchStaleRef.current = token;
const handle = setTimeout(async () => {
const found = await searchPlaces(
places.autocomplete,
@@ -435,13 +428,12 @@ function LocationPickerInline({
if (token.stale) return;
setResults(found);
setSearching(false);
combobox.openDropdown();
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(handle);
token.stale = true;
};
}, [query, places, combobox]);
}, [query, places]);
// Drop any in-flight reverse lookup when the picker unmounts.
useEffect(
@@ -453,7 +445,10 @@ function LocationPickerInline({
const selectResult = useCallback(
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.
if (!places) return;
setResolving(true);
@@ -474,10 +469,8 @@ function LocationPickerInline({
lat: resolved.lat,
lng: resolved.lng,
});
setQuery("");
setResults([]);
},
[onChange, combobox, places, placesLib],
[onChange, places, placesLib],
);
const handlePin = useCallback(
@@ -515,60 +508,44 @@ function LocationPickerInline({
? { lat: value.lat as number, lng: value.lng as number }
: 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 (
<Box>
<Combobox
store={combobox}
withinPortal={withinPortal}
shadow="md"
radius="md"
>
<Combobox.Target>
<InputBase
label={label || undefined}
placeholder={placeholder}
value={inputValue}
error={error}
radius={10}
styles={fieldStyles}
leftSection={<Search size={16} />}
rightSection={searching || resolving ? <Loader size={14} /> : null}
onChange={(e) => {
setQuery(e.currentTarget.value);
combobox.openDropdown();
}}
onFocus={() => {
if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown();
}}
/>
</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>
<Autocomplete
label={label || undefined}
placeholder={placeholder}
value={inputValue}
error={error}
radius={10}
styles={fieldStyles}
leftSection={<Search size={16} />}
rightSection={searching || resolving ? <Loader size={14} /> : null}
data={[...optionsByName.keys()]}
// Predictions are already ranked by the Places API for the typed
// query; Mantine's default substring filter would hide most of them.
filter={({ options }) => options}
maxDropdownHeight={240}
comboboxProps={{ withinPortal, shadow: "md", radius: "md" }}
onChange={setQuery}
onOptionSubmit={(name) => {
const prediction = optionsByName.get(name);
if (prediction) void selectResult(prediction);
}}
renderOption={({ option }) => (
<Text fz={13} lineClamp={2}>
{option.value}
</Text>
)}
/>
<Box
mt={10}

3
pnpm-lock.yaml generated
View File

@@ -217,6 +217,9 @@ importers:
'@mantine/core':
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)
'@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':
specifier: ^9.3.0
version: 9.3.0(react@19.2.6)