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; } } }); 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, ""); }); });