Skip to content

Commit d3f9676

Browse files
committed
fix(map): refine context menu accessibility
1 parent 2111c28 commit d3f9676

2 files changed

Lines changed: 208 additions & 19 deletions

File tree

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

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ vi.mock("@/lib/MapContext", () => {
1515
});
1616
vi.mock("@openmapx/core", async (importOriginal) => {
1717
const actual = await importOriginal<typeof import("@openmapx/core")>();
18-
const state = { reverseData: null as { address: string; city: string } | null };
18+
const state = {
19+
reverseData: null as { address: string; city: string } | null,
20+
requiredLocale: null as string | null,
21+
};
1922
return {
2023
...actual,
2124
__test: state,
22-
useReverseGeocoding: () => ({ data: state.reverseData, isLoading: false }),
25+
useReverseGeocoding: (_coordinates: [number, number] | null, locale?: string) => ({
26+
data: !state.requiredLocale || locale === state.requiredLocale ? state.reverseData : null,
27+
isLoading: false,
28+
}),
2329
};
2430
});
2531
vi.mock("@/lib/deepLink", async (importOriginal) => {
@@ -45,7 +51,10 @@ import { MapContextMenu } from "./MapContextMenu";
4551

4652
const coreTest = (
4753
coreModule as unknown as {
48-
__test: { reverseData: { address: string; city: string } | null };
54+
__test: {
55+
reverseData: { address: string; city: string } | null;
56+
requiredLocale: string | null;
57+
};
4958
}
5059
).__test;
5160
const mapContextTest = (
@@ -121,6 +130,22 @@ function deferred<T>() {
121130
return { promise, resolve };
122131
}
123132

133+
function focusVisibleStyle(element: HTMLElement): CSSStyleDeclaration | undefined {
134+
const generatedClass = [...element.classList].find((className) => className.startsWith("css-"));
135+
if (!generatedClass) return undefined;
136+
for (const sheet of document.styleSheets) {
137+
for (const rule of sheet.cssRules) {
138+
if (
139+
rule instanceof CSSStyleRule &&
140+
rule.selectorText === `.${generatedClass}.Mui-focusVisible`
141+
) {
142+
return rule.style;
143+
}
144+
}
145+
}
146+
return undefined;
147+
}
148+
124149
beforeEach(() => {
125150
fake = createFakeMap({
126151
baseLayers: [{ id: "poi-label", type: "symbol", "source-layer": "poi" } as never],
@@ -139,6 +164,7 @@ beforeEach(() => {
139164
mapContextTest.mapReady = true;
140165
mapContextTest.styleVersion = 0;
141166
coreTest.reverseData = null;
167+
coreTest.requiredLocale = null;
142168
shareUrlMock.mockReset();
143169
shareUrlMock.mockResolvedValue("shared");
144170
useDirectionsStore.getState().close();
@@ -218,6 +244,23 @@ describe("MapContextMenu opening", () => {
218244
expect(screen.getByText("1000 Jefferson Drive")).toBeDefined();
219245
});
220246

247+
it("clamps long identity title and address to at most two lines", () => {
248+
coreTest.reverseData = {
249+
city: "A deliberately long reverse-geocoded city identity",
250+
address: "A deliberately long reverse-geocoded street address",
251+
};
252+
render(<MapContextMenu />);
253+
254+
openAtMapPoint();
255+
256+
for (const text of [coreTest.reverseData.city, coreTest.reverseData.address]) {
257+
const style = getComputedStyle(screen.getByText(text));
258+
expect(style.display).toBe("-webkit-box");
259+
expect(style.webkitLineClamp).toBe("2");
260+
expect(style.overflow).toBe("hidden");
261+
}
262+
});
263+
221264
it("clears only the plain-map click target when opening", () => {
222265
useMapClickStore.setState({ clickedLngLat: [1, 2] });
223266
const selectedPlace = createPlace({
@@ -350,6 +393,20 @@ describe("MapContextMenu route and place actions", () => {
350393
expect(useSidebarStore.getState().activeDetailId).toBeNull();
351394
});
352395

396+
it("uses the active-locale reverse-geocoded address for a named POI", async () => {
397+
fake.setRenderedFeatures("poi-label", [poiFeature()]);
398+
coreTest.requiredLocale = "en";
399+
coreTest.reverseData = { city: "Washington", address: "1000 Jefferson Drive" };
400+
render(<MapContextMenu />);
401+
openAtMapPoint();
402+
403+
await userEvent.click(
404+
screen.getByRole("menuitem", { name: "mapContextMenu.openPlaceDetails" }),
405+
);
406+
407+
expect(usePlaceStore.getState().selectedPlace?.address).toBe("1000 Jefferson Drive");
408+
});
409+
353410
it("uses canonical coordinate identity and preserves an unrelated sidebar", async () => {
354411
coreTest.reverseData = { city: "Washington", address: "1000 Jefferson Drive" };
355412
useSidebarStore.setState({ activeSidebarId: PANEL.CATEGORY });
@@ -401,6 +458,59 @@ describe("MapContextMenu keyboard behavior", () => {
401458
});
402459
}
403460

461+
for (const status of ["navigating", "rerouting"] as const) {
462+
it(`suppresses keyboard opening and native behavior without mutating state while ${status}`, () => {
463+
useNavigationStore.setState({ status });
464+
useMapClickStore.setState({ clickedLngLat: [1, 2] });
465+
const selectedPlace = createPlace({
466+
primaryScheme: "test",
467+
ids: { test: "kept" },
468+
name: "Kept place",
469+
address: "Kept address",
470+
coordinates: [3, 4],
471+
});
472+
usePlaceStore.setState({ selectedPlace });
473+
useSidebarStore.setState({ activeSidebarId: PANEL.CATEGORY });
474+
const waypointsBefore = useDirectionsStore.getState().waypoints;
475+
render(<MapContextMenu />);
476+
const event = new KeyboardEvent("keydown", {
477+
key: "ContextMenu",
478+
bubbles: true,
479+
cancelable: true,
480+
});
481+
482+
fireEvent(fake.state.canvas, event);
483+
484+
expect(event.defaultPrevented).toBe(true);
485+
expect(screen.queryByRole("menu")).toBeNull();
486+
expect(useMapClickStore.getState().clickedLngLat).toEqual([1, 2]);
487+
expect(usePlaceStore.getState().selectedPlace).toBe(selectedPlace);
488+
expect(useDirectionsStore.getState().waypoints).toBe(waypointsBefore);
489+
expect(useSidebarStore.getState().activeSidebarId).toBe(PANEL.CATEGORY);
490+
});
491+
}
492+
493+
it("defines visible theme-aware focus rings for both keyboard-focused route pills", async () => {
494+
render(<MapContextMenu />);
495+
openAtMapPoint();
496+
const from = screen.getByRole("menuitem", { name: "mapContextMenu.fromHere" });
497+
const to = screen.getByRole("menuitem", { name: "mapContextMenu.toHere" });
498+
499+
await userEvent.keyboard("{ArrowRight}");
500+
expect(document.activeElement).toBe(to);
501+
const toStyle = focusVisibleStyle(to);
502+
expect(toStyle?.outline.startsWith("2px solid ")).toBe(true);
503+
expect(toStyle?.outline.includes("transparent")).toBe(false);
504+
expect(toStyle?.outlineOffset).toBe("2px");
505+
506+
await userEvent.keyboard("{ArrowLeft}");
507+
expect(document.activeElement).toBe(from);
508+
const fromStyle = focusVisibleStyle(from);
509+
expect(fromStyle?.outline.startsWith("2px solid ")).toBe(true);
510+
expect(fromStyle?.outline.includes("transparent")).toBe(false);
511+
expect(fromStyle?.outlineOffset).toBe("2px");
512+
});
513+
404514
it("supports roving focus, submenu return, Escape order, and canvas restoration", async () => {
405515
render(<MapContextMenu />);
406516
fireEvent.keyDown(fake.state.canvas, { key: "ContextMenu" });
@@ -454,6 +564,19 @@ describe("MapContextMenu keyboard behavior", () => {
454564
});
455565

456566
describe("MapContextMenu copy behavior", () => {
567+
it("exposes copy submenu state on its trigger", async () => {
568+
render(<MapContextMenu />);
569+
openAtMapPoint();
570+
const copy = screen.getByRole("menuitem", { name: "mapContextMenu.copyLocation" });
571+
572+
expect(copy.getAttribute("aria-haspopup")).toBe("menu");
573+
expect(copy.getAttribute("aria-expanded")).toBe("false");
574+
575+
await userEvent.click(copy);
576+
577+
expect(copy.getAttribute("aria-expanded")).toBe("true");
578+
});
579+
457580
it("opens the copy submenu to the left when the right edge has insufficient room", async () => {
458581
render(<MapContextMenu />);
459582
openAtMapPoint();
@@ -622,6 +745,33 @@ describe("MapContextMenu share behavior", () => {
622745
});
623746

624747
describe("MapContextMenu dismissal and semantics", () => {
748+
it("does not steal focus when the map moves with no open context menu", async () => {
749+
render(
750+
<>
751+
<input aria-label="Outside focus target" />
752+
<MapContextMenu />
753+
</>,
754+
);
755+
const outside = screen.getByRole("textbox", { name: "Outside focus target" });
756+
outside.focus();
757+
758+
act(() => fake.emit("movestart"));
759+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
760+
761+
expect(document.activeElement).toBe(outside);
762+
});
763+
764+
it("restores canvas focus when a style reload dismisses an open menu", async () => {
765+
const view = render(<MapContextMenu />);
766+
openAtMapPoint();
767+
768+
mapContextTest.styleVersion += 1;
769+
view.rerender(<MapContextMenu />);
770+
771+
expect(screen.queryByRole("menu")).toBeNull();
772+
await waitFor(() => expect(document.activeElement).toBe(fake.state.canvas));
773+
});
774+
625775
it("dismisses on outside click, map movement, and style-version change", async () => {
626776
const view = render(<MapContextMenu />);
627777
openAtMapPoint();

0 commit comments

Comments
 (0)