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