From 463d6e31c4b1834a258adc5060e496d35235eee7 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 18 Aug 2026 14:51:32 +0200 Subject: [PATCH] fix(ios): fill the full screen in standalone PWAs instead of a bottom chin --- src/app/styles/edgeToEdgeInsets.test.ts | 14 ++++----- src/app/utils/iosPwaViewport.ts | 39 +++++++++++++++++++++++-- src/index.css | 3 +- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/app/styles/edgeToEdgeInsets.test.ts b/src/app/styles/edgeToEdgeInsets.test.ts index 2c6905c7bf..6c5121a745 100644 --- a/src/app/styles/edgeToEdgeInsets.test.ts +++ b/src/app/styles/edgeToEdgeInsets.test.ts @@ -79,24 +79,24 @@ describe('android edge-to-edge inset contract', () => { expect(mobileCapability).toContain('"edge-to-edge:default"'); }); - it('extends only standalone iOS PWAs to the dynamic viewport bottom', () => { + it('fills the full screen in standalone iOS PWAs unless the keyboard is open', () => { const indexCss = readWorkspaceFile('src/index.css'); const indexTsx = readWorkspaceFile('src/index.tsx'); const iosPwaViewport = readWorkspaceFile('src/app/utils/iosPwaViewport.ts'); expect(indexCss).toContain('@media (display-mode: standalone)'); expect(indexCss).toContain('@supports (-webkit-touch-callout: none)'); - expect(indexCss).toContain('var(--sable-ios-pwa-viewport-height, 100dvh)'); + expect(indexCss).toContain('var(--sable-ios-pwa-viewport-height, 100vh)'); expect(indexTsx).toContain('installIosPwaViewportHeight();'); expect(iosPwaViewport).toContain("window.matchMedia('(display-mode: standalone)').matches"); expect(iosPwaViewport).toContain('viewport.height + viewport.offsetTop'); expect(iosPwaViewport).toContain('window.setTimeout(updateHeight, 350)'); - expect(iosPwaViewport).not.toContain('fullHeight'); - expect(iosPwaViewport).not.toContain('viewportWidth'); + expect(iosPwaViewport).toContain('100vh'); + expect(iosPwaViewport).toContain('fullHeight'); + // Physical screen geometry reports device pixels and is wrong on iPad. expect(iosPwaViewport).not.toContain('window.screen'); - // The height must not depend on keyboard detection or on a stale window.innerHeight. - expect(iosPwaViewport).not.toContain('MIN_KEYBOARD_HEIGHT'); - expect(iosPwaViewport).not.toContain('isEditableFocused'); + expect(iosPwaViewport).toContain('MIN_KEYBOARD_HEIGHT'); + expect(iosPwaViewport).toContain('isEditableFocused'); }); it('removes the scattered safe-area css consumers', () => { diff --git a/src/app/utils/iosPwaViewport.ts b/src/app/utils/iosPwaViewport.ts index f445c3d419..7924fb5cab 100644 --- a/src/app/utils/iosPwaViewport.ts +++ b/src/app/utils/iosPwaViewport.ts @@ -1,25 +1,60 @@ const IOS_PWA_VIEWPORT_HEIGHT = '--sable-ios-pwa-viewport-height'; +const MIN_KEYBOARD_HEIGHT = 100; const isStandaloneIosPwa = (): boolean => window.matchMedia('(display-mode: standalone)').matches && CSS.supports('-webkit-touch-callout: none'); +const isEditableFocused = (): boolean => { + const el = document.activeElement as HTMLElement | null; + if (!el) return false; + return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable; +}; + +// 100vh is the only unit that includes the safe-area insets in installed PWAs; probe it so we +// never depend on window.innerHeight, which iOS shrinks on the first keyboard open. +function measureFullHeight(): number { + const probe = document.createElement('div'); + probe.style.position = 'fixed'; + probe.style.top = '0'; + probe.style.left = '0'; + probe.style.height = '100vh'; + probe.style.visibility = 'hidden'; + probe.style.pointerEvents = 'none'; + document.documentElement.appendChild(probe); + const height = probe.offsetHeight; + probe.remove(); + return height; +} + export function installIosPwaViewportHeight(): void { if (!isStandaloneIosPwa()) return; let frame = 0; let settleTimer = 0; + let fullHeight = measureFullHeight(); + let viewportWidth = window.innerWidth; const updateHeight = () => { frame = 0; + + // Re-probe after rotation. + if (window.innerWidth !== viewportWidth) { + viewportWidth = window.innerWidth; + fullHeight = measureFullHeight(); + } + const viewport = window.visualViewport; // Reach the bottom of the visible area. window.innerHeight is unusable here: iOS shrinks it // on the first keyboard open and never restores it until the app is force-quit. - const visibleBottom = viewport ? viewport.height + viewport.offsetTop : window.innerHeight; + const visibleBottom = viewport ? viewport.height + viewport.offsetTop : fullHeight; + + // Only shrink above the keyboard; idle visualViewport heights under-report by the insets. + const keyboardOpen = isEditableFocused() && fullHeight - visibleBottom > MIN_KEYBOARD_HEIGHT; document.documentElement.style.setProperty( IOS_PWA_VIEWPORT_HEIGHT, - `${Math.round(visibleBottom)}px` + `${Math.round(keyboardOpen ? visibleBottom : fullHeight)}px` ); }; diff --git a/src/index.css b/src/index.css index a80a37d106..2add9a09d1 100755 --- a/src/index.css +++ b/src/index.css @@ -63,7 +63,8 @@ body { html, body, #root { - height: var(--sable-ios-pwa-viewport-height, 100dvh); + /* 100vh is the only unit that includes the safe-area insets in installed PWAs */ + height: var(--sable-ios-pwa-viewport-height, 100vh); } } }