Skip to content

Commit be02daa

Browse files
authored
Add swipe and button navigation for previous/next zip code on mobile map (#504)
Design context: #404 ## Problem The dot pagination on the map page is hidden below 768px (`hidden md:block`), so on mobile there is no way to move between zip codes except tapping a polygon directly. This was split out of #404 to keep that PR small. ## What this adds - **Swipe** on the zip details card - left for next, right for previous. Bound to the card rather than the map, so it never competes with MapLibre's pan and pinch-zoom. - **Previous/next buttons** below 768px with an "N of 13" position counter, placed at the top of the card so they're visible without scrolling. - Wrap-around at both ends, so no control is ever a dead end. Shared with the desktop dots via a `mod` helper moved into `lib/utils.ts`. - An `aria-live` region announcing the selected zip code. ## Design decision worth flagging @GTBarrows - the mockup in #404 annotates the `《 》` chevrons "Swipe indicator, not to be including in final production," which reads as swipe-only. I've shipped visible buttons anyway, because #482 is framed as an accessibility improvement and swipe-only has no keyboard path, no screen reader affordance, and no discoverability. The buttons are 44px to meet WCAG 2.5.5. The dots they replace are 12px, which is the same reason the carousel was cut from mobile in the first place. Happy to change this if you'd rather keep the card clean - wanted the reasoning explicit rather than quietly diverging from the design. ## Accessibility note This adds the app's first `aria-live` region. It sits on the card rather than inside `ZipDetailsContent`, which early-returns for error and empty states, so zip changes are announced regardless of source: map click, desktop dots, mobile buttons, or swipe. That fixes a pre-existing desktop gap as a side effect. `DotPagination`'s chevron buttons also had no accessible name; they now take optional label props. ## Out of scope - **Map recentering.** On mobile the card covers the lower half of the viewport, so navigating can highlight a polygon hidden behind it. Left out pending @CurtSavoie's call on whether it belongs here or in a follow-up. - **Other locales.** New keys added to `en-US.json` only. `es-ES`, `zh-CN`, and `zh-TW` need a team decision on whether new keys get real translations, English placeholders, or `null` like `template.json`. ## Testing `npm run test`, `npm run build`, `npm run lint`, and `npm run lint:css` all clean. The swipe decision is extracted as a pure `resolveSwipe()` with 8 unit tests, so it's covered by the existing Vitest setup. The repo has no component testing infrastructure (no Testing Library or jsdom) and adding it seemed out of scope for an issue explicitly about keeping the PR small. Manually verified at mobile widths in Chrome device emulation: - Swipe left and right advance and reverse correctly - Wrap-around works in both directions - Vertical card scrolling does not trigger navigation - Diagonal drags are rejected - Map pan and pinch-zoom unaffected - Buttons keyboard-reachable; Enter and Space both activate - Dots return and mobile nav disappears above 768px - All new i18n keys resolve, no raw key strings rendered
1 parent d22642e commit be02daa

8 files changed

Lines changed: 314 additions & 12 deletions

File tree

client/src/components/pages/maps/BostonZipCodeMap.module.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,9 @@
5757
max-height: 50vh;
5858
padding: 16px;
5959
pointer-events: auto;
60+
61+
/* Let the browser keep handling vertical scrolling natively while
62+
horizontal gestures are reserved for zip code navigation. */
63+
touch-action: pan-y;
6064
}
61-
}
65+
}

client/src/components/pages/maps/BostonZipCodeMap.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import {
55
useEffect,
66
RefObject,
77
useState,
8+
useCallback,
89
Dispatch,
910
SetStateAction,
1011
} from "react";
11-
import { FormattedMessage } from "react-intl";
12+
import { FormattedMessage, useIntl } from "react-intl";
1213
import { PageHeader } from "@/components/ui/pageheader";
14+
import { mod } from "@/lib/utils";
1315
import {
1416
BusinessLicense,
1517
EligibleBostonZipcode,
@@ -21,6 +23,8 @@ import licenseData from "../../../data/licenses.json";
2123
import DotPagination from "../../../components/ui/dot-pagination";
2224
import mapStyles from "./BostonZipCodeMap.module.css";
2325
import "./mapStyleOverrides.css";
26+
import { MobileZipNav } from "./MobileZipNav";
27+
import { useZipSwipe } from "./useZipSwipe";
2428
import { ZipDetailsContent } from "./ZipDetailsContent";
2529

2630
/* Map Styles */
@@ -283,6 +287,7 @@ const getValidatedLicenseData = (): BusinessLicense[] => {
283287
};
284288

285289
export const BostonZipCodeMap = () => {
290+
const intl = useIntl();
286291
const mapContainer = useRef<HTMLDivElement | null>(null);
287292
const map = useRef<Map | null>(null);
288293
const hoverZipId = useRef<string | number | undefined>("");
@@ -299,6 +304,30 @@ export const BostonZipCodeMap = () => {
299304
const licenses = getValidatedLicenseData();
300305
const [selectedZip, setSelectedZip] = useState<EligibleBostonZipcode>(uniqueZips[0]);
301306

307+
const selectedZipIndex = uniqueZips.indexOf(selectedZip);
308+
309+
// Shared by the dot pagination, the mobile buttons, and swipe. Wraps at both
310+
// ends so there is never a dead control.
311+
const goToZipOffset = useCallback(
312+
(offset: number) => {
313+
setSelectedZip((current) => {
314+
const currentIndex = uniqueZips.indexOf(current);
315+
return uniqueZips[mod(currentIndex + offset, uniqueZips.length)];
316+
});
317+
},
318+
// uniqueZips is derived from a module-level constant Set, so its contents
319+
// are stable across renders.
320+
// eslint-disable-next-line react-hooks/exhaustive-deps
321+
[]
322+
);
323+
324+
const goToPreviousZip = useCallback(() => goToZipOffset(-1), [goToZipOffset]);
325+
const goToNextZip = useCallback(() => goToZipOffset(1), [goToZipOffset]);
326+
327+
const swipeHandlers = useZipSwipe({
328+
onSwipe: (direction) => goToZipOffset(direction === "next" ? 1 : -1),
329+
});
330+
302331
// Initialize map
303332
useEffect(() => {
304333
if (map.current) return; // stops map from intializing more than once
@@ -368,16 +397,35 @@ export const BostonZipCodeMap = () => {
368397
<div
369398
className={mapStyles.mapCard}
370399
id="zip-code-details-card"
400+
{...swipeHandlers}
371401
>
402+
{/* Announces the change to screen readers, whichever control
403+
caused it: map click, dot pagination, buttons, or swipe. */}
404+
<p className="sr-only" role="status" aria-live="polite">
405+
<FormattedMessage
406+
id="map.zipNav.announcement"
407+
values={{ zipCode: selectedZip }}
408+
/>
409+
</p>
410+
{/* Kept outside ZipDetailsContent so navigation stays available
411+
in the error and empty-data states, which return early. */}
412+
<MobileZipNav
413+
currentIndex={selectedZipIndex}
414+
totalZips={uniqueZips.length}
415+
onPrevious={goToPreviousZip}
416+
onNext={goToNextZip}
417+
/>
372418
<ZipDetailsContent licenses={licenses} zipCode={selectedZip} />
373419
<div className="hidden md:block">
374420
<DotPagination
375-
currentPage={selectedZip ? uniqueZips.indexOf(selectedZip) : 0}
421+
currentPage={selectedZip ? selectedZipIndex : 0}
376422
totalPages={uniqueZips.length}
377423
onPageChange={(newZipIndex) => {
378424
setSelectedZip(uniqueZips[newZipIndex]);
379425
}}
380426
labels={uniqueZips}
427+
previousLabel={intl.formatMessage({ id: "map.zipNav.previous" })}
428+
nextLabel={intl.formatMessage({ id: "map.zipNav.next" })}
381429
/>
382430
</div>
383431
</div>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
2+
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
3+
import { FormattedMessage, useIntl } from "react-intl";
4+
5+
type MobileZipNavProps = {
6+
currentIndex: number;
7+
totalZips: number;
8+
onPrevious: () => void;
9+
onNext: () => void;
10+
};
11+
12+
// 44px matches the minimum touch target size in WCAG 2.5.5. The dot pagination
13+
// this replaces on mobile uses 12px dots, which is well under that.
14+
const navButtonClasses = [
15+
"flex items-center justify-center",
16+
"w-[44px] h-[44px] shrink-0",
17+
"border-[2px] border-button-hovered-light rounded-[4px]",
18+
"bg-background-light cursor-pointer",
19+
"hover:bg-button-hovered-light",
20+
"focus-visible:outline-2 focus-visible:outline-offset-2",
21+
].join(" ");
22+
23+
/**
24+
* Previous/next zip controls shown only below the 768px breakpoint, where the
25+
* dot pagination is hidden. The position counter stands in for the dots, which
26+
* conveyed both navigation and where you were in the sequence.
27+
*/
28+
export const MobileZipNav = ({
29+
currentIndex,
30+
totalZips,
31+
onPrevious,
32+
onNext,
33+
}: MobileZipNavProps) => {
34+
const intl = useIntl();
35+
36+
return (
37+
<nav
38+
className="flex md:hidden items-center justify-between gap-2 mb-3"
39+
aria-label={intl.formatMessage({ id: "map.zipNav.label" })}
40+
>
41+
<button
42+
type="button"
43+
onClick={onPrevious}
44+
className={navButtonClasses}
45+
aria-label={intl.formatMessage({ id: "map.zipNav.previous" })}
46+
>
47+
<ChevronLeftIcon
48+
aria-hidden
49+
sx={{ fill: "var(--color-button-default-dark)" }}
50+
/>
51+
</button>
52+
53+
<p className="text-center">
54+
<FormattedMessage
55+
id="map.zipNav.position"
56+
values={{ current: currentIndex + 1, total: totalZips }}
57+
/>
58+
</p>
59+
60+
<button
61+
type="button"
62+
onClick={onNext}
63+
className={navButtonClasses}
64+
aria-label={intl.formatMessage({ id: "map.zipNav.next" })}
65+
>
66+
<ChevronRightIcon
67+
aria-hidden
68+
sx={{ fill: "var(--color-button-default-dark)" }}
69+
/>
70+
</button>
71+
</nav>
72+
);
73+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
resolveSwipe,
4+
SWIPE_AXIS_RATIO,
5+
SWIPE_MIN_DISTANCE_PX,
6+
} from "./useZipSwipe";
7+
8+
describe("resolveSwipe", () => {
9+
it("advances on a decisive leftward swipe", () => {
10+
expect(resolveSwipe({ dx: -120, dy: 6 })).toBe("next");
11+
});
12+
13+
it("goes back on a decisive rightward swipe", () => {
14+
expect(resolveSwipe({ dx: 120, dy: 6 })).toBe("prev");
15+
});
16+
17+
it("ignores travel shorter than the minimum distance", () => {
18+
const justUnder = SWIPE_MIN_DISTANCE_PX - 1;
19+
expect(resolveSwipe({ dx: -justUnder, dy: 0 })).toBeNull();
20+
expect(resolveSwipe({ dx: justUnder, dy: 0 })).toBeNull();
21+
});
22+
23+
it("accepts travel exactly at the minimum distance", () => {
24+
expect(resolveSwipe({ dx: -SWIPE_MIN_DISTANCE_PX, dy: 0 })).toBe("next");
25+
});
26+
27+
it("ignores a vertical drag so card scrolling still works", () => {
28+
expect(resolveSwipe({ dx: 10, dy: 200 })).toBeNull();
29+
expect(resolveSwipe({ dx: -10, dy: -200 })).toBeNull();
30+
});
31+
32+
it("ignores a diagonal drag that is not decisively horizontal", () => {
33+
// 80px across, 70px down: over the distance floor, under the axis ratio.
34+
expect(resolveSwipe({ dx: -80, dy: 70 })).toBeNull();
35+
});
36+
37+
it("accepts a diagonal drag once it clears the axis ratio", () => {
38+
const dy = 40;
39+
const dx = -(dy * SWIPE_AXIS_RATIO + 1);
40+
expect(resolveSwipe({ dx, dy })).toBe("next");
41+
});
42+
43+
it("ignores a touch that did not move", () => {
44+
expect(resolveSwipe({ dx: 0, dy: 0 })).toBeNull();
45+
});
46+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { useCallback, useRef, type TouchEvent } from "react";
2+
3+
/**
4+
* Minimum horizontal travel, in px, before a touch counts as a swipe.
5+
* Anything shorter is treated as a tap or an accidental drag.
6+
*/
7+
export const SWIPE_MIN_DISTANCE_PX = 48;
8+
9+
/**
10+
* How much more horizontal than vertical the travel must be. The zip details
11+
* card scrolls vertically, so a mostly-vertical drag must never be read as a
12+
* swipe or the card becomes impossible to scroll.
13+
*/
14+
export const SWIPE_AXIS_RATIO = 1.5;
15+
16+
/**
17+
* Touches starting this close to either screen edge are ignored, so we don't
18+
* compete with the browser's own edge-swipe back/forward gesture (iOS Safari).
19+
*/
20+
export const SWIPE_EDGE_GUARD_PX = 24;
21+
22+
export type SwipeDirection = "prev" | "next";
23+
24+
export type SwipeVector = {
25+
dx: number;
26+
dy: number;
27+
};
28+
29+
/**
30+
* Pure swipe decision. Exported separately from the hook so it can be unit
31+
* tested without a DOM environment.
32+
*
33+
* Swiping left (negative dx) advances forward, matching the direction
34+
* convention of a carousel: the content moves left as you move forward.
35+
*/
36+
export function resolveSwipe({ dx, dy }: SwipeVector): SwipeDirection | null {
37+
const distanceX = Math.abs(dx);
38+
const distanceY = Math.abs(dy);
39+
40+
if (distanceX < SWIPE_MIN_DISTANCE_PX) return null;
41+
if (distanceX < distanceY * SWIPE_AXIS_RATIO) return null;
42+
43+
return dx < 0 ? "next" : "prev";
44+
}
45+
46+
type UseZipSwipeOptions = {
47+
onSwipe: (direction: SwipeDirection) => void;
48+
};
49+
50+
/**
51+
* Returns touch handlers to spread onto the element that should respond to
52+
* horizontal swipes. Attach these to the zip details card rather than the map
53+
* container: the card sits above the map as a sibling node, so touches on it
54+
* never reach MapLibre's canvas and cannot interfere with pan or pinch-zoom.
55+
*
56+
* The element should also set `touch-action: pan-y` so the browser keeps
57+
* handling vertical scrolling natively.
58+
*/
59+
export function useZipSwipe({ onSwipe }: UseZipSwipeOptions) {
60+
const origin = useRef<{ x: number; y: number } | null>(null);
61+
62+
const onTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
63+
// Multi-touch is a pinch, not a swipe.
64+
if (event.touches.length !== 1) {
65+
origin.current = null;
66+
return;
67+
}
68+
69+
const touch = event.touches[0];
70+
const nearLeftEdge = touch.clientX <= SWIPE_EDGE_GUARD_PX;
71+
const nearRightEdge =
72+
touch.clientX >= window.innerWidth - SWIPE_EDGE_GUARD_PX;
73+
74+
if (nearLeftEdge || nearRightEdge) {
75+
origin.current = null;
76+
return;
77+
}
78+
79+
origin.current = { x: touch.clientX, y: touch.clientY };
80+
}, []);
81+
82+
const onTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
83+
// A second finger landing mid-gesture cancels the swipe.
84+
if (event.touches.length > 1) {
85+
origin.current = null;
86+
}
87+
}, []);
88+
89+
const onTouchEnd = useCallback(
90+
(event: TouchEvent<HTMLDivElement>) => {
91+
const start = origin.current;
92+
origin.current = null;
93+
94+
if (!start) return;
95+
96+
const touch = event.changedTouches[0];
97+
if (!touch) return;
98+
99+
const direction = resolveSwipe({
100+
dx: touch.clientX - start.x,
101+
dy: touch.clientY - start.y,
102+
});
103+
104+
if (direction) {
105+
onSwipe(direction);
106+
}
107+
},
108+
[onSwipe]
109+
);
110+
111+
const onTouchCancel = useCallback(() => {
112+
origin.current = null;
113+
}, []);
114+
115+
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel };
116+
}

0 commit comments

Comments
 (0)