fix(web): prevent iOS PWA keyboard from pushing header behind status bar (#454) (#457)

On iOS PWA (black-translucent + viewport-fit=cover), opening the virtual
keyboard causes iOS to scroll the page upward, pushing the session header
behind the system status bar. Fix by resetting window.scrollTo(0, 0) when
the keyboard is detected open, and listening to visualViewport scroll
events in addition to resize to catch any deferred scrolling.

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
Haoqing Wang
2026-04-14 17:40:25 +08:00
committed by GitHub
co-authored by HAPI
parent 40e51cb8d4
commit fa8cf53c56
2 changed files with 54 additions and 0 deletions
+43
View File
@@ -60,4 +60,47 @@ describe('useViewportHeight update logic', () => {
expect(root.style.getPropertyValue('--app-viewport-height')).toBe('')
})
it('resets page scroll when keyboard is open', () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
// Simulate: keyboard open AND page has been scrolled by iOS
Object.defineProperty(window, 'scrollY', { value: 120, configurable: true })
const viewportHeight = 400
const windowHeight = 800
const diff = windowHeight - viewportHeight
if (diff > 1) {
root.style.setProperty('--app-viewport-height', `${viewportHeight}px`)
if (window.scrollY > 0) {
window.scrollTo(0, 0)
}
}
expect(scrollToSpy).toHaveBeenCalledWith(0, 0)
// Cleanup
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true })
scrollToSpy.mockRestore()
})
it('does not reset scroll when page is not scrolled', () => {
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true })
const viewportHeight = 400
const windowHeight = 800
const diff = windowHeight - viewportHeight
if (diff > 1) {
root.style.setProperty('--app-viewport-height', `${viewportHeight}px`)
if (window.scrollY > 0) {
window.scrollTo(0, 0)
}
}
expect(scrollToSpy).not.toHaveBeenCalled()
scrollToSpy.mockRestore()
})
})
+11
View File
@@ -32,15 +32,26 @@ export function useViewportHeight(): void {
const diff = window.innerHeight - viewport.height
if (diff > 1) {
root.style.setProperty('--app-viewport-height', `${viewport.height}px`)
// On iOS PWA (black-translucent status bar + viewport-fit=cover),
// the browser scrolls the page upward when the keyboard opens to
// keep the focused input visible. This pushes the header behind
// the iOS status bar. Reset the page scroll so the app stays
// pinned to the top — the inner flex layout already handles
// keeping the composer visible.
if (window.scrollY > 0) {
window.scrollTo(0, 0)
}
} else {
root.style.removeProperty('--app-viewport-height')
}
}
viewport.addEventListener('resize', update)
viewport.addEventListener('scroll', update)
return () => {
viewport.removeEventListener('resize', update)
viewport.removeEventListener('scroll', update)
root.style.removeProperty('--app-viewport-height')
}
}, [])