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 (
); } 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 (
  • {props.icon === undefined ? undefined : typeof props.icon === "string" ? ( ) : ( props.icon )} {props.label} {tooltip}
  • ); } /** * 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 ? (
  • ) : 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 ?
    ); } /** * 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 ( ); }