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
@@ -3,7 +3,6 @@ import type {
|
||||
CSSProperties,
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
@@ -57,7 +56,14 @@ const currentStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
|
||||
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||
export function isBrowserHandledClick(event: {
|
||||
readonly defaultPrevented: boolean;
|
||||
readonly button: number;
|
||||
readonly metaKey: boolean;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly shiftKey: boolean;
|
||||
readonly altKey: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
|
||||
@@ -20,7 +20,14 @@ import {
|
||||
signInWithEmail,
|
||||
signOutHost,
|
||||
} from "../auth-protocol.js";
|
||||
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
|
||||
import {
|
||||
type DashboardRoute,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
returnTargetFromSearch,
|
||||
} from "../dashboard-routes.js";
|
||||
import { resolveModuleRoute } from "../module-navigation.js";
|
||||
import { parseReturnTarget } from "../return-target.js";
|
||||
import {
|
||||
bootstrapHost,
|
||||
type DashboardNavigationEntry,
|
||||
@@ -42,6 +49,7 @@ import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js
|
||||
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { createModuleUiNavigation } from "./module-ui-navigation.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
import { ShellLayout } from "./ShellLayout.js";
|
||||
import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js";
|
||||
@@ -51,6 +59,7 @@ import {
|
||||
SettingsHomePage,
|
||||
SettingsLoadFailedPage,
|
||||
SettingsSectionPage,
|
||||
type SettingsReturn,
|
||||
SettingsUnavailablePage,
|
||||
} from "./SettingsPages.js";
|
||||
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
|
||||
@@ -194,19 +203,20 @@ function SettingsPageView(props: {
|
||||
readonly page: SettingsPage;
|
||||
readonly history: HostHistory;
|
||||
readonly onRetry: () => void;
|
||||
readonly returnTo: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const { page, history } = props;
|
||||
const { page, history, returnTo } = props;
|
||||
switch (page.kind) {
|
||||
case "home":
|
||||
return <SettingsHomePage history={history} settings={page.settings} />;
|
||||
case "section":
|
||||
return <SettingsSectionPage history={history} section={page.section} />;
|
||||
return <SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />;
|
||||
case "discover":
|
||||
return <DiscoverPage history={history} view={page.view} />;
|
||||
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
|
||||
case "module":
|
||||
return <ModuleSettingsPage history={history} module={page.module} />;
|
||||
return <ModuleSettingsPage history={history} module={page.module} returnTo={returnTo} />;
|
||||
case "unavailable":
|
||||
return <SettingsUnavailablePage history={history} reason={page.reason} />;
|
||||
return <SettingsUnavailablePage history={history} reason={page.reason} returnTo={returnTo} />;
|
||||
case "load-failed":
|
||||
return <SettingsLoadFailedPage onRetry={props.onRetry} />;
|
||||
case "not-found":
|
||||
@@ -220,6 +230,74 @@ function routeKey(route: DashboardRoute): string {
|
||||
return route.kind === "not-found" ? "not-found" : dashboardPath(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `?return=` value the Settings pages may use (ADR 0063), or `undefined`. `parseReturnTarget`
|
||||
* accepts only a canonical same-origin Dashboard address; a module's address is additionally checked
|
||||
* against the modules actually running here and the routes each declares, so Back never points at a
|
||||
* module that is not running or at a page it does not have.
|
||||
*/
|
||||
function usableReturnTarget(
|
||||
raw: string | undefined,
|
||||
entries: readonly DashboardNavigationEntry[],
|
||||
): string | undefined {
|
||||
const destination = parseReturnTarget(raw);
|
||||
if (destination === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (destination.kind !== "module") {
|
||||
return raw;
|
||||
}
|
||||
const entry = entries.find((candidate) => candidate.descriptor.id === destination.moduleId);
|
||||
return entry !== undefined && resolveModuleRoute(entry.pages, destination.path) !== undefined
|
||||
? raw
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mounted module UI and the navigation capability it is handed (ADR 0063). The capability is
|
||||
* created once per mount and reads the address bar live, so it is the same object for the module's
|
||||
* whole life however often it moves between its pages, and the frame never remounts because of it.
|
||||
*/
|
||||
function MountedModule(props: {
|
||||
readonly bootstrap: HostBootstrap;
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly entry: DashboardNavigationEntry;
|
||||
readonly history: HostHistory;
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
readonly onAuthorizationResponse: (status: 401 | 403) => void;
|
||||
}): ReactElement {
|
||||
const { bootstrap, entry, history } = props;
|
||||
const settingsRef = useRef(bootstrap.settings);
|
||||
settingsRef.current = bootstrap.settings;
|
||||
const moduleId = entry.descriptor.id;
|
||||
const navigation = useMemo(
|
||||
() =>
|
||||
createModuleUiNavigation({
|
||||
hasSettings: () => {
|
||||
const settings = settingsRef.current;
|
||||
return (
|
||||
settings !== "unavailable" &&
|
||||
settings.modules.some((module) => module.id === moduleId && module.available)
|
||||
);
|
||||
},
|
||||
history,
|
||||
moduleId,
|
||||
pages: entry.pages,
|
||||
}),
|
||||
[entry.pages, history, moduleId],
|
||||
);
|
||||
return (
|
||||
<ModuleUiFrame
|
||||
config={props.config}
|
||||
descriptor={entry.descriptor}
|
||||
{...(props.importModule ? { importModule: props.importModule } : {})}
|
||||
navigation={navigation}
|
||||
onAuthorizationResponse={props.onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostMessage(props: {
|
||||
readonly children: string;
|
||||
readonly testId: string;
|
||||
@@ -299,8 +377,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const appearance = props.appearance ?? fallbackAppearance;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
// The whole address, so a change of query alone (a new place to return to) re-renders too.
|
||||
const readAddress = useCallback(() => `${history.pathname()}${history.search()}`, [history]);
|
||||
const address = useSyncExternalStore(subscribe, readAddress, readAddress);
|
||||
const pathname = address.split("?")[0] ?? "/";
|
||||
const search = address.slice(pathname.length);
|
||||
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
|
||||
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
||||
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
||||
@@ -364,9 +445,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||
useEffect(() => {
|
||||
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||
history.replace(dashboardPath(route));
|
||||
// The query is kept: it is where to return to (ADR 0063), and respelling the path must not
|
||||
// lose it.
|
||||
history.replace(`${dashboardPath(route)}${search}`);
|
||||
}
|
||||
}, [history, pathname, route]);
|
||||
}, [history, pathname, route, search]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -522,6 +605,12 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
state.status === "ready" && route.kind === "module"
|
||||
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
|
||||
: undefined;
|
||||
// Which of the open module's own routes this is (ADR 0063): its root, a declared page, or a route
|
||||
// beneath one. Anything else is the host's not-found, and the module is not mounted for it.
|
||||
const openModuleRoute =
|
||||
openEntry !== undefined && route.kind === "module"
|
||||
? resolveModuleRoute(openEntry.pages, route.path)
|
||||
: undefined;
|
||||
const settingsPage =
|
||||
state.status === "ready" &&
|
||||
(route.kind === "settings" ||
|
||||
@@ -542,7 +631,13 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
: route.kind === "overview"
|
||||
? "Overview"
|
||||
: route.kind === "module"
|
||||
? (openEntry?.name ?? "This page isn’t available")
|
||||
? openEntry === undefined
|
||||
? "This page isn’t available"
|
||||
: openModuleRoute === undefined
|
||||
? "Page not found"
|
||||
: openModuleRoute.page === undefined
|
||||
? openEntry.name
|
||||
: `${openModuleRoute.page.label} · ${openEntry.name}`
|
||||
: route.kind === "account"
|
||||
? "Account"
|
||||
: route.kind === "account-section"
|
||||
@@ -675,7 +770,16 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
viewport={viewport}
|
||||
>
|
||||
{settingsPage !== undefined ? (
|
||||
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
|
||||
<SettingsPageView
|
||||
history={history}
|
||||
onRetry={retryBootstrap}
|
||||
page={settingsPage}
|
||||
returnTo={{
|
||||
moduleName: (moduleId) =>
|
||||
allEntries(bootstrap).find((entry) => entry.descriptor.id === moduleId)?.name,
|
||||
target: usableReturnTarget(returnTargetFromSearch(search), allEntries(bootstrap)),
|
||||
}}
|
||||
/>
|
||||
) : route.kind === "account" || route.kind === "account-section" ? (
|
||||
bootstrap.account === "unavailable" ? (
|
||||
<AccountLoadFailedPage onRetry={retryBootstrap} />
|
||||
@@ -698,17 +802,22 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
) : route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
openEntry ? (
|
||||
<ModuleUiFrame
|
||||
openEntry === undefined ? (
|
||||
// Installed but not running, or not known at all: never mounted, whatever the path.
|
||||
<ModuleUnavailablePage history={history} />
|
||||
) : openModuleRoute === undefined ? (
|
||||
<NotFoundPage history={history} />
|
||||
) : (
|
||||
<MountedModule
|
||||
bootstrap={bootstrap}
|
||||
config={config}
|
||||
descriptor={openEntry.descriptor}
|
||||
entry={openEntry}
|
||||
history={history}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
// Keyed by module identity, not path: moving between its own pages never remounts it.
|
||||
key={moduleKey(openEntry)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : (
|
||||
<ModuleUnavailablePage history={history} />
|
||||
)
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import type { ModuleUiNavigation, ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
|
||||
@@ -11,6 +11,9 @@ export interface ModuleUiFrameProps {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
/** The module's navigation capability (ADR 0063). Must be stable for the module's life: a new one
|
||||
* remounts the module. */
|
||||
readonly navigation?: ModuleUiNavigation;
|
||||
/** Host-internal session signal. It never enters the module-facing context. */
|
||||
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
||||
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
|
||||
@@ -52,7 +55,8 @@ export interface ModuleUiFrameProps {
|
||||
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||
const { config, descriptor, importModule, onAuthorizationResponse, principal } = props;
|
||||
const { config, descriptor, importModule, navigation, onAuthorizationResponse, principal } =
|
||||
props;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -81,6 +85,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
descriptor,
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
...(navigation ? { navigation } : {}),
|
||||
...(onAuthorizationResponse
|
||||
? { onAuthorizationResponse: reportAuthorizationResponse }
|
||||
: {}),
|
||||
@@ -114,7 +119,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
// already in progress, and a module that fails to clean up must not take the shell with it.
|
||||
void mounted?.unmount().catch(() => undefined);
|
||||
};
|
||||
}, [config, descriptor, importModule, onAuthorizationResponse, principal]);
|
||||
}, [config, descriptor, importModule, navigation, onAuthorizationResponse, principal]);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -28,13 +28,31 @@ import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" };
|
||||
|
||||
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings; once the
|
||||
* module navigation contract carries a return target, a validated one takes precedence. */
|
||||
function settingsBack(history: HostHistory, returnTarget?: unknown) {
|
||||
/**
|
||||
* Where a Settings page was opened from (ADR 0063): the raw `?return=` value, judged by
|
||||
* `parseReturnTarget` inside `dashboardBack`, and the names of the modules a person might be going
|
||||
* back to, so Back can say "Back to Signage".
|
||||
*/
|
||||
export interface SettingsReturn {
|
||||
readonly target: unknown;
|
||||
readonly moduleName: (moduleId: string) => string | undefined;
|
||||
}
|
||||
|
||||
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings — or, when a
|
||||
* module opened this page, to exactly the page it was opened from, if that is a real Dashboard page.
|
||||
* Never the browser's own `history.back()`. */
|
||||
function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
|
||||
return dashboardBack(history, {
|
||||
fallback: BACK_TO_SETTINGS,
|
||||
label: (destination) => (destination.kind === "settings" ? "Back to Settings" : "Back"),
|
||||
returnTarget,
|
||||
label: (destination) => {
|
||||
if (destination.kind === "settings") {
|
||||
return "Back to Settings";
|
||||
}
|
||||
const name =
|
||||
destination.kind === "module" ? returnTo?.moduleName(destination.moduleId) : undefined;
|
||||
return name === undefined ? "Back" : `Back to ${name}`;
|
||||
},
|
||||
returnTarget: returnTo?.target,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,6 +165,7 @@ const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = Object.fr
|
||||
export function DiscoverPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly view: DiscoverView;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const copy = DISCOVER_VIEW_COPY[props.view];
|
||||
return (
|
||||
@@ -155,7 +174,7 @@ export function DiscoverPage(props: {
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={copy.description}
|
||||
eyebrow="Discover"
|
||||
title={copy.title}
|
||||
@@ -169,6 +188,7 @@ export function DiscoverPage(props: {
|
||||
export function SettingsSectionPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly section: SettingsPageSection;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const copy = SETTINGS_SECTION_COPY[props.section];
|
||||
return (
|
||||
@@ -177,7 +197,7 @@ export function SettingsSectionPage(props: {
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={copy.description}
|
||||
title={copy.label}
|
||||
/>
|
||||
@@ -186,15 +206,17 @@ export function SettingsSectionPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/<module-id>`: a running module's settings, not built yet. */
|
||||
/** `/settings/<module-id>`: a running module's settings, not built yet. A module opens it through its
|
||||
* navigation capability, which carries where it was so Back returns exactly there (ADR 0063). */
|
||||
export function ModuleSettingsPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly module: DashboardSettingsModule;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div data-testid="module-settings" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={`How ${props.module.name} works for your church.`}
|
||||
eyebrow="Settings"
|
||||
title={props.module.name}
|
||||
@@ -217,12 +239,13 @@ export type SettingsUnavailableReason = "no-access" | "module-disabled";
|
||||
export function SettingsUnavailablePage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly reason: SettingsUnavailableReason;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const disabled = props.reason === "module-disabled";
|
||||
return (
|
||||
<div data-testid={disabled ? "settings-module-unavailable" : "settings-no-access"}>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={
|
||||
disabled
|
||||
? "The module these settings belong to is turned off. Its settings come back when it is turned on again."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
export interface HostHistory {
|
||||
/** The current path, without query or fragment. */
|
||||
pathname(): string;
|
||||
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
|
||||
/** The current query, `""` or starting with `?`. The shell reads exactly one thing from it: the
|
||||
* place to return to (`?return=…`, ADR 0063). */
|
||||
search(): string;
|
||||
/** Opens a new page, adding a history entry. `path` may carry a query. Opening the address already
|
||||
* showing adds none. */
|
||||
push(path: string): void;
|
||||
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
|
||||
* page. */
|
||||
@@ -28,7 +32,7 @@ export interface HostHistory {
|
||||
|
||||
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
|
||||
export interface HostHistoryWindow {
|
||||
readonly location: Pick<Location, "pathname">;
|
||||
readonly location: Pick<Location, "pathname" | "search">;
|
||||
readonly history: Pick<History, "pushState" | "replaceState">;
|
||||
addEventListener(type: "popstate", listener: () => void): void;
|
||||
removeEventListener(type: "popstate", listener: () => void): void;
|
||||
@@ -47,8 +51,9 @@ export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
|
||||
};
|
||||
return {
|
||||
pathname: () => view.location.pathname,
|
||||
search: () => view.location.search,
|
||||
push(path) {
|
||||
if (path === view.location.pathname) {
|
||||
if (path === `${view.location.pathname}${view.location.search}`) {
|
||||
return;
|
||||
}
|
||||
view.history.pushState(null, "", path);
|
||||
@@ -98,7 +103,12 @@ export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
|
||||
get index() {
|
||||
return index;
|
||||
},
|
||||
pathname: () => entries[index] ?? "/",
|
||||
pathname: () => (entries[index] ?? "/").split("?")[0] ?? "/",
|
||||
search: () => {
|
||||
const entry = entries[index] ?? "/";
|
||||
const query = entry.indexOf("?");
|
||||
return query === -1 ? "" : entry.slice(query);
|
||||
},
|
||||
push(path) {
|
||||
if (path === entries[index]) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
Baby,
|
||||
Bell,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
Bookmark,
|
||||
Briefcase,
|
||||
Building,
|
||||
Bus,
|
||||
Calendar,
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
Car,
|
||||
ChartColumn,
|
||||
ChartPie,
|
||||
Church,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
Coffee,
|
||||
Contact,
|
||||
CreditCard,
|
||||
Database,
|
||||
DoorOpen,
|
||||
FileText,
|
||||
Files,
|
||||
Film,
|
||||
Flag,
|
||||
Folder,
|
||||
Gift,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
HandCoins,
|
||||
HandHeart,
|
||||
Handshake,
|
||||
Headphones,
|
||||
Heart,
|
||||
HeartHandshake,
|
||||
House,
|
||||
Image,
|
||||
Images,
|
||||
Inbox,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
Mail,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Monitor,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
Newspaper,
|
||||
Notebook,
|
||||
Package,
|
||||
Palette,
|
||||
PartyPopper,
|
||||
Phone,
|
||||
Presentation,
|
||||
Printer,
|
||||
Projector,
|
||||
Radio,
|
||||
Receipt,
|
||||
School,
|
||||
Send,
|
||||
Shield,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Star,
|
||||
Sun,
|
||||
TabletSmartphone,
|
||||
Tag,
|
||||
Tent,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Truck,
|
||||
Tv,
|
||||
UserCheck,
|
||||
UserRound,
|
||||
Users,
|
||||
UsersRound,
|
||||
Utensils,
|
||||
Video,
|
||||
Wallet,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
import { type DashboardModuleIconName, isDashboardModuleIconName } from "../module-icons.js";
|
||||
import { ShellIcon } from "./shell-icons.js";
|
||||
|
||||
/**
|
||||
* The Lucide components for the icons a module may declare (manifest v9, ADR 0063), by name.
|
||||
*
|
||||
* Each icon is imported by name, so the Dashboard bundles exactly this curated set and never the
|
||||
* whole library. Typed against `DASHBOARD_MODULE_ICON_NAMES`, so the map can neither miss a name
|
||||
* the platform accepts nor carry one it does not; the shell's tests prove each entry is a real Lucide
|
||||
* component.
|
||||
*/
|
||||
export const MODULE_ICON_COMPONENTS: Readonly<Record<DashboardModuleIconName, LucideIcon>> =
|
||||
Object.freeze({
|
||||
activity: Activity,
|
||||
archive: Archive,
|
||||
baby: Baby,
|
||||
bell: Bell,
|
||||
"book-open": BookOpen,
|
||||
"book-open-text": BookOpenText,
|
||||
bookmark: Bookmark,
|
||||
briefcase: Briefcase,
|
||||
building: Building,
|
||||
bus: Bus,
|
||||
calendar: Calendar,
|
||||
"calendar-check": CalendarCheck,
|
||||
"calendar-days": CalendarDays,
|
||||
camera: Camera,
|
||||
car: Car,
|
||||
"chart-column": ChartColumn,
|
||||
"chart-pie": ChartPie,
|
||||
church: Church,
|
||||
"clipboard-check": ClipboardCheck,
|
||||
"clipboard-list": ClipboardList,
|
||||
clock: Clock,
|
||||
coffee: Coffee,
|
||||
contact: Contact,
|
||||
"credit-card": CreditCard,
|
||||
database: Database,
|
||||
"door-open": DoorOpen,
|
||||
"file-text": FileText,
|
||||
files: Files,
|
||||
film: Film,
|
||||
flag: Flag,
|
||||
folder: Folder,
|
||||
gift: Gift,
|
||||
globe: Globe,
|
||||
"graduation-cap": GraduationCap,
|
||||
"hand-coins": HandCoins,
|
||||
"hand-heart": HandHeart,
|
||||
handshake: Handshake,
|
||||
headphones: Headphones,
|
||||
heart: Heart,
|
||||
"heart-handshake": HeartHandshake,
|
||||
house: House,
|
||||
image: Image,
|
||||
images: Images,
|
||||
inbox: Inbox,
|
||||
"key-round": KeyRound,
|
||||
landmark: Landmark,
|
||||
"layout-grid": LayoutGrid,
|
||||
library: Library,
|
||||
"list-checks": ListChecks,
|
||||
"list-todo": ListTodo,
|
||||
mail: Mail,
|
||||
map: MapIcon,
|
||||
"map-pin": MapPin,
|
||||
megaphone: Megaphone,
|
||||
"message-circle": MessageCircle,
|
||||
"message-square": MessageSquare,
|
||||
mic: Mic,
|
||||
monitor: Monitor,
|
||||
"monitor-play": MonitorPlay,
|
||||
music: Music,
|
||||
newspaper: Newspaper,
|
||||
notebook: Notebook,
|
||||
package: Package,
|
||||
palette: Palette,
|
||||
"party-popper": PartyPopper,
|
||||
phone: Phone,
|
||||
presentation: Presentation,
|
||||
printer: Printer,
|
||||
projector: Projector,
|
||||
radio: Radio,
|
||||
receipt: Receipt,
|
||||
school: School,
|
||||
send: Send,
|
||||
shield: Shield,
|
||||
"shopping-bag": ShoppingBag,
|
||||
sparkles: Sparkles,
|
||||
star: Star,
|
||||
sun: Sun,
|
||||
"tablet-smartphone": TabletSmartphone,
|
||||
tag: Tag,
|
||||
tent: Tent,
|
||||
ticket: Ticket,
|
||||
trophy: Trophy,
|
||||
truck: Truck,
|
||||
tv: Tv,
|
||||
"user-check": UserCheck,
|
||||
"user-round": UserRound,
|
||||
users: Users,
|
||||
"users-round": UsersRound,
|
||||
utensils: Utensils,
|
||||
video: Video,
|
||||
wallet: Wallet,
|
||||
wrench: Wrench,
|
||||
});
|
||||
|
||||
/**
|
||||
* A module's icon: the Lucide icon it declared, or the generic module icon when it declared none or
|
||||
* one this Dashboard cannot draw. Decorative, like every navigation icon (ADR 0058): the module's
|
||||
* name is always beside it or in its control's accessible name.
|
||||
*/
|
||||
export function ModuleIcon(props: {
|
||||
readonly icon: string | undefined;
|
||||
readonly size?: number;
|
||||
}): ReactElement {
|
||||
if (!isDashboardModuleIconName(props.icon)) {
|
||||
return <ShellIcon name="module" {...(props.size === undefined ? {} : { size: props.size })} />;
|
||||
}
|
||||
const Icon = MODULE_ICON_COMPONENTS[props.icon];
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
data-icon={`module:${props.icon}`}
|
||||
focusable="false"
|
||||
size={props.size ?? 20}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
type ModuleUiLink,
|
||||
type ModuleUiLinkClickEvent,
|
||||
type ModuleUiLocation,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
type ModuleUiSettingsLocation,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type DashboardDestination,
|
||||
dashboardPath,
|
||||
dashboardPathReturningTo,
|
||||
parseDashboardPath,
|
||||
} from "../dashboard-routes.js";
|
||||
import {
|
||||
type DashboardModulePage,
|
||||
moduleDestination,
|
||||
moduleRouteFor,
|
||||
resolveModuleRoute,
|
||||
} from "../module-navigation.js";
|
||||
import { isBrowserHandledClick } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
|
||||
export interface CreateModuleUiNavigationOptions {
|
||||
readonly moduleId: string;
|
||||
/** The module's declared primary pages; empty for a single-page module. */
|
||||
readonly pages: readonly DashboardModulePage[];
|
||||
readonly history: HostHistory;
|
||||
/** Whether this module has a Settings page this viewer may open (ADR 0061) — read each time it is
|
||||
* asked, so a Settings view that finishes loading after the module mounted is still honoured. */
|
||||
readonly hasSettings: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dashboard's implementation of a module's navigation capability (ADR 0063).
|
||||
*
|
||||
* A narrow handle over the shell's own history, scoped to one module. It can only ever produce
|
||||
* addresses beneath `/<module-id>/` — a declared page or a route beneath one — or this module's
|
||||
* Settings page; it hands the module no history object, no router, and no way to name another
|
||||
* module or platform page. The module stays mounted as it moves between its pages: the shell keys
|
||||
* the mounted frame by module identity, not by path, and this reports each move to subscribers.
|
||||
*/
|
||||
export function createModuleUiNavigation(
|
||||
options: CreateModuleUiNavigationOptions,
|
||||
): ModuleUiNavigation {
|
||||
const { history, moduleId, pages } = options;
|
||||
|
||||
const currentPath = (): string | undefined => {
|
||||
const route = parseDashboardPath(history.pathname());
|
||||
return route.kind === "module" && route.moduleId === moduleId ? route.path : undefined;
|
||||
};
|
||||
|
||||
const locate = (path: string): ModuleUiLocation =>
|
||||
Object.freeze({ page: resolveModuleRoute(pages, path)?.page?.id, path });
|
||||
|
||||
// One frozen snapshot per distinct location, so `location()` is stable between moves — what a
|
||||
// React external-store read needs.
|
||||
let snapshot = locate(currentPath() ?? "");
|
||||
const refresh = (): boolean => {
|
||||
const path = currentPath();
|
||||
if (path === undefined || path === snapshot.path) {
|
||||
return false;
|
||||
}
|
||||
snapshot = locate(path);
|
||||
return true;
|
||||
};
|
||||
|
||||
// One subscription to the address bar, shared by every listener the module registers, and the
|
||||
// path they were last told about — so every listener hears every move exactly once, however
|
||||
// often the module reads `location()` in between.
|
||||
const listeners = new Set<() => void>();
|
||||
let stopWatching: (() => void) | undefined;
|
||||
let notifiedPath = snapshot.path;
|
||||
const notify = (): void => {
|
||||
refresh();
|
||||
if (snapshot.path === notifiedPath) {
|
||||
return;
|
||||
}
|
||||
notifiedPath = snapshot.path;
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const routeFor = (path: unknown): string => {
|
||||
const route = moduleRouteFor(pages, path);
|
||||
if (route === "invalid") {
|
||||
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_INVALID");
|
||||
}
|
||||
if (route === "unknown") {
|
||||
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_UNKNOWN");
|
||||
}
|
||||
return dashboardPath(moduleDestination(moduleId, route.path));
|
||||
};
|
||||
|
||||
const linkTo = (href: string): ModuleUiLink =>
|
||||
Object.freeze({
|
||||
href,
|
||||
onClick(event: ModuleUiLinkClickEvent) {
|
||||
if (isBrowserHandledClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
history.push(href);
|
||||
},
|
||||
});
|
||||
|
||||
const settingsDestination: DashboardDestination = { kind: "module-settings", moduleId };
|
||||
const settingsHref = (): string => {
|
||||
// Read the address bar now: a module with no listener of its own may have moved since.
|
||||
refresh();
|
||||
return dashboardPathReturningTo(
|
||||
settingsDestination,
|
||||
moduleDestination(moduleId, snapshot.path),
|
||||
);
|
||||
};
|
||||
const settings: ModuleUiSettingsLocation = Object.freeze({
|
||||
link: () => linkTo(settingsHref()),
|
||||
open: () => history.push(settingsHref()),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
link: (path: string) => linkTo(routeFor(path)),
|
||||
location: () => {
|
||||
refresh();
|
||||
return snapshot;
|
||||
},
|
||||
navigate(path: string, navigateOptions?: { readonly replace?: boolean }) {
|
||||
const href = routeFor(path);
|
||||
if (navigateOptions?.replace === true) {
|
||||
history.replace(href);
|
||||
} else {
|
||||
history.push(href);
|
||||
}
|
||||
},
|
||||
get settings() {
|
||||
return options.hasSettings() ? settings : undefined;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) {
|
||||
stopWatching = history.subscribe(notify);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
stopWatching?.();
|
||||
stopWatching = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user