From 0c7e873eb1ff19c306a05d5ec6ccedbf552ae283 Mon Sep 17 00:00:00 2001 From: jay23606 <4602056+jay23606@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:34:55 -0400 Subject: [PATCH] fix(map): skip flyTo and zoom animations when reduced motion is preferre --- src/components/map/map-view.test.tsx | 29 ++++++++++++++++++++++++ src/components/map/map-view.tsx | 33 +++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/components/map/map-view.test.tsx b/src/components/map/map-view.test.tsx index d081f5d..dbd8269 100644 --- a/src/components/map/map-view.test.tsx +++ b/src/components/map/map-view.test.tsx @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import MapView from "@/components/map/map-view"; +const { flyTo } = vi.hoisted(() => ({ flyTo: vi.fn() })); + vi.mock("react-leaflet", async () => { const React = await import("react"); const map = { @@ -15,6 +17,7 @@ vi.mock("react-leaflet", async () => { getZoom: () => 18, getContainer: () => document.createElement("div"), fitBounds: vi.fn(), + flyTo, scrollWheelZoom: { disable: vi.fn(), enable: vi.fn(), enabled: () => false }, on: vi.fn(), off: vi.fn(), @@ -88,4 +91,30 @@ describe("MapView configuration", () => { screen.getByRole("button", { name: /Heathrow Airport, Airport/ }), ).toBeTruthy(); }); + + it("flies to the user without animation when prefers-reduced-motion is set", () => { + vi.stubEnv("NEXT_PUBLIC_MAPTILER_KEY", "test-key"); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + addEventListener: () => undefined, + removeEventListener: () => undefined, + })); + flyTo.mockClear(); + + render(); + + expect(flyTo).toHaveBeenCalledWith( + [19.2, 72.9], + 14, + expect.objectContaining({ animate: false }), + ); + }); }); diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 63385d6..c0ae184 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -77,6 +77,14 @@ function pinIcon(color: string): L.DivIcon { }); } +/** True when the visitor has asked for reduced motion. Guarded for SSR and jsdom. */ +function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + function boundsToBbox(bounds: L.LatLngBounds): [number, number, number, number] { return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]; } @@ -152,7 +160,10 @@ function ClusteredMarkers({ places }: { places: Place[] }) { index.getClusterExpansionZoom(clusterId), 18, ); - map.flyTo([lat, lng], expansionZoom, { duration: 0.5 }); + map.flyTo([lat, lng], expansionZoom, { + duration: 0.5, + animate: !prefersReducedMotion(), + }); }, keydown: (e) => { if (e.originalEvent.key !== "Enter" && e.originalEvent.key !== " ") @@ -162,7 +173,10 @@ function ClusteredMarkers({ places }: { places: Place[] }) { index.getClusterExpansionZoom(clusterId), 18, ); - map.flyTo([lat, lng], expansionZoom, { duration: 0.5 }); + map.flyTo([lat, lng], expansionZoom, { + duration: 0.5, + animate: !prefersReducedMotion(), + }); }, }} /> @@ -263,7 +277,7 @@ function ClosePopupOnTrigger({ trigger }: { trigger: number }) { function FlyToUser({ lat, lng }: { lat: number; lng: number }) { const map = useMap(); useEffect(() => { - map.flyTo([lat, lng], 14, { duration: 1 }); + map.flyTo([lat, lng], 14, { duration: 1, animate: !prefersReducedMotion() }); }, [map, lat, lng]); return null; } @@ -272,7 +286,7 @@ function FlyToUser({ lat, lng }: { lat: number; lng: number }) { function FlyToPlace({ id, lat, lng }: { id: string; lat: number; lng: number }) { const map = useMap(); useEffect(() => { - map.flyTo([lat, lng], 15, { duration: 1 }); + map.flyTo([lat, lng], 15, { duration: 1, animate: !prefersReducedMotion() }); }, [map, id, lat, lng]); return null; } @@ -307,7 +321,12 @@ function FlyToBounds({ bounds }: { bounds: Bounds }) { [bounds.minLat, bounds.minLng], [bounds.maxLat, bounds.maxLng], ], - { padding: [48, 48], duration: 1, maxZoom: 15 }, + { + padding: [48, 48], + duration: 1, + maxZoom: 15, + animate: !prefersReducedMotion(), + }, ); }, [map, bounds]); return null; @@ -458,6 +477,7 @@ export default function MapView({ } const focusPlace = focusId ? places.find((place) => place.id === focusId) : undefined; + const reduceMotion = prefersReducedMotion(); // Same colorful MapTiler "streets" style in both themes — every dark-mode // map style (CARTO, MapTiler, Mapbox alike) is conventionally muted for @@ -486,6 +506,9 @@ export default function MapView({ zoomControl={interactive} attributionControl={interactive} closePopupOnClick={false} + zoomAnimation={!reduceMotion} + fadeAnimation={!reduceMotion} + markerZoomAnimation={!reduceMotion} className="size-full" >