feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped

The Dashboard's shell now follows DESIGN.md §4 (ADR 0058):

- A collapsible left sidebar replaces the header-and-column layout. It
  is icons and labels when expanded and icons only when collapsed; each
  collapsed item keeps its name, visually hidden, and shows a tooltip
  on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes
  first, then the server's groups and modules. There is no top bar.
- The expanded/collapsed choice is remembered in this browser's
  localStorage, behind a SidebarPreference interface that a synced
  store can replace later.
- An account menu at the bottom of the sidebar follows the WAI-ARIA menu
  button pattern and offers only Sign out, which now lives there rather
  than in the header.
- On phones (below 48em) the same navigation opens in a modal drawer:
  the page behind it is inert and focus is trapped inside it. Escape,
  its close button, and the backdrop close it and return focus to the
  menu button. Choosing a page closes it and focuses the page. Changing
  layout keeps the route, the desktop mode, and the mounted module.
- Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon
  vocabulary. Every module shows one generic icon until manifest v9
  lets modules declare their own; there is no per-module icon map.
- The identity slot is a neutral placeholder with no inputs, waiting
  for branding (#101).

The tooltip, menu, and drawer stay shell-local until a second consumer
needs them (ADR 0039). The deploy smoke now signs out through the
account menu, and drives the drawer at phone size in a real browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 17:49:56 -05:00
co-authored by Claude Opus 5.5
parent f3b7e19651
commit af31724cb4
24 changed files with 2559 additions and 189 deletions
+187
View File
@@ -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>
);
}