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 {