import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { HostApp } from "../src/browser/HostApp.js"; import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; import { createMemoryHostViewport } from "../src/browser/host-viewport.js"; import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js"; import type { DashboardSettings } from "../src/host-protocol.js"; declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } /** * The Settings shell (ADR 0061): its landing page, the platform sections and Discover, each * module's settings, and which of them the server said this person may see. */ const apiBaseUrl = "https://ekklesia.example.test"; let container: HTMLDivElement; let root: Root; let consoleError: ReturnType; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); document.body.append(container); root = createRoot(container); consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); consoleError.mockRestore(); }); function entry(id: string, name: string) { return { module: { api: { baseUrl: `/api/v1/modules/${id}` }, contributions: [], display: null, id, permissions: [], realtime: { baseUrl: `/api/v1/module-realtime/${id}` }, ui: { url: `/api/v1/module-ui/${id}/0.1.0` }, version: "0.1.0", }, name, navigation: { icon: null, pages: [] }, }; } const NAVIGATION = { groups: [], ungrouped: [entry("signage", "Signage"), entry("calendar", "Calendar")], }; /** An administrator who may see everything: every platform section, two modules' settings, and a * disabled module (`archive`) whose settings exist but are not navigable. Calendar declares no * settings, so it is a known module with no settings page. */ const ADMINISTRATOR: DashboardSettings = { modules: [ { available: false, id: "archive", name: "Archive" }, { available: true, id: "signage", name: "Signage" }, ], sections: ["general", "branding", "displays", "discover", "system"], }; const DEVICES = [ { createdAt: "2026-10-01T09:00:00.000Z", id: "device-1", lastSeenAt: new Date(Date.now() - 5 * 60_000).toISOString(), name: "Foyer TV", permissions: ["signage.display"], revoked: false, }, { createdAt: "2026-08-01T09:00:00.000Z", id: "device-2", name: "Old hall screen", permissions: ["signage.display"], revoked: true, }, ]; const LIFECYCLE = [ { configured: { enabled: true, explicit: false }, id: "signage", restartRequired: false, runtime: { state: "running" }, version: "0.1.5", }, { configured: { enabled: false, explicit: true }, id: "archive", restartRequired: false, runtime: { state: "disabled" }, version: "1.0.0", }, ]; /** Lets pending requests settle, so the overview's summaries have answered. */ async function settle(): Promise { await act(async () => { for (let index = 0; index < 5; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } }); } interface Rendered { readonly history: MemoryHostHistory; readonly calls: string[]; readonly titles: string[]; } async function render( path: string, options: { readonly settings?: DashboardSettings | "fails"; readonly compact?: boolean; readonly collapsed?: boolean; readonly history?: MemoryHostHistory; readonly devices?: unknown[]; } = {}, ): Promise { const history = options.history ?? createMemoryHostHistory(path); const calls: string[] = []; const titles: string[] = []; let settingsAttempts = 0; const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); calls.push(new URL(url).pathname); if (url.endsWith("/api/v1/principal")) { return Response.json({ kind: "authenticated", userId: "user-1" }); } if (url.endsWith("/api/v1/dashboard/navigation")) { return Response.json(NAVIGATION); } if (url.endsWith("/api/v1/dashboard/settings")) { settingsAttempts += 1; const settings = options.settings ?? ADMINISTRATOR; return settings === "fails" && settingsAttempts === 1 ? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 }) : Response.json(settings === "fails" ? ADMINISTRATOR : settings); } // The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse. const visible = options.settings ?? ADMINISTRATOR; const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections; if (url.endsWith("/api/v1/devices/setup")) { return sections.includes("displays") ? Response.json({ displays: [{ moduleId: "signage", name: "Signage", url: "/display/signage" }], }) : Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); } if (url.endsWith("/api/v1/devices")) { return sections.includes("displays") ? Response.json({ devices: options.devices ?? DEVICES }) : Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); } if (url.endsWith("/api/v1/module-lifecycle")) { return sections.includes("discover") ? Response.json({ modules: LIFECYCLE }) : Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); } return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); }) as unknown as typeof fetch; await act(async () => { root.render( ({ default: { abi: MODULE_UI_ABI_VERSION, id: "signage", mount(element: Element, context: ModuleUiHostContext) { element.textContent = `${context.moduleId} mounted`; }, version: "0.1.0", }, })} setDocumentTitle={(title) => titles.push(title)} sidebarPreference={createMemorySidebarPreference( options.collapsed ? "collapsed" : undefined, )} viewport={createMemoryHostViewport(options.compact ?? false)} />, ); }); return { calls, history, titles }; } function query(testId: string): HTMLElement | null { return container.querySelector(`[data-testid="${testId}"]`); } function required(testId: string): HTMLElement { const element = query(testId); if (!element) { throw new Error(`missing ${testId}`); } return element; } async function click(element: Element): Promise { await act(async () => { element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true })); }); } const current = () => [...container.querySelectorAll('nav [aria-current="page"]')].map( (element) => (element as HTMLElement).dataset.testid, ); describe("the Settings landing page", () => { it("is a real page, not a redirect: an overview of the installation, not a copy of the sidebar", async () => { const { history, titles, calls } = await render("/settings"); await settle(); expect(history.entries).toEqual(["/settings"]); const home = required("settings-home"); expect(home.querySelector("h1")?.textContent).toBe("Settings"); // Each area is a summary of its state, read from what already exists (ADR 0064). expect(required("settings-summary-displays-status").textContent).toBe("1 display paired"); expect(required("settings-summary-displays").textContent).toContain( "Foyer TV was last seen within the last hour.", ); expect(required("settings-summary-modules-status").textContent).toBe("2 modules installed"); expect(required("settings-summary-modules").textContent).toContain("1 turned off"); expect(required("settings-summary-branding-status").textContent).toBe( "Using the default name and colours", ); expect(calls).toContain("/api/v1/devices"); expect(calls).toContain("/api/v1/module-lifecycle"); // The one link that changes each area, rather than the sidebar's list again. expect( [...home.querySelectorAll('[data-testid^="settings-home-"]')].map((link) => link.getAttribute("href"), ), ).toEqual([ "/settings/displays", "/settings/discover/installed", "/settings/signage", "/settings/general", "/settings/branding", "/settings/system", ]); // Not one navigation item per section, the way the sidebar lists them. expect(home.querySelector(".ek-shell-settings-link")).toBeNull(); // A disabled module's settings are not offered. expect(query("settings-home-module-archive")).toBeNull(); expect( [...container.querySelectorAll("main h2")].map((heading) => heading.textContent), ).toEqual(["Displays", "Modules", "General", "Branding", "System"]); // Nothing on it takes focus by itself. expect(document.activeElement?.tagName).not.toBe("INPUT"); expect(titles.at(-1)).toBe("Settings"); expect(current()).toEqual(["select-settings"]); }); it("summarises only what this person may see, and asks for nothing else", async () => { const { calls } = await render("/settings", { settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: [] }, }); await settle(); expect(query("settings-summary-displays")).toBeNull(); expect(required("settings-summary-modules-status").textContent).toBe("Module settings"); expect(calls).not.toContain("/api/v1/devices"); expect(calls).not.toContain("/api/v1/module-lifecycle"); }); it("says plainly when nothing is paired yet, and when a summary cannot be read", async () => { await render("/settings", { devices: [] }); await settle(); expect(required("settings-summary-displays-status").textContent).toBe("No displays paired yet"); expect(required("settings-home-displays").textContent).toBe("Add a display"); }); it("says plainly when there is nothing for this person to change", async () => { await render("/settings", { settings: { modules: [], sections: [] } }); expect(query("settings-home")?.textContent).toContain( "There are no settings for you to change", ); }); }); describe("the platform sections", () => { it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => { for (const section of ["general", "branding", "system"] as const) { await act(async () => { root.unmount(); }); root = createRoot(container); const { titles } = await render(`/settings/${section}`); const page = required(`settings-section-${section}`); expect(page.querySelector("h1")?.textContent, section).toBeTruthy(); expect(page.textContent, section).toContain("Not available yet"); // No fake controls. expect(page.querySelector("input, select, textarea, form"), section).toBeNull(); const back = page.querySelector(".ek-page-header__back"); expect(back?.getAttribute("href"), section).toBe("/settings"); expect(back?.textContent, section).toContain("Back to Settings"); expect(current(), section).toEqual([`select-settings-${section}`]); expect(titles.at(-1), section).toMatch(/ settings$/u); } }); it("opens Discover itself at /settings/discover, as the place modules are found", async () => { const { history, titles } = await render("/settings/discover"); expect(history.entries).toEqual(["/settings/discover"]); expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe( "Discover modules", ); expect(query("settings-discover-browse")?.textContent).toContain( "Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.", ); expect(titles.at(-1)).toBe("Discover modules"); // Discover is the current page; it is not a parent of some separate Browse page. expect(current()).toEqual(["select-settings-discover"]); expect(query("select-settings-discover")?.getAttribute("href")).toBe("/settings/discover"); expect(query("select-discover-browse")).toBeNull(); }); it("respells the earlier /settings/discover/browse in place, adding no history entry", async () => { const { history } = await render("/settings/discover/browse?return=%2Fsignage"); expect(history.entries).toEqual(["/settings/discover?return=%2Fsignage"]); expect(query("settings-discover-browse")).not.toBeNull(); expect(current()).toEqual(["select-settings-discover"]); // Nothing links to the alias. expect(container.querySelector('a[href^="/settings/discover/browse"]')).toBeNull(); }); it("has Installed and Updates — and no Browse — beneath Discover, each speaking of modules", async () => { const { history } = await render("/settings/discover"); const children = required("discover-subnavigation"); const views = [...children.querySelectorAll("a")]; expect(views.map((view) => view.textContent)).toEqual(["Installed", "Updates"]); expect(views.map((view) => view.getAttribute("href"))).toEqual([ "/settings/discover/installed", "/settings/discover/updates", ]); // Children: text only, marked as children, and listed beneath Discover by name. expect(children.getAttribute("aria-label")).toBe("Discover"); expect(children.className).toContain("ek-shell-nav__children"); for (const view of views) { expect(view.dataset.navLevel).toBe("child"); expect(view.querySelector("svg")).toBeNull(); } expect(required("select-settings-discover-disclosure").dataset.open).toBe("true"); await click(required("select-discover-installed")); expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe( "Installed modules", ); // The child is the current page and Discover is marked as its parent, never also current. expect(current()).toEqual(["select-discover-installed"]); expect(required("select-settings-discover").dataset.navAncestor).toBe(""); await click(required("select-discover-updates")); expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe( "Module updates", ); await click(required("select-settings-discover")); expect(current()).toEqual(["select-settings-discover"]); expect(required("select-settings-discover").dataset.navAncestor).toBeUndefined(); expect(history.entries).toEqual([ "/settings/discover", "/settings/discover/installed", "/settings/discover/updates", "/settings/discover", ]); }); it("shows Discover closed, with no children, while another Settings page is open", async () => { await render("/settings/general"); expect(query("discover-subnavigation")).toBeNull(); expect(required("select-settings-discover-disclosure").dataset.open).toBe("false"); }); it("shows only the parent Discover item in the collapsed rail, current for every Discover page", async () => { for (const path of ["/settings/discover", "/settings/discover/installed"]) { await act(async () => { root.unmount(); }); root = createRoot(container); await render(path, { collapsed: true }); expect(query("discover-subnavigation"), path).toBeNull(); expect(query("select-discover-installed"), path).toBeNull(); expect(current(), path).toEqual(["select-settings-discover"]); // No disclosure chevron in the rail: it has no room for the children it would announce. expect(query("select-settings-discover-disclosure"), path).toBeNull(); } }); it("says a section is unavailable to someone who may not see it, rather than showing it", async () => { await render("/settings/discover/installed", { settings: { modules: [], sections: ["displays"] }, }); expect(query("settings-no-access")).not.toBeNull(); expect(query("settings-discover-installed")).toBeNull(); expect(query("select-settings-discover")).toBeNull(); }); }); describe("module settings", () => { it("opens a running module's settings at /settings/, as a placeholder", async () => { const { titles } = await render("/settings/signage"); const page = required("module-settings"); expect(page.querySelector("h1")?.textContent).toBe("Signage"); expect(page.textContent).toContain("Not available yet"); expect(page.querySelector("input, select, textarea, form")).toBeNull(); expect(titles.at(-1)).toBe("Signage settings"); expect(current()).toEqual(["select-module-settings-signage"]); }); it("says a disabled module's settings are turned off, rather than redirecting", async () => { const { history } = await render("/settings/archive"); expect(query("settings-module-unavailable")?.textContent).toContain( "This module is turned off", ); expect(history.entries).toEqual(["/settings/archive"]); expect(query("select-module-settings-archive")).toBeNull(); }); it("says a known module's settings aren't available when there are none for this person", async () => { await render("/settings/calendar"); expect(query("settings-no-access")).not.toBeNull(); }); it("is not found for a name that is no module", async () => { for (const path of ["/settings/definitely-not-a-module", "/settings/modules"]) { await act(async () => { root.unmount(); }); root = createRoot(container); await render(path); expect(query("host-not-found"), path).not.toBeNull(); } }); }); describe("Settings in the navigation", () => { it("is one item after the modules, expanding into Platform and Modules only inside Settings", async () => { const { history } = await render("/signage"); const settingsLink = required("select-settings"); expect(settingsLink.getAttribute("href")).toBe("/settings"); expect(query("settings-subnavigation")).toBeNull(); const links = [...container.querySelectorAll("nav a")].map((link) => link.textContent); expect(links).toEqual(["Overview", "Signage", "Calendar", "Settings"]); await click(settingsLink); expect(history.entries).toEqual(["/signage", "/settings"]); const sub = required("settings-subnavigation"); expect([...sub.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([ "Platform", "Modules", ]); expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ "General", "Branding", "Displays", "Discover", "System", "Signage", ]); // The rest of the navigation stays, so leaving Settings is one click. await click(required("select-signage")); expect(query("settings-subnavigation")).toBeNull(); expect(history.entries).toEqual(["/signage", "/settings", "/signage"]); }); it("shows only the sections and module settings this person may see", async () => { await render("/settings", { settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: ["displays"], }, }); const sub = required("settings-subnavigation"); expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ "Displays", "Signage", ]); }); it("is not offered at all to someone with nothing to change", async () => { await render("/", { settings: { modules: [], sections: [] } }); expect(query("select-settings")).toBeNull(); }); it("is withheld when Settings cannot be loaded, whose pages say so and retry", async () => { const { calls } = await render("/settings", { settings: "fails" }); expect(query("select-settings")).toBeNull(); expect(query("settings-load-failed")).not.toBeNull(); // The rest of the Dashboard still works. expect(query("select-signage")).not.toBeNull(); const retry = [...required("settings-load-failed").querySelectorAll("button")].find( (button) => button.textContent === "Retry", ) as HTMLButtonElement; await act(async () => { retry.click(); }); expect(query("settings-home")).not.toBeNull(); expect(calls.filter((path) => path === "/api/v1/dashboard/settings")).toHaveLength(2); }); it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => { const history = createMemoryHostHistory("/settings"); await render("/settings", { history }); await click(required("select-settings-branding")); await click(required("select-module-settings-signage")); await act(async () => { history.go(-1); }); expect(query("settings-section-branding")).not.toBeNull(); await act(async () => { history.go(1); }); expect(query("module-settings")).not.toBeNull(); // A refresh is the same address, rendered from scratch. await act(async () => { root.unmount(); }); root = createRoot(container); await render("/settings/signage", { history: createMemoryHostHistory(history.pathname()) }); expect(query("module-settings")).not.toBeNull(); expect(current()).toEqual(["select-module-settings-signage"]); }); it("moves focus to the page when moving between two Settings pages", async () => { await render("/settings/general"); const main = required("host-main-region"); await click(required("select-settings-branding")); expect(document.activeElement).toBe(main); expect(main.getAttribute("aria-label")).toBe("Branding settings"); }); it("uses the same hierarchy in the phone drawer, which closes once a page is chosen", async () => { const { history } = await render("/settings", { compact: true }); await click(required("mobile-navigation-trigger")); const drawer = required("navigation-drawer"); expect( [...drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')].map( (link) => link.textContent, ), ).toEqual(["General", "Branding", "Displays", "Discover", "System", "Signage"]); for (const link of drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')) { expect((link as HTMLElement).style.minHeight).toBe("var(--ekklesia-control-height-lg)"); } await click(required("select-settings-system")); expect(query("navigation-drawer")).toBeNull(); expect(history.entries).toEqual(["/settings", "/settings/system"]); expect(query("settings-section-system")).not.toBeNull(); }); }); describe("the sidebar is navigation only (ADR 0064)", () => { it("has no display-pairing block, expanded or in the phone drawer", async () => { await render("/settings"); const sidebar = required("host-sidebar"); expect(query("host-devices")).toBeNull(); expect(sidebar.querySelector("input, form")).toBeNull(); expect(sidebar.textContent).not.toMatch(/display devices|pairing code/iu); await act(async () => { root.unmount(); }); root = createRoot(container); await render("/settings", { compact: true }); await click(required("mobile-navigation-trigger")); const drawer = required("navigation-drawer"); expect(drawer.querySelector("input, form")).toBeNull(); expect(drawer.textContent).not.toMatch(/display devices|pairing code/iu); }); it("offers pairing under Settings → Displays instead, without focusing anything on arrival", async () => { for (const path of ["/settings", "/settings/displays", "/signage", "/"]) { await act(async () => { root.unmount(); }); root = createRoot(container); await render(path); await settle(); expect(document.activeElement?.tagName, path).not.toBe("INPUT"); expect(container.querySelector("input"), path).toBeNull(); if (path === "/settings/displays") { expect(required("settings-section-displays").querySelector("h1")?.textContent).toBe( "Displays", ); } } // Moving to Displays from the navigation moves focus to the page, never into a field. await act(async () => { root.unmount(); }); root = createRoot(container); await render("/settings"); await click(required("select-settings-displays")); await settle(); expect(document.activeElement).toBe(required("host-main-region")); expect(required("add-display").textContent).toContain("Add display"); }); }); describe("collapsed navigation geometry (ADR 0064)", () => { it("leaves every item's layout to the stylesheet, so the rail can centre each glyph", async () => { await render("/settings/general", { collapsed: true }); const sidebar = required("host-sidebar"); expect(sidebar.dataset.sidebar).toBe("collapsed"); const items = [...sidebar.querySelectorAll("nav a")]; expect(items.map((item) => item.dataset.testid)).toEqual([ "select-overview", "select-signage", "select-calendar", "select-settings", "select-settings-general", "select-settings-branding", "select-settings-displays", "select-settings-discover", "select-settings-system", "select-module-settings-signage", ]); for (const item of items) { // One class carries the geometry; no inline layout can outrank the collapsed rule. expect(item.classList.contains("ek-shell-nav-item"), item.dataset.testid).toBe(true); expect(item.style.justifyContent, item.dataset.testid).toBe(""); expect(item.style.width, item.dataset.testid).toBe(""); expect(item.style.padding, item.dataset.testid).toBe(""); // Its name stays for assistive technology; only one glyph is drawn. expect(item.querySelectorAll("svg"), item.dataset.testid).toHaveLength(1); expect(item.querySelector(".ek-shell-visually-hidden")?.textContent).toBeTruthy(); } const stylesheet = [...container.querySelectorAll("style")] .map((style) => style.textContent) .join("\n"); const collapsedRule = /\.ek-shell-sidebar\[data-sidebar="collapsed"\] \.ek-shell-nav-item \{([^}]*)\}/u.exec( stylesheet, )?.[1]; expect(collapsedRule).toContain("justify-content: center"); expect(collapsedRule).toContain("margin-inline: auto"); expect(collapsedRule).toContain("padding: 0"); expect(collapsedRule).toContain("width: var(--ekklesia-control-height-md)"); expect(collapsedRule).toContain("height: var(--ekklesia-control-height-md)"); }); }); describe("the phone header (ADR 0064)", () => { it("is a sticky header holding the menu trigger and the open page's name", async () => { await render("/settings/displays", { compact: true }); const header = required("mobile-header"); expect(header.tagName).toBe("HEADER"); expect(header.className).toBe("ek-shell-mobile-bar"); expect(header.contains(required("mobile-navigation-trigger"))).toBe(true); // A visual echo of the page title; the page's own h1 is what assistive technology reads. const title = required("mobile-header-title"); expect(title.textContent).toBe("Displays settings"); expect(title.getAttribute("aria-hidden")).toBe("true"); const trigger = required("mobile-navigation-trigger"); expect(trigger.style.minHeight).toBe("var(--ekklesia-control-height-lg)"); expect(trigger.style.minWidth).toBe("var(--ekklesia-control-height-lg)"); const stylesheet = [...container.querySelectorAll("style")] .map((style) => style.textContent) .join("\n"); const rule = /\.ek-shell-mobile-bar \{([^}]*)\}/u.exec(stylesheet)?.[1]; expect(rule).toContain("position: sticky"); expect(rule).toContain("top: 0"); expect(rule).toContain("env(safe-area-inset-top, 0px)"); }); it("follows the open page, and the drawer still opens from it and returns focus to it", async () => { await render("/settings", { compact: true }); expect(required("mobile-header-title").textContent).toBe("Settings"); await click(required("mobile-navigation-trigger")); const drawer = required("navigation-drawer"); expect(document.activeElement).toBe(required("navigation-drawer-close")); await act(async () => { drawer.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" })); }); expect(query("navigation-drawer")).toBeNull(); expect(document.activeElement).toBe(required("mobile-navigation-trigger")); await click(required("mobile-navigation-trigger")); await click(required("select-settings-discover")); expect(query("navigation-drawer")).toBeNull(); expect(required("mobile-header-title").textContent).toBe("Discover modules"); expect(document.activeElement).toBe(required("host-main-region")); }); }); describe("the phone drawer locks the page behind it (ADR 0065)", () => { const rootOverflow = () => document.documentElement.style.getPropertyValue("overflow"); const rootOverscroll = () => document.documentElement.style.getPropertyValue("overscroll-behavior"); it("stops the page scrolling while open, and releases it on Escape", async () => { await render("/settings", { compact: true }); expect(rootOverflow()).toBe(""); await click(required("mobile-navigation-trigger")); expect(rootOverflow()).toBe("hidden"); expect(rootOverscroll()).toBe("none"); expect(document.body.style.getPropertyValue("overflow")).toBe("hidden"); // The drawer's own list is the one region that still scrolls. expect(required("navigation-drawer").contains(required("navigation-drawer-scroll"))).toBe(true); await act(async () => { required("navigation-drawer").dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }), ); }); expect(query("navigation-drawer")).toBeNull(); expect(rootOverflow()).toBe(""); expect(rootOverscroll()).toBe(""); expect(document.activeElement).toBe(required("mobile-navigation-trigger")); }); it("cancels a drag on the page behind it, and releases it when a page is chosen", async () => { await render("/settings", { compact: true }); await click(required("mobile-navigation-trigger")); const drag = new Event("touchmove", { bubbles: true, cancelable: true }); Object.defineProperty(drag, "touches", { value: [{ clientY: 300 }] }); required("host-main-region").dispatchEvent(drag); expect(drag.defaultPrevented).toBe(true); await click(required("select-settings-displays")); expect(query("navigation-drawer")).toBeNull(); expect(rootOverflow()).toBe(""); const after = new Event("touchmove", { bubbles: true, cancelable: true }); Object.defineProperty(after, "touches", { value: [{ clientY: 300 }] }); required("host-main-region").dispatchEvent(after); expect(after.defaultPrevented).toBe(false); }); it("never locks anything on a wide screen", async () => { await render("/settings"); expect(query("mobile-navigation-trigger")).toBeNull(); expect(rootOverflow()).toBe(""); expect(document.body.style.getPropertyValue("overflow")).toBe(""); }); });