import { designToken } from "@ekklesia/design-tokens"; import { type KeyboardEvent, type ReactElement, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react"; import type { DashboardRoute } from "../dashboard-routes.js"; import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js"; import { AccountMenu } from "./AccountMenu.js"; import { DevicePanel } from "./DevicePanel.js"; import type { HostHistory } from "./host-history.js"; import type { HostViewport } from "./host-viewport.js"; import { useNavTooltip } from "./NavTooltip.js"; import { controlHeight, NavigationList, type NavigationPresentation, ShellBrand, } from "./ShellNavigation.js"; import { ShellIcon } from "./shell-icons.js"; import { SHELL_STYLESHEET } from "./shell-styles.js"; import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js"; const SIDEBAR_NAV_ID = "ekklesia-sidebar-navigation"; const DRAWER_ID = "ekklesia-navigation-drawer"; const DRAWER_NAV_ID = "ekklesia-drawer-navigation"; const SIDEBAR_WIDTH: Record = { collapsed: "4.5rem", expanded: "16rem" }; const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; export interface ShellLayoutProps { readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly route: DashboardRoute; readonly viewport: HostViewport; readonly sidebarPreference: SidebarPreference; readonly mainRef: RefObject; readonly pageTitle: string | undefined; readonly onSignOut: () => void; readonly children: ReactNode; } function SidebarToggle(props: { readonly mode: SidebarMode; readonly onToggle: () => void; }): ReactElement { const label = props.mode === "expanded" ? "Collapse navigation" : "Expand navigation"; const { tooltip, triggerProps } = useNavTooltip(label, props.mode === "collapsed"); return ( <> {tooltip} ); } /** * The desktop and tablet sidebar (DESIGN.md §4.2): identity at the top, the navigation, and the * account area anchored at the bottom. Collapsed, it is icons only, each named by a tooltip. */ function Sidebar(props: { readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly route: DashboardRoute; readonly mode: SidebarMode; readonly onToggle: () => void; readonly onSignOut: () => void; }): ReactElement { const presentation: NavigationPresentation = { collapsed: props.mode === "collapsed", compact: false, }; return ( ); } /** * The phone navigation (DESIGN.md §4.6): the same hierarchy, current page, and account area as the * sidebar, in a modal drawer. * * It is a real modal: `role="dialog"` with `aria-modal`, the page behind it `inert`, and Tab and * Shift+Tab kept inside it here as well, for a browser that does not yet honour `inert`. Opening * moves focus to its close button; Escape, the close button, and the backdrop dismiss it and the * layout returns focus to the trigger. */ function NavigationDrawer(props: { readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly route: DashboardRoute; readonly onDismiss: () => void; readonly onNavigate: () => void; readonly onSignOut: () => void; }): ReactElement { const panelRef = useRef(null); const closeRef = useRef(null); const presentation: NavigationPresentation = { collapsed: false, compact: true }; useEffect(() => { closeRef.current?.focus(); }, []); const onKeyDown = (event: KeyboardEvent): void => { if (event.key === "Escape") { event.preventDefault(); props.onDismiss(); return; } if (event.key !== "Tab" || !panelRef.current) { return; } const focusable = [...panelRef.current.querySelectorAll(FOCUSABLE)]; const first = focusable[0]; const last = focusable.at(-1); const active = panelRef.current.ownerDocument.activeElement; if (event.shiftKey && (active === first || !panelRef.current.contains(active))) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && (active === last || !panelRef.current.contains(active))) { event.preventDefault(); first?.focus(); } }; return ( <>