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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user