diff --git a/web/index.html b/web/index.html index c67fb83..8749601 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ diff --git a/web/src/styles.css b/web/src/styles.css index 73d2038..9c68219 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -211,8 +211,11 @@ body { #app { /* `--app-vh` tracks the *visual* viewport (set by installViewportSizing) so the shell stays above the mobile soft keyboard; falls back to the layout - viewport where `visualViewport` is unavailable. */ + viewport where `visualViewport` is unavailable. `--app-top` follows the + layout viewport's scroll under the keyboard so the shell stays pinned to + the visible region instead of drifting to the top. */ height: var(--app-vh, 100%); + transform: translateY(var(--app-top, 0px)); display: flex; flex-direction: column; overflow: hidden; diff --git a/web/src/viewport.ts b/web/src/viewport.ts index b241b5d..f5f400b 100644 --- a/web/src/viewport.ts +++ b/web/src/viewport.ts @@ -1,5 +1,5 @@ /** - * Bind the app shell's height to the *visual* viewport. + * Bind the app shell's height *and position* to the *visual* viewport. * * On mobile the layout viewport does not shrink when the on-screen keyboard * opens — only the visual viewport does. A shell sized at `height: 100%` (or @@ -8,6 +8,16 @@ * `visualViewport.height` onto a CSS custom property keeps the shell exactly as * tall as the visible area, so the key bar lands just above the keyboard. * + * Height alone isn't enough: when the keyboard opens the browser also scrolls + * the layout viewport to reveal the focused element, which drags the shell + * (anchored at layout-top) up and out of view — the key bar ends up at the top + * of the screen and the user has to scroll back down. `visualViewport.offsetTop` + * is how far the layout viewport has scrolled under the keyboard; translating + * the shell down by that amount re-pins it to the visible region. (Chrome's + * `interactive-widget=resizes-content`, set in the viewport meta, prevents the + * scroll entirely on Android; this covers iOS and older browsers that ignore + * it, where offsetTop stays non-zero.) + * * No-ops where `visualViewport` is unavailable; the CSS falls back to `100%`. */ export function installViewportSizing(): void { @@ -20,6 +30,7 @@ export function installViewportSizing(): void { const sync = () => { raf = 0; root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`); + root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`); }; const schedule = () => { if (raf) return;