From d52d384cf23d282b44e9251176f8f3402d808600 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 5 Jul 2026 21:27:41 +0100 Subject: [PATCH] fix(school): make hero map fullscreen work on iOS Safari iOS Safari has no Element.requestFullscreen (fullscreen is video-only), so tapping the map band or 'View on map' silently did nothing on iPhones. Fall back to a fixed-position CSS overlay driven by state when the Fullscreen API is missing or its promise rejects, locking body scroll while open. The Leaflet map already re-measures via the shared isFullscreen flag. New e2e journey simulates the iOS condition by deleting the API and asserts the overlay opens and closes; it fails against the current production build. Co-Authored-By: Claude Fable 5 --- e2e/tests/journeys.spec.ts | 25 ++++++++++++++ .../components/SchoolHeroMap.module.css | 9 +++++ nextjs-app/components/SchoolHeroMap.tsx | 33 ++++++++++++++++--- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 2ae9462..284baa1 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -50,6 +50,31 @@ test('school detail page renders name and performance data', async ({ page }) => await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); }); +test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => { + // iOS Safari has no Element.requestFullscreen; the map must fall back to a + // CSS overlay. Simulate that by removing the API before any page script runs. + await page.setViewportSize({ width: 390, height: 844 }); + await page.addInitScript(() => { + // @ts-expect-error deliberate API removal + delete Element.prototype.requestFullscreen; + }); + + await searchByName(page, 'primary'); + const firstSchool = schoolLinks(page).first(); + await expect(firstSchool).toBeVisible({ timeout: 15_000 }); + await firstSchool.click(); + await page.waitForURL(/\/school\//); + + const openMap = page.getByRole('button', { name: 'Open full map' }); + await expect(openMap).toBeVisible({ timeout: 15_000 }); + await openMap.click(); + + const closeMap = page.getByRole('button', { name: 'Close map' }); + await expect(closeMap).toBeVisible(); + await closeMap.click(); + await expect(openMap).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/SchoolHeroMap.module.css b/nextjs-app/components/SchoolHeroMap.module.css index 1dbb553..d7e33b9 100644 --- a/nextjs-app/components/SchoolHeroMap.module.css +++ b/nextjs-app/components/SchoolHeroMap.module.css @@ -34,6 +34,15 @@ background: #fff; } +/* 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 everything else except modals (9999+). */ +.wrapper[data-fs-fallback] { + position: fixed; + inset: 0; + z-index: 5000; +} + .skeleton { width: 100%; height: 100%; diff --git a/nextjs-app/components/SchoolHeroMap.tsx b/nextjs-app/components/SchoolHeroMap.tsx index 18e5cc9..05a7982 100644 --- a/nextjs-app/components/SchoolHeroMap.tsx +++ b/nextjs-app/components/SchoolHeroMap.tsx @@ -29,25 +29,50 @@ interface SchoolHeroMapProps { export const SchoolHeroMap = forwardRef( function SchoolHeroMap({ lat, lng }, ref) { 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; const open = useCallback(() => { - wrapperRef.current?.requestFullscreen?.().catch(() => {}); + const el = wrapperRef.current; + if (!el) return; + if (el.requestFullscreen) { + el.requestFullscreen().catch(() => setFallbackFullscreen(true)); + } else { + setFallbackFullscreen(true); + } }, []); const close = useCallback(() => { if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); + setFallbackFullscreen(false); }, []); useImperativeHandle(ref, () => ({ open }), [open]); useEffect(() => { - const onChange = () => setIsFullscreen(!!document.fullscreenElement); + const onChange = () => setNativeFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onChange); return () => document.removeEventListener('fullscreenchange', onChange); }, []); + // The fallback overlay sits on top of the page rather than replacing it, + // so lock body scroll while it is up. + useEffect(() => { + if (!fallbackFullscreen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, [fallbackFullscreen]); + return ( -
+
{isFullscreen ? (