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 (
Ekklesia
);
}
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 (
);
}
/**
* 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 (
);
}
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 (
<>
{open ? (
{DISCOVER_VIEWS.map((view) => (
))}
) : 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 (
{collapsed ? : undefined}
) : undefined}
);
}
/**
* 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 (
);
}