From aaf0876b635202c4888dd40d7f713d918cb95ea3 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 3 Jun 2026 18:15:11 -0700 Subject: [PATCH] fix: support keyboard map area selection --- .../src/components/maps/LocationMapCanvas.jsx | 20 +++++- .../src/styles/pages/LocationMapManager.css | 10 +++ frontend/tests/location-map-manager.spec.ts | 69 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index 5ec636f..c2b985d 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -89,15 +89,28 @@ export default function LocationMapCanvas({ setDragState(null); }; + const selectObject = (object) => { + if (mode === "view" || canEditObjects) { + setSelectedObjectKey(getObjectKey(object)); + } + }; + const handleObjectClick = (event, object) => { event.stopPropagation(); if (suppressPanClickRef.current) { suppressPanClickRef.current = false; return; } - if (mode === "view" || canEditObjects) { - setSelectedObjectKey(getObjectKey(object)); + selectObject(object); + }; + + const handleObjectKeyDown = (event, object) => { + if (!["Enter", " ", "Spacebar"].includes(event.key)) { + return; } + event.stopPropagation(); + event.preventDefault(); + selectObject(object); }; const clearSelection = () => { @@ -242,8 +255,11 @@ export default function LocationMapCanvas({ rx="12" role="button" aria-label={`Map area ${object.label || index + 1}`} + aria-pressed={isSelected} + tabIndex={mode === "view" || canEditObjects ? 0 : -1} onPointerDown={(event) => startDrag(event, object, "move")} onClick={(event) => handleObjectClick(event, object)} + onKeyDown={(event) => handleObjectKeyDown(event, object)} /> {filters.showLabels ? ( diff --git a/frontend/src/styles/pages/LocationMapManager.css b/frontend/src/styles/pages/LocationMapManager.css index ed6cd6e..e58900e 100644 --- a/frontend/src/styles/pages/LocationMapManager.css +++ b/frontend/src/styles/pages/LocationMapManager.css @@ -264,12 +264,22 @@ cursor: grabbing; } +.location-map-object rect:first-child:focus-visible { + outline: none; + stroke: #bfdbfe; + stroke-width: 4; +} + .location-map-object.is-selected rect:first-child { fill: rgba(20, 184, 166, 0.34); stroke: rgb(45, 212, 191); stroke-width: 4; } +.location-map-object.is-selected rect:first-child:focus-visible { + stroke: rgb(45, 212, 191); +} + .location-map-label { fill: #f8fafc; font-size: 24px; diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 9c1b4e4..69eff46 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -1082,6 +1082,75 @@ test("viewer handles empty and many-item zone panels without default pins", asyn await expect(page.locator(".location-map-pin")).toHaveCount(0); }); +test("viewer map areas are keyboard selectable", async ({ page }) => { + await mockMapShell(page); + + const mapState = publishedMapState([ + { + id: 1571, + location_map_id: 901, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 40, + y: 40, + width: 260, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + { + id: 1572, + location_map_id: 901, + zone_id: 502, + zone_name: "Frozen Foods", + type: "zone", + label: "Frozen Foods", + x: 340, + y: 40, + width: 260, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 2, + }, + ], true); + + await page.route("**/households/1/locations/10/map", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mapState), + }); + }); + + await page.goto("/stores/100/locations/10/map"); + + const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); + const frozenArea = page.getByRole("button", { name: "Map area Frozen Foods" }); + await expect(bakeryArea).toHaveAttribute("aria-pressed", "false"); + + await bakeryArea.focus(); + await expect(bakeryArea).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(bakeryArea).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); + await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items"); + await expect(page.getByText("french bread")).toBeVisible(); + + await frozenArea.focus(); + await expect(frozenArea).toBeFocused(); + await page.keyboard.press("Space"); + await expect(frozenArea).toHaveAttribute("aria-pressed", "true"); + await expect(bakeryArea).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Frozen Foods"); + await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown"); +}); + test("mobile fit zoom fits the map into the visible canvas", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page);