feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058): - A collapsible left sidebar replaces the header-and-column layout. It is icons and labels when expanded and icons only when collapsed; each collapsed item keeps its name, visually hidden, and shows a tooltip on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes first, then the server's groups and modules. There is no top bar. - The expanded/collapsed choice is remembered in this browser's localStorage, behind a SidebarPreference interface that a synced store can replace later. - An account menu at the bottom of the sidebar follows the WAI-ARIA menu button pattern and offers only Sign out, which now lives there rather than in the header. - On phones (below 48em) the same navigation opens in a modal drawer: the page behind it is inert and focus is trapped inside it. Escape, its close button, and the backdrop close it and return focus to the menu button. Choosing a page closes it and focuses the page. Changing layout keeps the route, the desktop mode, and the mounted module. - Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon vocabulary. Every module shows one generic icon until manifest v9 lets modules declare their own; there is no per-module icon map. - The identity slot is a neutral placeholder with no inputs, waiting for branding (#101). The tooltip, menu, and drawer stay shell-local until a second consumer needs them (ADR 0039). The deploy smoke now signs out through the account menu, and drives the drawer at phone size in a real browser. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3b7e19651
commit
af31724cb4
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user