import { designToken } from "@ekklesia/design-tokens"; import type { CSSProperties, ReactElement } from "react"; import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js"; import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js"; import { DashboardLink } from "./DashboardLink.js"; import type { HostHistory } from "./host-history.js"; import { useNavTooltip } from "./NavTooltip.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; readonly icon: ShellIconName; 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.label} {tooltip}
  • ); } function ModuleItems(props: { readonly entries: readonly DashboardNavigationEntry[]; readonly route: DashboardRoute; readonly history: HostHistory; readonly presentation: NavigationPresentation; readonly onNavigate?: () => void; readonly labelledBy?: string; }): ReactElement { return ( ); } export interface NavigationListProps { readonly id: string; readonly navigation: DashboardNavigation; readonly route: DashboardRoute; readonly history: HostHistory; readonly presentation: NavigationPresentation; /** Called once a destination is chosen, for the drawer to close. */ readonly onNavigate?: () => void; } /** * 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 ( ); }