Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
456 lines
17 KiB
TypeScript
456 lines
17 KiB
TypeScript
import { designToken } from "@ekklesia/design-tokens";
|
|
import { type CSSProperties, Fragment, type ReactElement } from "react";
|
|
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
|
|
import type {
|
|
DashboardNavigation,
|
|
DashboardNavigationEntry,
|
|
DashboardSettings,
|
|
} from "../host-protocol.js";
|
|
import { moduleDestination, resolveModuleRoute } from "../module-navigation.js";
|
|
import { DashboardLink } from "./DashboardLink.js";
|
|
import { ModuleIcon } from "./module-icon.js";
|
|
import type { HostHistory } from "./host-history.js";
|
|
import { useNavTooltip } from "./NavTooltip.js";
|
|
import { DISCOVER_VIEWS } from "../dashboard-routes.js";
|
|
import {
|
|
DISCOVER_VIEW_COPY,
|
|
hasVisibleSettings,
|
|
isSettingsRoute,
|
|
navigableModuleSettings,
|
|
SETTINGS_SECTION_COPY,
|
|
settingsSectionDestination,
|
|
} from "./settings-catalog.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;
|
|
/** A shell icon by name, a module's own icon, or none — a module's pages are text beneath it. */
|
|
readonly icon?: ShellIconName | ReactElement;
|
|
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}
|
|
>
|
|
{props.icon === undefined ? undefined : typeof props.icon === "string" ? (
|
|
<ShellIcon name={props.icon} />
|
|
) : (
|
|
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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The modules in one navigation group, each with its own icon (manifest v9, ADR 0063).
|
|
*
|
|
* While a module is open, its declared primary pages appear beneath it, in its declared order
|
|
* (DESIGN.md §4.4) — the same in-place expansion Settings uses (ADR 0061), so the rest of the
|
|
* navigation stays where it was and one item carries `aria-current`: the open page, or the module
|
|
* itself on its landing view. A route beneath a page (a detail view) marks that page. Collapsed,
|
|
* the sidebar shows icons only, so the pages stay hidden and the module item is the current one; its
|
|
* tooltip and accessible name are the module's name. A module that declares no pages — every
|
|
* manifest v8 module — is one item, exactly as before.
|
|
*/
|
|
function ModuleItems(props: {
|
|
readonly entries: readonly DashboardNavigationEntry[];
|
|
readonly route: DashboardRoute;
|
|
readonly history: HostHistory;
|
|
readonly presentation: NavigationPresentation;
|
|
readonly onNavigate?: () => void;
|
|
readonly labelledBy?: string;
|
|
}): ReactElement {
|
|
const { route, presentation } = props;
|
|
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
|
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;
|
|
const resolution =
|
|
route.kind === "module" && route.moduleId === moduleId
|
|
? resolveModuleRoute(entry.pages, route.path)
|
|
: undefined;
|
|
const showPages =
|
|
resolution !== undefined && entry.pages.length > 0 && !presentation.collapsed;
|
|
const openPage = resolution?.page;
|
|
return (
|
|
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
|
|
<NavigationItem
|
|
current={resolution !== undefined && (openPage === undefined || !showPages)}
|
|
destination={moduleDestination(moduleId)}
|
|
history={props.history}
|
|
icon={<ModuleIcon icon={entry.icon} />}
|
|
label={entry.name}
|
|
{...onNavigate}
|
|
presentation={presentation}
|
|
testId={`select-${moduleId}`}
|
|
/>
|
|
{showPages ? (
|
|
<li>
|
|
<ul
|
|
aria-label={entry.name}
|
|
className="ek-shell-nav__group ek-shell-nav__nested"
|
|
data-testid={`module-subnavigation-${moduleId}`}
|
|
style={{ listStyle: "none", margin: 0, padding: 0 }}
|
|
>
|
|
{entry.pages.map((page) => (
|
|
<NavigationItem
|
|
current={openPage?.id === page.id}
|
|
destination={moduleDestination(moduleId, page.path)}
|
|
history={props.history}
|
|
key={page.id}
|
|
label={page.label}
|
|
{...onNavigate}
|
|
presentation={presentation}
|
|
testId={`select-${moduleId}-${page.id}`}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</li>
|
|
) : undefined}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
export interface NavigationListProps {
|
|
readonly id: string;
|
|
readonly navigation: DashboardNavigation;
|
|
readonly route: DashboardRoute;
|
|
readonly history: HostHistory;
|
|
readonly presentation: NavigationPresentation;
|
|
/** Which Settings destinations this person may see (ADR 0061). */
|
|
readonly settings: DashboardSettings | "unavailable";
|
|
/** Called once a destination is chosen, for the drawer to close. */
|
|
readonly onNavigate?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Discover in the Settings navigation: one item that opens Browse, which — while a Discover view is
|
|
* open — shows its three views beneath it. The open view carries `aria-current`; Discover itself does
|
|
* not, so exactly one item is the current page.
|
|
*/
|
|
function DiscoverNavigation(props: {
|
|
readonly id: string;
|
|
readonly route: DashboardRoute;
|
|
readonly history: HostHistory;
|
|
readonly presentation: NavigationPresentation;
|
|
readonly onNavigate?: () => void;
|
|
}): ReactElement {
|
|
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
|
const open = props.route.kind === "discover";
|
|
const copy = SETTINGS_SECTION_COPY.discover;
|
|
return (
|
|
<>
|
|
<NavigationItem
|
|
current={false}
|
|
destination={settingsSectionDestination("discover")}
|
|
history={props.history}
|
|
icon={copy.icon}
|
|
label={copy.label}
|
|
{...onNavigate}
|
|
presentation={props.presentation}
|
|
testId="select-settings-discover"
|
|
/>
|
|
{open ? (
|
|
<li>
|
|
<ul
|
|
aria-label={copy.label}
|
|
className={
|
|
props.presentation.collapsed
|
|
? "ek-shell-nav__group"
|
|
: "ek-shell-nav__group ek-shell-nav__nested"
|
|
}
|
|
data-testid="discover-subnavigation"
|
|
style={{ listStyle: "none", margin: 0, padding: 0 }}
|
|
>
|
|
{DISCOVER_VIEWS.map((view) => (
|
|
<NavigationItem
|
|
current={props.route.kind === "discover" && props.route.view === view}
|
|
destination={{ kind: "discover", view }}
|
|
history={props.history}
|
|
icon={DISCOVER_VIEW_COPY[view].icon}
|
|
key={view}
|
|
label={DISCOVER_VIEW_COPY[view].label}
|
|
{...onNavigate}
|
|
presentation={props.presentation}
|
|
testId={`select-discover-${view}`}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</li>
|
|
) : undefined}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Settings, in the same sidebar as everything else (DESIGN.md §4.2, ADR 0061): one Settings item
|
|
* after the modules, which — while a Settings page is open — expands in place into the platform's
|
|
* own sections and each module's settings. The rest of the navigation stays where it was, so leaving
|
|
* Settings is one click on any other item, and the hierarchy is identical in the phone drawer.
|
|
*
|
|
* Only what the server said this person may see is listed, and nothing at all when that is nothing:
|
|
* no Settings link leads to a page of things they cannot change.
|
|
*/
|
|
function SettingsNavigation(props: {
|
|
readonly id: string;
|
|
readonly settings: DashboardSettings | "unavailable";
|
|
readonly route: DashboardRoute;
|
|
readonly history: HostHistory;
|
|
readonly presentation: NavigationPresentation;
|
|
readonly onNavigate?: () => void;
|
|
}): ReactElement | null {
|
|
if (!hasVisibleSettings(props.settings) || props.settings === "unavailable") {
|
|
return null;
|
|
}
|
|
const { route, settings } = props;
|
|
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
|
const open = isSettingsRoute(route.kind);
|
|
const modules = navigableModuleSettings(settings);
|
|
const collapsed = props.presentation.collapsed;
|
|
const listStyle = { listStyle: "none", margin: 0, padding: 0 } as const;
|
|
const labelClass = collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label";
|
|
return (
|
|
<section data-testid="settings-navigation">
|
|
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
|
|
<ul className="ek-shell-nav__group" style={listStyle}>
|
|
<NavigationItem
|
|
current={route.kind === "settings"}
|
|
destination={{ kind: "settings" }}
|
|
history={props.history}
|
|
icon="settings"
|
|
label="Settings"
|
|
{...onNavigate}
|
|
presentation={props.presentation}
|
|
testId="select-settings"
|
|
/>
|
|
</ul>
|
|
{open ? (
|
|
<div
|
|
className={collapsed ? undefined : "ek-shell-nav__nested"}
|
|
data-testid="settings-subnavigation"
|
|
>
|
|
{settings.sections.length > 0 ? (
|
|
<>
|
|
<h3 className={labelClass} id={`${props.id}-settings-platform`}>
|
|
Platform
|
|
</h3>
|
|
<ul
|
|
aria-labelledby={`${props.id}-settings-platform`}
|
|
className="ek-shell-nav__group"
|
|
style={listStyle}
|
|
>
|
|
{settings.sections.map((section) =>
|
|
section === "discover" ? (
|
|
<DiscoverNavigation
|
|
history={props.history}
|
|
id={props.id}
|
|
key={section}
|
|
{...onNavigate}
|
|
presentation={props.presentation}
|
|
route={route}
|
|
/>
|
|
) : (
|
|
<NavigationItem
|
|
current={route.kind === "settings-section" && route.section === section}
|
|
destination={settingsSectionDestination(section)}
|
|
history={props.history}
|
|
icon={SETTINGS_SECTION_COPY[section].icon}
|
|
key={section}
|
|
label={SETTINGS_SECTION_COPY[section].label}
|
|
{...onNavigate}
|
|
presentation={props.presentation}
|
|
testId={`select-settings-${section}`}
|
|
/>
|
|
),
|
|
)}
|
|
</ul>
|
|
</>
|
|
) : undefined}
|
|
{modules.length > 0 ? (
|
|
<>
|
|
<h3 className={labelClass} id={`${props.id}-settings-modules`}>
|
|
Modules
|
|
</h3>
|
|
<ul
|
|
aria-labelledby={`${props.id}-settings-modules`}
|
|
className="ek-shell-nav__group"
|
|
style={listStyle}
|
|
>
|
|
{modules.map((module) => (
|
|
<NavigationItem
|
|
current={route.kind === "module-settings" && route.moduleId === module.id}
|
|
destination={{ kind: "module-settings", moduleId: module.id }}
|
|
history={props.history}
|
|
icon="module"
|
|
key={module.id}
|
|
label={module.name}
|
|
{...onNavigate}
|
|
presentation={props.presentation}
|
|
testId={`select-module-settings-${module.id}`}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : undefined}
|
|
</div>
|
|
) : undefined}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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}
|
|
<SettingsNavigation
|
|
history={props.history}
|
|
id={props.id}
|
|
{...onNavigate}
|
|
presentation={props.presentation}
|
|
route={props.route}
|
|
settings={props.settings}
|
|
/>
|
|
</nav>
|
|
);
|
|
}
|