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
6 changes: 6 additions & 0 deletions internal/web/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
19 changes: 16 additions & 3 deletions internal/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
})();
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions scripts/browser/modal-scroll-lock.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading