import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; // scrollLocks.js coordinates holders through a module-level manager. const { scrollLocks } = await import("/js/scrollLocks.js?fresh-for-test"); describe("ScrollLock", () => { let container; let createdLocks; const createLock = (target) => { const lock = scrollLocks.acquire({ target }); createdLocks.push(lock); return lock; }; beforeEach(() => { container = document.createElement("div"); container.className = "current-page"; container.innerHTML = "
"; document.body.appendChild(container); scrollLocks.setContainerProvider(() => container); createdLocks = []; document.body.style.position = ""; }); afterEach(() => { for (const lock of createdLocks) { lock.release(); } container.remove(); document.body.style.position = ""; document.body.style.overflow = ""; document.body.style.top = ""; document.body.style.width = ""; document.body.style.height = ""; }); it("locks and unlocks page scroll", () => { const lock = createLock(); assert.deepEqual(document.body.style.position, "fixed"); lock.release(); assert.deepEqual(document.body.style.position, ""); }); it("keeps the page locked when the first holder unlocks before the second", () => { const menuLock = createLock(); const dialogLock = createLock(); menuLock.release(); assert.deepEqual(document.body.style.position, "fixed"); dialogLock.release(); assert.deepEqual(document.body.style.position, ""); }); it("keeps the page locked when a stacked dialog unlocks first", () => { const composerLock = createLock(); const nestedDialogLock = createLock(); nestedDialogLock.release(); assert.deepEqual(document.body.style.position, "fixed"); composerLock.release(); assert.deepEqual(document.body.style.position, ""); }); it("returns a harmless release when no page container is available", () => { scrollLocks.setContainerProvider(() => null); const warn = console.warn; console.warn = () => {}; try { const lock = createLock(); assert.deepEqual(document.body.style.position, ""); lock.release(); assert.deepEqual(document.body.style.position, ""); } finally { console.warn = warn; } }); it("ignores repeated releases from the same lease", () => { const lock = createLock(); lock.release(); lock.release(); assert.deepEqual(document.body.style.position, ""); }); it("compensates with the scroll position from before the header is pinned", () => { // Pinning the sticky header removes it from flow, and at the bottom of // the page the browser clamps scrollY down by the header height — // simulate that clamp to ensure the margin uses the pre-pin value. const header = container.querySelector("header"); const descriptor = Object.getOwnPropertyDescriptor(window, "scrollY"); Object.defineProperty(window, "scrollY", { configurable: true, get: () => header.classList.contains("scroll-lock-pinned") ? 3553.5 : 3600, }); try { createLock(); const main = container.querySelector("main"); assert.deepEqual(main.style.marginTop, "-3600px"); } finally { if (descriptor) { Object.defineProperty(window, "scrollY", descriptor); } else { delete window.scrollY; } } }); describe("with a fixed header", () => { let scrollYDescriptor; let originalScrollTo; let scrollToCalls; beforeEach(() => { const header = container.querySelector("header"); header.style.position = "fixed"; header.getBoundingClientRect = () => ({ height: 100 }); scrollYDescriptor = Object.getOwnPropertyDescriptor(window, "scrollY"); Object.defineProperty(window, "scrollY", { configurable: true, get: () => 3600, }); originalScrollTo = window.scrollTo; scrollToCalls = []; window.scrollTo = (...args) => scrollToCalls.push(args); }); afterEach(() => { if (scrollYDescriptor) { Object.defineProperty(window, "scrollY", scrollYDescriptor); } else { delete window.scrollY; } window.scrollTo = originalScrollTo; }); it("doesn't compensate for the header height when pinning", () => { createLock(); const main = container.querySelector("main"); assert.deepEqual(main.style.marginTop, "-3600px"); }); it("leaves the header's inline position alone", () => { const header = container.querySelector("header"); header.style.left = "5px"; header.style.width = "300px"; const lock = createLock(); assert(!header.classList.contains("scroll-lock-pinned")); lock.release(); assert.deepEqual(header.style.left, "5px"); assert.deepEqual(header.style.width, "300px"); }); it("restores the scroll position on unlock", () => { const main = container.querySelector("main"); main.getBoundingClientRect = () => ({ top: -3500 }); const lock = createLock(); lock.release(); assert.deepEqual(scrollToCalls[0], [0, 3600]); }); // Locked, main sits where its margin puts it; unlocked, its rect follows // its document position and the scroll position const mockMainRect = (main, layout) => { main.getBoundingClientRect = () => ({ top: main.style.marginTop ? layout.lockedTop : layout.documentTop - window.scrollY, }); }; it("follows main if it moved while locked", () => { const main = container.querySelector("main"); const layout = { lockedTop: -3500, documentTop: 100 }; mockMainRect(main, layout); const lock = createLock(); layout.lockedTop = -3540; lock.release(); assert.deepEqual(scrollToCalls[0], [0, 3640]); }); it("ignores a stale lock-time rect (iOS WebKit)", () => { const main = container.querySelector("main"); // Seen on iOS: right after locking, the rect is still offset by scrollY main.getBoundingClientRect = () => ({ top: 100 }); const lock = createLock(); mockMainRect(main, { lockedTop: -3500, documentTop: 100 }); lock.release(); assert.deepEqual(scrollToCalls[0], [0, 3600]); }); it("keeps main in place when content above it changed while locked", () => { const main = container.querySelector("main"); const layout = { lockedTop: -3500, documentTop: 100 }; mockMainRect(main, layout); const lock = createLock(); // 300px inserted above main layout.lockedTop = -3200; layout.documentTop = 400; lock.release(); assert.deepEqual(scrollToCalls[0], [0, 3600]); }); }); it("restores a locked scrollable ancestor's overflow on unlock", () => { const scrollable = document.createElement("div"); scrollable.style.overflowY = "auto"; Object.defineProperty(scrollable, "scrollHeight", { value: 200 }); Object.defineProperty(scrollable, "clientHeight", { value: 100 }); const target = document.createElement("div"); scrollable.appendChild(target); container.appendChild(scrollable); const lock = createLock(target); assert.deepEqual(scrollable.style.overflow, "hidden"); lock.release(); assert.deepEqual(scrollable.style.overflow, ""); }); });