Files
Ekklesia/surfaces/shell/tests/scroll-lock.test.ts
T
ekklesiaandClaude Opus 5.5 906c015ca8
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
Explain display setup in Settings, stop iOS form zoom, lock the page behind the drawer
A second real-iPhone review of the Stage 1 Dashboard found three
defects. This fixes them, and ADR 0065 records the decisions.

- Settings -> Displays now sets up a screen in one place. A "Set up a
  new screen" panel shows the platform display page to open on the
  screen, a Copy link button, and Add display directly beneath it; the
  step-by-step version is folded into a <details>. The addresses come
  from a new GET /api/v1/devices/setup, which requires
  core.devices.manage and lists each running display module's
  /display/<module-id> page. Nothing names Signage. With no display
  module installed, the panel says so and offers no Add display.
- iOS Safari zoomed into the pairing field because the shared
  .ek-control rendered at 14px. On phones and touch screens it now
  renders at max(1rem, 16px). The viewport is untouched, so zoom stays
  available, and a test guards that. The shared focus trap now returns
  focus with preventScroll, so closing a dialog no longer moves the page
  under the phone header.
- While the phone drawer is open, the page behind it no longer scrolls.
  The root's overflow is hidden, a non-passive touchmove listener
  cancels drags outside the drawer's list and overscroll past its ends,
  and root overscroll is turned off. The list itself still scrolls.
  Closing restores the exact scroll position unless another page opened.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:56:22 -05:00

126 lines
5.1 KiB
TypeScript

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