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]>
209 lines
7.7 KiB
TypeScript
209 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|