import * as react_jsx_runtime from 'react/jsx-runtime'; import * as React from 'react'; import { Product, CustomProductBrand } from '@exxatdesignux/product-framework'; import { NavRowActiveOverride } from '../../lib/nav-active-state.js'; import { NavLinkItem, NavPrimaryLayout, NavSecondaryItem, NavUserIdentity } from './nav-render-types.js'; /** Props the shell passes into the notification slot. */ interface NotificationsSlotProps { className?: string; } /** Props the shell passes into the product mark (collapsed rail) slot. */ 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. */ type ProductLogoSlotVariant = "default" | "mutedSuffix" | "sidebar" | "utility-bar"; /** Props the shell passes into the product lock-up (expanded header) slot. */ interface ProductLogoSlotProps extends ProductMarkSlotProps { variant?: ProductLogoSlotVariant; } /** * One level of the scope hierarchy that carries a picture — a school, a brand. */ 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. */ 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. */ interface ScopeSwitcherMenuSlotProps { scope: ShellScope; } /** The product named by the switcher trigger. */ 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; } /** * 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. */ 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. */ 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; } /** Where the primary rail lands after a secondary panel closes. */ 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. */ 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. */ 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. */ 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. */ 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. */ 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; } interface AppShellSlotsValue { slots: AppShellSlots; on: AppShellCallbacks; routes: AppShellRoutePolicy; data: Required; } 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. */ declare function AppShellSlotsProvider({ slots, on, routes, data, children, }: AppShellSlotsProviderProps): react_jsx_runtime.JSX.Element; /** Read the filled slots and callbacks. Safe with no provider above. */ declare function useAppShellSlots(): AppShellSlotsValue; export { type AppShellCallbacks, type AppShellData, type AppShellRoutePolicy, type AppShellSlots, AppShellSlotsProvider, type AppShellSlotsProviderProps, type AppShellSlotsValue, type NotificationsSlotProps, type ProductLogoSlotProps, type ProductLogoSlotVariant, type ProductMarkSlotProps, type ScopeSwitcherMenuSlotProps, type SecondaryPanelCloseTarget, type SecondaryPanelSpec, type ShellNav, type ShellProductEntry, type ShellScope, type ShellScopeParent, type SidebarDrillInPanelSpec, useAppShellSlots };