feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058): - A collapsible left sidebar replaces the header-and-column layout. It is icons and labels when expanded and icons only when collapsed; each collapsed item keeps its name, visually hidden, and shows a tooltip on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes first, then the server's groups and modules. There is no top bar. - The expanded/collapsed choice is remembered in this browser's localStorage, behind a SidebarPreference interface that a synced store can replace later. - An account menu at the bottom of the sidebar follows the WAI-ARIA menu button pattern and offers only Sign out, which now lives there rather than in the header. - On phones (below 48em) the same navigation opens in a modal drawer: the page behind it is inert and focus is trapped inside it. Escape, its close button, and the backdrop close it and return focus to the menu button. Choosing a page closes it and focuses the page. Changing layout keeps the route, the desktop mode, and the mounted module. - Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon vocabulary. Every module shows one generic icon until manifest v9 lets modules declare their own; there is no per-module icon map. - The identity slot is a neutral placeholder with no inputs, waiting for branding (#101). The tooltip, menu, and drawer stay shell-local until a second consumer needs them (ADR 0039). The deploy smoke now signs out through the account menu, and drives the drawer at phone size in a real browser. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3b7e19651
commit
af31724cb4
@@ -147,6 +147,13 @@ async function render(
|
||||
});
|
||||
}
|
||||
|
||||
/** Sign-out lives in the account menu at the bottom of the sidebar (ADR 0058). */
|
||||
async function openAccountMenu(): Promise<void> {
|
||||
await act(async () => {
|
||||
(query("host-account-menu") as HTMLButtonElement).click();
|
||||
});
|
||||
}
|
||||
|
||||
function text(): string {
|
||||
return container.textContent ?? "";
|
||||
}
|
||||
@@ -391,7 +398,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
await signInPending;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(query("host-logout")).not.toBeNull();
|
||||
expect(query("host-account-menu")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clears the password while post-sign-in principal resolution is still pending", async () => {
|
||||
@@ -424,7 +431,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
await principalPending;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(query("host-logout")).not.toBeNull();
|
||||
expect(query("host-account-menu")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("submits from the password field with the Enter key", async () => {
|
||||
@@ -454,7 +461,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
await signInPending;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(query("host-logout")).not.toBeNull();
|
||||
expect(query("host-account-menu")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("re-resolves principal and navigation after login without a page reload", async () => {
|
||||
@@ -467,7 +474,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
await submitLogin();
|
||||
|
||||
expect(query("host-login")).toBeNull();
|
||||
expect(query("host-logout")).not.toBeNull();
|
||||
expect(query("host-account-menu")).not.toBeNull();
|
||||
expect(isolatedText(query("module-container"))).toBe("example mounted");
|
||||
expect(importer).toHaveBeenCalledOnce();
|
||||
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
|
||||
@@ -524,7 +531,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
await act(async () => {
|
||||
(retry as HTMLButtonElement).click();
|
||||
});
|
||||
expect(query("host-logout")).not.toBeNull();
|
||||
expect(query("host-account-menu")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("fails safely on a malformed successful principal response and recovers on retry", async () => {
|
||||
@@ -555,7 +562,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
(retry as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(query("host-logout")).not.toBeNull();
|
||||
expect(query("host-account-menu")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -610,6 +617,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await openAccountMenu();
|
||||
await act(async () => {
|
||||
(query("host-logout") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
@@ -668,6 +676,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
||||
if (!lateRequest) {
|
||||
throw new Error("module context was not captured");
|
||||
}
|
||||
await openAccountMenu();
|
||||
await act(async () => {
|
||||
(query("host-logout") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
@@ -707,6 +716,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
|
||||
});
|
||||
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
|
||||
|
||||
await openAccountMenu();
|
||||
await act(async () => {
|
||||
(query("host-logout") as HTMLButtonElement).click();
|
||||
await Promise.resolve();
|
||||
@@ -1024,9 +1034,10 @@ describe("Ekklesia shell — mount lifecycle", () => {
|
||||
const aside = shell.querySelector("aside") as HTMLElement;
|
||||
const main = shell.querySelector("main") as HTMLElement;
|
||||
const frame = query("module-container") as HTMLElement;
|
||||
expect(shell.style.gridTemplateColumns).toBe("15rem minmax(0, 1fr)");
|
||||
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)");
|
||||
expect(aside.style.width).toBe("15rem");
|
||||
// The sidebar sizes its own track (ADR 0058); the page track can shrink to nothing but never
|
||||
// pushes the sidebar.
|
||||
expect(shell.style.gridTemplateColumns).toBe("auto minmax(0, 1fr)");
|
||||
expect(aside.style.width).toBe("16rem");
|
||||
expect(main.style.minWidth).toBe("0");
|
||||
expect(main.style.overflowX).toBe("auto");
|
||||
expect(frame.style.minWidth).toBe("0");
|
||||
|
||||
@@ -95,6 +95,18 @@ describe("the shell is coupled to no module", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never calls a browser-native dialog: alert, confirm, and prompt are prohibited in product UI", () => {
|
||||
for (const [file, source] of sources) {
|
||||
const code = source
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*(\*|\/\/)/u.test(line))
|
||||
.join("\n");
|
||||
expect(code, path.relative(packageDirectory, file)).not.toMatch(
|
||||
/\b(?:window\.|globalThis\.|self\.)?(?:alert|confirm|prompt)\s*\(/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
|
||||
for (const [file, source] of sources) {
|
||||
// Comment lines are dropped so a doc comment may still *describe* the loader's own
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createBrowserHostViewport,
|
||||
createMemoryHostViewport,
|
||||
HOST_COMPACT_MEDIA_QUERY,
|
||||
} from "../src/browser/host-viewport.js";
|
||||
import {
|
||||
createLocalSidebarPreference,
|
||||
createMemorySidebarPreference,
|
||||
SIDEBAR_PREFERENCE_KEY,
|
||||
} from "../src/browser/sidebar-preference.js";
|
||||
|
||||
function fakeStorage(initial: Record<string, string> = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
describe("the sidebar preference (this browser only, ADR 0058)", () => {
|
||||
it("survives a reload: what one store writes, a new store over the same storage reads", () => {
|
||||
const storage = fakeStorage();
|
||||
createLocalSidebarPreference(storage).write("collapsed");
|
||||
|
||||
expect(storage.values.get(SIDEBAR_PREFERENCE_KEY)).toBe("collapsed");
|
||||
expect(createLocalSidebarPreference(storage).read()).toBe("collapsed");
|
||||
|
||||
createLocalSidebarPreference(storage).write("expanded");
|
||||
expect(createLocalSidebarPreference(storage).read()).toBe("expanded");
|
||||
});
|
||||
|
||||
it("uses one namespaced key that names no person", () => {
|
||||
expect(SIDEBAR_PREFERENCE_KEY).toBe("ekklesia.dashboard.sidebar");
|
||||
});
|
||||
|
||||
it("ignores a stored value it does not recognise rather than trusting it", () => {
|
||||
for (const value of ["", "COLLAPSED", "true", "{}", "collapsed "]) {
|
||||
expect(
|
||||
createLocalSidebarPreference(fakeStorage({ [SIDEBAR_PREFERENCE_KEY]: value })).read(),
|
||||
value,
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps working, for this page only, when storage is missing or throws", () => {
|
||||
const throwing = {
|
||||
getItem: () => {
|
||||
throw new Error("SecurityError");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("QuotaExceededError");
|
||||
},
|
||||
};
|
||||
for (const storage of [undefined, throwing]) {
|
||||
const preference = createLocalSidebarPreference(storage);
|
||||
expect(preference.read()).toBeUndefined();
|
||||
expect(() => preference.write("collapsed")).not.toThrow();
|
||||
expect(preference.read()).toBe("collapsed");
|
||||
}
|
||||
});
|
||||
|
||||
it("has an in-memory store with the same contract, for tests and as the fallback", () => {
|
||||
const preference = createMemorySidebarPreference("collapsed");
|
||||
expect(preference.read()).toBe("collapsed");
|
||||
preference.write("expanded");
|
||||
expect(preference.read()).toBe("expanded");
|
||||
expect(createMemorySidebarPreference().read()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the host viewport", () => {
|
||||
it("is compact below 48em, the phone layout's breakpoint", () => {
|
||||
expect(HOST_COMPACT_MEDIA_QUERY).toBe("(max-width: 47.999em)");
|
||||
});
|
||||
|
||||
it("reads and follows a media query list", () => {
|
||||
let matches = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const mediaQuery = {
|
||||
addEventListener: vi.fn((_type: "change", listener: () => void) => listeners.add(listener)),
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
removeEventListener: vi.fn((_type: "change", listener: () => void) =>
|
||||
listeners.delete(listener),
|
||||
),
|
||||
};
|
||||
const matchMedia = vi.fn(() => mediaQuery);
|
||||
const viewport = createBrowserHostViewport(matchMedia);
|
||||
expect(matchMedia).toHaveBeenCalledWith(HOST_COMPACT_MEDIA_QUERY);
|
||||
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = viewport.subscribe(listener);
|
||||
expect(viewport.isCompact()).toBe(false);
|
||||
matches = true;
|
||||
for (const notify of listeners) {
|
||||
notify();
|
||||
}
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(viewport.isCompact()).toBe(true);
|
||||
unsubscribe();
|
||||
expect(mediaQuery.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the wide layout where media queries are unavailable", () => {
|
||||
const viewport = createBrowserHostViewport(undefined);
|
||||
expect(viewport.isCompact()).toBe(false);
|
||||
expect(() => viewport.subscribe(() => undefined)()).not.toThrow();
|
||||
});
|
||||
|
||||
it("has an in-memory viewport a test can resize", () => {
|
||||
const viewport = createMemoryHostViewport(true);
|
||||
const listener = vi.fn();
|
||||
viewport.subscribe(listener);
|
||||
viewport.setCompact(false);
|
||||
expect(viewport.isCompact()).toBe(false);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
viewport.setCompact(false);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,687 @@
|
||||
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");
|
||||
// Only the opaque user id is known to the shell, and it is never shown.
|
||||
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 actions that work today", 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(["Sign out"]);
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
expect(menu.querySelector("a")).toBeNull();
|
||||
});
|
||||
|
||||
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-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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user