Skip to content

Commit 2111c28

Browse files
committed
fix(map): preserve context menu lifecycle
1 parent 3d1b995 commit 2111c28

2 files changed

Lines changed: 330 additions & 131 deletions

File tree

apps/web/src/components/map/MapContextMenu.test.tsx

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const mapContextTest = (
5656
const shareUrlMock = (deepLinkModule as unknown as { __test: ReturnType<typeof vi.fn> }).__test;
5757

5858
let fake: ReturnType<typeof createFakeMap>;
59+
let domContextCoordinates: [number, number];
5960

6061
function poiFeature(name = "Smithsonian Institution Building"): MapGeoJSONFeature {
6162
return {
@@ -95,10 +96,44 @@ function openAtMapPoint(
9596
return preventDefault;
9697
}
9798

99+
function dispatchDomMapContextMenu(coordinates: [number, number]) {
100+
domContextCoordinates = coordinates;
101+
const modalRoot = document.querySelector<HTMLElement>(".MuiPopover-root");
102+
const blockingLayer =
103+
modalRoot && getComputedStyle(modalRoot).pointerEvents !== "none"
104+
? (modalRoot.querySelector<HTMLElement>(".MuiBackdrop-root") ?? modalRoot)
105+
: fake.state.canvas;
106+
const event = new MouseEvent("contextmenu", {
107+
bubbles: true,
108+
cancelable: true,
109+
clientX: 240,
110+
clientY: 260,
111+
});
112+
fireEvent(blockingLayer, event);
113+
return { blockingLayer, event };
114+
}
115+
116+
function deferred<T>() {
117+
let resolve!: (value: T) => void;
118+
const promise = new Promise<T>((done) => {
119+
resolve = done;
120+
});
121+
return { promise, resolve };
122+
}
123+
98124
beforeEach(() => {
99125
fake = createFakeMap({
100126
baseLayers: [{ id: "poi-label", type: "symbol", "source-layer": "poi" } as never],
101127
});
128+
domContextCoordinates = [-77.02573, 38.88859];
129+
fake.state.canvas.addEventListener("contextmenu", (event) => {
130+
const [lng, lat] = domContextCoordinates;
131+
fake.emit("contextmenu", {
132+
point: { x: event.clientX, y: event.clientY },
133+
lngLat: { lng, lat },
134+
originalEvent: event,
135+
});
136+
});
102137
document.body.appendChild(fake.state.canvas);
103138
mapContextTest.mapRef.current = fake.map;
104139
mapContextTest.mapReady = true;
@@ -402,6 +437,20 @@ describe("MapContextMenu keyboard behavior", () => {
402437

403438
await waitFor(() => expect(document.activeElement).toBe(from));
404439
});
440+
441+
it("lets an already-open menu receive a second real DOM map contextmenu", async () => {
442+
render(<MapContextMenu />);
443+
dispatchDomMapContextMenu([-77.02573, 38.88859]);
444+
expect(screen.getByText("38.888590, -77.025730")).toBeDefined();
445+
446+
const second = dispatchDomMapContextMenu([13.405, 52.52]);
447+
448+
expect(second.blockingLayer).toBe(fake.state.canvas);
449+
expect(second.event.defaultPrevented).toBe(true);
450+
expect(screen.getByText("52.520000, 13.405000")).toBeDefined();
451+
await userEvent.click(screen.getByRole("menuitem", { name: "mapContextMenu.fromHere" }));
452+
expect(useDirectionsStore.getState().origin).toEqual([13.405, 52.52]);
453+
});
405454
});
406455

407456
describe("MapContextMenu copy behavior", () => {
@@ -454,6 +503,46 @@ describe("MapContextMenu copy behavior", () => {
454503
expect(screen.getByText("mapContextMenu.copied")).toBeDefined();
455504
});
456505

506+
it("ignores a pending copy completion after unmount", async () => {
507+
const pending = deferred<void>();
508+
installClipboard(vi.fn(() => pending.promise));
509+
const view = render(<MapContextMenu />);
510+
openAtMapPoint();
511+
const submenu = await openCopySubmenu();
512+
await userEvent.click(
513+
within(submenu).getByRole("menuitem", { name: /38\.888590, -77\.025730/ }),
514+
);
515+
const focusCanvas = vi.spyOn(fake.state.canvas, "focus");
516+
517+
view.unmount();
518+
focusCanvas.mockClear();
519+
await act(async () => pending.resolve());
520+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
521+
522+
expect(focusCanvas).not.toHaveBeenCalled();
523+
});
524+
525+
it("does not let a pending copy from target A close or update target B", async () => {
526+
const pending = deferred<void>();
527+
installClipboard(vi.fn(() => pending.promise));
528+
render(<MapContextMenu />);
529+
openAtMapPoint();
530+
const submenu = await openCopySubmenu();
531+
await userEvent.click(
532+
within(submenu).getByRole("menuitem", { name: /38\.888590, -77\.025730/ }),
533+
);
534+
535+
openAtMapPoint({ coordinates: [13.405, 52.52] });
536+
await act(async () => pending.resolve());
537+
538+
expect(
539+
within(screen.getByRole("menu", { name: "mapContextMenu.ariaLabel" })).getByText(
540+
"52.520000, 13.405000",
541+
),
542+
).toBeDefined();
543+
expect(screen.queryByText("mapContextMenu.copied")).toBeNull();
544+
});
545+
457546
for (const mode of ["rejected", "unavailable"] as const) {
458547
it(`keeps the main card open and shows failure when Clipboard is ${mode}`, async () => {
459548
if (mode === "rejected") installClipboard(vi.fn().mockRejectedValue(new Error("denied")));
@@ -500,13 +589,44 @@ describe("MapContextMenu share behavior", () => {
500589
expect(window.location.href).toBe(hrefBefore);
501590
});
502591
}
592+
593+
it("ignores a pending share completion after unmount", async () => {
594+
const pending = deferred<"copied">();
595+
shareUrlMock.mockImplementation(() => pending.promise);
596+
const view = render(<MapContextMenu />);
597+
openAtMapPoint();
598+
await userEvent.click(screen.getByRole("menuitem", { name: "mapContextMenu.shareLocation" }));
599+
const focusCanvas = vi.spyOn(fake.state.canvas, "focus");
600+
601+
view.unmount();
602+
focusCanvas.mockClear();
603+
await act(async () => pending.resolve("copied"));
604+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
605+
606+
expect(focusCanvas).not.toHaveBeenCalled();
607+
});
608+
609+
it("does not let a pending share from target A close or update target B", async () => {
610+
const pending = deferred<"copied">();
611+
shareUrlMock.mockImplementation(() => pending.promise);
612+
render(<MapContextMenu />);
613+
openAtMapPoint();
614+
await userEvent.click(screen.getByRole("menuitem", { name: "mapContextMenu.shareLocation" }));
615+
616+
openAtMapPoint({ coordinates: [13.405, 52.52] });
617+
await act(async () => pending.resolve("copied"));
618+
619+
expect(screen.getByText("52.520000, 13.405000")).toBeDefined();
620+
expect(screen.queryByText("mapContextMenu.linkCopied")).toBeNull();
621+
});
503622
});
504623

505624
describe("MapContextMenu dismissal and semantics", () => {
506-
it("dismisses on outside click, map movement, and style-version change", () => {
625+
it("dismisses on outside click, map movement, and style-version change", async () => {
507626
const view = render(<MapContextMenu />);
508627
openAtMapPoint();
509-
fireEvent.click(document.querySelector(".MuiBackdrop-root") as Element);
628+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
629+
fireEvent.click(fake.state.canvas);
510630
expect(screen.queryByRole("menu")).toBeNull();
511631

512632
openAtMapPoint();

0 commit comments

Comments
 (0)