From b43a092d0f66eff8151c202bf38a75f09e7bdd54 Mon Sep 17 00:00:00 2001 From: Nicholas Penree Date: Wed, 7 Oct 2026 00:53:43 -0400 Subject: [PATCH] Keep the page behind a modal from scrolling under a finger iOS Safari ignores overflow: hidden on the root for touch scrolling, so a drag on an open drawer's backdrop or header scrolled the page behind it, collapsed the toolbar, and slid the drawer off screen. Refuse touch moves while a modal is open unless they land in a scroller inside it that can move along the gesture, and contain overscroll in those scrollers so they don't chain to the page. --- internal/web/assets/app.css | 6 +- internal/web/assets/app.js | 39 +++++++++++ .../web/modal_scroll_lock_browser_test.go | 35 ++++++++++ scripts/browser/modal-scroll-lock.cjs | 70 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 internal/web/modal_scroll_lock_browser_test.go create mode 100644 scripts/browser/modal-scroll-lock.cjs diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 8ab8bd96..a56ffe12 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -125,8 +125,12 @@ html { min-width: 320px; background: var(--shell-background); scroll-behavior: smooth; scrollbar-gutter: stable; } /* A modal dialog does not stop the page behind it from scrolling, so a wheel or trackpad flick slides the background out from under the dialog. The - gutter reserved above means freezing the document costs no sideways nudge. */ + gutter reserved above means freezing the document costs no sideways nudge. + iOS Safari ignores overflow on the root for touch, so app.js also refuses + touch scrolls that would reach the page, and scrollers inside a modal keep + their overscroll to themselves. */ html:has(dialog[open]) { overflow: hidden; } +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); } a { color: inherit; text-decoration: none; } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 379f493a..860f8b5e 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -3922,6 +3922,45 @@ syncSidebarToggleState(); }); + // The page behind a modal stays put under a finger. The stylesheet's + // overflow: hidden on the root covers wheels and trackpads, but iOS Safari + // still scrolls the document by touch, and once it does the toolbar + // collapses and the drawer slides away with the page. A touch move is + // refused unless it lands in something inside the modal that can scroll + // along the gesture; those scrollers contain their own overscroll. + (() => { + const scrolls = (element, vertical) => { + const style = getComputedStyle(element); + const overflow = vertical ? style.overflowY : style.overflowX; + if (overflow !== "auto" && overflow !== "scroll") return false; + return vertical ? element.scrollHeight > element.clientHeight + 1 : element.scrollWidth > element.clientWidth + 1; + }; + const ownsScroll = (target, dialog, vertical) => { + for (let element = target; element && element !== dialog.parentElement; element = element.parentElement) { + if (scrolls(element, vertical)) return true; + if (element === dialog) break; + } + return false; + }; + let start = null; + document.addEventListener("touchstart", (event) => { + const touch = event.touches[0]; + start = touch ? { x: touch.clientX, y: touch.clientY } : null; + }, { passive: true }); + document.addEventListener("touchmove", (event) => { + if (!event.cancelable || event.touches.length !== 1 || !start) return; + const modal = [...document.querySelectorAll("dialog[open]")].find((dialog) => dialog.matches(":modal")); + if (!modal) return; + const target = event.target instanceof Element ? event.target : null; + const inside = target && modal.contains(target) ? target : null; + // Text fields and range inputs own their drags. + if (inside?.closest("input, textarea, select, [contenteditable]")) return; + const touch = event.touches[0]; + const vertical = Math.abs(touch.clientY - start.y) >= Math.abs(touch.clientX - start.x); + if (!inside || !ownsScroll(inside, modal, vertical)) event.preventDefault(); + }, { passive: false }); + })(); + // On phones a drag to the right anywhere on the page pulls the drawer out // with the finger, as Slack and Discord do. It never claims the screen // edge: iOS Safari plays its back gesture there before the page hears of diff --git a/internal/web/modal_scroll_lock_browser_test.go b/internal/web/modal_scroll_lock_browser_test.go new file mode 100644 index 00000000..b1bf3993 --- /dev/null +++ b/internal/web/modal_scroll_lock_browser_test.go @@ -0,0 +1,35 @@ +//go:build browser + +package web + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "testing" + + webassets "github.com/drudge/sable/internal/web/assets" + "github.com/drudge/sable/internal/web/pages" +) + +// The page behind an open modal does not scroll under a finger, while +// scrollers inside the modal keep their own touch scrolling. +func TestBrowserModalScrollLock(t *testing.T) { + mux := http.NewServeMux() + mux.Handle("/assets/", webassets.Handler()) + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + view := pages.CachePageView{Console: pages.DashboardView{CSRFToken: "fixture-csrf"}, Entries: 1000} + _ = pages.CachePage(view).Render(r.Context(), w) + }) + server := httptest.NewServer(secureHeaders(mux, false)) + defer server.Close() + command := exec.Command("node", "../../scripts/browser/modal-scroll-lock.cjs", server.URL) + command.Env = os.Environ() + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("browser modal scroll lock: %v\n%s", err, output) + } else { + t.Log(string(output)) + } +} diff --git a/scripts/browser/modal-scroll-lock.cjs b/scripts/browser/modal-scroll-lock.cjs new file mode 100644 index 00000000..0f842fd4 --- /dev/null +++ b/scripts/browser/modal-scroll-lock.cjs @@ -0,0 +1,70 @@ +const assert = require('node:assert/strict'); +const {chromium} = require('playwright'); + +// Same real-touch helper as nav-swipe.cjs: the browser decides on scrolling +// the way a phone does. +const drag = async (cdp, from, to, steps = 12) => { + const at = ([x, y]) => [{x, y, id: 1, radiusX: 4, radiusY: 4, force: 1}]; + let timestamp = Date.now() / 1000; + await cdp.send('Input.dispatchTouchEvent', {type: 'touchStart', touchPoints: at(from), timestamp}); + for (let i = 1; i <= steps; i++) { + timestamp += 0.016; + await cdp.send('Input.dispatchTouchEvent', {type: 'touchMove', touchPoints: at([from[0] + (to[0] - from[0]) * i / steps, from[1] + (to[1] - from[1]) * i / steps]), timestamp}); + } + await cdp.send('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: [], timestamp: timestamp + 0.008}); + // Let any fling finish: Chromium will not let a page cancel touches mid-fling. + await new Promise(resolve => setTimeout(resolve, 1200)); +}; +// Whether the page refused the last touch move: a window listener runs after +// the document-level guard has had its say. +const refused = page => page.evaluate(() => window.lastTouchRefused); + +(async () => { + const browser = await chromium.launch({headless: true, ...(process.env.SABLE_TEST_BROWSER ? {executablePath: process.env.SABLE_TEST_BROWSER} : {})}); + try { + const context = await browser.newContext({viewport: {width: 390, height: 844}, hasTouch: true, isMobile: true}); + const page = await context.newPage(); + await page.goto(process.argv[2]); + await page.evaluate(() => { + document.documentElement.style.scrollBehavior = 'auto'; + const filler = document.createElement('div'); + filler.style.height = '4000px'; + document.getElementById('main-content').append(filler); + window.addEventListener('touchmove', event => { window.lastTouchRefused = event.defaultPrevented; }, {passive: true}); + }); + const cdp = await context.newCDPSession(page); + + // With no modal open the page scrolls freely. + await drag(cdp, [195, 700], [195, 300]); + assert.equal(await refused(page), false, 'touch scrolls reach the page without a modal'); + + // A finger on the backdrop or on a part of the modal that cannot scroll + // moves nothing. + await page.evaluate(() => document.getElementById('cache-flush-dialog').showModal()); + await drag(cdp, [195, 830], [195, 300]); + assert.equal(await refused(page), true, 'the backdrop does not scroll the page'); + const title = await page.evaluate(() => { const r = document.querySelector('#cache-flush-dialog h2, #cache-flush-dialog [id$=title]').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }); + await drag(cdp, title, [title[0], title[1] - 200]); + assert.equal(await refused(page), true, 'a modal that cannot scroll does not hand the touch to the page'); + await page.evaluate(() => document.getElementById('cache-flush-dialog').close()); + + // A scroller inside the modal keeps its own touch scroll, and contains it. + await page.evaluate(() => { + const dialog = document.getElementById('cache-browser-dialog'); + const body = dialog.querySelector('.cache-browser-body'); + const tall = document.createElement('div'); + tall.style.height = '3000px'; + body.append(tall); + dialog.showModal(); + }); + const inner = await page.evaluate(() => { const r = document.querySelector('#cache-browser-dialog .cache-browser-body').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }); + await drag(cdp, inner, [inner[0], inner[1] - 150]); + assert.equal(await refused(page), false, 'the modal scroller takes the touch'); + await page.waitForTimeout(200); + assert.ok(await page.evaluate(() => document.querySelector('#cache-browser-dialog .cache-browser-body').scrollTop) > 0, 'the modal scroller moves'); + assert.equal(await page.evaluate(() => getComputedStyle(document.querySelector('#cache-browser-dialog .cache-browser-body')).overscrollBehaviorY), 'contain'); + console.log('modal scroll lock: ok'); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exit(1); });