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