diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 6e62d189..a93ab11a 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -134,6 +134,12 @@ html { min-width: 320px; background: var(--shell-background); scroll-behavior: s should scroll the drawer around it. Nested scrollers hand their overscroll on to the scroller around them, as they do outside a modal. */ html:has(dialog[open]) { overflow: hidden; } +/* iOS Safari still scrolls a frozen root under a finger, a focus or a fling, + and when it does the modal and its backdrop, fixed to a viewport the + browser no longer moves, slide off the screen with the page. app.js pins + the body where the page was while a dialog is open, so the document has + nothing left to scroll. */ +html.modal-scroll-lock body { position: fixed; inset-inline: 0; top: var(--modal-scroll-top, 0); } dialog[open] { overscroll-behavior: contain; } body { min-width: 320px; min-height: 100vh; margin: 0; background: var(--background); color: var(--foreground); font-size: 14px; } body:has(.app-shell) { background: var(--shell-background); } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 23e9881b..7f7b4c60 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -4094,16 +4094,26 @@ // open attribute does both before the browser lays out the change, and // before anything a close sets off afterwards, such as the command palette // scrolling to what was picked. + // While it is open the body is pinned where the page was, so iOS has no + // document left to scroll the modal away with. (() => { + const root = document.documentElement; const anyOpen = () => document.querySelector("dialog[open]") !== null; - const position = () => ({ x: window.scrollX, y: window.scrollY }); - let saved = anyOpen() ? position() : null; + let saved = null; + const lock = () => { + saved = { x: window.scrollX, y: window.scrollY }; + root.style.setProperty("--modal-scroll-top", `${-saved.y}px`); + root.classList.add("modal-scroll-lock"); + }; + if (anyOpen()) lock(); new MutationObserver(() => { const open = anyOpen(); - if (open && !saved) saved = position(); + if (open && !saved) lock(); if (open || !saved) return; const { x, y } = saved; saved = null; + root.classList.remove("modal-scroll-lock"); + root.style.removeProperty("--modal-scroll-top"); window.scrollTo({ left: x, top: y, behavior: "instant" }); }).observe(document.body, { attributes: true, attributeFilter: ["open"], subtree: true }); })(); @@ -4260,6 +4270,9 @@ const reveal = () => document.documentElement.classList.remove(hiddenClass); const update = () => { queued = false; + // A modal pins the page at the top of the document; the page hasn't + // moved, so the header stays as it was. + if (document.documentElement.classList.contains("modal-scroll-lock")) return; const current = Math.max(0, window.scrollY); // Anything still behind the header stays visible, so a page shorter // than the viewport can never leave the header stuck off screen. diff --git a/scripts/browser/modal-scroll-lock.cjs b/scripts/browser/modal-scroll-lock.cjs index bc23303c..150bbd2c 100644 --- a/scripts/browser/modal-scroll-lock.cjs +++ b/scripts/browser/modal-scroll-lock.cjs @@ -99,6 +99,21 @@ const refused = page => page.evaluate(() => window.lastTouchRefused); assert.equal(await refused(page), true, 'a scroller at its edge does not pass the touch to the page'); await page.evaluate(() => document.getElementById('cache-browser-dialog').close()); + // While a modal is open the page sits where it was with nothing left to + // scroll, so iOS Safari can't carry the modal off the screen with it. + await page.evaluate(() => window.scrollTo(0, 1200)); + const marker = () => page.evaluate(() => document.getElementById('main-content').getBoundingClientRect().top); + const resting = await marker(); + await page.evaluate(() => document.getElementById('cache-flush-dialog').showModal()); + await page.waitForTimeout(100); + assert.equal(await marker(), resting, 'opening a modal leaves the page where it was'); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).position), 'fixed', 'the body is pinned while a modal is open'); + assert.ok(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1), 'the document has nothing to scroll'); + await page.evaluate(() => document.getElementById('cache-flush-dialog').close()); + await page.waitForTimeout(100); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).position), 'static', 'closing the modal unpins the body'); + assert.equal(await page.evaluate(() => window.scrollY), 1200, 'and puts the page back'); + // Closing a modal leaves the page where it was, even when the browser // lost the position while the root was frozen, as iOS Safari does. await page.evaluate(() => window.scrollTo(0, 1500));