From b2dc4d0779ced02709429afaa5985acf4abda794 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 6 Jul 2026 14:09:37 +0100 Subject: [PATCH] fix(map): results map fullscreen falls back to an overlay on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The results-view map's fullscreen button called requestFullscreen(), which iOS Safari doesn't implement (fullscreen is video-only there), so tapping it did nothing on iPhones — the same gap already fixed for the school hero map. When the Fullscreen API is missing or its promise rejects, fall back to a fixed-position overlay (.fsFallback, z-index 5000) driven by state, locking body scroll while open. Leaflet re-measures on window resize, so dispatch a resize when fullscreen toggles (the CSS overlay fires none) or the map would fill only part of the screen. Native fullscreen is unchanged. New e2e journey deletes Element.requestFullscreen on a mobile viewport, opens the results map fullscreen, and asserts the exit control appears then releases; it fails against current production, reproducing the bug. Co-Authored-By: Claude Fable 5 --- e2e/tests/journeys.spec.ts | 25 ++++++++++++ nextjs-app/components/SchoolMap.module.css | 9 +++++ nextjs-app/components/SchoolMap.tsx | 46 ++++++++++++++++++---- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 2be8c7a..3d46882 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -75,6 +75,31 @@ test('school hero map opens fullscreen on mobile without the Fullscreen API', as await expect(openMap).toBeVisible(); }); +test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => { + // Same iOS gap as the hero map: no Element.requestFullscreen, so the results + // map's fullscreen button must fall back to a CSS overlay. + await page.setViewportSize({ width: 390, height: 844 }); + await page.addInitScript(() => { + // @ts-expect-error deliberate API removal + delete Element.prototype.requestFullscreen; + }); + + await searchByName(page, 'B1 1BB'); + await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); + + // Switch to the map view, then open the map fullscreen. + await page.getByRole('button', { name: 'Map', exact: true }).click(); + const openFs = page.getByRole('button', { name: 'View map fullscreen' }); + await expect(openFs).toBeVisible({ timeout: 15_000 }); + await openFs.click(); + + // The button flips to its exit state once the overlay is up. + const exitFs = page.getByRole('button', { name: 'Exit fullscreen' }); + await expect(exitFs).toBeVisible(); + await exitFs.click(); + await expect(openFs).toBeVisible(); +}); + test('comparing two schools shows both side by side', async ({ page }) => { // Collect two school URNs from search results, then load the share URL await searchByName(page, 'primary'); diff --git a/nextjs-app/components/SchoolMap.module.css b/nextjs-app/components/SchoolMap.module.css index 98a004c..e4a4e9c 100644 --- a/nextjs-app/components/SchoolMap.module.css +++ b/nextjs-app/components/SchoolMap.module.css @@ -10,6 +10,15 @@ height: 100dvh; } +/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API + can't promote the element, so pin it over the page ourselves. Above the + comparison toast (3000) and the bottom nav; below modals (9999+). */ +.mapWrapper.fsFallback { + position: fixed; + inset: 0; + z-index: 5000; +} + .fullscreenBtn { position: absolute; top: 0.625rem; diff --git a/nextjs-app/components/SchoolMap.tsx b/nextjs-app/components/SchoolMap.tsx index 4cd8119..dd58f4c 100644 --- a/nextjs-app/components/SchoolMap.tsx +++ b/nextjs-app/components/SchoolMap.tsx @@ -33,22 +33,52 @@ interface SchoolMapProps { export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) { const wrapperRef = useRef(null); - const [isFullscreen, setIsFullscreen] = useState(false); + const [nativeFullscreen, setNativeFullscreen] = useState(false); + // iOS Safari has no Element.requestFullscreen — fall back to a fixed-position + // overlay driven by state instead of the Fullscreen API. + const [fallbackFullscreen, setFallbackFullscreen] = useState(false); + const isFullscreen = nativeFullscreen || fallbackFullscreen; // Sync state with browser fullscreen events (e.g. Escape key) useEffect(() => { - const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); + const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange); }, []); + // Lock body scroll while the fallback overlay is up. + useEffect(() => { + if (!fallbackFullscreen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, [fallbackFullscreen]); + + // Leaflet re-measures on window resize (trackResize). Native fullscreen fires + // one; the CSS fallback overlay changes size without a resize event, so nudge + // Leaflet after the layout settles or the map fills only part of the screen. + useEffect(() => { + const id = requestAnimationFrame(() => window.dispatchEvent(new Event('resize'))); + return () => cancelAnimationFrame(id); + }, [isFullscreen]); + const toggleFullscreen = useCallback(() => { - if (!document.fullscreenElement) { - wrapperRef.current?.requestFullscreen(); - } else { - document.exitFullscreen(); + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + return; } - }, []); + if (fallbackFullscreen) { + setFallbackFullscreen(false); + return; + } + const el = wrapperRef.current; + if (!el) return; + if (el.requestFullscreen) { + el.requestFullscreen().catch(() => setFallbackFullscreen(true)); + } else { + setFallbackFullscreen(true); + } + }, [fallbackFullscreen]); // Calculate center if not provided const mapCenter: [number, number] = center || (() => { @@ -64,7 +94,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker })(); return ( -
+