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