Add the module navigation contract: manifest v9, module routes, return-to-origin
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -1,12 +1,14 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
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";
|
||||
@@ -56,7 +58,8 @@ export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement
|
||||
function NavigationItem(props: {
|
||||
readonly destination: DashboardDestination;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
/** 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;
|
||||
@@ -77,7 +80,11 @@ function NavigationItem(props: {
|
||||
to={props.destination}
|
||||
{...triggerProps}
|
||||
>
|
||||
<ShellIcon name={props.icon} />
|
||||
{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"
|
||||
@@ -92,6 +99,17 @@ function NavigationItem(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -100,6 +118,8 @@ function ModuleItems(props: {
|
||||
readonly onNavigate?: () => void;
|
||||
readonly labelledBy?: string;
|
||||
}): ReactElement {
|
||||
const { route, presentation } = props;
|
||||
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
||||
return (
|
||||
<ul
|
||||
aria-labelledby={props.labelledBy}
|
||||
@@ -108,20 +128,49 @@ function ModuleItems(props: {
|
||||
>
|
||||
{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 (
|
||||
<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}`}
|
||||
/>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user