diff --git a/apps/web/src/components/map/MapClickHandler.test.tsx b/apps/web/src/components/map/MapClickHandler.test.tsx
index 7608e5f8c..b2bd3971f 100644
--- a/apps/web/src/components/map/MapClickHandler.test.tsx
+++ b/apps/web/src/components/map/MapClickHandler.test.tsx
@@ -70,6 +70,7 @@ beforeEach(() => {
});
afterEach(() => {
+ touch("touchend", []);
vi.useRealTimers();
});
@@ -114,6 +115,22 @@ describe("MapClickHandler on a touch screen", () => {
expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]);
});
+ it("keeps the pin when the finger lifts long after it dropped", () => {
+ vi.useFakeTimers();
+ render();
+ touch("touchstart", [[20, 30]]);
+ vi.advanceTimersByTime(LONG_PRESS_MS + 2500);
+ touch("touchend", []);
+ click("touch");
+ expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]);
+
+ // The next tap is a tap of its own, and clears the pin.
+ touch("touchstart", [[20, 30]]);
+ touch("touchend", []);
+ click("touch");
+ expect(useMapClickStore.getState().clickedLngLat).toBeNull();
+ });
+
it("leaves a long-press to the parking picker while it is choosing a spot", () => {
vi.useFakeTimers();
useParkingStore.setState({ picking: true });
diff --git a/apps/web/src/components/map/MapClickHandler.tsx b/apps/web/src/components/map/MapClickHandler.tsx
index 4be7eef4d..1e863052d 100644
--- a/apps/web/src/components/map/MapClickHandler.tsx
+++ b/apps/web/src/components/map/MapClickHandler.tsx
@@ -21,7 +21,7 @@ import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivati
import { closePlaceSelection } from "@/lib/closeSurfaces";
import { haptics } from "@/lib/haptics";
import { initializeMapClickModes } from "@/lib/mapClickModes";
-import { bindMapLongPress, consumeLongPressClick, isTouchClick } from "@/lib/mapTouch";
+import { bindMapLongPress, cameFromTouch, consumeLongPressClick } from "@/lib/mapTouch";
// Configure before any map listener mounts. Read live mode state at the first
// callback for each click; capture may clear that state before later callbacks.
@@ -106,7 +106,7 @@ export function MapClickHandler() {
}
}
- if (isTouchClick(e.originalEvent)) {
+ if (cameFromTouch(e.originalEvent)) {
dismissOnTap();
return;
}
diff --git a/apps/web/src/components/map/MapContextMenu.test.tsx b/apps/web/src/components/map/MapContextMenu.test.tsx
index ab5bf8b6a..da6c2ee2b 100644
--- a/apps/web/src/components/map/MapContextMenu.test.tsx
+++ b/apps/web/src/components/map/MapContextMenu.test.tsx
@@ -313,6 +313,37 @@ describe("MapContextMenu opening", () => {
});
}
+ it("leaves MapLibre's own long-press contextmenu to the dropped pin", () => {
+ render();
+ act(() => useMapClickStore.setState({ clickedLngLat: [1, 2] }));
+ const fingers = (type: string, count: number) => {
+ const event = new Event(type, { bubbles: true });
+ Object.defineProperty(event, "touches", {
+ value: Array.from({ length: count }, () => ({ clientX: 120, clientY: 140 })),
+ });
+ document.body.dispatchEvent(event);
+ };
+ fingers("touchstart", 1);
+ // What MapLibre fires once a finger has rested on the map: a plain
+ // MouseEvent, with no pointer type or source capabilities to tell touch.
+ act(() => {
+ fake.emit("contextmenu", {
+ point: { x: 40, y: 50 },
+ lngLat: { lng: 6.08, lat: 50.77 },
+ originalEvent: new MouseEvent("contextmenu", {
+ clientX: 120,
+ clientY: 140,
+ button: 2,
+ cancelable: true,
+ }),
+ });
+ });
+ fingers("touchend", 0);
+
+ expect(screen.queryByRole("menu")).toBeNull();
+ expect(useMapClickStore.getState().clickedLngLat).toEqual([1, 2]);
+ });
+
for (const status of ["navigating", "rerouting"] as const) {
it(`suppresses the native menu without opening or mutating state while ${status}`, () => {
useNavigationStore.setState({ status });
diff --git a/apps/web/src/components/map/MapContextMenu.tsx b/apps/web/src/components/map/MapContextMenu.tsx
index 1716c7a6e..6a9ed57d3 100644
--- a/apps/web/src/components/map/MapContextMenu.tsx
+++ b/apps/web/src/components/map/MapContextMenu.tsx
@@ -20,8 +20,6 @@ import {
Typography,
} from "@mui/material";
import {
- coordinateId,
- createPlace,
isLiveNavigationStatus,
type Place,
useMapClickStore,
@@ -44,28 +42,22 @@ import Popover from "@/components/ui/Popover";
import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers";
import { useMap } from "@/integration-api/map/MapContext";
import { openLocationDetails, routeFromOrTo, shareLocationLink } from "@/lib/locationActions";
-import { findStylePoiAtPoint, getStylePoiLayerIds, type StylePoiTarget } from "./mapStylePoiTarget";
+import { cameFromTouch } from "@/lib/mapTouch";
+import {
+ describeMapLocation,
+ type MapLocation,
+ mapLocationCopyRows,
+ mapLocationPlace,
+} from "./mapLocation";
+import { findStylePoiAtPoint, getStylePoiLayerIds } from "./mapStylePoiTarget";
-interface MapContextTarget {
- coordinates: [number, number];
+interface MapContextTarget extends MapLocation {
anchorPosition: { top: number; left: number };
- poi: StylePoiTarget | null;
}
type ActionId = "from" | "to" | "copy" | "details" | "parking" | "share";
const ACTION_ORDER: ActionId[] = ["from", "to", "copy", "details", "parking", "share"];
-function formatContextCoordinates([lng, lat]: [number, number]): string {
- return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
-}
-
-function cameFromTouch(event: MouseEvent & { pointerType?: string }): boolean {
- const capabilities = (
- event as MouseEvent & { sourceCapabilities?: { firesTouchEvents?: boolean } }
- ).sourceCapabilities;
- return event.pointerType === "touch" || capabilities?.firesTouchEvents === true;
-}
-
export function MapContextMenu(): React.ReactNode {
const t = useTranslations("mapContextMenu");
const tParking = useTranslations("parking");
@@ -104,32 +96,15 @@ export function MapContextMenu(): React.ReactNode {
const coordinates = target?.coordinates ?? null;
const { data: reverseGeo } = useReverseGeocoding(coordinates, locale);
- const coordinateLabel = target ? formatContextCoordinates(target.coordinates) : "";
- const title = target?.poi?.name ?? reverseGeo?.city ?? reverseGeo?.address ?? coordinateLabel;
- const address = reverseGeo?.address ?? coordinateLabel;
- const actionLabel = target?.poi?.name ?? reverseGeo?.address ?? coordinateLabel;
+ const text = target ? describeMapLocation(target, reverseGeo) : null;
+ const title = text?.title ?? "";
+ const address = text?.address ?? "";
+ const actionLabel = text?.routeLabel ?? "";
- const place = useMemo(() => {
- if (!target) return null;
- if (target.poi) {
- return createPlace({
- primaryScheme: "stylePoi",
- ids: { stylePoi: target.poi.featureId },
- name: target.poi.name,
- address: reverseGeo?.address ?? target.poi.name,
- coordinates: target.poi.coordinates,
- category: target.poi.category,
- rawCategory: target.poi.rawCategory,
- });
- }
- return createPlace({
- primaryScheme: "coordinate",
- ids: { coordinate: coordinateId(target.coordinates) },
- name: title,
- address,
- coordinates: target.coordinates,
- });
- }, [address, reverseGeo?.address, target, title]);
+ const place = useMemo(
+ () => (target ? mapLocationPlace(target, reverseGeo) : null),
+ [reverseGeo, target],
+ );
const restoreCanvasFocus = useCallback(() => {
if (restoreFocusFrameRef.current !== null) cancelAnimationFrame(restoreFocusFrameRef.current);
@@ -200,6 +175,10 @@ export function MapContextMenu(): React.ReactNode {
originalEvent.preventDefault();
return;
}
+ // Touch never opens this menu: a long-press drops the pin, whose card
+ // carries these actions (MapClickHandler). That includes the contextmenu
+ // MapLibre makes up for a held finger, which would otherwise open the
+ // menu under it only for the click-away listener to close it on the lift.
if (cameFromTouch(originalEvent)) return;
originalEvent.preventDefault();
captureTarget([event.lngLat.lng, event.lngLat.lat], event.point, {
@@ -307,20 +286,17 @@ export function MapContextMenu(): React.ReactNode {
setFeedback(outcome === "saved" ? tParking("savedToast") : tParking("locationUnavailable"));
};
- const copyRows = useMemo(() => {
- if (!target) return [];
- const candidates = [
- { label: t("coordinates"), value: coordinateLabel },
- ...(target.poi?.name ? [{ label: t("name"), value: target.poi.name }] : []),
- ...(reverseGeo?.address ? [{ label: t("address"), value: reverseGeo.address }] : []),
- ];
- const values = new Set();
- return candidates.filter(({ value }) => {
- if (values.has(value)) return false;
- values.add(value);
- return true;
- });
- }, [coordinateLabel, reverseGeo?.address, t, target]);
+ const copyRows = useMemo(
+ () =>
+ target
+ ? mapLocationCopyRows(target, reverseGeo, {
+ coordinates: t("coordinates"),
+ name: t("name"),
+ address: t("address"),
+ })
+ : [],
+ [reverseGeo, t, target],
+ );
const copyValue = async (value: string) => {
const targetGeneration = targetGenerationRef.current;
diff --git a/apps/web/src/components/map/mapLocation.test.ts b/apps/web/src/components/map/mapLocation.test.ts
new file mode 100644
index 000000000..a961702fc
--- /dev/null
+++ b/apps/web/src/components/map/mapLocation.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import { describeMapLocation, mapLocationCopyRows, mapLocationPlace } from "./mapLocation";
+
+const POINT: [number, number] = [6.083, 50.776];
+const LABELS = { coordinates: "Coordinates", name: "Name", address: "Address" };
+const CAFE = {
+ featureId: "42",
+ name: "Café Kaiser",
+ coordinates: [6.0831, 50.7761] as [number, number],
+};
+
+describe("describeMapLocation", () => {
+ it("falls back from the place's name to the city, the address and the coordinates", () => {
+ const bare = { coordinates: POINT, poi: null };
+ expect(describeMapLocation(bare, null).title).toBe("50.776000, 6.083000");
+ expect(describeMapLocation(bare, { address: "Markt 1" }).title).toBe("Markt 1");
+ expect(describeMapLocation(bare, { address: "Markt 1", city: "Aachen" }).title).toBe("Aachen");
+ expect(describeMapLocation({ coordinates: POINT, poi: CAFE }, { city: "Aachen" }).title).toBe(
+ "Café Kaiser",
+ );
+ });
+
+ it("routes under the place's name, else the address", () => {
+ expect(
+ describeMapLocation({ coordinates: POINT, poi: null }, { address: "Markt 1", city: "Aachen" })
+ .routeLabel,
+ ).toBe("Markt 1");
+ expect(
+ describeMapLocation({ coordinates: POINT, poi: CAFE }, { address: "Markt 1" }).routeLabel,
+ ).toBe("Café Kaiser");
+ });
+});
+
+describe("mapLocationPlace", () => {
+ it("is the basemap place when there is one, else the point", () => {
+ expect(mapLocationPlace({ coordinates: POINT, poi: CAFE }, null).id).toBe("stylePoi:42");
+ expect(mapLocationPlace({ coordinates: POINT, poi: null }, null).coordinates).toEqual(POINT);
+ });
+});
+
+describe("mapLocationCopyRows", () => {
+ it("lists coordinates, name and address without repeating a value", () => {
+ const rows = mapLocationCopyRows(
+ { coordinates: POINT, poi: { ...CAFE, name: "Markt 1" } },
+ { address: "Markt 1" },
+ LABELS,
+ );
+ expect(rows).toEqual([
+ { label: "Coordinates", value: "50.776000, 6.083000" },
+ { label: "Name", value: "Markt 1" },
+ ]);
+ });
+
+ it("offers only the coordinates for a bare point with no address yet", () => {
+ expect(mapLocationCopyRows({ coordinates: POINT, poi: null }, null, LABELS)).toHaveLength(1);
+ });
+});
diff --git a/apps/web/src/components/map/mapLocation.ts b/apps/web/src/components/map/mapLocation.ts
new file mode 100644
index 000000000..06608de56
--- /dev/null
+++ b/apps/web/src/components/map/mapLocation.ts
@@ -0,0 +1,96 @@
+import { coordinateId, createPlace, type Place } from "@openmapx/core";
+import type { StylePoiTarget } from "./mapStylePoiTarget";
+
+/**
+ * A point the user picked on the map, by right-click or by long-press, and the
+ * basemap place under it if there is one. The desktop context menu and the
+ * dropped-pin card describe it the same way through these helpers.
+ */
+export interface MapLocation {
+ coordinates: [number, number];
+ poi: StylePoiTarget | null;
+}
+
+interface ReverseGeocoded {
+ address?: string | null;
+ city?: string | null;
+}
+
+export interface MapLocationText {
+ /** Latitude, longitude to six decimals. */
+ coordinates: string;
+ /** What the location is called: the place's name, else where it is. */
+ title: string;
+ /** Its address, else its coordinates. */
+ address: string;
+ /** The label a route to or from it carries. */
+ routeLabel: string;
+}
+
+export function formatMapCoordinates([lng, lat]: [number, number]): string {
+ return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
+}
+
+export function describeMapLocation(
+ { coordinates, poi }: MapLocation,
+ reverse: ReverseGeocoded | null | undefined,
+): MapLocationText {
+ const label = formatMapCoordinates(coordinates);
+ return {
+ coordinates: label,
+ title: poi?.name || reverse?.city || reverse?.address || label,
+ address: reverse?.address || label,
+ routeLabel: poi?.name || reverse?.address || label,
+ };
+}
+
+/** The location as a place: the basemap place under it, else the bare point. */
+export function mapLocationPlace(
+ location: MapLocation,
+ reverse: ReverseGeocoded | null | undefined,
+): Place {
+ const text = describeMapLocation(location, reverse);
+ const { poi } = location;
+ if (poi) {
+ return createPlace({
+ primaryScheme: "stylePoi",
+ ids: { stylePoi: poi.featureId },
+ name: poi.name,
+ address: reverse?.address || poi.name,
+ coordinates: poi.coordinates,
+ category: poi.category,
+ rawCategory: poi.rawCategory,
+ });
+ }
+ return createPlace({
+ primaryScheme: "coordinate",
+ ids: { coordinate: coordinateId(location.coordinates) },
+ name: text.title,
+ address: text.address,
+ coordinates: location.coordinates,
+ });
+}
+
+export interface MapLocationCopyRow {
+ label: string;
+ value: string;
+}
+
+/** What can be copied about the location, without repeating a value. */
+export function mapLocationCopyRows(
+ location: MapLocation,
+ reverse: ReverseGeocoded | null | undefined,
+ labels: { coordinates: string; name: string; address: string },
+): MapLocationCopyRow[] {
+ const candidates = [
+ { label: labels.coordinates, value: formatMapCoordinates(location.coordinates) },
+ ...(location.poi?.name ? [{ label: labels.name, value: location.poi.name }] : []),
+ ...(reverse?.address ? [{ label: labels.address, value: reverse.address }] : []),
+ ];
+ const seen = new Set();
+ return candidates.filter(({ value }) => {
+ if (seen.has(value)) return false;
+ seen.add(value);
+ return true;
+ });
+}
diff --git a/apps/web/src/components/panels/MapClickFloatingCard.test.tsx b/apps/web/src/components/panels/MapClickFloatingCard.test.tsx
index 347173320..93dabc811 100644
--- a/apps/web/src/components/panels/MapClickFloatingCard.test.tsx
+++ b/apps/web/src/components/panels/MapClickFloatingCard.test.tsx
@@ -1,10 +1,27 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { render, screen, userEvent, waitFor } from "@/test";
+import { render, screen, userEvent, waitFor, within } from "@/test";
vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
-vi.mock("@/integration-api/map/MapContext", () => ({ useMap: () => ({ flyTo: vi.fn() }) }));
+vi.mock("@/integration-api/map/MapContext", () => ({
+ useMap: () => ({
+ flyTo: vi.fn(),
+ mapRef: { current: { project: () => ({ x: 120, y: 140 }) } },
+ }),
+}));
+const basemap = vi.hoisted(() => ({
+ poi: null as null | {
+ featureId: string;
+ name: string;
+ coordinates: [number, number];
+ category?: string;
+ },
+}));
+vi.mock("@/components/map/mapStylePoiTarget", () => ({
+ getStylePoiLayerIds: () => ["poi-label"],
+ findStylePoiAtPoint: () => basemap.poi,
+}));
vi.mock("@openmapx/core", async (importOriginal) => ({
...(await importOriginal()),
useReverseGeocoding: () => ({
@@ -22,14 +39,22 @@ vi.mock("@/lib/deepLink", async (importOriginal) => ({
shareUrl: share.shareUrl,
}));
-import { PANEL, useDirectionsStore, useMapClickStore, useSidebarStore } from "@openmapx/core";
+import {
+ PANEL,
+ useDirectionsStore,
+ useMapClickStore,
+ usePlaceStore,
+ useSidebarStore,
+} from "@openmapx/core";
import { MapClickFloatingCard } from "./MapClickFloatingCard";
const PIN: [number, number] = [6.083, 50.776];
-function asPhone(phone: boolean): void {
+function asDevice(device: "phone" | "touch tablet" | "desktop"): void {
vi.stubGlobal("matchMedia", (query: string) => ({
- matches: phone && query.includes("max-width"),
+ matches:
+ (device === "phone" && query.includes("max-width")) ||
+ (device !== "desktop" && query.includes("pointer: coarse")),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
@@ -38,7 +63,15 @@ function asPhone(phone: boolean): void {
}));
}
+function installClipboard() {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+ return writeText;
+}
+
beforeEach(() => {
+ basemap.poi = null;
+ usePlaceStore.getState().setSelectedPlace(null);
useMapClickStore.getState().setClickedLngLat(PIN);
useDirectionsStore.getState().close();
useSidebarStore.setState({ activeSidebarId: null, activeDetailId: null, collapsed: false });
@@ -51,7 +84,7 @@ afterEach(() => {
});
describe("MapClickFloatingCard on a phone", () => {
- beforeEach(() => asPhone(true));
+ beforeEach(() => asDevice("phone"));
it("offers the context menu's actions as a row", () => {
render();
@@ -92,8 +125,69 @@ describe("MapClickFloatingCard on a phone", () => {
});
});
+describe("MapClickFloatingCard on a touch screen wider than a phone", () => {
+ beforeEach(() => asDevice("touch tablet"));
+
+ it("offers the same row of actions", () => {
+ render();
+ for (const name of ["mapContextMenu.fromHere", "common.share", "parking.saveParking"]) {
+ expect(screen.getByRole("button", { name })).toBeDefined();
+ }
+ });
+});
+
+describe("MapClickFloatingCard's Copy", () => {
+ beforeEach(() => asDevice("phone"));
+
+ it("offers the coordinates and the address, as the right-click menu does", async () => {
+ const writeText = installClipboard();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "common.copy" }));
+ const menu = await screen.findByRole("menu");
+ expect(within(menu).getByText("50.776000, 6.083000")).toBeDefined();
+ await userEvent.click(within(menu).getByText("Marktplatz 1"));
+ expect(writeText).toHaveBeenCalledWith("Marktplatz 1");
+ expect(await screen.findByText("mapContextMenu.copied")).toBeDefined();
+ });
+});
+
+describe("MapClickFloatingCard on a basemap place", () => {
+ beforeEach(() => {
+ asDevice("phone");
+ basemap.poi = {
+ featureId: "42",
+ name: "Café Kaiser",
+ coordinates: [6.0831, 50.7761],
+ category: "cafe",
+ };
+ });
+
+ it("names the place and opens it, as right-click does", async () => {
+ render();
+ expect(screen.getByText("Café Kaiser")).toBeDefined();
+ await userEvent.click(screen.getByRole("button", { name: "common.details" }));
+ const selected = usePlaceStore.getState().selectedPlace;
+ expect(selected?.name).toBe("Café Kaiser");
+ expect(selected?.id).toBe("stylePoi:42");
+ });
+
+ it("offers its name to copy", async () => {
+ installClipboard();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "common.copy" }));
+ const menu = await screen.findByRole("menu");
+ expect(within(menu).getByText("Café Kaiser")).toBeDefined();
+ });
+
+ it("routes to it under its name", async () => {
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "mapContextMenu.fromHere" }));
+ expect(useDirectionsStore.getState().waypoints[0].label).toBe("Café Kaiser");
+ });
+});
+
describe("MapClickFloatingCard on a desktop", () => {
- beforeEach(() => asPhone(false));
+ beforeEach(() => asDevice("desktop"));
it("keeps the compact card without the action row", () => {
render();
diff --git a/apps/web/src/components/panels/MapClickFloatingCard.tsx b/apps/web/src/components/panels/MapClickFloatingCard.tsx
index a816acad7..3061e97e1 100644
--- a/apps/web/src/components/panels/MapClickFloatingCard.tsx
+++ b/apps/web/src/components/panels/MapClickFloatingCard.tsx
@@ -11,6 +11,8 @@ import TripOriginIcon from "@mui/icons-material/TripOrigin";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
+import ListItemText from "@mui/material/ListItemText";
+import MenuItem from "@mui/material/MenuItem";
import Paper from "@mui/material/Paper";
import Skeleton from "@mui/material/Skeleton";
import Snackbar from "@mui/material/Snackbar";
@@ -18,8 +20,6 @@ import { useTheme } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import {
- coordinateId,
- createPlace,
PANEL,
type Place,
useMapClickStore,
@@ -29,9 +29,18 @@ import {
useSidebarStore,
} from "@openmapx/core";
import { useLocale, useTranslations } from "next-intl";
-import type { ReactNode } from "react";
+import type { MouseEvent, ReactNode } from "react";
import { useMemo, useState, useSyncExternalStore } from "react";
+import {
+ describeMapLocation,
+ type MapLocation,
+ mapLocationCopyRows,
+ mapLocationPlace,
+} from "@/components/map/mapLocation";
+import { findStylePoiAtPoint, getStylePoiLayerIds } from "@/components/map/mapStylePoiTarget";
import { useSaveParking } from "@/components/panels/parking/useSaveParking";
+import Menu from "@/components/ui/Menu";
+import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers";
import { useMap } from "@/integration-api/map/MapContext";
import { BRAND } from "@/integration-api/runtime/theme";
import { routeFromOrTo, shareLocationLink } from "@/lib/locationActions";
@@ -65,34 +74,53 @@ export function MapClickFloatingCard() {
const locale = useLocale();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
+ // Touch-first screens wider than a phone (tablets, a phone held sideways)
+ // get the same actions: touch has no right-click menu to fall back on.
+ const touchFirst = useMediaQuery("(pointer: coarse)");
const { clickedLngLat, setClickedLngLat } = useMapClickStore();
const { setQuery, setIsFocused } = useSearchStore();
- const { flyTo } = useMap();
+ const { flyTo, mapRef } = useMap();
const { saveAt } = useSaveParking();
const [feedback, setFeedback] = useState(null);
+ const [copyAnchor, setCopyAnchor] = useState(null);
const phoneCardBottom = usePhoneCardBottom();
const { data: reverseGeo, isLoading } = useReverseGeocoding(clickedLngLat, locale);
useBackDismiss(clickedLngLat !== null, () => setClickedLngLat(null));
- const coordLabel = clickedLngLat
- ? `${clickedLngLat[1].toFixed(6)}, ${clickedLngLat[0].toFixed(6)}`
- : "";
- const placeName = reverseGeo?.city || reverseGeo?.address || coordLabel;
- const address = reverseGeo?.address ?? coordLabel;
+ // The basemap place under the pin, as the right-click menu finds it, read
+ // once when the pin lands: the pin stands where the finger or pointer was.
+ const location = useMemo(() => {
+ if (!clickedLngLat) return null;
+ const map = mapRef?.current;
+ const poi = map
+ ? findStylePoiAtPoint(
+ map,
+ map.project(clickedLngLat),
+ getStylePoiLayerIds(map),
+ INTERACTIVE_LAYER_IDS,
+ )
+ : null;
+ return { coordinates: clickedLngLat, poi };
+ }, [clickedLngLat, mapRef]);
+ const text = location ? describeMapLocation(location, reverseGeo) : null;
+ const coordLabel = text?.coordinates ?? "";
+ const placeName = text?.title ?? "";
const place = useMemo(
+ () => (location ? mapLocationPlace(location, reverseGeo) : null),
+ [location, reverseGeo],
+ );
+ const copyRows = useMemo(
() =>
- clickedLngLat
- ? createPlace({
- primaryScheme: "coordinate",
- ids: { coordinate: coordinateId(clickedLngLat) },
- name: placeName,
- address,
- coordinates: clickedLngLat,
+ location
+ ? mapLocationCopyRows(location, reverseGeo, {
+ coordinates: tm("coordinates"),
+ name: tm("name"),
+ address: tm("address"),
})
- : null,
- [address, clickedLngLat, placeName],
+ : [],
+ [location, reverseGeo, tm],
);
const feedbackToast = (
@@ -104,11 +132,11 @@ export function MapClickFloatingCard() {
/>
);
- if (!clickedLngLat || !place) return feedbackToast;
+ if (!clickedLngLat || !place || !location || !text) return feedbackToast;
const handleCoordClick = () => {
flyTo(clickedLngLat, 15);
- setQuery(coordLabel);
+ setQuery(location.poi ? text.title : coordLabel);
setIsFocused(false);
usePlaceStore.getState().setSelectedPlace(place);
useSidebarStore.getState().openSidebar(PANEL.PLACE);
@@ -116,7 +144,7 @@ export function MapClickFloatingCard() {
};
const handleRoute = (endpoint: "origin" | "destination") => {
- routeFromOrTo(endpoint, clickedLngLat, address);
+ routeFromOrTo(endpoint, clickedLngLat, text.routeLabel);
setClickedLngLat(null);
};
@@ -126,16 +154,24 @@ export function MapClickFloatingCard() {
else if (result === "copied") setFeedback(tm("linkCopied"));
};
- const handleCopy = async () => {
+ const copyValue = async (value: string) => {
+ setCopyAnchor(null);
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
- await navigator.clipboard.writeText(coordLabel);
+ await navigator.clipboard.writeText(value);
setFeedback(tm("copied"));
} catch {
setFeedback(tm("copyFailed"));
}
};
+ // One thing to copy goes straight to the clipboard; more offer a choice, as
+ // the right-click menu's Copy location does.
+ const handleCopy = (button: HTMLElement) => {
+ if (copyRows.length > 1) setCopyAnchor(button);
+ else void copyValue(copyRows[0]?.value ?? coordLabel);
+ };
+
const handleSaveParking = async () => {
const outcome = await saveAt(clickedLngLat, { address: reverseGeo?.address ?? null });
setFeedback(outcome === "saved" ? tParking("savedToast") : tParking("locationUnavailable"));
@@ -157,8 +193,8 @@ export function MapClickFloatingCard() {
);
- if (isMobile) {
- // A dropped pin is where a phone user lands after a long-press, so the
+ if (isMobile || touchFirst) {
+ // A dropped pin is where a touch user lands after a long-press, so the
// card carries what the desktop context menu offers, as a row of actions
// within thumb reach instead of a menu under the finger.
return (
@@ -169,12 +205,17 @@ export function MapClickFloatingCard() {
aria-label={tm("ariaLabel", { location: placeName })}
sx={{
position: "absolute",
- left: CARD_GAP,
- right: CARD_GAP,
- bottom: phoneCardBottom,
zIndex: 11,
borderRadius: 3,
overflow: "hidden",
+ ...(isMobile
+ ? { left: CARD_GAP, right: CARD_GAP, bottom: phoneCardBottom }
+ : {
+ bottom: 24,
+ left: "50%",
+ transform: "translateX(-50%)",
+ width: "min(480px, calc(100% - 32px))",
+ }),
}}
>
@@ -217,9 +258,15 @@ export function MapClickFloatingCard() {
px: 2,
pt: 1.5,
pb: 1.5,
- overflowX: "auto",
- scrollbarWidth: "none",
- "&::-webkit-scrollbar": { display: "none" },
+ // A phone scrolls the row under the thumb; wider screens have
+ // the room to show every action at once.
+ ...(isMobile
+ ? {
+ overflowX: "auto",
+ scrollbarWidth: "none",
+ "&::-webkit-scrollbar": { display: "none" },
+ }
+ : { flexWrap: "wrap" }),
}}
>
}
label={tc("copy")}
- onClick={() => void handleCopy()}
+ onClick={(event) => handleCopy(event.currentTarget)}
+ menuOpen={copyRows.length > 1 ? copyAnchor !== null : undefined}
/>
} label={tc("details")} onClick={handleCoordClick} />
+
{feedbackToast}
>
);
@@ -334,17 +400,24 @@ function Action({
label,
onClick,
primary,
+ menuOpen,
}: {
icon: ReactNode;
label: string;
- onClick: () => void;
+ onClick: (event: MouseEvent) => void;
primary?: boolean;
+ /** Set on a button that opens a menu: whether that menu is open. */
+ menuOpen?: boolean;
}) {
return (