import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { lockDocumentScroll } from "../src/browser/scroll-lock.js"; /** * The page behind the phone navigation drawer stays put while the drawer is open (ADR 0065): no * scrolling, no pull-to-refresh, the drawer's own list still scrolls, and closing it puts the page * back exactly as it was. */ let region: HTMLDivElement; let inside: HTMLSpanElement; let outside: HTMLDivElement; beforeEach(() => { region = document.createElement("div"); inside = document.createElement("span"); region.append(inside); outside = document.createElement("div"); document.body.append(region, outside); }); afterEach(() => { region.remove(); outside.remove(); document.documentElement.removeAttribute("style"); document.body.removeAttribute("style"); vi.restoreAllMocks(); }); /** Gives the region a scrollable size and position, which a DOM without layout does not compute. */ function scrollable(element: HTMLElement, scrollTop: number): void { Object.defineProperty(element, "scrollHeight", { configurable: true, value: 1000 }); Object.defineProperty(element, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(element, "scrollTop", { configurable: true, value: scrollTop }); } function touch(target: EventTarget, type: "touchstart" | "touchmove", y: number, points = 1) { const event = new Event(type, { bubbles: true, cancelable: true }) as Event & { touches: { clientY: number }[]; }; Object.defineProperty(event, "touches", { value: Array.from({ length: points }, () => ({ clientY: y })), }); target.dispatchEvent(event); return event; } describe("the document scroll lock", () => { it("stops the root from scrolling and overscrolling, and puts the styles back as they were", () => { document.documentElement.style.setProperty("overflow", "auto"); const release = lockDocumentScroll(document, region); for (const element of [document.documentElement, document.body]) { expect(element.style.getPropertyValue("overflow")).toBe("hidden"); expect(element.style.getPropertyValue("overscroll-behavior")).toBe("none"); } release(); expect(document.documentElement.style.getPropertyValue("overflow")).toBe("auto"); expect(document.documentElement.style.getPropertyValue("overscroll-behavior")).toBe(""); expect(document.body.style.getPropertyValue("overflow")).toBe(""); expect(document.body.style.getPropertyValue("overscroll-behavior")).toBe(""); }); it("cancels a touch drag that starts outside the drawer, which also stops pull-to-refresh", () => { const release = lockDocumentScroll(document, region); touch(outside, "touchstart", 100); expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(true); release(); // Once released, the page scrolls normally again. touch(outside, "touchstart", 100); expect(touch(outside, "touchmove", 300).defaultPrevented).toBe(false); }); it("lets the drawer's own list scroll, but never past its ends into the page", () => { const release = lockDocumentScroll(document, region); scrollable(region, 200); touch(inside, "touchstart", 300); expect(touch(inside, "touchmove", 200).defaultPrevented, "scrolling down").toBe(false); touch(inside, "touchstart", 200); expect(touch(inside, "touchmove", 300).defaultPrevented, "scrolling up").toBe(false); // At its top, pulling down would only overscroll — the gesture behind pull-to-refresh. scrollable(region, 0); touch(inside, "touchstart", 200); expect(touch(inside, "touchmove", 300).defaultPrevented, "pulled past the top").toBe(true); // At its bottom, pushing up would only overscroll. scrollable(region, 600); touch(inside, "touchstart", 300); expect(touch(inside, "touchmove", 200).defaultPrevented, "pushed past the bottom").toBe(true); release(); }); it("never takes pinch zoom away", () => { const release = lockDocumentScroll(document, region); touch(outside, "touchstart", 100, 2); expect(touch(outside, "touchmove", 300, 2).defaultPrevented).toBe(false); release(); }); it("restores the exact scroll position on release, unless a new page opened", () => { const view = document.defaultView as Window; Object.defineProperty(view, "scrollY", { configurable: true, value: 1234 }); const scrollTo = vi.spyOn(view, "scrollTo").mockImplementation(() => undefined); const release = lockDocumentScroll(document, region); Object.defineProperty(view, "scrollY", { configurable: true, value: 0 }); release(); expect(scrollTo).toHaveBeenCalledWith({ behavior: "instant", left: 0, top: 1234 }); scrollTo.mockClear(); const again = lockDocumentScroll(document, region); Object.defineProperty(view, "scrollY", { configurable: true, value: 50 }); again({ restoreScroll: false }); expect(scrollTo).not.toHaveBeenCalled(); // Releasing twice does nothing the second time. again(); expect(scrollTo).not.toHaveBeenCalled(); Object.defineProperty(view, "scrollY", { configurable: true, value: 0 }); }); });