From 757967bf1866031fbe1689eb7d520666327e78e4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 12:03:12 +0200 Subject: [PATCH] fix(map): the location button follows the viewport instead of the mount Both renderers read window.innerWidth once while rendering, so the button that turns GPS on was decided by the width the map happened to see when it mounted: a phone turned sideways crosses the 768px cut and kept whatever it had, and turning back did not bring it home either. Both now use the same useIsPhone the shell switches on. The test stub for matchMedia answers width queries from window.innerWidth and re-evaluates on a resize, so a breakpoint can be driven from a test the way the browser drives it. --- client/src/components/Map/MapView.test.tsx | 20 +++++++++ client/src/components/Map/MapView.tsx | 6 ++- client/src/components/Map/MapViewGL.tsx | 10 +++-- client/tests/setup.ts | 49 +++++++++++++++++----- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index e81ec544f..53404c79e 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -1030,6 +1030,26 @@ describe('MapView live location', () => { expect(geoMock.cycleMode).toHaveBeenCalled() }) + it('FE-COMP-MAPVIEW-064b: turning the phone sideways and back takes the button with it', () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 }) + geoMock.mode = 'off' + render() + expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument() + + // landscape crosses the 768px cut without remounting the map + act(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 844 }) + window.dispatchEvent(new Event('resize')) + }) + expect(screen.queryByRole('button', { name: 'Show my location' })).toBeNull() + + act(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 }) + window.dispatchEvent(new Event('resize')) + }) + expect(screen.getByRole('button', { name: 'Show my location' })).toBeInTheDocument() + }) + it('FE-COMP-MAPVIEW-077: the map draws no credit control of its own, on either width', () => { const desktop = render() // The desktop credit is Leaflet's own, in its own container; this component adds none. diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index a1aae0dab..9acfb8be6 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -547,6 +547,7 @@ import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } f import { useAuthStore } from '../../store/authStore' import { useGeolocation } from '../../hooks/useGeolocation' import LocationButton from './LocationButton' +import { useIsPhone } from '../../mobile/useIsPhone' // Live-location rendering inside the Leaflet map. Subscribes via the // shared useGeolocation hook so the Leaflet and Mapbox variants behave @@ -1033,7 +1034,10 @@ export const MapView = memo(function MapView({ const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation() // Desktop browsers only get IP-based geolocation (city-level accuracy), // so the button would be misleading. Mobile, where real GPS lives, keeps it. - const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 + // The width is followed rather than read once: a phone turned sideways and + // back crosses the breakpoint twice, and the button used to stay with + // whatever the map saw when it mounted. + const isMobile = useIsPhone() // When the day-detail panel is open it slides up over the map (bottom: navh+20, // height var(--day-panel-h)) and covers the button's band, so lift the button // above it; otherwise keep the plain bottom-nav offset. #1348 diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index bb159d40e..36102b7d1 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -22,6 +22,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes' import { safeHexColor } from '../../utils/safeColor' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import LocationButton from './LocationButton' +import { useIsPhone } from '../../mobile/useIsPhone' import { useGeolocation } from '../../hooks/useGeolocation' import type { Day, Place, Reservation, RouteVia } from '../../types' import type { MapHoverInfo } from './mapHover' @@ -1159,6 +1160,12 @@ export function MapViewGL({ const onMapReadyRef = useRef(onMapReady) onMapReadyRef.current = onMapReady const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation() + // Desktop browsers only get IP-based geolocation (city-level accuracy), so + // the location button would be misleading; the phone, where real GPS lives, + // keeps it. Read here with the other hooks rather than beside the button: + // the tokenless branch below returns early, and a hook after it runs on some + // renders and not others. + const isMobile = useIsPhone() const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu }) onClickRefs.current.marker = onMarkerClick onClickRefs.current.map = onMapClick @@ -2382,9 +2389,6 @@ export function MapViewGL({ ) } - // Desktop browsers only get IP-based geolocation (city-level accuracy), - // so the button would be misleading. Mobile, where real GPS lives, keeps it. - const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 // When the day-detail panel is open it slides up over the map (bottom: navh+20, // height var(--day-panel-h)) and covers the button's band, so lift the button // above it; otherwise keep the plain bottom-nav offset. #1348 diff --git a/client/tests/setup.ts b/client/tests/setup.ts index 4d8c1482e..441298247 100644 --- a/client/tests/setup.ts +++ b/client/tests/setup.ts @@ -50,21 +50,48 @@ Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, op return _origToLocaleDateString.call(this, locales ?? 'en-US', options) } -// window.matchMedia — used by dark mode / responsive components +// window.matchMedia — used by dark mode / responsive components. +// Width queries are answered from window.innerWidth, which is what a test sets +// when it wants a phone viewport, and the lists re-evaluate on a resize event, +// so a component that follows the breakpoint can be driven from a test the same +// way the browser drives it. Every other query keeps the old constant false. +const mediaLists = new Set<{ media: string; matches: boolean; listeners: Set<(e: MediaQueryListEvent) => void> }>() + +function widthMatches(query: string): boolean { + const max = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query) + if (max) return window.innerWidth <= Number(max[1]) + const min = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query) + if (min) return window.innerWidth >= Number(min[1]) + return false +} + Object.defineProperty(window, 'matchMedia', { writable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + value: vi.fn().mockImplementation((query: string) => { + const entry = { media: query, matches: widthMatches(query), listeners: new Set<(e: MediaQueryListEvent) => void>() } + mediaLists.add(entry) + return { + get matches() { return entry.matches }, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.add(fn)), + removeEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.delete(fn)), + dispatchEvent: vi.fn(), + } + }), }); +window.addEventListener('resize', () => { + for (const entry of mediaLists) { + const matches = widthMatches(entry.media) + if (matches === entry.matches) continue + entry.matches = matches + for (const fn of entry.listeners) fn({ matches, media: entry.media } as MediaQueryListEvent) + } +}) + // IntersectionObserver — used by lazy loading // Must use a class or regular function (not arrow function) so 'new IntersectionObserver()' works class _MockIntersectionObserver {