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} /> + setCopyAnchor(null)} + anchorOrigin={{ vertical: "top", horizontal: "left" }} + transformOrigin={{ vertical: "bottom", horizontal: "left" }} + slotProps={{ paper: { sx: { minWidth: 240, maxWidth: "calc(100vw - 16px)" } } }} + > + {copyRows.map((row) => ( + void copyValue(row.value)} + sx={{ minHeight: "48px !important" }} + > + + + ))} + {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 (