Merge pull request 'feat(shell): collapsible sidebar, account menu, and phone drawer (Stage 1, ADR 0058)' (#104) from feat/dashboard-shell-navigation into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 34s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m15s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 59s
CI / deploy-smoke (split) (push) Successful in 2m32s
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 34s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m15s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 59s
CI / deploy-smoke (split) (push) Successful in 2m32s
Reviewed-on: #104 Reviewed-by: Zayin_Admin <[email protected]>
This commit was merged in pull request #104.
This commit is contained in:
@@ -916,8 +916,18 @@ each installed module's `/<module-id>`; any other top-level path is its sanitize
|
||||
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
|
||||
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
|
||||
*This page isn't available* rather than loading anything. A module's own
|
||||
sub-pages, Settings and Account pages, the sidebar redesign, and returning to the workflow a Settings
|
||||
link came from are later Stage 1 work.
|
||||
sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
|
||||
later Stage 1 work.
|
||||
|
||||
The Dashboard's **shell** follows DESIGN.md §4
|
||||
([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a
|
||||
collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the
|
||||
choice remembered in this browser — with the Overview first, the server's groups and modules below it,
|
||||
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
|
||||
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
|
||||
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
|
||||
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
|
||||
sidebar's identity slot is still to come.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
||||
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
|
||||
});
|
||||
|
||||
it("never puts a reset token in browser storage", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||
const code = withoutComments(file.text);
|
||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||
// the life of one form and nowhere else.
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
|
||||
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
|
||||
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
|
||||
// is its own fixed key and a validated mode.
|
||||
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
|
||||
const writes: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = withoutComments(file.text);
|
||||
expect(code, file.path).not.toContain("sessionStorage");
|
||||
expect(code, file.path).not.toContain("document.cookie");
|
||||
writes.push(
|
||||
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
|
||||
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||
),
|
||||
);
|
||||
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toMatch(
|
||||
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
);
|
||||
} else {
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
}
|
||||
}
|
||||
expect(writes).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
|
||||
]);
|
||||
// The files that ever hold the token touch no storage API at all.
|
||||
for (const file of files.filter((candidate) =>
|
||||
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
|
||||
candidate.path.endsWith(name),
|
||||
),
|
||||
)) {
|
||||
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# 0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-14
|
||||
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||
[0039](0039-shared-design-tokens-and-elements.md),
|
||||
[0057](0057-dashboard-urls-are-host-owned-routes.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0057 gave every Dashboard page a real address, but the shell around those pages was still the
|
||||
first proof's layout: a top header holding a hard-coded product name and a Sign out button, a fixed
|
||||
`15rem` column of text buttons, and the transitional device panel above the navigation. It had no
|
||||
icons, could not collapse, had no account area, and on a phone it was the same two columns squeezed
|
||||
until the page was unusable.
|
||||
|
||||
DESIGN.md §4 fixes what the shell is: a collapsible left sidebar — icons and labels expanded, icons
|
||||
only collapsed, with accessible names — church identity at the top, the account area anchored at the
|
||||
bottom, no persistent top bar, and on phones a drawer with the **same** hierarchy (locked decisions
|
||||
4–8). It left one thing open that this work cannot proceed without: **which icon library**
|
||||
(§27, "Final free icon library selection"). The maintainer has decided it: Lucide.
|
||||
|
||||
## Decision
|
||||
|
||||
### Lucide is Ekklesia's icon vocabulary
|
||||
|
||||
Lucide (`lucide-react`, ISC-licensed, no runtime dependencies) is the one icon set for first-party
|
||||
Ekklesia UI: the shell, `@ekklesia/ui` if it ever draws an icon, and first-party modules. Where
|
||||
Lucide has an appropriate icon, nobody draws their own; a meaning that Lucide genuinely cannot
|
||||
express is the only reason to design one, and that is a decision to record, not a shortcut.
|
||||
|
||||
Icons are **decorative**. Every control that shows one also has a text name — visible, or visually
|
||||
hidden when the sidebar is collapsed — and every icon is `aria-hidden`. An icon never carries
|
||||
meaning alone, and its stroke is `currentColor`, so it takes its colour from the design tokens
|
||||
exactly as text does (ADR 0039). Icons are imported by name, so a bundle contains exactly the icons
|
||||
it uses.
|
||||
|
||||
The shell imports every icon it uses in one file (`surfaces/shell/src/browser/shell-icons.tsx`),
|
||||
keyed by meaning — `overview`, `module`, `account`, `signOut`, … — so the vocabulary is reviewable
|
||||
and an icon is chosen once per meaning rather than per call site. `lucide-react` is a dependency of
|
||||
`@ekklesia/shell` only, pinned exactly (`1.48.0`); no public `@ekklesia/*` package gains it here.
|
||||
A module that needs icons in its own UI uses `lucide-react` itself, which `pnpm boundaries` already
|
||||
permits (module `.svg` presentation assets remain rejected).
|
||||
|
||||
### Modules have no icon of their own yet
|
||||
|
||||
Every module in the navigation is drawn with **one generic module icon** (Lucide `Blocks`). There is
|
||||
no table mapping known module ids to icons: that would be a second, host-owned answer to a question
|
||||
the module owns, and every new module would wait on a shell release to look right. A module will
|
||||
declare its own icon — by Lucide name — through the module navigation contract that also brings its
|
||||
sub-pages (manifest v9). Nothing in the manifest changes here.
|
||||
|
||||
### The shell's layout
|
||||
|
||||
On a wide screen (at or above `48em`, which is 768px at the default text size and follows a
|
||||
person's own text-size setting), the Dashboard is a sidebar and the page:
|
||||
|
||||
```text
|
||||
┌────────────┬──────────────────────────────┐
|
||||
│ identity ◂│ │
|
||||
│ Overview │ │
|
||||
│ GROUP │ the page │
|
||||
│ Module │ │
|
||||
│ Module │ │
|
||||
│ (devices) │ │
|
||||
│────────────│ │
|
||||
│ Your account │
|
||||
└────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Top:** the identity slot and the collapse toggle. The slot is a neutral placeholder (a plain mark
|
||||
and the product name) and deliberately takes **no inputs**: the branding work (issue #101) owns
|
||||
what goes there and where it comes from, and replaces this one component rather than undoing a
|
||||
configuration model invented early. It is not a heading; each page's own title is.
|
||||
- **Middle:** the navigation, scrolling on its own — the Overview first, then the server's groups and
|
||||
modules in the server's order (ADR 0027). The shell sorts and filters nothing; a disabled module is
|
||||
absent because the navigation endpoint does not list it (ADR 0028), and its address still shows
|
||||
*This page isn't available* (ADR 0057). Below it, while expanded, the transitional device panel
|
||||
(ADR 0036) until Settings → Displays replaces it.
|
||||
- **Bottom:** the account control, a product-owned menu button (below).
|
||||
|
||||
**Collapsed**, the sidebar is icons only (`4.5rem` rather than `16rem`). Each item keeps its text in
|
||||
the DOM, visually hidden, as its accessible name, and shows a tooltip on hover **and on keyboard
|
||||
focus**. The tooltip is a visual duplicate of that name, so it is `aria-hidden` and nothing is
|
||||
announced twice; it meets WCAG 2.2 §1.4.13 — dismissible with Escape without moving focus, hoverable,
|
||||
and persistent until focus or the pointer leaves — and is positioned beside the item so a scrolling
|
||||
sidebar cannot clip it. Group labels stay in the DOM for assistive technology, with a divider
|
||||
standing in for them visually. The toggle states what it does ("Collapse navigation" / "Expand
|
||||
navigation") and carries `aria-expanded` and `aria-controls`.
|
||||
|
||||
There is **no application top bar**: the page begins at the top of its column.
|
||||
|
||||
### Expanded or collapsed is a preference of this browser
|
||||
|
||||
The mode is remembered in this browser's `localStorage` under `ekklesia.dashboard.sidebar` (exactly
|
||||
`expanded` or `collapsed`; anything else is ignored), so it survives a reload. It is a preference of
|
||||
the device rather than the person: a narrow laptop and a wide desktop reasonably want different
|
||||
answers, and DESIGN.md's synced personal preferences belong to the account area, which does not exist
|
||||
yet. Storage can be missing, blocked, or throw; then the mode is remembered for the page only and
|
||||
nothing breaks.
|
||||
|
||||
The shell reads and writes it through a two-method `SidebarPreference` interface built by the entry
|
||||
point, so a synced or per-person store replaces the implementation without touching a component. The
|
||||
entry point likewise builds the browser's media query (`HostViewport`) and history (ADR 0057), and
|
||||
remains the only file in the package that touches `window`.
|
||||
|
||||
### The account area
|
||||
|
||||
The signed-in person's control at the bottom of the sidebar opens a product-owned menu following the
|
||||
WAI-ARIA menu button pattern: `aria-haspopup="menu"` and `aria-expanded` on the trigger; opening moves
|
||||
focus to the first item (ArrowUp opens on the last); arrow keys, Home, and End move between items;
|
||||
Escape closes it and returns focus to the trigger; Tab or a press outside closes it and leaves focus
|
||||
where it went. It contains **only actions that work today** — Sign out. Profile, personal preferences,
|
||||
appearance, and notifications belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages;
|
||||
until then there is no dead link to the reserved `/account`.
|
||||
|
||||
The shell knows the person only as an opaque user id (`/api/v1/principal`, ADR 0034), which is
|
||||
never displayed, so the control reads "Your account" with a generic avatar rather than a name or
|
||||
email. Showing the person's name needs a deliberate principal or profile projection, which this
|
||||
decision does not add.
|
||||
|
||||
### Phones: the same navigation in a drawer
|
||||
|
||||
Below the breakpoint the sidebar is not rendered. A single menu button ("Open navigation", with
|
||||
`aria-expanded` and `aria-controls`) sits above the page — the one control there, not a bar — and opens
|
||||
a drawer containing the **same** identity slot, navigation, current page, device panel, and account
|
||||
area, always with labels and with large-control touch targets (48px). The drawer is a real modal:
|
||||
|
||||
- `role="dialog"`, `aria-modal="true"`, named "Navigation";
|
||||
- the page behind it is `inert`, and Tab and Shift+Tab are also kept inside it, for a browser that
|
||||
does not yet honour `inert`;
|
||||
- opening moves focus to its close button; Escape, the close button, and the backdrop dismiss it and
|
||||
return focus to the menu button;
|
||||
- choosing a destination closes it and moves focus to the new page (ADR 0057), including when the
|
||||
destination is the page already open;
|
||||
- Back and Forward close it and still work.
|
||||
|
||||
### Changing layout never disturbs the page
|
||||
|
||||
The page's `<main>` keeps one position in the tree in both layouts, so rotating a tablet or resizing
|
||||
a window never remounts the open module. The route comes from the URL and the desktop mode from the
|
||||
preference, so neither is touched by a layout change. Only an open drawer is dropped, and focus that
|
||||
was inside navigation that has just gone away lands on the page rather than on the document.
|
||||
|
||||
### Shell-local, not yet `@ekklesia/ui`
|
||||
|
||||
The tooltip, the account menu, and the drawer live in the shell. ADR 0039 adds an element to
|
||||
`@ekklesia/ui` once two independent consumers need it, and today the shell is the only one; adding
|
||||
them to the public package would put unproven API in front of every module. Each is written as a
|
||||
self-contained, generic piece so it can be promoted unchanged when a module needs one. The shell's
|
||||
own stylesheet (hover, `:focus-visible`, reduced motion) is a token-only, `ek-shell-`-prefixed string
|
||||
installed into the shell's document, beside `@ekklesia/ui`'s, and cannot reach a module's isolated
|
||||
root (ADR 0040).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Another icon library** (Heroicons, Phosphor, Tabler, Material Symbols). Rejected by the
|
||||
maintainer's decision; Lucide's breadth, consistent stroke style, ISC licence, and per-icon imports
|
||||
fit a calm interface that many modules will extend.
|
||||
- **Ekklesia's own SVG set.** Rejected: an icon set is a long-term design and maintenance commitment
|
||||
that buys nothing a mature open set does not already have.
|
||||
- **A host-owned map of module ids to icons.** Rejected: the module owns its identity, and a map makes
|
||||
every new module wait on a shell release. The generic icon is honest until modules declare one.
|
||||
- **A module-manifest `icon` field now.** Deferred to the manifest-v9 navigation contract, so modules
|
||||
change manifest version once for icons, sub-pages, and the rest of that contract together.
|
||||
- **Persisting the mode on the server, per person.** Deferred to the account preferences work; the
|
||||
interface is shaped so that store can replace this one.
|
||||
- **CSS-only responsive switching.** Rejected: the drawer must be a modal with focus management, which
|
||||
needs the layout in script, and a media query adapter keeps it testable.
|
||||
- **A top bar holding the brand and the menu button on phones.** Rejected: DESIGN.md has no persistent
|
||||
top bar; one button is all the phone layout needs above the page.
|
||||
- **Native `<dialog>` for the drawer.** Not chosen for now: its focus return and inertness differ
|
||||
across the browsers a church's volunteers use, and the shell's own handling is explicit and tested.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Dashboard has the shell DESIGN.md describes on desktop, tablet, and phone, proven in unit tests
|
||||
against in-memory history, viewport, and preference, and in the deploy smoke test in a real browser:
|
||||
at desktop size (the account menu's Sign out) and at phone size (the drawer's focus, Escape, and
|
||||
choosing a module), and back again.
|
||||
- Sign out moved from the header into the account menu.
|
||||
- The hard-coded product name now lives in one placeholder component, waiting for branding; there is
|
||||
no other person-facing "Ekklesia" in the authenticated shell.
|
||||
- `@ekklesia/shell` depends on `lucide-react`. No public package changed.
|
||||
|
||||
## Deferred
|
||||
|
||||
Church branding in the identity slot (issue #101); module-declared icons and module sub-pages in the
|
||||
sidebar (manifest v9); group icons and the navigation editor; the account pages and synced personal
|
||||
preferences, including a per-person sidebar mode; showing the person's name or picture; Settings in the
|
||||
navigation; promoting the tooltip, menu, or drawer to `@ekklesia/ui`.
|
||||
@@ -66,3 +66,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
||||
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
|
||||
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
|
||||
|
||||
@@ -344,8 +344,10 @@ shell renders that arrangement and computes none of it.
|
||||
|
||||
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
||||
Dashboard Home or widgets, no design system, and no client routing (client routing and an Overview
|
||||
landing page have since landed; see
|
||||
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md)). A production CSP and deployment
|
||||
landing page have since landed, see
|
||||
[ADR 0057](decisions/0057-dashboard-urls-are-host-owned-routes.md), and the shell itself is now the
|
||||
collapsible sidebar, account menu, and phone drawer of
|
||||
[ADR 0058](decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). A production CSP and deployment
|
||||
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
|
||||
pass 1 later supplies the host-owned sign-in flow described below.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
|
||||
@@ -233,7 +233,9 @@ password, URL, or stack reaches browser output or logging. The form offers **no
|
||||
because public registration is not a supported production flow. It does offer **Forgot password?**,
|
||||
which is now a real backend flow (ADR 0038).
|
||||
|
||||
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
|
||||
Sign-out is the account menu's one item, at the bottom of the sidebar or the phone drawer
|
||||
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). It
|
||||
posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
|
||||
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
|
||||
During that transition the Dashboard subtree is unmounted, which invokes the generic module
|
||||
loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
|
||||
|
||||
@@ -403,7 +403,10 @@ parser is exercised as the pure function it is, including that it names no desti
|
||||
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
|
||||
schema gained a token column and no identity code hashes or compares a token, that nothing
|
||||
interpolates a token, link, address, or body into a log line, that a device has no email, password,
|
||||
or verification concept, and that no browser source imports Better Auth or Core's mail.
|
||||
or verification concept, that no browser source imports Better Auth or Core's mail, and that the
|
||||
shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named
|
||||
only by the entry point, one write of one fixed key, and no storage API in any file that holds the
|
||||
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)).
|
||||
|
||||
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
|
||||
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||
|
||||
@@ -576,6 +576,17 @@ module's own HTTP routes, and creating relationship-derived grants automatically
|
||||
own business logic, are both still later work; this only makes a declared permission *usable*
|
||||
through Core's existing, generic surface.
|
||||
|
||||
### Icons in your module's UI
|
||||
|
||||
Lucide is Ekklesia's icon vocabulary
|
||||
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). If your
|
||||
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
|
||||
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
|
||||
decorative: give every control a text name, visible or visually hidden, and keep the icon
|
||||
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
|
||||
module will declare its own navigation icon through the module navigation contract (manifest v9), not
|
||||
yet.
|
||||
|
||||
### Your module's display name
|
||||
|
||||
`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees
|
||||
|
||||
Generated
+12
@@ -407,6 +407,9 @@ importers:
|
||||
'@ekklesia/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
lucide-react:
|
||||
specifier: 1.48.0
|
||||
version: 1.48.0([email protected])
|
||||
react:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8
|
||||
@@ -2401,6 +2404,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -4369,6 +4377,10 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
@@ -191,8 +191,10 @@ check(
|
||||
const moduleButton =
|
||||
'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')';
|
||||
const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation");
|
||||
let openedModuleId;
|
||||
if (hasModule) {
|
||||
const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`);
|
||||
openedModuleId = moduleId;
|
||||
await evaluate(`${moduleButton}.click()`);
|
||||
const mounted = await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`,
|
||||
@@ -282,9 +284,74 @@ if (displayPath === null) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sign out on the Dashboard -------------------------------------------------------------------
|
||||
// --- The phone layout: the same navigation, in a drawer (ADR 0058) ------------------------------
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: 844,
|
||||
mobile: true,
|
||||
width: 390,
|
||||
});
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-logout"), "the sign-out control");
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("mobile-navigation-trigger")} && !${selector("host-sidebar")}`,
|
||||
"the phone navigation trigger",
|
||||
),
|
||||
"a phone-sized window shows the navigation trigger instead of the sidebar",
|
||||
);
|
||||
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`document.activeElement === ${selector("navigation-drawer-close")} && ${selector("host-main-region")}.inert`,
|
||||
"focus inside the opened drawer",
|
||||
),
|
||||
"the drawer opens as a modal, with focus inside it and the page inert",
|
||||
);
|
||||
await evaluate(
|
||||
`document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }))`,
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
`!${selector("navigation-drawer")} && document.activeElement === ${selector("mobile-navigation-trigger")}`,
|
||||
"the drawer to close on Escape",
|
||||
),
|
||||
"Escape closes the drawer and returns focus to its trigger",
|
||||
);
|
||||
if (openedModuleId) {
|
||||
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||
await waitFor(selector(`select-${openedModuleId}`), "the module in the drawer");
|
||||
await evaluate(`${selector(`select-${openedModuleId}`)}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(`/${openedModuleId}`)} && !${selector("navigation-drawer")} && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`,
|
||||
"the module chosen from the drawer to open",
|
||||
),
|
||||
`choosing ${openedModuleId} in the drawer closes it and opens the module at its own address`,
|
||||
);
|
||||
}
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-sidebar")} && !${selector("mobile-navigation-trigger")}${
|
||||
openedModuleId
|
||||
? ` && ${selector(`select-${openedModuleId}`)}?.getAttribute("aria-current") === "page"`
|
||||
: ""
|
||||
}`,
|
||||
"the sidebar after widening the window",
|
||||
),
|
||||
"widening the window brings the sidebar back on the same page",
|
||||
);
|
||||
|
||||
// --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar ---------------
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-account-menu"), "the account control");
|
||||
await evaluate(`${selector("host-account-menu")}.click()`);
|
||||
await waitFor(selector("host-logout"), "the sign-out item in the account menu");
|
||||
await evaluate(`${selector("host-logout")}.click()`);
|
||||
check(
|
||||
await waitFor(selector("login-email"), "the sign-in form after sign-out"),
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/ui": "workspace:*",
|
||||
"lucide-react": "1.48.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
|
||||
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
export interface AccountMenuProps {
|
||||
readonly presentation: NavigationPresentation;
|
||||
readonly onSignOut: () => void;
|
||||
}
|
||||
|
||||
interface AccountAction {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
readonly run: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5).
|
||||
*
|
||||
* A product-owned menu button — never browser UI — following the WAI-ARIA menu button pattern: the
|
||||
* trigger says it opens a menu and whether it is open; opening moves focus to the first item (or
|
||||
* the last, from ArrowUp); arrow keys, Home, and End move between items; Escape closes it and
|
||||
* returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
|
||||
* went.
|
||||
*
|
||||
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and
|
||||
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there
|
||||
* is no dead link to `/account`. The shell knows the person only by an opaque user id
|
||||
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person.
|
||||
*/
|
||||
export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const menuId = useId();
|
||||
const triggerId = useId();
|
||||
const { collapsed } = props.presentation;
|
||||
const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open);
|
||||
|
||||
const close = useCallback((returnFocus: boolean) => {
|
||||
setOpen(false);
|
||||
if (returnFocus) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const actions: readonly AccountAction[] = [
|
||||
{
|
||||
icon: "signOut",
|
||||
id: "sign-out",
|
||||
label: "Sign out",
|
||||
run: () => {
|
||||
setOpen(false);
|
||||
props.onSignOut();
|
||||
},
|
||||
testId: "host-logout",
|
||||
},
|
||||
];
|
||||
|
||||
const openMenu = (focus: "first" | "last"): void => {
|
||||
setInitialFocus(focus);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
(initialFocus === "last" ? items.at(-1) : items[0])?.focus();
|
||||
}, [initialFocus, open]);
|
||||
|
||||
// A press anywhere outside the menu and its trigger dismisses it, leaving focus alone.
|
||||
useEffect(() => {
|
||||
const owner = wrapperRef.current?.ownerDocument;
|
||||
if (!open || !owner) {
|
||||
return;
|
||||
}
|
||||
const dismiss = (event: Event): void => {
|
||||
if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
owner.addEventListener("pointerdown", dismiss);
|
||||
return () => owner.removeEventListener("pointerdown", dismiss);
|
||||
}, [open]);
|
||||
|
||||
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
const index = items.indexOf(event.target as HTMLButtonElement);
|
||||
const focusAt = (next: number): void => {
|
||||
event.preventDefault();
|
||||
items[(next + items.length) % items.length]?.focus();
|
||||
};
|
||||
if (event.key === "Escape") {
|
||||
// Only the menu closes: a drawer around it stays open.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close(true);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
focusAt(index + 1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
focusAt(index - 1);
|
||||
} else if (event.key === "Home") {
|
||||
focusAt(0);
|
||||
} else if (event.key === "End") {
|
||||
focusAt(items.length - 1);
|
||||
} else if (event.key === "Tab") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
{open ? (
|
||||
<div
|
||||
aria-labelledby={triggerId}
|
||||
className="ek-shell-menu"
|
||||
data-testid="host-account-menu-list"
|
||||
id={menuId}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
role="menu"
|
||||
>
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
|
||||
data-testid={action.testId}
|
||||
key={action.id}
|
||||
onClick={action.run}
|
||||
ref={(element) => {
|
||||
itemRefs.current[index] = element;
|
||||
}}
|
||||
role="menuitem"
|
||||
style={{ minHeight: controlHeight(props.presentation) }}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<ShellIcon name={action.icon} />
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : undefined}
|
||||
<button
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="ek-button ek-focusable ek-button--quiet ek-shell-nav-item"
|
||||
data-testid="host-account-menu"
|
||||
id={triggerId}
|
||||
onClick={() => (open ? close(false) : openMenu("first"))}
|
||||
ref={triggerRef}
|
||||
style={{ minHeight: controlHeight(props.presentation), width: "100%" }}
|
||||
type="button"
|
||||
{...triggerProps}
|
||||
onKeyDown={(event) => {
|
||||
triggerProps.onKeyDown(event);
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
openMenu("first");
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
openMenu("last");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="account" />
|
||||
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
|
||||
Your account
|
||||
</span>
|
||||
</button>
|
||||
{tooltip}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react";
|
||||
import type {
|
||||
CSSProperties,
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
|
||||
@@ -10,6 +18,16 @@ export interface DashboardLinkProps {
|
||||
/** Marks the link as the page currently open, for assistive technology and visually. */
|
||||
readonly current?: boolean;
|
||||
readonly variant?: "navigation" | "action";
|
||||
/** Called after an in-place navigation, including to the page already open — for a drawer that
|
||||
* closes once a destination is chosen. Not called for a click the browser handles itself. */
|
||||
readonly onNavigate?: () => void;
|
||||
readonly className?: string;
|
||||
readonly style?: CSSProperties;
|
||||
readonly onBlur?: FocusEventHandler<HTMLAnchorElement>;
|
||||
readonly onFocus?: FocusEventHandler<HTMLAnchorElement>;
|
||||
readonly onKeyDown?: KeyboardEventHandler<HTMLAnchorElement>;
|
||||
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
|
||||
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
|
||||
readonly "data-testid"?: string;
|
||||
}
|
||||
|
||||
@@ -55,26 +73,34 @@ function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||
export function DashboardLink(props: DashboardLinkProps): ReactElement {
|
||||
const href = dashboardPath(props.to);
|
||||
const variant = props.variant ?? "navigation";
|
||||
const baseStyle =
|
||||
variant === "action"
|
||||
? { textDecoration: "none" }
|
||||
: props.current
|
||||
? currentStyle
|
||||
: navigationStyle;
|
||||
return (
|
||||
<a
|
||||
aria-current={props.current ? "page" : undefined}
|
||||
className={variantClass[variant]}
|
||||
className={
|
||||
props.className ? `${variantClass[variant]} ${props.className}` : variantClass[variant]
|
||||
}
|
||||
data-testid={props["data-testid"]}
|
||||
href={href}
|
||||
onBlur={props.onBlur}
|
||||
onClick={(event) => {
|
||||
if (isBrowserHandledClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
props.history.push(href);
|
||||
props.onNavigate?.();
|
||||
}}
|
||||
style={
|
||||
variant === "action"
|
||||
? { textDecoration: "none" }
|
||||
: props.current
|
||||
? currentStyle
|
||||
: navigationStyle
|
||||
}
|
||||
onFocus={props.onFocus}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
|
||||
@@ -29,14 +29,15 @@ import {
|
||||
HostProtocolError,
|
||||
loadHostPrincipal,
|
||||
} from "../host-protocol.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
|
||||
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
import { ShellLayout } from "./ShellLayout.js";
|
||||
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
|
||||
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
|
||||
|
||||
type AuthenticatedPrincipal = Extract<
|
||||
@@ -122,77 +123,26 @@ export interface HostAppProps {
|
||||
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
|
||||
* left unset, the shell names nothing. */
|
||||
readonly setDocumentTitle?: (title: string) => void;
|
||||
/**
|
||||
* Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media
|
||||
* query; left unset, the wide layout. Must be a stable reference.
|
||||
*/
|
||||
readonly viewport?: HostViewport;
|
||||
/**
|
||||
* Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point
|
||||
* passes this browser's storage; left unset, the mode lasts only as long as this component.
|
||||
*/
|
||||
readonly sidebarPreference?: SidebarPreference;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
|
||||
* colours (ADR 0039). Nothing here is a brand decision any more: changing
|
||||
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
|
||||
* artifact together, because custom properties inherit through the artifacts' isolated roots.
|
||||
* The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens
|
||||
* (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058).
|
||||
*/
|
||||
const shellStyles = {
|
||||
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
|
||||
aside: {
|
||||
background: designToken("background.canvas"),
|
||||
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
||||
boxSizing: "border-box",
|
||||
minWidth: "15rem",
|
||||
overflowY: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
width: "15rem",
|
||||
},
|
||||
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
|
||||
brandMark: {
|
||||
background: designToken("accent.base"),
|
||||
borderRadius: designToken("radius.md"),
|
||||
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
|
||||
display: "block",
|
||||
height: designToken("spacing.xl"),
|
||||
width: designToken("spacing.xl"),
|
||||
},
|
||||
groupLabel: {
|
||||
color: designToken("text.muted"),
|
||||
fontSize: designToken("typography.size-xs"),
|
||||
letterSpacing: "0.05em",
|
||||
margin: `0 0 ${designToken("spacing.xs")}`,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
background: designToken("background.surface"),
|
||||
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
||||
display: "flex",
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
gridColumn: "1 / -1",
|
||||
justifyContent: "space-between",
|
||||
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
|
||||
},
|
||||
heading: {
|
||||
fontSize: designToken("typography.size-lg"),
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
margin: 0,
|
||||
},
|
||||
layout: {
|
||||
background: designToken("background.page"),
|
||||
color: designToken("text.primary"),
|
||||
display: "grid",
|
||||
fontFamily: designToken("typography.family-sans"),
|
||||
gridTemplateColumns: "15rem minmax(0, 1fr)",
|
||||
gridTemplateRows: "auto minmax(0, 1fr)",
|
||||
minHeight: "100vh",
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
},
|
||||
main: {
|
||||
boxSizing: "border-box",
|
||||
minWidth: 0,
|
||||
// A programmatic focus target after a page change (never in the tab order), not a control.
|
||||
outline: "none",
|
||||
overflowX: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
},
|
||||
message: {
|
||||
color: designToken("text.secondary"),
|
||||
display: "grid",
|
||||
@@ -217,24 +167,6 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
|
||||
];
|
||||
}
|
||||
|
||||
function ModuleLink(props: {
|
||||
readonly entry: DashboardNavigationEntry;
|
||||
readonly history: HostHistory;
|
||||
readonly route: DashboardRoute;
|
||||
}): ReactElement {
|
||||
const moduleId = props.entry.descriptor.id;
|
||||
return (
|
||||
<DashboardLink
|
||||
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||
data-testid={`select-${moduleId}`}
|
||||
history={props.history}
|
||||
to={{ kind: "module", moduleId }}
|
||||
>
|
||||
{props.entry.name}
|
||||
</DashboardLink>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
|
||||
* not a page change. */
|
||||
function routeKey(route: DashboardRoute): string {
|
||||
@@ -310,6 +242,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const shownRouteRef = useRef<string | undefined>(undefined);
|
||||
const { config, history, importModule, setDocumentTitle } = props;
|
||||
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
|
||||
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
|
||||
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
|
||||
const viewport = props.viewport ?? fallbackViewport;
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
@@ -619,69 +556,19 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
}
|
||||
|
||||
const { bootstrap } = state;
|
||||
const entries = allEntries(bootstrap);
|
||||
const { groups, ungrouped } = bootstrap.navigation;
|
||||
|
||||
return (
|
||||
<div data-testid="host-shell" style={shellStyles.layout}>
|
||||
<header style={shellStyles.header}>
|
||||
<div style={shellStyles.brand}>
|
||||
<span aria-hidden="true" style={shellStyles.brandMark} />
|
||||
<h1 style={shellStyles.heading}>Ekklesia</h1>
|
||||
</div>
|
||||
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
|
||||
Sign out
|
||||
</Button>
|
||||
</header>
|
||||
<aside style={shellStyles.aside}>
|
||||
<DevicePanel config={config} />
|
||||
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<DashboardLink
|
||||
current={route.kind === "overview"}
|
||||
data-testid="select-overview"
|
||||
<ShellLayout
|
||||
config={config}
|
||||
history={history}
|
||||
to={{ kind: "overview" }}
|
||||
mainRef={mainRef}
|
||||
navigation={bootstrap.navigation}
|
||||
onSignOut={() => void signOut()}
|
||||
pageTitle={pageTitle}
|
||||
route={route}
|
||||
sidebarPreference={sidebarPreference}
|
||||
viewport={viewport}
|
||||
>
|
||||
Overview
|
||||
</DashboardLink>
|
||||
{entries.length === 0 && groups.length === 0 ? (
|
||||
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : undefined}
|
||||
{groups.map((group) => (
|
||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{group.modules.map((entry) => (
|
||||
<ModuleLink
|
||||
entry={entry}
|
||||
history={history}
|
||||
key={moduleKey(entry)}
|
||||
route={route}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 ? (
|
||||
<section data-testid="group-ungrouped">
|
||||
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{ungrouped.map((entry) => (
|
||||
<ModuleLink
|
||||
entry={entry}
|
||||
history={history}
|
||||
key={moduleKey(entry)}
|
||||
route={route}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : undefined}
|
||||
</nav>
|
||||
</aside>
|
||||
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
|
||||
{route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
@@ -700,7 +587,6 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ShellLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/** How long a tooltip stays after the pointer leaves its control, so the pointer can cross the gap
|
||||
* onto the tooltip itself (WCAG 2.2 §1.4.13, "hoverable"). */
|
||||
const HIDE_DELAY_MS = 150;
|
||||
|
||||
interface Anchor {
|
||||
readonly left: number;
|
||||
readonly top: number;
|
||||
}
|
||||
|
||||
/** The handlers a control spreads onto itself. Typed loosely enough for a link or a button. */
|
||||
export interface NavTooltipTriggerProps {
|
||||
onBlur(event: FocusEvent<HTMLElement>): void;
|
||||
onFocus(event: FocusEvent<HTMLElement>): void;
|
||||
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
|
||||
onMouseEnter(event: MouseEvent<HTMLElement>): void;
|
||||
onMouseLeave(event: MouseEvent<HTMLElement>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible name of an icon-only control in the collapsed sidebar (ADR 0058).
|
||||
*
|
||||
* The control's accessible name is its own text, kept in the DOM and visually hidden, so the
|
||||
* tooltip is a **visual** duplicate of that name: it is `aria-hidden`, and a screen reader hears the
|
||||
* name once. It meets WCAG 2.2 §1.4.13 for content on hover or focus: it appears on keyboard focus as
|
||||
* well as on hover (nothing is hover-only), Escape dismisses it without moving focus, the pointer can
|
||||
* move onto it without it vanishing, and it stays until focus or the pointer leaves.
|
||||
*
|
||||
* Positioned `fixed` beside the control, from the control's own rectangle, so a scrolling sidebar
|
||||
* cannot clip it. Disabled, it renders nothing and changes nothing: an expanded sidebar already
|
||||
* shows every label.
|
||||
*
|
||||
* Deliberately shell-local. ADR 0039 adds an element to `@ekklesia/ui` once two independent
|
||||
* consumers need it; when a module needs a tooltip, this is the shape to promote.
|
||||
*/
|
||||
export function useNavTooltip(
|
||||
label: string,
|
||||
enabled: boolean,
|
||||
): { readonly triggerProps: NavTooltipTriggerProps; readonly tooltip: ReactElement | null } {
|
||||
const [anchor, setAnchor] = useState<Anchor | undefined>(undefined);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const cancelHide = useCallback(() => {
|
||||
if (hideTimer.current !== undefined) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
cancelHide();
|
||||
setAnchor(undefined);
|
||||
}, [cancelHide]);
|
||||
|
||||
const show = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
cancelHide();
|
||||
const rect = element.getBoundingClientRect();
|
||||
setAnchor({ left: rect.right + 8, top: rect.top + rect.height / 2 });
|
||||
},
|
||||
[cancelHide],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
hide();
|
||||
}
|
||||
}, [enabled, hide]);
|
||||
|
||||
useEffect(() => cancelHide, [cancelHide]);
|
||||
|
||||
const triggerProps: NavTooltipTriggerProps = {
|
||||
onBlur: hide,
|
||||
onFocus: (event) => {
|
||||
if (enabled) {
|
||||
show(event.currentTarget);
|
||||
}
|
||||
},
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === "Escape" && anchor !== undefined) {
|
||||
// Only this tooltip is dismissed; the event goes no further.
|
||||
event.stopPropagation();
|
||||
hide();
|
||||
}
|
||||
},
|
||||
onMouseEnter: (event) => {
|
||||
if (enabled) {
|
||||
show(event.currentTarget);
|
||||
}
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
cancelHide();
|
||||
hideTimer.current = setTimeout(hide, HIDE_DELAY_MS);
|
||||
},
|
||||
};
|
||||
|
||||
const tooltip =
|
||||
enabled && anchor !== undefined ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ek-shell-tooltip"
|
||||
data-testid="nav-tooltip"
|
||||
onMouseEnter={cancelHide}
|
||||
onMouseLeave={hide}
|
||||
style={{ left: anchor.left, top: anchor.top }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return { tooltip, triggerProps };
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { DashboardRoute } from "../dashboard-routes.js";
|
||||
import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js";
|
||||
import { AccountMenu } from "./AccountMenu.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import type { HostViewport } from "./host-viewport.js";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import {
|
||||
controlHeight,
|
||||
NavigationList,
|
||||
type NavigationPresentation,
|
||||
ShellBrand,
|
||||
} from "./ShellNavigation.js";
|
||||
import { ShellIcon } from "./shell-icons.js";
|
||||
import { SHELL_STYLESHEET } from "./shell-styles.js";
|
||||
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
|
||||
|
||||
const SIDEBAR_NAV_ID = "ekklesia-sidebar-navigation";
|
||||
const DRAWER_ID = "ekklesia-navigation-drawer";
|
||||
const DRAWER_NAV_ID = "ekklesia-drawer-navigation";
|
||||
const SIDEBAR_WIDTH: Record<SidebarMode, string> = { collapsed: "4.5rem", expanded: "16rem" };
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export interface ShellLayoutProps {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly route: DashboardRoute;
|
||||
readonly viewport: HostViewport;
|
||||
readonly sidebarPreference: SidebarPreference;
|
||||
readonly mainRef: RefObject<HTMLElement | null>;
|
||||
readonly pageTitle: string | undefined;
|
||||
readonly onSignOut: () => void;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
function SidebarToggle(props: {
|
||||
readonly mode: SidebarMode;
|
||||
readonly onToggle: () => void;
|
||||
}): ReactElement {
|
||||
const label = props.mode === "expanded" ? "Collapse navigation" : "Expand navigation";
|
||||
const { tooltip, triggerProps } = useNavTooltip(label, props.mode === "collapsed");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-controls={SIDEBAR_NAV_ID}
|
||||
aria-expanded={props.mode === "expanded"}
|
||||
aria-label={label}
|
||||
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
|
||||
data-testid="sidebar-toggle"
|
||||
onClick={props.onToggle}
|
||||
style={{ minHeight: controlHeight({ collapsed: false, compact: false }) }}
|
||||
type="button"
|
||||
{...triggerProps}
|
||||
>
|
||||
<ShellIcon name={props.mode === "expanded" ? "collapseSidebar" : "expandSidebar"} />
|
||||
</button>
|
||||
{tooltip}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop and tablet sidebar (DESIGN.md §4.2): identity at the top, the navigation, and the
|
||||
* account area anchored at the bottom. Collapsed, it is icons only, each named by a tooltip.
|
||||
*/
|
||||
function Sidebar(props: {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly route: DashboardRoute;
|
||||
readonly mode: SidebarMode;
|
||||
readonly onToggle: () => void;
|
||||
readonly onSignOut: () => void;
|
||||
}): ReactElement {
|
||||
const presentation: NavigationPresentation = {
|
||||
collapsed: props.mode === "collapsed",
|
||||
compact: false,
|
||||
};
|
||||
return (
|
||||
<aside
|
||||
className="ek-shell-sidebar"
|
||||
data-sidebar={props.mode}
|
||||
data-testid="host-sidebar"
|
||||
style={{ width: SIDEBAR_WIDTH[props.mode] }}
|
||||
>
|
||||
<div className="ek-shell-sidebar__top">
|
||||
<ShellBrand collapsed={presentation.collapsed} />
|
||||
<SidebarToggle mode={props.mode} onToggle={props.onToggle} />
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__scroll">
|
||||
<NavigationList
|
||||
history={props.history}
|
||||
id={SIDEBAR_NAV_ID}
|
||||
navigation={props.navigation}
|
||||
presentation={presentation}
|
||||
route={props.route}
|
||||
/>
|
||||
{/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding
|
||||
the sidebar reveals it. Stage 1 moves it to Settings → Displays. */}
|
||||
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__bottom">
|
||||
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone navigation (DESIGN.md §4.6): the same hierarchy, current page, and account area as the
|
||||
* sidebar, in a modal drawer.
|
||||
*
|
||||
* It is a real modal: `role="dialog"` with `aria-modal`, the page behind it `inert`, and Tab and
|
||||
* Shift+Tab kept inside it here as well, for a browser that does not yet honour `inert`. Opening
|
||||
* moves focus to its close button; Escape, the close button, and the backdrop dismiss it and the
|
||||
* layout returns focus to the trigger.
|
||||
*/
|
||||
function NavigationDrawer(props: {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly route: DashboardRoute;
|
||||
readonly onDismiss: () => void;
|
||||
readonly onNavigate: () => void;
|
||||
readonly onSignOut: () => void;
|
||||
}): ReactElement {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const presentation: NavigationPresentation = { collapsed: false, compact: true };
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
props.onDismiss();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !panelRef.current) {
|
||||
return;
|
||||
}
|
||||
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
const active = panelRef.current.ownerDocument.activeElement;
|
||||
if (event.shiftKey && (active === first || !panelRef.current.contains(active))) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && (active === last || !panelRef.current.contains(active))) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="ek-shell-drawer-backdrop"
|
||||
data-testid="navigation-drawer-backdrop"
|
||||
onClick={props.onDismiss}
|
||||
/>
|
||||
<div
|
||||
aria-label="Navigation"
|
||||
aria-modal="true"
|
||||
className="ek-shell-drawer"
|
||||
data-testid="navigation-drawer"
|
||||
id={DRAWER_ID}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
>
|
||||
<div className="ek-shell-sidebar__top">
|
||||
<ShellBrand collapsed={false} />
|
||||
<button
|
||||
aria-label="Close navigation"
|
||||
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
|
||||
data-testid="navigation-drawer-close"
|
||||
onClick={props.onDismiss}
|
||||
ref={closeRef}
|
||||
style={{
|
||||
minHeight: controlHeight(presentation),
|
||||
minWidth: controlHeight(presentation),
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ShellIcon name="closeNavigation" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__scroll">
|
||||
<NavigationList
|
||||
history={props.history}
|
||||
id={DRAWER_NAV_ID}
|
||||
navigation={props.navigation}
|
||||
onNavigate={props.onNavigate}
|
||||
presentation={presentation}
|
||||
route={props.route}
|
||||
/>
|
||||
<DevicePanel config={props.config} />
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__bottom">
|
||||
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function routeKey(route: DashboardRoute): string {
|
||||
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authenticated Dashboard's frame (ADR 0058): a collapsible sidebar on wide screens, a drawer
|
||||
* on phones, and the page.
|
||||
*
|
||||
* The page's `<main>` keeps one position in the tree whichever layout is showing, so changing
|
||||
* layout — rotating a tablet, resizing a window — never remounts the open module. The route comes
|
||||
* from the URL (ADR 0057) and the desktop mode from the sidebar preference, so neither is touched by
|
||||
* a layout change; only an open drawer is dropped, and focus that was inside it lands on the page.
|
||||
* There is no application top bar: on a phone the one control above the page is the menu trigger.
|
||||
*/
|
||||
export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
const { viewport, sidebarPreference, mainRef, route } = props;
|
||||
const subscribe = useCallback((listener: () => void) => viewport.subscribe(listener), [viewport]);
|
||||
const readCompact = useCallback(() => viewport.isCompact(), [viewport]);
|
||||
const compact = useSyncExternalStore(subscribe, readCompact, readCompact);
|
||||
const [mode, setMode] = useState<SidebarMode>(() => sidebarPreference.read() ?? "expanded");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const previousCompact = useRef(compact);
|
||||
const previousRoute = useRef(routeKey(route));
|
||||
|
||||
const toggleMode = (): void => {
|
||||
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
|
||||
setMode(next);
|
||||
sidebarPreference.write(next);
|
||||
};
|
||||
|
||||
const closeDrawer = useCallback((focus: "trigger" | "main") => {
|
||||
pendingFocus.current = focus;
|
||||
setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
// A layout change drops an open drawer. Focus inside a drawer or sidebar that just went away
|
||||
// would otherwise fall to the document; it lands on the page instead.
|
||||
useLayoutEffect(() => {
|
||||
if (previousCompact.current === compact) {
|
||||
return;
|
||||
}
|
||||
previousCompact.current = compact;
|
||||
if (drawerOpen) {
|
||||
closeDrawer("main");
|
||||
return;
|
||||
}
|
||||
const owner = mainRef.current?.ownerDocument;
|
||||
if (owner && (owner.activeElement === null || owner.activeElement === owner.body)) {
|
||||
mainRef.current?.focus();
|
||||
}
|
||||
}, [closeDrawer, compact, drawerOpen, mainRef]);
|
||||
|
||||
// Back and Forward close the drawer too; the shell moves focus to the new page itself.
|
||||
useEffect(() => {
|
||||
const key = routeKey(route);
|
||||
if (previousRoute.current !== key) {
|
||||
previousRoute.current = key;
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawerOpen || pendingFocus.current === undefined) {
|
||||
return;
|
||||
}
|
||||
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
|
||||
pendingFocus.current = undefined;
|
||||
target?.focus();
|
||||
}, [drawerOpen, mainRef]);
|
||||
|
||||
const showDrawer = compact && drawerOpen;
|
||||
const pageInert = showDrawer ? true : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-layout={compact ? "compact" : "wide"}
|
||||
data-testid="host-shell"
|
||||
style={{
|
||||
display: compact ? "block" : "grid",
|
||||
gridTemplateColumns: compact ? undefined : "auto minmax(0, 1fr)",
|
||||
minHeight: "100vh",
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<style>{SHELL_STYLESHEET}</style>
|
||||
{compact ? undefined : (
|
||||
<Sidebar
|
||||
config={props.config}
|
||||
history={props.history}
|
||||
mode={mode}
|
||||
navigation={props.navigation}
|
||||
onSignOut={props.onSignOut}
|
||||
onToggle={toggleMode}
|
||||
route={route}
|
||||
/>
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{compact ? (
|
||||
<div className="ek-shell-mobile-bar" inert={pageInert}>
|
||||
<button
|
||||
aria-controls={DRAWER_ID}
|
||||
aria-expanded={showDrawer}
|
||||
aria-label="Open navigation"
|
||||
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
|
||||
data-testid="mobile-navigation-trigger"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
ref={triggerRef}
|
||||
style={{
|
||||
minHeight: controlHeight({ collapsed: false, compact: true }),
|
||||
minWidth: controlHeight({ collapsed: false, compact: true }),
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ShellIcon name="openNavigation" size={24} />
|
||||
</button>
|
||||
</div>
|
||||
) : undefined}
|
||||
<main
|
||||
aria-label={props.pageTitle}
|
||||
data-testid="host-main-region"
|
||||
inert={pageInert}
|
||||
ref={mainRef}
|
||||
style={{
|
||||
boxSizing: "border-box",
|
||||
minWidth: 0,
|
||||
// A programmatic focus target after a page change (never in the tab order).
|
||||
outline: "none",
|
||||
overflowX: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
{showDrawer ? (
|
||||
<NavigationDrawer
|
||||
config={props.config}
|
||||
history={props.history}
|
||||
navigation={props.navigation}
|
||||
onDismiss={() => closeDrawer("trigger")}
|
||||
onNavigate={() => closeDrawer("main")}
|
||||
onSignOut={props.onSignOut}
|
||||
route={route}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
|
||||
import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
/**
|
||||
* How a navigation surface presents itself. `collapsed` is the desktop sidebar's icon-only mode;
|
||||
* `compact` is the phone drawer, which always shows labels and uses larger touch targets.
|
||||
*/
|
||||
export interface NavigationPresentation {
|
||||
readonly collapsed: boolean;
|
||||
readonly compact: boolean;
|
||||
}
|
||||
|
||||
/** The control height for this presentation: the medium control on desktop, the large one in the
|
||||
* drawer, so a fingertip has room (WCAG 2.2 §2.5.8 asks for 24px; this is 40px and 48px). */
|
||||
export function controlHeight(presentation: NavigationPresentation): string {
|
||||
return designToken(presentation.compact ? "control.height-lg" : "control.height-md");
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity slot at the top of the navigation (ADR 0058).
|
||||
*
|
||||
* A neutral placeholder — a plain mark and the product's name — until the installation's own
|
||||
* branding replaces it (DESIGN.md §2.1, §3.4). It is one component with no inputs on purpose: the
|
||||
* branding work owns the decision of what goes here and where it comes from, and replaces this
|
||||
* component's body rather than undoing a configuration model invented early. It is not a heading;
|
||||
* each page's own title is. Collapsed, only the mark shows.
|
||||
*/
|
||||
export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement {
|
||||
return (
|
||||
<div className="ek-shell-brand" data-testid="shell-brand">
|
||||
<span aria-hidden="true" className="ek-shell-brand__mark" />
|
||||
<span className={props.collapsed ? "ek-shell-visually-hidden" : undefined}>Ekklesia</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationItem(props: {
|
||||
readonly destination: DashboardDestination;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly current: boolean;
|
||||
readonly history: HostHistory;
|
||||
readonly presentation: NavigationPresentation;
|
||||
readonly onNavigate?: () => void;
|
||||
readonly testId: string;
|
||||
}): ReactElement {
|
||||
const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed);
|
||||
const style: CSSProperties = { minHeight: controlHeight(props.presentation) };
|
||||
return (
|
||||
<li>
|
||||
<DashboardLink
|
||||
className="ek-shell-nav-item"
|
||||
current={props.current}
|
||||
data-testid={props.testId}
|
||||
history={props.history}
|
||||
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
|
||||
style={style}
|
||||
to={props.destination}
|
||||
{...triggerProps}
|
||||
>
|
||||
<ShellIcon name={props.icon} />
|
||||
<span
|
||||
className={
|
||||
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
|
||||
}
|
||||
data-nav-label=""
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
</DashboardLink>
|
||||
{tooltip}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleItems(props: {
|
||||
readonly entries: readonly DashboardNavigationEntry[];
|
||||
readonly route: DashboardRoute;
|
||||
readonly history: HostHistory;
|
||||
readonly presentation: NavigationPresentation;
|
||||
readonly onNavigate?: () => void;
|
||||
readonly labelledBy?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<ul
|
||||
aria-labelledby={props.labelledBy}
|
||||
className="ek-shell-nav__group"
|
||||
style={{ listStyle: "none", margin: 0, padding: 0 }}
|
||||
>
|
||||
{props.entries.map((entry) => {
|
||||
const moduleId = entry.descriptor.id;
|
||||
return (
|
||||
<NavigationItem
|
||||
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||
destination={{ kind: "module", moduleId }}
|
||||
history={props.history}
|
||||
// Every module gets the one generic icon until modules can declare their own
|
||||
// (ADR 0058); there is no table of known modules.
|
||||
icon="module"
|
||||
key={`${moduleId}@${entry.descriptor.version}`}
|
||||
label={entry.name}
|
||||
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
|
||||
presentation={props.presentation}
|
||||
testId={`select-${moduleId}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NavigationListProps {
|
||||
readonly id: string;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly route: DashboardRoute;
|
||||
readonly history: HostHistory;
|
||||
readonly presentation: NavigationPresentation;
|
||||
/** Called once a destination is chosen, for the drawer to close. */
|
||||
readonly onNavigate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer
|
||||
* (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any
|
||||
* ungrouped modules. The shell sorts and filters nothing — a disabled module is absent because the
|
||||
* server's navigation does not list it (ADR 0027, ADR 0028).
|
||||
*
|
||||
* Collapsed, group labels stay in the DOM for assistive technology and a divider stands in for them
|
||||
* visually.
|
||||
*/
|
||||
export function NavigationList(props: NavigationListProps): ReactElement {
|
||||
const { groups, ungrouped } = props.navigation;
|
||||
const { collapsed } = props.presentation;
|
||||
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
||||
const empty = groups.length === 0 && ungrouped.length === 0;
|
||||
return (
|
||||
<nav aria-label="Dashboard" className="ek-shell-nav" id={props.id}>
|
||||
<ul className="ek-shell-nav__group" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
<NavigationItem
|
||||
current={props.route.kind === "overview"}
|
||||
destination={{ kind: "overview" }}
|
||||
history={props.history}
|
||||
icon="overview"
|
||||
label="Overview"
|
||||
{...onNavigate}
|
||||
presentation={props.presentation}
|
||||
testId="select-overview"
|
||||
/>
|
||||
</ul>
|
||||
{empty && !collapsed ? (
|
||||
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : undefined}
|
||||
{groups.map((group) => {
|
||||
const labelId = `${props.id}-group-${group.id}`;
|
||||
return (
|
||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
|
||||
<h2
|
||||
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
|
||||
id={labelId}
|
||||
>
|
||||
{group.label}
|
||||
</h2>
|
||||
<ModuleItems
|
||||
entries={group.modules}
|
||||
history={props.history}
|
||||
labelledBy={labelId}
|
||||
{...onNavigate}
|
||||
presentation={props.presentation}
|
||||
route={props.route}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{ungrouped.length > 0 ? (
|
||||
<section data-testid="group-ungrouped">
|
||||
{groups.length > 0 ? (
|
||||
<>
|
||||
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
|
||||
<h2
|
||||
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
|
||||
id={`${props.id}-group-other`}
|
||||
>
|
||||
Other
|
||||
</h2>
|
||||
</>
|
||||
) : undefined}
|
||||
<ModuleItems
|
||||
entries={ungrouped}
|
||||
history={props.history}
|
||||
{...(groups.length > 0 ? { labelledBy: `${props.id}-group-other` } : {})}
|
||||
{...onNavigate}
|
||||
presentation={props.presentation}
|
||||
route={props.route}
|
||||
/>
|
||||
</section>
|
||||
) : undefined}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Whether the Dashboard is laid out for a phone (ADR 0058).
|
||||
*
|
||||
* Below the breakpoint the sidebar becomes a drawer with the same navigation; at and above it the
|
||||
* collapsible sidebar is shown, tablets included (DESIGN.md §4.6–4.7). The shell reads the answer
|
||||
* through this interface rather than from `window.matchMedia`, so the entry point stays the only
|
||||
* file that touches a browser global and every responsive behaviour is testable by resizing an
|
||||
* in-memory viewport.
|
||||
*/
|
||||
export interface HostViewport {
|
||||
isCompact(): boolean;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
/** `em` in a media query is the browser's initial font size, so the breakpoint follows a person's
|
||||
* own text-size setting: 48em is 768px at the default 16px. */
|
||||
export const HOST_COMPACT_MEDIA_QUERY = "(max-width: 47.999em)";
|
||||
|
||||
type MediaQueryListLike = Pick<MediaQueryList, "matches"> & {
|
||||
addEventListener(type: "change", listener: () => void): void;
|
||||
removeEventListener(type: "change", listener: () => void): void;
|
||||
};
|
||||
|
||||
/** The browser's media query, handed in by the entry point. Without one, the wide layout. */
|
||||
export function createBrowserHostViewport(
|
||||
matchMedia: ((query: string) => MediaQueryListLike) | undefined,
|
||||
): HostViewport {
|
||||
const mediaQuery = matchMedia?.(HOST_COMPACT_MEDIA_QUERY);
|
||||
return {
|
||||
isCompact: () => mediaQuery?.matches ?? false,
|
||||
subscribe(listener) {
|
||||
mediaQuery?.addEventListener("change", listener);
|
||||
return () => mediaQuery?.removeEventListener("change", listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemoryHostViewport extends HostViewport {
|
||||
setCompact(compact: boolean): void;
|
||||
}
|
||||
|
||||
export function createMemoryHostViewport(compact = false): MemoryHostViewport {
|
||||
let current = compact;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
isCompact: () => current,
|
||||
setCompact(next) {
|
||||
if (next === current) {
|
||||
return;
|
||||
}
|
||||
current = next;
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { consumeAuthLanding } from "../auth-protocol.js";
|
||||
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
import { createBrowserHostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport } from "./host-viewport.js";
|
||||
import { createLocalSidebarPreference } from "./sidebar-preference.js";
|
||||
|
||||
/**
|
||||
* The one place the shell touches a browser global.
|
||||
@@ -31,6 +33,22 @@ const setDocumentTitle = (title: string): void => {
|
||||
document.title = title;
|
||||
};
|
||||
|
||||
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely
|
||||
* reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's
|
||||
* mode for this page only. */
|
||||
const viewport = createBrowserHostViewport(
|
||||
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
|
||||
);
|
||||
const sidebarPreference = createLocalSidebarPreference(
|
||||
(() => {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
const container = document.getElementById("ekklesia-host");
|
||||
if (!container) {
|
||||
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
|
||||
@@ -66,9 +84,17 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
|
||||
config={config}
|
||||
history={history}
|
||||
setDocumentTitle={setDocumentTitle}
|
||||
sidebarPreference={sidebarPreference}
|
||||
viewport={viewport}
|
||||
/>
|
||||
) : (
|
||||
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
|
||||
<HostApp
|
||||
config={config}
|
||||
history={history}
|
||||
setDocumentTitle={setDocumentTitle}
|
||||
sidebarPreference={sidebarPreference}
|
||||
viewport={viewport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Blocks,
|
||||
CircleUserRound,
|
||||
LayoutDashboard,
|
||||
type LucideIcon,
|
||||
LogOut,
|
||||
Menu,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* Every icon the shell uses, imported from Lucide in this one file (ADR 0058).
|
||||
*
|
||||
* Lucide is Ekklesia's icon vocabulary. Importing each icon by name keeps the bundle to exactly the
|
||||
* icons named here, and keeping them in one place keeps the vocabulary reviewable: an icon is chosen
|
||||
* for a meaning, once, rather than per call site.
|
||||
*
|
||||
* There is deliberately **no per-module entry**. Every module is drawn with the one generic
|
||||
* {@link SHELL_ICONS.module} until modules can declare their own icon through the module navigation
|
||||
* contract; a table of known module ids here would be a second, host-owned answer to a question the
|
||||
* module should own.
|
||||
*/
|
||||
export const SHELL_ICONS = Object.freeze({
|
||||
account: CircleUserRound,
|
||||
closeNavigation: X,
|
||||
collapseSidebar: PanelLeftClose,
|
||||
expandSidebar: PanelLeftOpen,
|
||||
module: Blocks,
|
||||
openNavigation: Menu,
|
||||
overview: LayoutDashboard,
|
||||
signOut: LogOut,
|
||||
} satisfies Record<string, LucideIcon>);
|
||||
|
||||
export type ShellIconName = keyof typeof SHELL_ICONS;
|
||||
|
||||
/**
|
||||
* A decorative icon. Always hidden from assistive technology: every control that shows one also
|
||||
* has a text name, visible or not, so an icon never carries meaning on its own. The stroke follows
|
||||
* the surrounding text colour, so icons take their colour from the design tokens like text does.
|
||||
*/
|
||||
export function ShellIcon(props: {
|
||||
readonly name: ShellIconName;
|
||||
readonly size?: number;
|
||||
}): ReactElement {
|
||||
const Icon = SHELL_ICONS[props.name];
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
data-icon={props.name}
|
||||
focusable="false"
|
||||
size={props.size ?? 20}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
|
||||
/**
|
||||
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover,
|
||||
* `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip,
|
||||
* and drawer.
|
||||
*
|
||||
* Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-`
|
||||
* prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed
|
||||
* into the shell's own document only: each module artifact still mounts in its own isolated root
|
||||
* (ADR 0040), which this stylesheet cannot reach.
|
||||
*/
|
||||
export const SHELL_STYLESHEET = `
|
||||
.ek-shell-visually-hidden {
|
||||
border: 0;
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.ek-shell-sidebar {
|
||||
background: ${designToken("background.canvas")};
|
||||
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${designToken("spacing.md")};
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding: ${designToken("spacing.md")};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.ek-shell-sidebar__top {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
justify-content: space-between;
|
||||
min-height: ${designToken("control.height-md")};
|
||||
}
|
||||
|
||||
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ek-shell-sidebar__scroll {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
gap: ${designToken("spacing.xl")};
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ek-shell-sidebar__bottom {
|
||||
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
padding-top: ${designToken("spacing.sm")};
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ek-shell-brand {
|
||||
align-items: center;
|
||||
color: ${designToken("text.primary")};
|
||||
display: flex;
|
||||
font-size: ${designToken("typography.size-md")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
gap: ${designToken("spacing.sm")};
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ek-shell-brand__mark {
|
||||
background: ${designToken("accent.base")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
box-shadow: inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")};
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
height: ${designToken("spacing.xl")};
|
||||
width: ${designToken("spacing.xl")};
|
||||
}
|
||||
|
||||
.ek-shell-nav,
|
||||
.ek-shell-nav__group {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
}
|
||||
|
||||
.ek-shell-nav {
|
||||
gap: ${designToken("spacing.lg")};
|
||||
}
|
||||
|
||||
.ek-shell-nav__group-label {
|
||||
color: ${designToken("text.muted")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 ${designToken("spacing.2xs")};
|
||||
padding-inline: ${designToken("control.padding-inline")};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ek-shell-nav__divider {
|
||||
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
margin: 0 ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
.ek-shell-nav-item {
|
||||
box-sizing: border-box;
|
||||
gap: ${designToken("spacing.sm")};
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.ek-shell-nav-item svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ek-shell-nav-item__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.ek-shell-tooltip {
|
||||
background: ${designToken("background.raised")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.sm")};
|
||||
box-shadow: ${designToken("shadow.md")};
|
||||
color: ${designToken("text.primary")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
|
||||
position: fixed;
|
||||
transform: translateY(-50%);
|
||||
white-space: nowrap;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.ek-shell-menu {
|
||||
background: ${designToken("background.raised")};
|
||||
border: ${designToken("border.width")} solid ${designToken("border.base")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
box-shadow: ${designToken("shadow.md")};
|
||||
bottom: calc(100% + ${designToken("spacing.xs")});
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
left: 0;
|
||||
margin: 0;
|
||||
min-width: 12rem;
|
||||
padding: ${designToken("spacing.xs")};
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.ek-shell-menu__caption {
|
||||
color: ${designToken("text.muted")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
margin: 0;
|
||||
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-shell-menu__item {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-shell-mobile-bar {
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
|
||||
}
|
||||
|
||||
.ek-shell-drawer-backdrop {
|
||||
background: ${designToken("background.overlay")};
|
||||
inset: 0;
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.ek-shell-drawer {
|
||||
background: ${designToken("background.canvas")};
|
||||
box-shadow: ${designToken("shadow.lg")};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${designToken("spacing.md")};
|
||||
inset: 0 auto 0 0;
|
||||
max-width: 85vw;
|
||||
outline: none;
|
||||
padding: ${designToken("spacing.md")};
|
||||
position: fixed;
|
||||
width: 20rem;
|
||||
z-index: 41;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ek-shell-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Whether the desktop sidebar is expanded or collapsed (ADR 0058).
|
||||
*
|
||||
* A preference of **this browser on this device**, not of the person: a narrow laptop and a wide
|
||||
* office monitor reasonably want different answers, and DESIGN.md's synced personal preferences
|
||||
* (appearance, motion) belong to the account area, which does not exist yet. The shell reads and
|
||||
* writes it only through this interface, so a later store — synced, or per person — replaces the
|
||||
* implementation without touching a component.
|
||||
*/
|
||||
export type SidebarMode = "expanded" | "collapsed";
|
||||
|
||||
export interface SidebarPreference {
|
||||
/** The remembered mode, or `undefined` when nothing usable was ever stored. */
|
||||
read(): SidebarMode | undefined;
|
||||
write(mode: SidebarMode): void;
|
||||
}
|
||||
|
||||
/** One namespaced key. Names no person and holds nothing but the mode. */
|
||||
export const SIDEBAR_PREFERENCE_KEY = "ekklesia.dashboard.sidebar";
|
||||
|
||||
function isSidebarMode(value: unknown): value is SidebarMode {
|
||||
return value === "expanded" || value === "collapsed";
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's `localStorage`, handed in by the entry point (the only file that touches `window`).
|
||||
*
|
||||
* Storage can be missing, blocked, full, or throw on every access (a private window, a sandboxed
|
||||
* frame, site data cleared or disabled), and none of that may break the Dashboard. Every access is
|
||||
* guarded, and the last mode chosen on this page is still remembered in memory, so the toggle keeps
|
||||
* working and only persistence is lost. A stored value that is not exactly one of the two modes is
|
||||
* ignored rather than trusted.
|
||||
*/
|
||||
export function createLocalSidebarPreference(
|
||||
storage: Pick<Storage, "getItem" | "setItem"> | undefined,
|
||||
): SidebarPreference {
|
||||
let remembered: SidebarMode | undefined;
|
||||
return {
|
||||
read() {
|
||||
if (remembered !== undefined) {
|
||||
return remembered;
|
||||
}
|
||||
try {
|
||||
const stored = storage?.getItem(SIDEBAR_PREFERENCE_KEY);
|
||||
return isSidebarMode(stored) ? stored : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
write(mode) {
|
||||
remembered = mode;
|
||||
try {
|
||||
storage?.setItem(SIDEBAR_PREFERENCE_KEY, mode);
|
||||
} catch {
|
||||
// Persistence is a convenience; the in-memory mode above still holds for this page.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Nothing persisted: for tests, and for a host composed without browser storage. */
|
||||
export function createMemorySidebarPreference(initial?: SidebarMode): SidebarPreference {
|
||||
let mode = initial;
|
||||
return {
|
||||
read: () => mode,
|
||||
write(next) {
|
||||
mode = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import { createMemoryHostHistory } from "../src/browser/host-history.js";
|
||||
import {
|
||||
createLocalSidebarPreference,
|
||||
SIDEBAR_PREFERENCE_KEY,
|
||||
} from "../src/browser/sidebar-preference.js";
|
||||
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
|
||||
|
||||
/**
|
||||
@@ -99,6 +103,9 @@ async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding)
|
||||
<HostApp
|
||||
config={config}
|
||||
history={createMemoryHostHistory("/")}
|
||||
// The production store over this browser's real storage, so the assertions below cover
|
||||
// the shell's one use of it (ADR 0058) as well.
|
||||
sidebarPreference={createLocalSidebarPreference(window.localStorage)}
|
||||
{...(authLanding ? { authLanding } : {})}
|
||||
/>,
|
||||
);
|
||||
@@ -463,7 +470,11 @@ describe("reset password", () => {
|
||||
|
||||
// A reset token is a bearer credential for one account. It lives in React state for the life of
|
||||
// the form and nowhere else.
|
||||
expect(localStorage.length).toBe(0);
|
||||
// The only thing this browser's storage may ever hold is the sidebar's mode (ADR 0058).
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
expect(localStorage.key(index)).toBe(SIDEBAR_PREFERENCE_KEY);
|
||||
}
|
||||
expect(JSON.stringify({ ...localStorage })).not.toContain("reset-token-abc");
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
expect(text()).not.toContain("reset-token-abc");
|
||||
expect(container.innerHTML).not.toContain("reset-token-abc");
|
||||
|
||||
@@ -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