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 {