diff --git a/apps/web/src/components/map/MapClickHandler.tsx b/apps/web/src/components/map/MapClickHandler.tsx
index 2a97f1d86..e9b1ac1a5 100644
--- a/apps/web/src/components/map/MapClickHandler.tsx
+++ b/apps/web/src/components/map/MapClickHandler.tsx
@@ -17,6 +17,7 @@ import { useEffect, useRef } from "react";
import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers";
import { useMap } from "@/integration-api/map/MapContext";
import { claimMapClick, getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
+import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
import { initializeMapClickModes } from "@/lib/mapClickModes";
// Configure before any map listener mounts. Read live mode state at the first
@@ -119,6 +120,9 @@ export function MapClickHandler() {
`;
+ // Tapping the dropped pin would otherwise re-drop it at the tapped spot,
+ // a pin's height above where it stood.
+ bindMapMarkerActivation(el);
markerRef.current = new ml.Marker({ element: el, anchor: "bottom" })
.setLngLat(clickedLngLat)
.addTo(mapRef.current);
diff --git a/apps/web/src/components/map/MapClickOwnership.test.tsx b/apps/web/src/components/map/MapClickOwnership.test.tsx
index 528197422..a4ae0666c 100644
--- a/apps/web/src/components/map/MapClickOwnership.test.tsx
+++ b/apps/web/src/components/map/MapClickOwnership.test.tsx
@@ -5,9 +5,10 @@ import { MeasurementLayer } from "@integrations/overlay-tool-measurement/map-lay
import { useMeasurementStore } from "@integrations/overlay-tool-measurement/store";
import { TravelTimeLayer } from "@integrations/overlay-tool-travel-time/map-layer";
import { useTravelTimeStore } from "@integrations/overlay-tool-travel-time/store";
-import { useParkingStore } from "@openmapx/core";
+import { useMapClickStore, useParkingStore } from "@openmapx/core";
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { claimMapClickForMarker } from "@/integration-api/map/mapClickOwnership";
import { createFakeMap } from "@/test/fakeMap";
import { MapClickHandler } from "./MapClickHandler";
@@ -94,6 +95,48 @@ describe("physical map click ownership", () => {
},
);
+ it("leaves a click a marker claimed to the marker", () => {
+ const fake = toolMap();
+ useMapClickStore.getState().setClickedLngLat(null);
+ render();
+ const onMarker = new MouseEvent("click");
+ expect(claimMapClickForMarker(onMarker)).toBe(true);
+ act(() =>
+ fake.emit("click", {
+ point: { x: 1, y: 2 },
+ lngLat: { lng: 8, lat: 50 },
+ originalEvent: onMarker,
+ }),
+ );
+ expect(useMapClickStore.getState().clickedLngLat).toBeNull();
+
+ act(() =>
+ fake.emit("click", {
+ point: { x: 1, y: 2 },
+ lngLat: { lng: 8, lat: 50 },
+ originalEvent: new MouseEvent("click"),
+ }),
+ );
+ expect(useMapClickStore.getState().clickedLngLat).toEqual([8, 50]);
+ useMapClickStore.getState().setClickedLngLat(null);
+ });
+
+ it("does not let a marker claim a click an active tool owns", () => {
+ const fake = toolMap();
+ act(() => useMeasurementStore.getState().activate());
+ render();
+ const onMarker = new MouseEvent("click");
+ expect(claimMapClickForMarker(onMarker)).toBe(false);
+ act(() =>
+ fake.emit("click", {
+ point: { x: 1, y: 2 },
+ lngLat: { lng: 8, lat: 50 },
+ originalEvent: onMarker,
+ }),
+ );
+ expect(useMeasurementStore.getState().points).toEqual([[8, 50]]);
+ });
+
it("gives measurement priority over travel-time when both tools are active", () => {
const fake = toolMap();
act(() => {
diff --git a/apps/web/src/components/map/ParkedVehicleMarkers.test.tsx b/apps/web/src/components/map/ParkedVehicleMarkers.test.tsx
index eba728f3f..1e0dd9569 100644
--- a/apps/web/src/components/map/ParkedVehicleMarkers.test.tsx
+++ b/apps/web/src/components/map/ParkedVehicleMarkers.test.tsx
@@ -36,6 +36,7 @@ vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
import { PANEL, useParkingStore, useSidebarStore } from "@openmapx/core";
import * as mapContext from "@/integration-api/map/MapContext";
+import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
import { ParkedVehicleMarkers } from "./ParkedVehicleMarkers";
const mapContextTest = (
@@ -89,6 +90,20 @@ describe("ParkedVehicleMarkers", () => {
fireEvent.keyDown(container.firstElementChild as HTMLElement, { key: "Enter" });
expect(useParkingStore.getState().selectedParkedId).toBe("p1");
});
+ it("claims its click from the map, but leaves a picker's click to it", async () => {
+ const mapClickOwners: unknown[] = [];
+ container.addEventListener("click", (event) => mapClickOwners.push(getMapClickOwner(event)));
+ render();
+ await waitFor(() => expect(container.children).toHaveLength(1));
+
+ (container.firstElementChild as HTMLElement).click();
+ expect(useParkingStore.getState().selectedParkedId).toBe("p1");
+
+ useParkingStore.getState().setPicking(true);
+ (container.firstElementChild as HTMLElement).click();
+ expect(mapClickOwners).toEqual(["marker", "parking"]);
+ });
+
it("renders one marker per parked record", async () => {
render();
await waitFor(() => expect(container.children).toHaveLength(1));
diff --git a/apps/web/src/components/map/ParkedVehicleMarkers.tsx b/apps/web/src/components/map/ParkedVehicleMarkers.tsx
index 219bd5167..d34499a8e 100644
--- a/apps/web/src/components/map/ParkedVehicleMarkers.tsx
+++ b/apps/web/src/components/map/ParkedVehicleMarkers.tsx
@@ -11,7 +11,7 @@ import type * as maplibregl from "maplibre-gl";
import { useTranslations } from "next-intl";
import { useEffect, useRef } from "react";
import { useMap } from "@/integration-api/map/MapContext";
-import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
+import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
const BADGE_FILL = "#1A73E8";
@@ -68,16 +68,7 @@ function buildMarkerElement(label: string, onSelect: () => void): HTMLElement {
].join(";");
wrapper.append(badge, caption);
- wrapper.addEventListener("click", (event) => {
- if (getMapClickOwner(event)) return;
- onSelect();
- });
- wrapper.addEventListener("keydown", (event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- event.stopPropagation();
- onSelect();
- });
+ bindMapMarkerActivation(wrapper, { onActivate: onSelect });
return wrapper;
}
diff --git a/apps/web/src/components/map/UserLocationMarker.test.tsx b/apps/web/src/components/map/UserLocationMarker.test.tsx
index dfd393aff..230543186 100644
--- a/apps/web/src/components/map/UserLocationMarker.test.tsx
+++ b/apps/web/src/components/map/UserLocationMarker.test.tsx
@@ -2,6 +2,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
vi.mock("@/components/panels/MyLocationCard", () => ({
@@ -80,6 +81,22 @@ describe("UserLocationMarker", () => {
fireEvent.keyDown(mapContainer.firstElementChild as HTMLElement, { key: " " });
expect(screen.getByText("my-location-card")).toBeInTheDocument();
});
+ it("claims its click from the map", async () => {
+ const mapContainer = document.createElement("div");
+ const mapClickOwners: unknown[] = [];
+ mapContainer.addEventListener("click", (event) => mapClickOwners.push(getMapClickOwner(event)));
+ mapContextTest.mapRef.current = { container: mapContainer };
+ mapContextTest.mapReady = true;
+ useMapStore.setState({ userLocation: [13.4, 52.5] });
+ render();
+ await waitFor(() => expect(mapContainer.children).toHaveLength(1));
+
+ fireEvent.click(mapContainer.firstElementChild as HTMLElement);
+
+ expect(screen.getByText("my-location-card")).toBeInTheDocument();
+ expect(mapClickOwners).toEqual(["marker"]);
+ });
+
it("adds a location published before map readiness when the map becomes ready", async () => {
const mapContainer = document.createElement("div");
useMapStore.setState({ userLocation: [13.4, 52.5] });
diff --git a/apps/web/src/components/map/UserLocationMarker.tsx b/apps/web/src/components/map/UserLocationMarker.tsx
index 42e742b83..ad93efb55 100644
--- a/apps/web/src/components/map/UserLocationMarker.tsx
+++ b/apps/web/src/components/map/UserLocationMarker.tsx
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { MyLocationCard } from "@/components/panels/MyLocationCard";
import { useMap } from "@/integration-api/map/MapContext";
-import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
+import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
export function UserLocationMarker() {
const t = useTranslations("parking");
@@ -51,16 +51,7 @@ export function UserLocationMarker() {
wrapper.setAttribute("aria-label", locationLabel);
wrapper.style.cssText =
"position:relative;width:16px;height:16px;padding:0;border:0;background:none;cursor:pointer;";
- wrapper.addEventListener("click", (event) => {
- if (getMapClickOwner(event)) return;
- setCardOpen(true);
- });
- wrapper.addEventListener("keydown", (event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- event.stopPropagation();
- setCardOpen(true);
- });
+ bindMapMarkerActivation(wrapper, { onActivate: () => setCardOpen(true) });
// Outer pulsing ring
const pulse = document.createElement("div");
diff --git a/apps/web/src/components/map/WaypointMarkers.tsx b/apps/web/src/components/map/WaypointMarkers.tsx
index 928c8b61b..58778bab8 100644
--- a/apps/web/src/components/map/WaypointMarkers.tsx
+++ b/apps/web/src/components/map/WaypointMarkers.tsx
@@ -5,6 +5,7 @@ import { useDirectionsStore } from "@openmapx/core";
import type * as maplibregl from "maplibre-gl";
import { useEffect, useRef } from "react";
import { useMap } from "@/integration-api/map/MapContext";
+import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
import { BRAND } from "@/integration-api/runtime/theme";
const PIN_SVG_NS = "http://www.w3.org/2000/svg";
@@ -113,6 +114,9 @@ export function WaypointMarkers() {
const map = mapRef.current;
if (!map) return;
+ // A waypoint is moved by dragging; a plain tap on it must not fill the
+ // next empty waypoint or drop a pin at the spot it covers.
+ bindMapMarkerActivation(el);
const marker = new maplibregl.Marker({ element: el, anchor, draggable: true })
.setLngLat(wp.coords)
.addTo(map);
diff --git a/apps/web/src/components/map/layers/RouteLayer.test.tsx b/apps/web/src/components/map/layers/RouteLayer.test.tsx
index 4e03fcfb9..f6d792d75 100644
--- a/apps/web/src/components/map/layers/RouteLayer.test.tsx
+++ b/apps/web/src/components/map/layers/RouteLayer.test.tsx
@@ -2,6 +2,7 @@ import { type EvChargeStop, useParkingStore } from "@openmapx/core";
import { act, fireEvent, render } from "@testing-library/react";
import type { MapGeoJSONFeature } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
+import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
import { useMapAttributionStore } from "@/integration-api/overlay/mapAttributionStore";
import { createFakeMap, expectStyleSwapIsLossless } from "@/test";
@@ -121,6 +122,22 @@ describe("RouteLayer across a style change", () => {
view.unmount();
useParkingStore.getState().reset();
});
+ it("claims clicks on either route badge from the map", async () => {
+ activateRoute.mockClear();
+ const view = render();
+ await act(async () => {});
+ const canvasContainer = fake.map.getCanvasContainer();
+ const mapClickOwners: unknown[] = [];
+ const mapClick = (event: Event) => mapClickOwners.push(getMapClickOwner(event));
+ canvasContainer.addEventListener("click", mapClick);
+ const pills = [...canvasContainer.querySelectorAll(".omx-route-map-pill")];
+ expect(new Set(pills.map((pill) => pill.tagName))).toEqual(new Set(["SPAN", "BUTTON"]));
+ for (const pill of pills) fireEvent.click(pill);
+ expect(activateRoute).toHaveBeenCalledTimes(1);
+ expect(mapClickOwners).toEqual(["marker", "marker"]);
+ canvasContainer.removeEventListener("click", mapClick);
+ view.unmount();
+ });
it("keeps the drawn route", () => {
render();
const before = fake.state.sources.get("route-source")?.data as { features: unknown[] };
diff --git a/apps/web/src/components/map/layers/RouteLayer.tsx b/apps/web/src/components/map/layers/RouteLayer.tsx
index 5c54ce0a3..31866758f 100644
--- a/apps/web/src/components/map/layers/RouteLayer.tsx
+++ b/apps/web/src/components/map/layers/RouteLayer.tsx
@@ -25,6 +25,7 @@ import {
type MapLayerGroup,
type SlottedLayer,
} from "@/integration-api/map/mapLayerGroup";
+import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
import { useDrawnDirectionsRoutes } from "@/integration-api/map/useDrawnDirectionsRoutes";
import { useMapLayerGroup } from "@/integration-api/map/useMapLayerGroup";
import { markerMapContributors } from "@/integration-api/overlay/markerMapContribution";
@@ -278,17 +279,11 @@ export function RouteLayer() {
: theme.palette.divider;
if (element instanceof HTMLButtonElement) {
element.type = "button";
- element.addEventListener("click", (event) => {
- if (getMapClickOwner(event)) return;
- event.stopPropagation();
- setActiveRouteIndex(routeIndex);
- });
- element.addEventListener("keydown", (event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- event.stopPropagation();
- setActiveRouteIndex(routeIndex);
- });
+ bindMapMarkerActivation(element, { onActivate: () => setActiveRouteIndex(routeIndex) });
+ } else {
+ // The selected route's pill has nothing to do, but a tap on it is
+ // still not a tap on the map below.
+ bindMapMarkerActivation(element);
}
elements.set(routeIndex, element);
pillReceipts.current.set(
diff --git a/apps/web/src/components/navigation/ArrivalParkingMarkers.test.tsx b/apps/web/src/components/navigation/ArrivalParkingMarkers.test.tsx
index 1b0f90030..042cdee08 100644
--- a/apps/web/src/components/navigation/ArrivalParkingMarkers.test.tsx
+++ b/apps/web/src/components/navigation/ArrivalParkingMarkers.test.tsx
@@ -3,6 +3,7 @@
import type { CategoryPlace } from "@openmapx/core";
import { useParkingStore } from "@openmapx/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
import { fireEvent, render } from "@/test";
const { createdMarkers, FakeMarker } = vi.hoisted(() => {
@@ -154,6 +155,36 @@ describe("ArrivalParkingMarkers", () => {
expect(onSelectPlace).toHaveBeenCalledWith(samplePlaces[0]);
});
+ it("claims clicks from the map, even while disabled", () => {
+ mockMapContext.current = { mapRef: { current: { container: mapContainer } } };
+ const mapClickOwners: unknown[] = [];
+ const mapClick = (event: Event) => mapClickOwners.push(getMapClickOwner(event));
+ mapContainer.addEventListener("click", mapClick);
+ const onSelectPlace = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.click(mapContainer.querySelector(".omx-arrival-parking-marker") as HTMLElement);
+ expect(onSelectPlace).toHaveBeenCalledWith(samplePlaces[0]);
+
+ rerender(
+ ,
+ );
+ fireEvent.click(mapContainer.querySelector(".omx-arrival-parking-marker") as HTMLElement);
+ expect(onSelectPlace).toHaveBeenCalledTimes(1);
+ expect(mapClickOwners).toEqual(["marker", "marker"]);
+ mapContainer.removeEventListener("click", mapClick);
+ });
+
it("renders without crashing when map context is null", () => {
const { container } = render(
,
diff --git a/apps/web/src/components/navigation/ArrivalParkingMarkers.tsx b/apps/web/src/components/navigation/ArrivalParkingMarkers.tsx
index 6e3844fae..a79f2063f 100644
--- a/apps/web/src/components/navigation/ArrivalParkingMarkers.tsx
+++ b/apps/web/src/components/navigation/ArrivalParkingMarkers.tsx
@@ -7,7 +7,7 @@ import { Marker } from "maplibre-gl";
import { useTranslations } from "next-intl";
import { useEffect, useId, useRef } from "react";
import { useMapOptional } from "@/integration-api/map/MapContext";
-import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
+import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
import { useMapAttributionStore } from "@/integration-api/overlay/mapAttributionStore";
import { markerMapContributors } from "@/integration-api/overlay/markerMapContribution";
import { renderedMapAttributionTracker } from "@/integration-api/overlay/renderedMapAttributionTracker";
@@ -80,16 +80,12 @@ export function ArrivalParkingMarkers({
el.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
el.textContent = "P";
- el.addEventListener("click", (event) => {
- if (getMapClickOwner(event)) return;
- if (!disabled) onSelectPlace(isSelected ? null : place);
- });
-
- el.addEventListener("keydown", (e) => {
- if (!disabled && (e.key === "Enter" || e.key === " " || e.key === "Spacebar")) {
- e.preventDefault();
- onSelectPlace(isSelected ? null : place);
- }
+ // A disabled marker still owns its clicks: it is drawn, so a tap on it
+ // is not a tap on the map below.
+ bindMapMarkerActivation(el, {
+ onActivate: () => {
+ if (!disabled) onSelectPlace(isSelected ? null : place);
+ },
});
const marker = new Marker({ element: el }).setLngLat(coords).addTo(map);
diff --git a/apps/web/src/hooks/usePinMarker.ts b/apps/web/src/hooks/usePinMarker.ts
index c53eb9aae..d512f95b0 100644
--- a/apps/web/src/hooks/usePinMarker.ts
+++ b/apps/web/src/hooks/usePinMarker.ts
@@ -9,7 +9,7 @@ import {
import type * as maplibregl from "maplibre-gl";
import { useEffect, useRef, useState } from "react";
import { useMap } from "@/integration-api/map/MapContext";
-import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
+import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
import { useMarkerMapAttributions } from "@/integration-api/overlay/useMarkerMapAttributions";
import { getMapObstructionInsets, subscribeMapObstructions } from "@/lib/mapObstructions";
@@ -266,17 +266,11 @@ export function usePinMarker(
el.tabIndex = 0;
el.setAttribute("aria-label", label);
}
- el.addEventListener("click", (event) => {
- if (getMapClickOwner(event)) return;
- if (!onActivateRef.current) return;
- event.stopPropagation();
- onActivateRef.current();
- });
- el.addEventListener("keydown", (event) => {
- if (!onActivateRef.current || (event.key !== "Enter" && event.key !== " ")) return;
- event.preventDefault();
- event.stopPropagation();
- onActivateRef.current();
+ // Without an action the pin is see-through: a hover pin drawn over a
+ // feature still lets that feature take the click.
+ bindMapMarkerActivation(el, {
+ onActivate: () => onActivateRef.current?.(),
+ isEnabled: () => Boolean(onActivateRef.current),
});
const svgDiv = document.createElement("div");
diff --git a/apps/web/src/integration-api/map/mapClickOwnership.ts b/apps/web/src/integration-api/map/mapClickOwnership.ts
index 4f24f521c..c3b5b1061 100644
--- a/apps/web/src/integration-api/map/mapClickOwnership.ts
+++ b/apps/web/src/integration-api/map/mapClickOwnership.ts
@@ -1,18 +1,18 @@
import { useParkingStore } from "@openmapx/core";
-type MapClickOwner = "parking" | "crowd" | "measurement" | "travel-time";
+/** `marker`: a DOM marker took the click; see `bindMapMarkerActivation`. */
+type MapClickOwner = "parking" | "crowd" | "measurement" | "travel-time" | "marker";
+type ModeOwner = Exclude;
interface Ownership {
owner: MapClickOwner | null;
claimed: boolean;
}
const ownership = new WeakMap