Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/app/styles/edgeToEdgeInsets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
39 changes: 37 additions & 2 deletions src/app/utils/iosPwaViewport.ts
Original file line number Diff line number Diff line change
@@ -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`
);
};

Expand Down
3 changes: 2 additions & 1 deletion src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
Loading