"use client" /** * AppShell slots — the seam between the DS shell (L3) and the app content it * frames (L4 to L6), per ADR 0003. * * The shell renders chrome: sidebar structure, drill-in stacks, secondary * panels, the identity menu. The *content* of several spots is app-owned and * cannot live in the package without dragging Exxat domain knowledge across the * boundary — a notification bell that reads a notifications store, a product * lock-up that knows Exxat brand assets, a Library nav that reads library data. * * Those spots become slots filled through {@link AppShellSlotsProvider}. * * ## Why components, not nodes * * A first pass at this API typed the slots as `ReactNode`. That does not work: * the shell decides `className` per render state (icon rail vs expanded) and * passes the active product into the brand lock-up. A node is already * constructed, so the shell would have no way to vary it. Slots are therefore * `ComponentType`s with a prop contract the shell owns. * * ## Why absent slots render nothing * * Every slot and callback is optional and degrades silently — a missing slot * renders nothing, a missing callback is a no-op. This is deliberate: a * consumer on an older wiring file must still get a working sidebar after a DS * bump, which is the entire point of the ADR. A slot that threw on absence * would turn every new spot into a breaking change. */ import * as React from "react" import type { CustomProductBrand, Product } from "@exxatdesignux/product-framework" import { useProduct } from "./product-context" import { useProductOrganizationSettingsHref } from "./product-route-sync" import type { NavRowActiveOverride } from "../../lib/nav-active-state" import type { NavLinkItem, NavPrimaryLayout, NavSecondaryItem, NavUserIdentity, } from "./nav-render-types" /** Props the shell passes into the notification slot. */ export interface NotificationsSlotProps { className?: string } /** Props the shell passes into the product mark (collapsed rail) slot. */ export interface ProductMarkSlotProps { product: Product className?: string /** Set while previewing an unsaved tenant brand; overrides the active brand. */ previewCustomBrand?: CustomProductBrand | null } /** Product lock-up variants the shell asks for. */ export type ProductLogoSlotVariant = | "default" | "mutedSuffix" | "sidebar" | "utility-bar" /** Props the shell passes into the product lock-up (expanded header) slot. */ export interface ProductLogoSlotProps extends ProductMarkSlotProps { variant?: ProductLogoSlotVariant } /** * One level of the scope hierarchy that carries a picture — a school, a brand. */ export interface ShellScopeParent { name: string /** Image URL. Falls back to {@link ShellScopeParent.initials} when it fails. */ logo: string initials: string } /** * What the shell needs in order to draw the scope trigger. * * Deliberately narrower than the app's own scope bag. The app's bag carries the * full picker state — sub-view, licensed parent list, per-row select handlers — * and none of that is the trigger's business; it belongs to the menu body, which * is a slot. Declaring the minimum here is what stops the package from acquiring * a dependency on Exxat's scope vocabulary. */ export interface ShellScope { /** * `"open"`: both names are set. `"choose"`: scoped product, nothing picked yet, * so the trigger reads {@link ShellScope.choosePrompt}. `"none"`: nothing * licensed, and the shell renders no trigger at all. */ status: "open" | "choose" | "none" /** * True when this session cannot switch scope. The shell renders the two names * as static text with no press target and no chevron. */ fixed: boolean parent: ShellScopeParent | null child: { name: string } | null /** Trigger text: the child's name, or the prompt when there is nothing to name. */ label: string /** Full `aria-label` for the trigger, including how to change the scope. */ ariaLabel: string /** Shown on the collapsed rail's tooltip when no scope is picked. */ choosePrompt: string /** Icon class for the empty-scope placeholder circle. */ emptyIconClass: string /** Pass to the menu so it reopens on its main view. */ onOpenChange?: (open: boolean) => void /** * Opaque app payload handed straight back to * {@link AppShellSlots.ScopeSwitcherMenu}. * * The trigger and the open menu must share **one** state instance: the app's * picker holds a sub-view (`main` / `parents`) that the trigger's * `onOpenChange` resets on close. If the menu slot called the app's scope hook * a second time it would get its own sub-view, and reopening would land on * whichever view the *other* copy was last left on. Threading the app's own bag * through as an opaque value keeps it to one instance without the package * having to know the bag's shape. */ menuContext?: unknown } /** Props the shell passes into the scope picker menu-body slot. */ export interface ScopeSwitcherMenuSlotProps { scope: ShellScope } const NO_SCOPE: ShellScope = { status: "none", fixed: true, parent: null, child: null, label: "", ariaLabel: "", choosePrompt: "", emptyIconClass: "", } /** Default when the app fills no scope: the shell renders no scope trigger. */ function useNoScope(): ShellScope { return NO_SCOPE } /** The product named by the switcher trigger. */ export interface ShellProductEntry { id: Product /** Full accessible name — `aria-label`, collapsed tooltip, expanded wordmark. */ label: string /** * Index into the tenant custom-product list, when this is a custom product. * `undefined` for built-ins, and the shell must treat it that way rather than * defaulting to `0`, or every built-in would render the first tenant brand. */ customIndex?: number } /** * Default when the app names no product: the id stands in for the label. * * Deliberately not pretty. A real label needs the app's brand table, so anything * the package invented here would be a wrong guess presented as a real name; * showing the id makes the unfilled hook obvious instead. */ function useActiveProductFromContext(): ShellProductEntry { const { product } = useProduct() return React.useMemo(() => ({ id: product, label: product }), [product]) } /** * App-owned *state* the shell reads, as hooks rather than values. * * A hook rather than a plain field because this state lives in app stores and is * keyed by arguments the shell holds — the shell knows the active product, the * app knows how to resolve a scope for it. Passing the resolved value down * instead would force the wiring component to re-render the whole shell whenever * any of it changed. * * Each entry has a default that reports "nothing here", so the hook is always * called unconditionally and the rules of hooks hold whether or not the app * filled it. */ export interface AppShellData { /** Resolve the scope shown in the sidebar header for a product. */ useScope?: (product: Product) => ShellScope /** * The product the switcher trigger names. The rows *inside* the open menu are * {@link AppShellSlots.ProductSwitcherItems}, so this is only what the closed * trigger needs. */ useActiveProduct?: () => ShellProductEntry /** * Destination of the identity menu's "Workspace settings" row. * * Defaults to the active product's settings root, which is where a product * that scopes its settings puts them. An app whose workspace settings live at a * shared route, or that varies the destination per product, fills this instead * — the shell owns the row, not the URL behind it. */ useWorkspaceSettingsHref?: () => string /** * Every row the sidebar draws, for the active product. * * One hook rather than a field per group because the groups are not * independent: the URL corpus has to cover all of them at once or * longest-prefix matching picks the wrong row, and the drill-in search runs * across all of them too. Splitting them into separate fields would let a * consumer fill four and forget the fifth, and the symptom would be a * mis-highlighted row rather than an error. */ useNav?: () => ShellNav } /** * The resolved nav for one product. * * `primary` is the flat row list and `primaryLayout` the same rows grouped into * labelled sections. Both are present because the sidebar renders the grouped * form and searches the flat one, and deriving either from the other on every * render would churn identities that memoised rows depend on. */ export interface ShellNav { primary: NavLinkItem[] primaryLayout: NavPrimaryLayout /** Rows above the primary nav that open UI (command menu, assistant). */ quickActions: NavSecondaryItem[] /** Secondary row group, rendered under its own heading. */ documents: NavLinkItem[] documentsLabel?: string /** Footer utilities (settings, help). */ secondary: NavSecondaryItem[] user: NavUserIdentity /** * Every href the sidebar can expose in **any** product, not just the active * one. Longest-prefix matching is only correct when it can see the more * specific hrefs it should lose to, and a user who switches products keeps * URLs from the previous one in history. */ knownUrls: readonly string[] /** Per-row active-state override. See {@link NavRowActiveOverride}. */ rowActive?: NavRowActiveOverride } const EMPTY_LAYOUT: NavPrimaryLayout = { preamble: [], sections: [] } const NO_ROWS: never[] = [] const ANONYMOUS: NavUserIdentity = { name: "", email: "", avatar: "" } /** * Default when the app registers no nav: an empty sidebar. * * Empty rather than a sample tree. Invented rows would render as real * navigation pointing at routes the app does not serve, which is worse than a * bare rail that makes the missing wiring obvious. */ const NO_NAV: ShellNav = { primary: NO_ROWS, primaryLayout: EMPTY_LAYOUT, quickActions: NO_ROWS, documents: NO_ROWS, secondary: NO_ROWS, user: ANONYMOUS, knownUrls: NO_ROWS, } function useNoNav(): ShellNav { return NO_NAV } /** Where the primary rail lands after a secondary panel closes. */ export type SecondaryPanelCloseTarget = "restore" | "expand" | "collapse" | "leave" /** * One nested scope rail the shell can mount between the icon sidebar and the * page — Library's question scopes, Learning activities' course groups. * * The shell owns the rail: its width and persistence, the compact icon strip, * the flyout sheet at reflow zoom, the panel title bar, and the whole open/close * state machine. A spec supplies only the two things the shell cannot know — * which of the app's routes the rail belongs to, and what goes inside it. */ export interface SecondaryPanelSpec { /** Matches `openPanel(id)` and the `secondaryPanel` field on a nav row. */ id: string /** Panel heading, shown in the docked title bar and the flyout sheet. */ title: string /** * The rail's contents. Rendered bare on the compact icon strip and inside the * scrolling body when expanded, so it must not draw its own heading — the * shell draws that from {@link SecondaryPanelSpec.title}. */ Body: React.ComponentType /** * Routes this rail belongs to. At reflow zoom the shell's nav-flyout toggle * opens this panel from these routes even when another one is active. */ flyoutRoute: (pathname: string) => boolean /** * Routes that mount the rail. Defaults to * {@link SecondaryPanelSpec.flyoutRoute}. * * Separate because the two are not the same set: a hub's list route may be the * only place the toggle should reach for the rail, while every record under * that hub still shows it. The shell treats the URL as the source of truth and * consults this on every navigation, so a panel cannot be left stuck open * after a route change. */ autoOpen?: (pathname: string) => boolean } /** * A drill-in body the app draws itself, instead of the shell's row list. * * A drill-in normally renders the `drillIn.items` on the nav row it came from, * and that covers most of them. Some are not a list at all: the assistant's * drill-in is a search field, a "New chat" button, and a persisted recents list. * Those rows carry an empty `items` array and name a body here. */ export interface SidebarDrillInPanelSpec { /** Nav row key whose `drillIn.items` this body replaces. */ rowKey: string Body: React.ComponentType /** * Suppress the drill-in header's section title. * * For a body that draws its own heading, which would otherwise appear twice. */ hidesSectionTitle?: boolean } /** * App-owned content the shell renders in named spots. * * Anything here may be omitted; the shell renders the spot empty. */ export interface AppShellSlots { /** Notification affordance in the utility row. */ Notifications?: React.ComponentType /** Product lock-up shown in the expanded sidebar header. */ ProductLogo?: React.ComponentType /** Product mark shown in the collapsed icon rail. */ ProductMark?: React.ComponentType /** Rows inside the product switcher menu. */ ProductSwitcherItems?: React.ComponentType /** * Body of the scope picker menu (school > program, brand > site > location). * The shell owns the trigger and draws it from {@link AppShellData.useScope}; * everything inside the open menu is app-owned. */ ScopeSwitcherMenu?: React.ComponentType /** * Preference groups in the identity menu (theme mode, search mode, home * layout). Each renders as its own separator-delimited group, in order. * * Split from {@link AppShellSlots.userMenuConsoleItems} rather than being one * flat list because the two sit at different nesting levels in the menu: a * preference group owns a separator, a console row does not. Collapsing them * would move separators and change the rendered menu. */ userMenuPreferences?: React.ComponentType[] /** * Extra plain rows appended to the identity menu's workspace-console group, * alongside the built-in console entries. No separator of their own. */ userMenuConsoleItems?: React.ComponentType[] /** * Extra rows appended to the identity menu's account group, after Profile and * Workspace settings. No separator of their own. * * The third seam in this menu rather than a reuse of the two above, because * this is the group a per-tenant account action belongs in — switching * organization, impersonation, a support handoff. Without it apps had one * placement left for such a row, down beside the workspace consoles, and the * only alternative was forking `NavUser`, which is how a consumer stops * receiving shell fixes. */ userMenuAccountItems?: React.ComponentType[] /** * Nested scope rails, in priority order. When more than one spec claims the * current route the first wins, so a broad hub rail should come after the * narrower ones it would otherwise shadow. */ secondaryPanels?: SecondaryPanelSpec[] /** * Drill-ins whose body the app draws, keyed by nav row. Rows with no entry * here get the shell's row list. */ drillInPanels?: readonly SidebarDrillInPanelSpec[] } /** * Imperative hooks out of the shell into app behaviour. * * Absent entries are no-ops, so the shell renders the affordance and clicking * it does nothing rather than crashing. */ export interface AppShellCallbacks { /** Open the global command palette. */ openCommandMenu?: () => void /** Open or close the Ask Leo panel. */ setAskLeoOpen?: (open: boolean) => void /** Whether the Ask Leo panel is currently open — drives row active state. */ askLeoOpen?: boolean /** * Whether Ask Leo is docked open, which is a different question from * {@link AppShellCallbacks.askLeoOpen}: a docked Leo takes rail width from * the sidebar, so the shell has to react to it even though the panel itself * is app-rendered. */ askLeoDockedOpen?: boolean /** Sign the user out. */ logOut?: () => void } /** * Route predicates the app owns but the shell has to consult. * * These are policy, not data: only the app knows which of its routes are focus * workflows. Passing predicates rather than a route list keeps the shell out of * the business of parsing app URLs. */ export interface AppShellRoutePolicy { /** * True on routes that strip shell chrome — exam lock, compose. The shell * hides the sidebar rather than letting a focus surface inherit it. */ hidesSidebar?: (pathname: string) => boolean /** * Where the primary rail lands when a secondary panel closes on arrival at * `pathname`. Defaults to `"leave"`, which keeps the rail as it was. * * Keyed on the destination route rather than on the panel that is closing, * because it answers a question about the page being opened: a record detail * wants the extra canvas of the icon rail, and it wants that whichever hub the * reader came from. */ mainSidebarOnPanelClose?: (pathname: string) => SecondaryPanelCloseTarget } export interface AppShellSlotsValue { slots: AppShellSlots on: AppShellCallbacks routes: AppShellRoutePolicy data: Required } const DEFAULT_DATA: Required = { useScope: useNoScope, useActiveProduct: useActiveProductFromContext, useWorkspaceSettingsHref: useProductOrganizationSettingsHref, useNav: useNoNav, } const EMPTY_VALUE: AppShellSlotsValue = { slots: {}, on: {}, routes: {}, data: DEFAULT_DATA, } const AppShellSlotsContext = React.createContext(EMPTY_VALUE) export interface AppShellSlotsProviderProps { slots?: AppShellSlots on?: AppShellCallbacks routes?: AppShellRoutePolicy /** * Partial on the way in, total on the way out: the provider fills unsupplied * hooks with defaults so the shell can call every one unconditionally. */ data?: AppShellData children: React.ReactNode } /** * Fill the shell's slots once, near the router root. Everything below reads * them with {@link useAppShellSlots}, so deeply nested shell parts (identity * menu, secondary panels) do not need props threaded through every layer. */ export function AppShellSlotsProvider({ slots, on, routes, data, children, }: AppShellSlotsProviderProps) { // Callers usually build these inline, so memoize on the field identities // rather than the wrapper objects to avoid re-rendering the whole shell on // every parent render. const value = React.useMemo( () => ({ slots: slots ?? {}, on: on ?? {}, routes: routes ?? {}, // Filled per field so an app that supplies only some hooks still gets the // "nothing here" default for the rest, keeping every hook call // unconditional in the shell. data: { ...DEFAULT_DATA, ...data }, }), [slots, on, routes, data], ) return ( {children} ) } /** Read the filled slots and callbacks. Safe with no provider above. */ export function useAppShellSlots(): AppShellSlotsValue { return React.useContext(AppShellSlotsContext) }