Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/src/components/map/MapClickHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,6 +120,9 @@ export function MapClickHandler() {
<path d="M13.5 0C6.044 0 0 6.044 0 13.5c0 9.219 13.5 29.5 13.5 29.5S27 22.719 27 13.5C27 6.044 20.956 0 13.5 0z" fill="#757575" stroke="#424242" stroke-width="1"/>
<circle cx="13.5" cy="13.5" r="5.5" fill="white"/>
</svg>`;
// 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);
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/components/map/MapClickOwnership.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(<MapClickHandler />);
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(<MeasurementLayer />);
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(() => {
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/map/ParkedVehicleMarkers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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(<ParkedVehicleMarkers />);
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(<ParkedVehicleMarkers />);
await waitFor(() => expect(container.children).toHaveLength(1));
Expand Down
13 changes: 2 additions & 11 deletions apps/web/src/components/map/ParkedVehicleMarkers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}

Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/map/UserLocationMarker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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(<UserLocationMarker />);
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] });
Expand Down
13 changes: 2 additions & 11 deletions apps/web/src/components/map/UserLocationMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/map/WaypointMarkers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/map/layers/RouteLayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(<RouteLayer />);
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<HTMLElement>(".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(<RouteLayer />);
const before = fake.state.sources.get("route-source")?.data as { features: unknown[] };
Expand Down
17 changes: 6 additions & 11 deletions apps/web/src/components/map/layers/RouteLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(
<ArrivalParkingMarkers
places={samplePlaces}
selectedPlace={null}
onSelectPlace={onSelectPlace}
/>,
);
fireEvent.click(mapContainer.querySelector(".omx-arrival-parking-marker") as HTMLElement);
expect(onSelectPlace).toHaveBeenCalledWith(samplePlaces[0]);

rerender(
<ArrivalParkingMarkers
places={samplePlaces}
selectedPlace={null}
onSelectPlace={onSelectPlace}
disabled
/>,
);
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(
<ArrivalParkingMarkers places={[]} selectedPlace={null} onSelectPlace={vi.fn()} />,
Expand Down
18 changes: 7 additions & 11 deletions apps/web/src/components/navigation/ArrivalParkingMarkers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 6 additions & 12 deletions apps/web/src/hooks/usePinMarker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading