CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
696 lines
25 KiB
TypeScript
696 lines
25 KiB
TypeScript
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, type MemoryHostViewport } from "../src/browser/host-viewport.js";
|
|
import {
|
|
createLocalSidebarPreference,
|
|
createMemorySidebarPreference,
|
|
type SidebarPreference,
|
|
} from "../src/browser/sidebar-preference.js";
|
|
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
|
import { isolatedText } from "./support/isolated-dom.js";
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
}
|
|
|
|
/**
|
|
* The Dashboard shell's navigation (ADR 0058): the collapsible desktop sidebar, its account area,
|
|
* and the phone drawer. Every test drives the real `HostApp` with in-memory history, viewport, and
|
|
* sidebar preference, so routing, resizing, and persistence are all observable.
|
|
*/
|
|
|
|
const apiBaseUrl = "https://ekklesia.example.test";
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
|
|
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();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
function entry(id: string, name: string, version = "0.1.0") {
|
|
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}/${version}` },
|
|
version,
|
|
},
|
|
name,
|
|
};
|
|
}
|
|
|
|
// What the server's navigation returns: the eligible modules only. A disabled module (`retired`) is
|
|
// simply absent from it (ADR 0028), which is the whole of "hidden from navigation".
|
|
const NAVIGATION = {
|
|
groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }],
|
|
ungrouped: [entry("calendar", "Calendar", "2.0.0")],
|
|
};
|
|
|
|
interface Platform {
|
|
readonly fetch: typeof fetch;
|
|
readonly calls: string[];
|
|
}
|
|
|
|
function platform(): Platform {
|
|
let authenticated = true;
|
|
const calls: string[] = [];
|
|
const implementation = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
calls.push(url);
|
|
if (url.endsWith("/api/auth/sign-out")) {
|
|
authenticated = false;
|
|
return Response.json({ success: true });
|
|
}
|
|
if (url.endsWith("/api/v1/principal")) {
|
|
return Response.json(
|
|
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
|
|
);
|
|
}
|
|
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
|
return authenticated
|
|
? Response.json(NAVIGATION)
|
|
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
|
}
|
|
if (url.endsWith("/api/v1/devices")) {
|
|
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
|
}
|
|
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
return { calls, fetch: implementation };
|
|
}
|
|
|
|
function artifactFor(url: string) {
|
|
const id = url.includes("/calendar/") ? "calendar" : "signage";
|
|
return {
|
|
default: {
|
|
abi: MODULE_UI_ABI_VERSION,
|
|
id,
|
|
mount(element: Element, context: ModuleUiHostContext) {
|
|
element.textContent = `${context.moduleId} mounted`;
|
|
return () => {
|
|
element.textContent = "";
|
|
};
|
|
},
|
|
version: id === "calendar" ? "2.0.0" : "0.1.0",
|
|
},
|
|
};
|
|
}
|
|
|
|
interface Rendered {
|
|
readonly history: MemoryHostHistory;
|
|
readonly viewport: MemoryHostViewport;
|
|
readonly preference: SidebarPreference;
|
|
readonly platform: Platform;
|
|
}
|
|
|
|
async function render(
|
|
options: {
|
|
readonly path?: string;
|
|
readonly compact?: boolean;
|
|
readonly preference?: SidebarPreference;
|
|
readonly history?: MemoryHostHistory;
|
|
readonly viewport?: MemoryHostViewport;
|
|
} = {},
|
|
): Promise<Rendered> {
|
|
const history = options.history ?? createMemoryHostHistory(options.path ?? "/");
|
|
const viewport = options.viewport ?? createMemoryHostViewport(options.compact ?? false);
|
|
const preference = options.preference ?? createMemorySidebarPreference();
|
|
const stub = platform();
|
|
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: stub.fetch };
|
|
await act(async () => {
|
|
root.render(
|
|
<HostApp
|
|
config={config}
|
|
history={history}
|
|
importModule={async (url) => artifactFor(url)}
|
|
sidebarPreference={preference}
|
|
viewport={viewport}
|
|
/>,
|
|
);
|
|
});
|
|
return { history, platform: stub, preference, viewport };
|
|
}
|
|
|
|
async function remount(rendered: Rendered, preference = rendered.preference): Promise<void> {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render({ history: rendered.history, preference, viewport: rendered.viewport });
|
|
}
|
|
|
|
function query(testId: string): HTMLElement | null {
|
|
return container.querySelector<HTMLElement>(`[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, init: MouseEventInit = {}): Promise<MouseEvent> {
|
|
const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init });
|
|
await act(async () => {
|
|
element.dispatchEvent(event);
|
|
});
|
|
return event;
|
|
}
|
|
|
|
async function key(element: Element, keyName: string, init: KeyboardEventInit = {}) {
|
|
const event = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: keyName,
|
|
...init,
|
|
});
|
|
await act(async () => {
|
|
element.dispatchEvent(event);
|
|
});
|
|
return event;
|
|
}
|
|
|
|
async function focus(element: HTMLElement): Promise<void> {
|
|
await act(async () => {
|
|
element.focus();
|
|
});
|
|
}
|
|
|
|
const sidebar = (): HTMLElement => required("host-sidebar");
|
|
const navLink = (id: string): HTMLElement => required(`select-${id}`);
|
|
|
|
describe("desktop sidebar", () => {
|
|
it("is expanded by default: icons and labels, Overview first, then the modules in the server's order", async () => {
|
|
await render();
|
|
|
|
expect(sidebar().dataset.sidebar).toBe("expanded");
|
|
const links = [...sidebar().querySelectorAll("nav a")];
|
|
expect(links.map((link) => link.textContent)).toEqual(["Overview", "Signage", "Calendar"]);
|
|
for (const link of links) {
|
|
const icon = link.querySelector("svg");
|
|
expect(icon?.getAttribute("aria-hidden"), link.textContent ?? "").toBe("true");
|
|
}
|
|
expect(query("nav-tooltip")).toBeNull();
|
|
});
|
|
|
|
it("draws the Overview with its own icon and every module with the one generic module icon", async () => {
|
|
await render();
|
|
|
|
expect(navLink("overview").querySelector("svg")?.dataset.icon).toBe("overview");
|
|
for (const id of ["signage", "calendar"]) {
|
|
expect(navLink(id).querySelector("svg")?.dataset.icon, id).toBe("module");
|
|
expect(navLink(id).querySelector("svg")?.classList.contains("lucide-blocks"), id).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("marks the open page, the Overview or a module, with aria-current", async () => {
|
|
await render({ path: "/calendar" });
|
|
|
|
expect(navLink("calendar").getAttribute("aria-current")).toBe("page");
|
|
expect(navLink("overview").getAttribute("aria-current")).toBeNull();
|
|
|
|
await click(navLink("overview"));
|
|
expect(navLink("overview").getAttribute("aria-current")).toBe("page");
|
|
expect(navLink("calendar").getAttribute("aria-current")).toBeNull();
|
|
});
|
|
|
|
it("does not list a disabled module, whose address still says it is unavailable", async () => {
|
|
await render({ path: "/retired" });
|
|
|
|
expect(query("select-retired")).toBeNull();
|
|
expect(query("host-module-unavailable")).not.toBeNull();
|
|
expect(container.querySelector('nav [aria-current="page"]')).toBeNull();
|
|
});
|
|
|
|
it("collapses to icons only, through a labelled toggle that says what it controls", async () => {
|
|
const { preference } = await render();
|
|
const toggle = required("sidebar-toggle");
|
|
const nav = sidebar().querySelector("nav");
|
|
|
|
expect(toggle.tagName).toBe("BUTTON");
|
|
expect(toggle.getAttribute("aria-label")).toBe("Collapse navigation");
|
|
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
|
expect(toggle.getAttribute("aria-controls")).toBe(nav?.id);
|
|
expect(nav?.id).toBeTruthy();
|
|
|
|
await click(toggle);
|
|
|
|
expect(sidebar().dataset.sidebar).toBe("collapsed");
|
|
expect(toggle.getAttribute("aria-label")).toBe("Expand navigation");
|
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
|
expect(preference.read()).toBe("collapsed");
|
|
// Icon-only to the eye, still named to assistive technology.
|
|
for (const id of ["overview", "signage", "calendar"]) {
|
|
const label = navLink(id).querySelector("[data-nav-label]");
|
|
expect(label?.classList.contains("ek-shell-visually-hidden"), id).toBe(true);
|
|
expect(navLink(id).textContent, id).not.toBe("");
|
|
}
|
|
|
|
await click(toggle);
|
|
expect(sidebar().dataset.sidebar).toBe("expanded");
|
|
expect(preference.read()).toBe("expanded");
|
|
});
|
|
|
|
it("keeps the chosen mode across a remount, as across a reload", async () => {
|
|
const rendered = await render();
|
|
await click(required("sidebar-toggle"));
|
|
|
|
await remount(rendered);
|
|
|
|
expect(sidebar().dataset.sidebar).toBe("collapsed");
|
|
});
|
|
|
|
it("keeps it in this browser's own storage, so a reload with a fresh page still finds it", async () => {
|
|
const rendered = await render({
|
|
preference: createLocalSidebarPreference(window.localStorage),
|
|
});
|
|
await click(required("sidebar-toggle"));
|
|
|
|
await remount(rendered, createLocalSidebarPreference(window.localStorage));
|
|
|
|
expect(sidebar().dataset.sidebar).toBe("collapsed");
|
|
});
|
|
|
|
it("names a collapsed item in a tooltip on keyboard focus and on hover, and Escape dismisses it", async () => {
|
|
await render({ preference: createMemorySidebarPreference("collapsed") });
|
|
|
|
await focus(navLink("signage"));
|
|
const tooltip = required("nav-tooltip");
|
|
expect(tooltip.textContent).toBe("Signage");
|
|
// A visual duplicate of the link's own accessible name, so it is not announced twice.
|
|
expect(tooltip.getAttribute("aria-hidden")).toBe("true");
|
|
|
|
await key(navLink("signage"), "Escape");
|
|
expect(query("nav-tooltip")).toBeNull();
|
|
// Escape hides the tooltip without moving focus.
|
|
expect(document.activeElement).toBe(navLink("signage"));
|
|
|
|
await act(async () => {
|
|
navLink("signage").blur();
|
|
});
|
|
await act(async () => {
|
|
navLink("calendar").dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
});
|
|
expect(required("nav-tooltip").textContent).toBe("Calendar");
|
|
});
|
|
|
|
it("shows no tooltip while expanded, where the label is already visible", async () => {
|
|
await render();
|
|
|
|
await focus(navLink("signage"));
|
|
|
|
expect(query("nav-tooltip")).toBeNull();
|
|
});
|
|
|
|
it("names the collapse toggle and the account control in tooltips when collapsed", async () => {
|
|
await render({ preference: createMemorySidebarPreference("collapsed") });
|
|
|
|
await focus(required("sidebar-toggle"));
|
|
expect(required("nav-tooltip").textContent).toBe("Expand navigation");
|
|
await focus(required("host-account-menu"));
|
|
expect(required("nav-tooltip").textContent).toBe("Your account");
|
|
});
|
|
|
|
it("is the only navigation: no application top bar", async () => {
|
|
await render();
|
|
|
|
expect(container.querySelector('[data-testid="host-shell"] > header')).toBeNull();
|
|
expect(query("mobile-navigation-trigger")).toBeNull();
|
|
// The neutral identity placeholder sits at the top of the sidebar, and is not a heading: each
|
|
// page's own title is.
|
|
const brand = required("shell-brand");
|
|
expect(sidebar().contains(brand)).toBe(true);
|
|
expect(brand.querySelector("h1, h2")).toBeNull();
|
|
});
|
|
|
|
it("orders keyboard focus top to bottom: toggle, Overview, modules, then the account control", async () => {
|
|
await render();
|
|
|
|
const focusable = [
|
|
...sidebar().querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
|
|
].map((element) => element.dataset.testid);
|
|
|
|
expect(focusable).toEqual([
|
|
"sidebar-toggle",
|
|
"select-overview",
|
|
"select-signage",
|
|
"select-calendar",
|
|
"host-account-menu",
|
|
]);
|
|
});
|
|
|
|
it("gives every navigation item a pointer and touch target of at least the medium control height", async () => {
|
|
await render();
|
|
|
|
for (const id of ["overview", "signage", "calendar"]) {
|
|
expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-md)");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the account area", () => {
|
|
it("is anchored at the bottom of the sidebar as a product-owned menu button", async () => {
|
|
await render();
|
|
const trigger = required("host-account-menu");
|
|
|
|
expect(sidebar().lastElementChild?.contains(trigger)).toBe(true);
|
|
expect(trigger.tagName).toBe("BUTTON");
|
|
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
expect(trigger.textContent).toContain("Your account");
|
|
// This harness serves no account, so the control names the area; the opaque user id is never
|
|
// shown either way. With an account it names the person (account-appearance.test.tsx).
|
|
expect(container.textContent).not.toContain("user-1");
|
|
expect(query("host-account-menu-list")).toBeNull();
|
|
});
|
|
|
|
it("opens on activation with focus on its first item, and offers only destinations that work", async () => {
|
|
await render();
|
|
const trigger = required("host-account-menu");
|
|
|
|
await click(trigger);
|
|
|
|
const menu = required("host-account-menu-list");
|
|
expect(menu.getAttribute("role")).toBe("menu");
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
|
|
const items = [...menu.querySelectorAll('[role="menuitem"]')];
|
|
expect(items.map((item) => item.textContent)).toEqual(["Account", "Appearance", "Sign out"]);
|
|
expect(document.activeElement).toBe(items[0]);
|
|
// Account and Appearance are real links to real pages (ADR 0062); Sign out is an action.
|
|
expect([...menu.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([
|
|
"/account",
|
|
"/account/appearance",
|
|
]);
|
|
expect(items[2]?.tagName).toBe("BUTTON");
|
|
});
|
|
|
|
it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => {
|
|
await render();
|
|
const trigger = required("host-account-menu");
|
|
await focus(trigger);
|
|
|
|
await key(trigger, "ArrowDown");
|
|
expect(document.activeElement).toBe(required("host-account-link"));
|
|
await key(required("host-account-link"), "ArrowUp");
|
|
expect(document.activeElement).toBe(required("host-logout"));
|
|
|
|
await key(required("host-logout"), "Escape");
|
|
expect(query("host-account-menu-list")).toBeNull();
|
|
expect(document.activeElement).toBe(trigger);
|
|
});
|
|
|
|
it("closes when focus or a pointer leaves it, without signing anyone out", async () => {
|
|
const { platform: stub } = await render();
|
|
await click(required("host-account-menu"));
|
|
|
|
await act(async () => {
|
|
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
|
});
|
|
expect(query("host-account-menu-list")).toBeNull();
|
|
|
|
await click(required("host-account-menu"));
|
|
await key(required("host-logout"), "Tab");
|
|
expect(query("host-account-menu-list")).toBeNull();
|
|
expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(false);
|
|
});
|
|
|
|
it("signs out from the menu, back to the sign-in form", async () => {
|
|
const { platform: stub } = await render({ path: "/calendar" });
|
|
|
|
await click(required("host-account-menu"));
|
|
await click(required("host-logout"));
|
|
|
|
expect(query("host-login")).not.toBeNull();
|
|
expect(query("host-sidebar")).toBeNull();
|
|
expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(true);
|
|
});
|
|
|
|
it("never uses a browser-native dialog", async () => {
|
|
const alert = vi.fn();
|
|
const confirm = vi.fn(() => true);
|
|
const prompt = vi.fn(() => null);
|
|
vi.stubGlobal("alert", alert);
|
|
vi.stubGlobal("confirm", confirm);
|
|
vi.stubGlobal("prompt", prompt);
|
|
try {
|
|
await render({ compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
await key(navLink("overview"), "Escape");
|
|
await click(required("mobile-navigation-trigger"));
|
|
await click(required("host-account-menu"));
|
|
await click(required("host-logout"));
|
|
|
|
expect(alert).not.toHaveBeenCalled();
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
expect(prompt).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("phone navigation drawer", () => {
|
|
it("replaces the sidebar with one labelled menu trigger, and renders no navigation until opened", async () => {
|
|
await render({ compact: true });
|
|
|
|
expect(query("host-sidebar")).toBeNull();
|
|
const trigger = required("mobile-navigation-trigger");
|
|
expect(trigger.tagName).toBe("BUTTON");
|
|
expect(trigger.getAttribute("aria-label")).toBe("Open navigation");
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
expect(trigger.getAttribute("aria-controls")).toBe("ekklesia-navigation-drawer");
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(query("host-overview")).not.toBeNull();
|
|
});
|
|
|
|
it("opens as a modal dialog with the same navigation, focus inside, and the page inert", async () => {
|
|
await render({ compact: true, path: "/calendar" });
|
|
const trigger = required("mobile-navigation-trigger");
|
|
|
|
await click(trigger);
|
|
|
|
const drawer = required("navigation-drawer");
|
|
expect(drawer.id).toBe("ekklesia-navigation-drawer");
|
|
expect(drawer.getAttribute("role")).toBe("dialog");
|
|
expect(drawer.getAttribute("aria-modal")).toBe("true");
|
|
expect(drawer.getAttribute("aria-label")).toBe("Navigation");
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
expect(document.activeElement).toBe(required("navigation-drawer-close"));
|
|
expect([...drawer.querySelectorAll("nav a")].map((link) => link.textContent)).toEqual([
|
|
"Overview",
|
|
"Signage",
|
|
"Calendar",
|
|
]);
|
|
// The same hierarchy and the same current page as on desktop, always with labels.
|
|
expect(navLink("calendar").getAttribute("aria-current")).toBe("page");
|
|
expect(drawer.querySelector(".ek-shell-visually-hidden[data-nav-label]")).toBeNull();
|
|
expect(required("host-main-region").hasAttribute("inert")).toBe(true);
|
|
expect(drawer.contains(required("host-account-menu"))).toBe(true);
|
|
});
|
|
|
|
it("closes on Escape and returns focus to the trigger", async () => {
|
|
await render({ compact: true });
|
|
const trigger = required("mobile-navigation-trigger");
|
|
await click(trigger);
|
|
|
|
await key(navLink("signage"), "Escape");
|
|
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(document.activeElement).toBe(trigger);
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
expect(required("host-main-region").hasAttribute("inert")).toBe(false);
|
|
});
|
|
|
|
it("closes from its close button and from the backdrop, returning focus each time", async () => {
|
|
await render({ compact: true });
|
|
const trigger = required("mobile-navigation-trigger");
|
|
|
|
await click(trigger);
|
|
await click(required("navigation-drawer-close"));
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(document.activeElement).toBe(trigger);
|
|
|
|
await click(trigger);
|
|
await click(required("navigation-drawer-backdrop"));
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(document.activeElement).toBe(trigger);
|
|
});
|
|
|
|
it("traps Tab and Shift+Tab inside itself while open", async () => {
|
|
await render({ compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
const drawer = required("navigation-drawer");
|
|
const focusable = [...drawer.querySelectorAll<HTMLElement>("a[href], button:not([disabled])")];
|
|
const first = focusable[0] as HTMLElement;
|
|
const last = focusable.at(-1) as HTMLElement;
|
|
expect(first).toBe(required("navigation-drawer-close"));
|
|
expect(last).toBe(required("host-account-menu"));
|
|
|
|
await focus(last);
|
|
const forward = await key(last, "Tab");
|
|
expect(forward.defaultPrevented).toBe(true);
|
|
expect(document.activeElement).toBe(first);
|
|
|
|
const backward = await key(first, "Tab", { shiftKey: true });
|
|
expect(backward.defaultPrevented).toBe(true);
|
|
expect(document.activeElement).toBe(last);
|
|
|
|
// Tab between two inner items is left to the browser.
|
|
await focus(navLink("overview"));
|
|
expect((await key(navLink("overview"), "Tab")).defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("closes when a destination is chosen, opens that page, and puts focus on the page", async () => {
|
|
const { history } = await render({ compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
await click(navLink("signage"));
|
|
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(history.entries).toEqual(["/", "/signage"]);
|
|
expect(isolatedText(query("module-container"))).toBe("signage mounted");
|
|
expect(document.activeElement).toBe(required("host-main-region"));
|
|
});
|
|
|
|
it("closes when the page already open is chosen, and still moves focus to the page", async () => {
|
|
const { history } = await render({ compact: true, path: "/calendar" });
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
await click(navLink("calendar"));
|
|
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(history.entries).toEqual(["/calendar"]);
|
|
expect(document.activeElement).toBe(required("host-main-region"));
|
|
});
|
|
|
|
it("closes on Back and Forward, which keep working with the drawer", async () => {
|
|
const { history } = await render({ compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
await click(navLink("calendar"));
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
await act(async () => {
|
|
history.go(-1);
|
|
});
|
|
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(query("host-overview")).not.toBeNull();
|
|
await act(async () => {
|
|
history.go(1);
|
|
});
|
|
expect(isolatedText(query("module-container"))).toBe("calendar mounted");
|
|
});
|
|
|
|
it("signs out from the account menu inside the drawer", async () => {
|
|
await render({ compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
await click(required("host-account-menu"));
|
|
await click(required("host-logout"));
|
|
|
|
expect(query("host-login")).not.toBeNull();
|
|
});
|
|
|
|
it("gives drawer items a touch target of at least the large control height", async () => {
|
|
await render({ compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
for (const id of ["overview", "signage", "calendar"]) {
|
|
expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-lg)");
|
|
}
|
|
expect(required("mobile-navigation-trigger").style.minHeight).toBe(
|
|
"var(--ekklesia-control-height-lg)",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("moving between phone and desktop layouts", () => {
|
|
it("keeps the open page and the desktop mode, and drops an open drawer cleanly", async () => {
|
|
const { viewport, history } = await render({
|
|
compact: true,
|
|
path: "/signage",
|
|
preference: createMemorySidebarPreference("collapsed"),
|
|
});
|
|
await click(required("mobile-navigation-trigger"));
|
|
await focus(navLink("calendar"));
|
|
|
|
await act(async () => {
|
|
viewport.setCompact(false);
|
|
});
|
|
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(sidebar().dataset.sidebar).toBe("collapsed");
|
|
expect(navLink("signage").getAttribute("aria-current")).toBe("page");
|
|
expect(history.entries).toEqual(["/signage"]);
|
|
expect(isolatedText(query("module-container"))).toBe("signage mounted");
|
|
expect(required("host-main-region").hasAttribute("inert")).toBe(false);
|
|
// Focus was inside the drawer that went away; it lands on the page rather than nowhere.
|
|
expect(document.activeElement).toBe(required("host-main-region"));
|
|
|
|
await act(async () => {
|
|
viewport.setCompact(true);
|
|
});
|
|
expect(query("host-sidebar")).toBeNull();
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(required("mobile-navigation-trigger").getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
|
|
it("does not remount the open module when the layout changes", async () => {
|
|
const mounts: string[] = [];
|
|
const viewport = createMemoryHostViewport(false);
|
|
const history = createMemoryHostHistory("/signage");
|
|
const stub = platform();
|
|
await act(async () => {
|
|
root.render(
|
|
<HostApp
|
|
config={{ apiBaseUrl, fetch: stub.fetch }}
|
|
history={history}
|
|
importModule={async (url) => {
|
|
mounts.push(url);
|
|
return artifactFor(url);
|
|
}}
|
|
sidebarPreference={createMemorySidebarPreference()}
|
|
viewport={viewport}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
viewport.setCompact(true);
|
|
});
|
|
await act(async () => {
|
|
viewport.setCompact(false);
|
|
});
|
|
|
|
expect(mounts).toHaveLength(1);
|
|
expect(isolatedText(query("module-container"))).toBe("signage mounted");
|
|
});
|
|
});
|