import React from 'react';
import { ViewStyle } from 'react-native';
import { UiTheme } from '@dloizides/ui-feedback';
import { DropdownVariant } from '@dloizides/ui-layout';
import { resolveAccessibleRoutes, RoleRouteTable, RoleRoute } from '@dloizides/auth-web';
/**
* Public prop types for the `@dloizides/ui-nav` config-driven navigation shell.
*
* The rendering chrome (sidebar + topbar) was byte-identical across erevna-web
* and katalogos-web; only the *item data* (which routes, how they group) differs
* per app. So this package renders a caller-supplied `NavItem[]` — labels are
* pre-localized strings and icons are render slots, keeping the package free of
* any app's i18n helper, icon set, router, or store.
*/
/** One navigation entry. Labels are already localized by the caller. */
interface NavItem {
/** Stable key + default testID. */
key: string;
/** Localized display label. */
label: string;
/** Route/path this item navigates to (passed back to `onNavigate`). */
route: string;
/** Optional testID override (defaults to `key`). */
testID?: string;
/**
* Optional leading icon. Receives the resolved foreground colour and a size,
* so the app supplies its own icon component without this package importing
* an icon set.
*/
renderIcon?: (color: string, size: number) => React.ReactNode;
/** Optional nested items — rendered as an expandable section. */
children?: NavItem[];
/**
* Opt this leaf out of prefix matching: when `true` it is active ONLY when the
* pathname equals its route exactly (never for a nested `route/child` path). A
* complementary escape hatch — the default most-specific/longest-match resolution
* (see `resolveActiveRoute`) already prevents a module-home leaf like `/accounting`
* from co-activating with `/accounting/journals`, so most consumers never need
* this. Reach for it only when a leaf must NEVER light up on any descendant path
* even when no longer sibling route exists.
*/
exact?: boolean;
}
/**
* Active-route matching for the nav shells.
*
* `isRouteActive` is the low-level per-route predicate (prefix-based, ported
* verbatim from the twin app sidebars): a route is active when the current
* pathname equals it or is nested under it (`/foo` matches `/foo` and `/foo/bar`,
* but `/` only matches `/`). With `exact`, only an exact path matches.
*
* `resolveActiveRoute` is the CROSS-ITEM resolver that fixes the sidebar
* co-activation bug: because a module-home leaf (`/accounting`) is a prefix of
* every sibling (`/accounting/journals`), the naive per-item predicate lights up
* BOTH on `/accounting/journals`. `resolveActiveRoute` instead returns the ONE
* most-specific (longest) leaf route that matches — so at most one leaf is ever
* active, the longest wins on nested paths, and the module-home leaf is active
* only on its own exact path. For a nav model with no parent/child route overlap
* (the common case) this is identical to the old behaviour.
*/
declare function isRouteActive(pathname: string, route: string, exact?: boolean): boolean;
/**
* The single active leaf route for `pathname`: the LONGEST leaf route that
* matches (most-specific wins). Returns `undefined` when no leaf matches. Marking
* a leaf active iff `item.route === resolveActiveRoute(items, pathname)` guarantees
* at most one active leaf — the whack-a-mole prefix co-activation fix.
*/
declare function resolveActiveRoute(items: NavItem[], pathname: string): string | undefined;
/**
* SidebarSearch — the persistent, DESKTOP inline search field that lives at the
* top of the {@link Sidebar} and narrows the nav list live as the operator types.
* On a wide viewport a field is cheap and one keystroke filters the rail; on a
* narrow viewport the ⌘K {@link CommandPalette} is used instead (a persistent
* field would steal scarce vertical space) — see {@link resolveSearchAffordance}.
*
* Package discipline (as the rest of ui-nav): no FM / router / icon-set imports.
* The placeholder + a11y strings are pre-localized by the caller; every colour is
* read from the `@dloizides/ui-feedback` UiProvider theme. A clear ("✕") button
* appears once there is a query, wired to `onClear`.
*/
/** Pre-localized labels/strings for the inline search field. */
interface SidebarSearchLabels {
/** Placeholder + accessible name of the input, e.g. "Search". */
placeholder: string;
/** a11y hint describing what typing does. */
hint: string;
/** Accessible name of the clear ("✕") button. */
clearLabel: string;
/** Accessible hint of the clear ("✕") button. */
clearHint: string;
}
interface SidebarSearchProps {
/** Current query value (controlled). */
value: string;
/** Called with the new query on every keystroke. */
onChangeText: (query: string) => void;
/** Clear the query (also called by the ✕ button). */
onClear: () => void;
/** Pre-localized chrome strings. */
labels: SidebarSearchLabels;
/** testID for the input (the clear button is `${testID}-clear`). */
testID: string;
}
/** The inline sidebar search field (desktop) with a clear button. */
declare const SidebarSearch: ({ value, onChangeText, onClear, labels, testID, }: SidebarSearchProps) => React.ReactElement;
/**
* Sidebar — the config-driven left navigation shell promoted from the
* byte-identical erevna-web / katalogos-web `Sidebar`. It renders a caller
* supplied `NavItem[]` (leaf + expandable), highlights the active route, and
* exposes header/footer slots for app-specific chrome (title, dark-mode toggle,
* logout, notification bell). Every colour is read from the UiProvider theme.
*
* Desktop inline search (v1.16, opt-in): pass `enableInlineSearch` + `search`
* labels and a persistent {@link SidebarSearch} field renders under the title and
* narrows the nav list live (via {@link filterNavItems}). It is OFF by default,
* so existing consumers — and the mobile drawer, which should use the ⌘K palette
* instead (see {@link resolveSearchAffordance}) — are byte-identical to before.
*/
/** Inline-search chrome strings — the field labels plus the no-match empty text. */
interface SidebarSearchConfig extends SidebarSearchLabels {
/** Shown in place of the list when the query matches nothing. */
emptyText: string;
}
/**
* The ⌘K command-palette trigger the sidebar renders as its NARROW-viewport search
* affordance (a persistent inline field would steal scarce vertical space on mobile
* — see {@link resolveSearchAffordance}). Pre-localized strings + the open handler;
* wire `onOpen` to the same handler as the ⌘K hotkey ({@link useCommandPaletteHotkey}).
*/
interface SidebarPaletteTriggerConfig {
/** Placeholder-style label, e.g. "Search". */
label: string;
/** a11y hint describing what activating it does. */
hint: string;
/** The shortcut badge text, e.g. "⌘K". */
shortcut: string;
/** Open the palette (same handler as the ⌘K hotkey). */
onOpen: () => void;
/** testID for the trigger. Defaults to `sidebar-command-palette-trigger`. */
testID?: string;
}
interface SidebarProps {
/** Nav entries — already role-filtered / grouped by the app. */
items: NavItem[];
/** Current active route/path. */
pathname: string;
/** Navigation callback — receives a `NavItem.route`. */
onNavigate: (route: string) => void;
/** Localized menu title (heading). */
title: string;
/** Localized accessibility label for the navigation landmark. */
regionLabel: string;
/** a11y hint for a leaf item, given its label. Defaults to the label. */
navigateHint?: (label: string) => string;
/** a11y hint shown when an expandable section is collapsed. */
expandHint?: string;
/** a11y hint shown when an expandable section is expanded. */
collapseHint?: string;
/** Optional chevron renderer for expandable sections. */
renderChevron?: (expanded: boolean, color: string, size: number) => React.ReactNode;
/**
* Opt into the persistent inline search field (desktop). Requires `search`
* labels; when omitted the sidebar renders exactly as before. The field shows
* ONLY at the inline affordance (viewport ≥ 768, {@link resolveSearchAffordance});
* on a narrow viewport the ⌘K `paletteTrigger` is the search affordance instead.
*/
enableInlineSearch?: boolean;
/** Pre-localized inline-search strings — required for `enableInlineSearch`. */
search?: SidebarSearchConfig;
/**
* Opt into the ⌘K {@link CommandPaletteTrigger} as the NARROW-viewport search
* affordance. Rendered ONLY at the palette affordance (viewport < 768); on a wide
* viewport it is suppressed so the inline field and the trigger are never both
* shown — they are mutually exclusive by affordance. The ⌘K hotkey stays bound at
* every width independently ({@link useCommandPaletteHotkey}).
*/
paletteTrigger?: SidebarPaletteTriggerConfig;
/** Controlled search query (optional — the sidebar owns the state when omitted). */
searchQuery?: string;
/** Called on each query change (with either controlled or internal state). */
onSearchChange?: (query: string) => void;
/** Optional header slot rendered above the items (e.g. a Home shortcut). */
header?: React.ReactNode;
/** Optional footer slot rendered after a flex spacer (dark-mode toggle, logout). */
footer?: React.ReactNode;
/** Extra container style overrides. */
containerStyle?: ViewStyle | ViewStyle[];
}
declare const Sidebar: ({ items, pathname, onNavigate, title, regionLabel, navigateHint, expandHint, collapseHint, renderChevron, enableInlineSearch, search, paletteTrigger, searchQuery, onSearchChange, header, footer, containerStyle, }: SidebarProps) => React.ReactElement;
/**
* SidebarChevron — the built-in disclosure caret for an expandable sidebar
* group. A single "›" glyph that ROTATES from pointing-right (collapsed) to
* pointing-down (expanded); on web the rotation is a smooth CSS transform tween
* (reduced-motion snaps). Used as the DEFAULT when a `Sidebar`/`NavExpandableItem`
* caller supplies no `renderChevron`, so grouped rails get an animated caret for
* free while callers keep the option to render their own icon set.
*
* Package discipline: no icon-set import (the glyph is a plain character); the
* colour is passed in from the theme by the caller.
*/
interface SidebarChevronProps {
/** Whether the section is expanded (rotates the caret down). */
expanded: boolean;
/** Resolved caret colour (from the theme). */
color: string;
/** Caret font size (px). */
size: number;
}
/** The default animated disclosure caret for expandable sidebar sections. */
declare const SidebarChevron: ({ expanded, color, size }: SidebarChevronProps) => React.ReactElement;
/**
* Pure tree-filter for the desktop inline sidebar search. Kept framework-free so
* the narrowing is unit-testable without rendering. Given a `NavItem[]` tree and
* a query, it returns a PRUNED tree: a leaf survives when every whitespace-token
* of the query appears (case-insensitively) in its label; a group survives when
* it matches itself (kept with ALL its children, so the whole section stays
* browsable) OR when any descendant survives (kept with only the surviving
* descendants, so ancestors of a match are never orphaned). An empty/whitespace
* query returns the tree unchanged — the sidebar shows everything.
*
* Requiring ALL tokens lets an operator narrow with "pay dash" the way a fuzzy
* launcher would, matching the {@link filterCommands} palette semantics so the
* inline field and the ⌘K palette rank identically.
*/
/** Split a raw query into lower-cased match tokens (empty ⇒ no filtering). */
declare function searchTokens(query: string): string[];
/**
* Prune `items` to those matching `query`. Empty query ⇒ a shallow clone of the
* input (unchanged). A matching group keeps all its children; a non-matching
* group is kept only if it has surviving descendants (and then carries only
* those). Order is preserved.
*/
declare function filterNavItems(items: readonly NavItem[], query: string): NavItem[];
/** True when the query is non-empty (filtering is active) — drives the empty state. */
declare function isFilterActive(query: string): boolean;
/**
* The desktop-vs-mobile search decision, split out as a pure rule so it is
* directly unit-testable. On a WIDE viewport a persistent inline field lives at
* the top of the sidebar (space is cheap, one keystroke narrows the rail); on a
* NARROW viewport a persistent field would steal scarce vertical space, so the
* ⌘K command palette is the search affordance instead. The ⌘K hotkey itself
* stays bound at every width (see {@link useCommandPaletteHotkey}); this rule
* only decides which VISIBLE affordance the shell renders.
*
* Expressed as a string union (not a `const enum`) to match the package's other
* public mode types ({@link RailMode}, {@link NavShellLayout}) and to stay safe
* for the Babel/Expo `isolatedModules` builds of the seven consuming portals,
* which cannot read a `declare const enum` across the package boundary.
*/
/** Which search affordance the shell shows at a given viewport. */
type SearchAffordance = 'inline' | 'palette';
/**
* Viewport width (px) at/above which the inline field shows. Mirrors the nav
* shell's {@link RAIL_FULL_BREAKPOINT} / {@link MOBILE_BREAKPOINT} so the inline
* field appears exactly where the persistent full rail does.
*/
declare const DEFAULT_SEARCH_BREAKPOINT = 768;
/**
* Resolve the affordance: `'inline'` at/above `breakpoint`, `'palette'` below.
* `breakpoint` defaults to {@link DEFAULT_SEARCH_BREAKPOINT}.
*/
declare function resolveSearchAffordance(viewport: number, breakpoint?: number): SearchAffordance;
/**
* Topbar — the config-driven top navigation bar promoted from the byte-identical
* erevna-web / katalogos-web `Topbar`. Structured slots replace the app-specific
* wiring: a `left` logo slot, an optional language toggle, a notification slot,
* an optional user block, an optional account button, and a required logout
* action. Every colour comes from the UiProvider theme.
*
* Move 1c enriches this WITHOUT breaking the promoted contract: `account` now
* accepts EITHER the legacy `TopbarAction` (today's erevna/katalogos calls) OR a
* richer `AccountState` (displayName + muted tenant line + plan badge + upgrade
* action). The `logout` action stays required, so every existing call keeps
* compiling and the logout button keeps its caller-supplied testID.
*/
interface TopbarAction {
label: string;
hint: string;
onPress: () => void;
testID?: string;
}
interface TopbarUser {
name: string;
email: string;
}
/** Plan tier shown as a small badge in the rich account header. */
interface AccountPlan {
label: string;
tone?: 'free' | 'pro' | 'enterprise';
}
/**
* Richer account model for the header (Move 1c). Supplied via `account` as an
* alternative to the legacy `TopbarAction` — the Topbar renders the display name
* (tappable when `onAccount` is set), a muted tenant line, a plan badge, and an
* `upgrade` action beside logout. No FM/router/store — everything is a
* pre-localized string or a callback.
*/
interface AccountState {
displayName: string;
tenantName?: string;
plan?: AccountPlan;
onLogout: () => void;
onAccount?: () => void;
/**
* a11y hint for the account-name button (what pressing it DOES).
*
* Without this the hint fell back to `displayName` — byte-identical to the
* accessibility LABEL, so a screen reader announced the name twice and never
* said where the button goes. The sibling `upgrade?: TopbarAction` on this very
* interface has always carried a `hint`; this closes that inconsistency. The
* `displayName` fallback is retained so existing callers are unaffected.
*/
accountHint?: string;
/** testID for the account-name button. Defaults to `NAV_TEST_IDS.accountName`. */
accountTestID?: string;
upgrade?: TopbarAction;
}
interface TopbarProps {
/** Left slot — typically the tenant logo. */
left?: React.ReactNode;
/**
* Optional language toggle. `testID` is optional but the repo standard wants
* one — before 1.10.0 this inline type declared none, so a testID was
* structurally unreachable for this button while every other Topbar action
* (which use `TopbarAction`) accepted one.
*/
language?: {
label: string;
hint: string;
onPress: () => void;
testID?: string;
};
/** Optional notification slot (e.g. a notification bell). */
notificationSlot?: React.ReactNode;
/** Optional user identity block. */
user?: TopbarUser;
/**
* Optional account slot. Legacy callers pass a `TopbarAction` (an account
* button). Move-1c callers pass an `AccountState` for the richer header.
*/
account?: TopbarAction | AccountState;
/** Required logout action (drives the stable logout button testID). */
logout: TopbarAction;
/** Extra container style overrides. */
containerStyle?: ViewStyle | ViewStyle[];
}
declare const Topbar: ({ left, language, notificationSlot, user, account, logout, containerStyle, }: TopbarProps) => React.ReactElement;
/**
* NavBar — the config-driven HORIZONTAL top navigation bar. It is the top-bar
* counterpart to `Sidebar`: it renders the SAME caller-supplied `NavItem[]`
* (already role-filtered / localized by the app) as inline links across the top
* instead of a left rail, with an optional brand slot on the left and a free-form
* right slot (auth link / user / logout). Below a configurable breakpoint it
* collapses the links behind a hamburger toggle — mirroring the v1 AML console's
* responsive `.global-nav`.
*
* Look + feel: each link is a rounded-pill target with the v1 `.gn-links a`
* affordance — muted at rest, brightening text + a subtle pill background on
* HOVER, and a solid accent pill when ACTIVE. All colours are THEME-DRIVEN from
* the `@dloizides/ui-feedback` UiProvider (rest=`textSecondary`, hover text=`text`,
* hover bg=`surfaceElevated`, active=`palette.primary`, ring=`palette.primary`).
* Hover/focus are web-only (react-native-web); native stays at the rest style.
*
* Overflow ("…" priority+ menu). Above the `collapseBelow` breakpoint the links sit
* on ONE row; when they don't all fit, a measure pass ({@link useNavOverflow} +
* {@link computeVisibleCount}) keeps the leading items that fit inline and collapses
* the rest behind a "More ▾" trigger — the shared `ModalDropdown` (see
* {@link NavOverflowMenu}) — so no item is ever cut off. BELOW `collapseBelow` the
* whole set still collapses into the responsive hamburger drawer (all items stacked),
* so the two mechanisms don't overlap: hamburger for true mobile, "…" for the
* in-between desktop case where only SOME items spill.
*
* Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM, router,
* icon set, or store imports. Labels are pre-localized strings, icons are render
* slots, and every colour is read from the theme. This is purely additive —
* `Sidebar`/`Topbar` are unchanged and the new overflow labels are optional.
*/
interface NavBarProps {
/** Nav entries — already role-filtered / grouped by the app. */
items: NavItem[];
/** Current active route/path. */
pathname: string;
/** Navigation callback — receives a `NavItem.route`. */
onNavigate: (route: string) => void;
/** Localized accessibility label for the navigation landmark. */
regionLabel: string;
/** a11y hint for a link, given its label. Defaults to the label. */
navigateHint?: (label: string) => string;
/** Left slot — typically the brand / tenant logo. */
brand?: React.ReactNode;
/** Right slot — free-form (auth link, user block, logout). */
right?: React.ReactNode;
/** Localized accessibility label for the responsive menu toggle. */
menuLabel?: string;
/** Localized accessibility hint for the responsive menu toggle. */
menuHint?: string;
/** Optional custom glyph renderer for the toggle; defaults to ☰. */
renderMenuIcon?: (color: string, open: boolean) => React.ReactNode;
/** Collapse the links behind a hamburger below this viewport width (default 760). */
collapseBelow?: number;
/**
* Localized visible + accessible label for the "More ▾" overflow trigger shown when
* the inline links don't all fit on one row (priority+ menu). Defaults to `"More"`.
*/
overflowLabel?: string;
/** Localized accessibility hint for the overflow trigger. */
overflowHint?: string;
/** Extra container style overrides. */
containerStyle?: ViewStyle | ViewStyle[];
/**
* Optional content-column cap (px). When set, the OUTER container — which carries
* the background + bottom border (`barTheme`/theme surface + any `containerStyle`,
* e.g. the AML radial-glow appbar) — stays FULL WIDTH (full-bleed), while the INNER
* row (brand + links + right) is capped to this width and centred. Omit for the
* default where the inner spans the full container. The priority+ overflow "…" fit
* measures the (now capped) inner's rendered width via `onLayout`, so it keeps
* working against the capped column, not the full-bleed container.
*/
contentMaxWidth?: number;
/**
* Optional CHROME theme override — re-provisions the `@dloizides/ui-feedback`
* theme for the bar ONLY, so the top bar can be dark while the page below keeps
* the light workspace theme (the AML v1-console "dark appbar over light content"
* pattern). The live `t` (+ `navigate`) from the surrounding provider are
* re-passed, so nav labels keep localizing. Omit for a bar that shares the page
* theme. Style-only: every colour still flows through the theme — no per-app fork.
*/
barTheme?: UiTheme;
}
/**
* `NavBar` — the horizontal top bar. When `barTheme` is supplied it re-provisions
* the `@dloizides/ui-feedback` theme around the bar (dark bar over a light page),
* re-passing the live `t` / `navigate` so labels keep localizing; otherwise it
* renders directly against the surrounding theme (byte-identical to before).
*/
declare const NavBar: ({ barTheme, ...props }: NavBarProps) => React.ReactElement;
/**
* `Nav` — ONE component that renders EITHER the horizontal top bar (`NavBar`) or the
* vertical rail (`Sidebar`) from the same `NavItem[]`, chosen by a single
* `orientation` prop. It lets an app flip its navigation between a top bar and a side
* menu without swapping components or re-plumbing props:
*
* // NavBar
* // Sidebar
*
* `orientation` defaults to `"horizontal"`. The remaining props are exactly the
* delegate's props — a discriminated union — so TypeScript enforces the right prop
* set per orientation (e.g. `title` is required only for the vertical/Sidebar side,
* `collapseBelow` / `overflowLabel` only for the horizontal/NavBar side).
*
* `Nav` is purely additive: `NavBar` and `Sidebar` remain exported and unchanged, so
* every existing consumer (erevna / katalogos / kefi / agora) is untouched. Vertical
* navigation is the existing Sidebar — stacked links that scroll vertically, so it
* needs no overflow menu; the priority+ "…" overflow is a horizontal-bar concern.
*/
/** Which axis the navigation renders on. */
type NavOrientation = 'horizontal' | 'vertical';
type NavProps = ({
orientation?: 'horizontal';
} & NavBarProps) | ({
orientation: 'vertical';
} & SidebarProps);
declare const Nav: (props: NavProps) => React.ReactElement;
/**
* AppShell content-body helpers — the page-state cards (loading / error /
* forbidden) and the precedence rule that picks what renders inside the content
* column. Split out of `AppShell` so the shell file stays focused on layout /
* responsive composition. Colours are routed through the `@dloizides/ui-feedback`
* theme, same as the rest of `@dloizides/ui-nav`.
*/
/** A titled message card (error / forbidden). Colours come from the theme. */
interface ShellMessage {
titleText: string;
messageText: string;
}
/** Page-state model — a loading spinner card, or a titled error / forbidden card. */
interface ShellState {
loading?: boolean;
error?: ShellMessage | null;
forbidden?: ShellMessage | null;
}
/**
* MobileDrawer — the below-the-breakpoint face of `AppShell`'s back-office
* `sidebar` layout. On a narrow viewport (<768px) the persistent left rail would
* starve the content column, so instead of the rail we render a hamburger toggle
* (`MenuToggle`) in the header and slide the same `Sidebar` in as an overlay
* drawer (a scrim + a left panel). The drawer closes on a scrim tap and on any
* nav-item press inside the panel (web: a bubbled click on a `role="button"` /
* link element). Desktop is untouched — AppShell never mounts this above 768px.
*
* Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM / router /
* store imports. Labels are pre-localized strings (English defaults, overridable
* by the consumer via `AppShell`'s `mobileMenu` prop); every colour is read from
* the `@dloizides/ui-feedback` UiProvider theme.
*/
/** Pre-localized labels for the drawer's hamburger + scrim. */
interface DrawerLabels {
/** Accessible name of the hamburger that opens the drawer. */
openLabel: string;
/** Accessible hint of the hamburger that opens the drawer. */
openHint: string;
/** Accessible name of the scrim (tap to close). */
closeLabel: string;
/** Accessible hint of the scrim (tap to close). */
closeHint: string;
}
/**
* Rail responsive mode — the pure (viewport → which rail face) rule behind
* `AppShell`'s optional THREE-tier layout, split out so it is directly
* unit-testable.
*
* Two-tier (default, back-compat): a persistent full rail at/above
* {@link RAIL_FULL_BREAKPOINT}, an overlay drawer below it. This is what every
* existing `AppShell` `sidebar` consumer already gets.
*
* Three-tier (opt-in — supply a `collapsedSidebar`): a persistent full rail on
* desktop, an intermediate persistent COLLAPSED (icon-only) rail on tablet, and
* the overlay drawer on a phone — the erevna / katalogos responsive scheme.
*/
/** At/above this viewport width the persistent FULL rail shows (two-tier boundary). */
declare const RAIL_FULL_BREAKPOINT = 768;
/** Default upper bound of the collapsed-rail band (full rail at/above this). */
declare const DEFAULT_COLLAPSED_RAIL_MAX = 1024;
/** Which face the rail takes at a given viewport. */
type RailMode = 'none' | 'full' | 'collapsed' | 'drawer';
/**
* Viewport band in which the intermediate COLLAPSED icon-rail shows: `[min, max)`.
* At/above `max` the full rail shows; below `min` the overlay drawer shows.
*/
interface CollapsedRailRange {
min: number;
max: number;
}
interface ResolveRailModeArgs {
viewport: number;
/** Whether a `sidebar` (full rail) node was supplied at all. */
hasSidebar: boolean;
/** Whether an intermediate `collapsedSidebar` node was supplied (enables 3-tier). */
hasCollapsed: boolean;
/** The collapsed-rail band; only consulted in 3-tier mode. */
range: CollapsedRailRange;
}
/**
* Resolve the rail mode. No sidebar ⇒ `'none'`. With a `collapsedSidebar`
* (3-tier): full at/above `range.max`, collapsed in `[range.min, range.max)`,
* drawer below `range.min`. Without one (2-tier, unchanged): full at/above
* {@link RAIL_FULL_BREAKPOINT}, drawer below.
*/
declare function resolveRailMode({ viewport, hasSidebar, hasCollapsed, range }: ResolveRailModeArgs): RailMode;
/** Content-width policy: a capped column, optionally wider past a viewport breakpoint, or full-bleed. */
type AppShellWidth = {
max: number;
wideMax?: number;
wideMinViewport?: number;
} | 'full';
/**
* Resolve the content column's max width for a given viewport. `'full'` passes
* through (no cap); an object caps at `max`, widening to `wideMax` once the
* viewport reaches `wideMinViewport`.
*/
declare function resolveContentMaxWidth(width: AppShellWidth, viewport: number): number | 'full';
/** Hook wrapper: resolves the content max width against the live window width. */
declare function useContentMaxWidth(width: AppShellWidth): number | 'full';
/**
* AppShell — the shared dashboard-chrome composition (Move 1c). It hosts, top to
* bottom: an app-supplied header (full-bleed), an optional per-app nav region, an
* optional banner, and a width-disciplined scrollable content column. It also
* folds in the two things every product page re-implements: an auth gate
* (pending spinner / redirect-when-unauthenticated) and page state cards
* (loading / error / forbidden).
*
* OVERLAY DISCIPLINE (do not regress): a TRANSITIONAL status — `gate.pending` and
* `state.loading` — renders as an absolute overlay OVER stably-mounted `children`,
* never as a replacement. Replacing the subtree unmounts whatever `children` host
* (typically a navigator/``); on remount React Navigation re-derives its
* default route, so a deep-linked sub-route silently collapses to its default on
* every reload. Only TERMINAL states replace: unauthenticated returns null (we are
* navigating away) and error/forbidden cards win inside the content column.
* Corollary for consumers: `children` mount BENEATH the pending overlay, so gate
* your data FETCHES on auth state (`enabled: isAuthenticated`), not on mount.
*
* A BACK-OFFICE variant is supported via the optional `sidebar` slot (added for
* Agora's merchant admin): supply a `` and the body below the header /
* nav / banner becomes a ROW — a persistent left rail beside the scrolling
* content column — instead of the default single vertical stack. Omit `sidebar`
* and the render tree is byte-identical to before, so every existing consumer
* (erevna / katalogos / kefi) is untouched.
*
* Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM, router,
* or store imports. The app passes wired elements, pre-localized strings, and
* callbacks; every colour is routed through the `@dloizides/ui-feedback` theme.
*/
interface AppShellProps {
/** App-supplied wired header (Topbar / AppHeader), rendered full-bleed. */
header: React.ReactNode;
/** Per-app nav region (kefi coral pills / erevna sidebar+drawer). */
nav?: React.ReactNode;
/**
* Optional persistent LEFT RAIL (a ``), for back-office surfaces. When
* supplied, the region below header/nav/banner lays out as a row: the rail on the
* left, the scrolling content column on the right. Omit for the default vertical
* stack — the tree is then byte-identical to the pre-`sidebar` AppShell.
*
* Responsive: at/under ~768px the persistent rail is REPLACED by a hamburger (in
* the header) that opens the same `sidebar` as an overlay drawer, so the content
* takes the full width. Desktop (≥768px) is unchanged. No effect without `sidebar`.
*/
sidebar?: React.ReactNode;
/**
* Optional intermediate COLLAPSED (icon-only) rail — opts the `sidebar` layout
* into a THREE-tier responsive scheme (erevna / katalogos): the full `sidebar`
* on desktop, this collapsed rail on a tablet-width band, and the overlay drawer
* (hosting the full `sidebar`) on a phone. Omit it and the layout stays the
* unchanged two-tier scheme (full rail ≥768px, drawer below). No effect without
* `sidebar`. Exposed as `${testID}${APP_SHELL_SUFFIX.collapsedSidebar}`.
*/
collapsedSidebar?: React.ReactNode;
/**
* The viewport band `[min, max)` in which `collapsedSidebar` shows: at/above
* `max` the full rail shows, below `min` the drawer shows. Defaults to
* `{ min: 768, max: 1024 }`. Only consulted when `collapsedSidebar` is supplied.
*/
collapsedRailRange?: CollapsedRailRange;
/**
* Pre-localized labels for the mobile nav drawer (only used in the `sidebar`
* layout, below the breakpoint): the hamburger's open label/hint and the scrim's
* close label/hint. Optional — English defaults apply. NO i18n in this package.
*/
mobileMenu?: Partial;
/** Banner slot (e.g. verification-pending). */
banner?: React.ReactNode;
/** Content-width policy. Defaults to full-bleed. */
width?: AppShellWidth;
/** Content padding inside the scroll column. Defaults to 24. */
contentPadding?: number;
/**
* Whether the nav strip aligns with the width-capped content column
* (`'content'`) or runs full-bleed (`'full'`, the default). Only has an effect
* when `width` caps the content. The header always renders full-bleed.
*/
chromeAlignment?: 'full' | 'content';
/** Auth gate — spinner while pending, `onRedirect` when unauthenticated. */
gate?: {
pending: boolean;
authenticated: boolean;
onRedirect: () => void;
};
/** Page state — loading spinner card, or a titled error / forbidden card. */
state?: ShellState;
/** Root testID; the content region is exposed as `${testID}-content`. */
testID: string;
children: React.ReactNode;
}
declare const AppShell: ({ header, nav, sidebar, collapsedSidebar, collapsedRailRange, mobileMenu, banner, width, contentPadding, chromeAlignment, gate, state, testID, children, }: AppShellProps) => React.ReactElement | null;
/**
* NavShell — the ONE first-class navigation shell that renders a **top bar**, a
* **side rail**, or **BOTH together**, chosen by a single `layout` prop
* (`'top' | 'side' | 'both'`). It is the shell-level answer to `Nav`'s
* single-axis question: where `Nav` returns one element (a `NavBar` OR a
* `Sidebar`), `NavShell` owns the whole page composition — it wires a top bar
* into `AppShell`'s `header` slot and a side rail into `AppShell`'s `sidebar`
* slot, and inherits every `AppShell` capability (auth gate, loading / error /
* forbidden state cards, width discipline, banner, the responsive
* hamburger + overlay drawer for the rail).
*
* layout="top" → a horizontal top bar only (brand + links + overflow "…" + right).
* layout="side" → a header + a persistent left rail of sections (the erevna / katalogos shape).
* layout="both" → a top bar (brand / global links + overflow "…" / user) AND a side rail
* (sections), together — the AML-console shape, now first-class.
*
* "both" is inherently a shell composition (a top region + a side region + a
* content column), which a single-element `Nav` cannot own; `NavShell` is that
* composition, so no app hand-wires `Topbar` + `Sidebar` + a `ScrollView` row
* again. Customizability is STYLE-only: every colour flows through the
* `@dloizides/ui-feedback` UiProvider theme, and brand / user / language /
* notification / section content are render slots. Structure + behaviour are
* shared.
*
* Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM, router,
* icon-set, or store imports. Labels are pre-localized strings, icons are render
* slots, and every colour is read from the theme.
*/
/** Which regions the shell renders: a top bar, a side rail, or both together. */
type NavShellLayout = 'top' | 'side' | 'both';
/**
* Config for the TOP bar (rendered as a `NavBar` in `AppShell`'s header slot).
* Used when `layout` is `'top'` or `'both'`. Every field is optional except the
* items — an empty `items` array renders a bar with just the brand + right slots
* (a plain top bar with no nav links), which is the natural `'side'`-mode header.
*/
interface NavShellTopBar {
/** Top-bar nav entries (global links). Empty ⇒ brand + right only. */
items: NavItem[];
/** Left slot — typically the tenant brand / logo. */
brand?: React.ReactNode;
/** Right slot — free-form (language toggle, notification bell, user chip, logout). */
right?: React.ReactNode;
/** Collapse the links behind a hamburger below this viewport width (default 760). */
collapseBelow?: number;
/** Localized visible + accessible label for the "More ▾" overflow trigger (default "More"). */
overflowLabel?: string;
/** Localized accessibility hint for the overflow trigger. */
overflowHint?: string;
/** Localized accessibility label for the responsive menu toggle. */
menuLabel?: string;
/** Localized accessibility hint for the responsive menu toggle. */
menuHint?: string;
/** Optional custom glyph renderer for the responsive toggle; defaults to ☰. */
renderMenuIcon?: (color: string, open: boolean) => React.ReactNode;
/**
* Extra container style overrides (e.g. an app-specific appbar background —
* including a web `background`/`backgroundImage` gradient cast through
* `ViewStyle`, the AML radial-glow appbar). Style-only.
*/
containerStyle?: ViewStyle | ViewStyle[];
/**
* Optional content-column cap (px) for the top bar. When set, the bar's
* background + bottom border (from `containerStyle` / `barTheme`) stay FULL WIDTH
* (full-bleed) while the inner brand + links + right row is capped to this width
* and centred — the AML v1-console appbar shape. Forwarded to `NavBar.contentMaxWidth`.
*/
contentMaxWidth?: number;
/**
* Optional CHROME theme override for the top bar only (dark bar over a light
* page). Forwarded to `NavBar.barTheme`. Style-only — see `NavBar`.
*/
barTheme?: UiTheme;
}
/**
* Config for the intermediate COLLAPSED (icon-only) rail — opts the side layout
* into a THREE-tier responsive scheme (full rail → collapsed rail → drawer). When
* present, NavShell builds a `` and hands it to `AppShell` for the
* tablet band; omit it and the layout stays two-tier.
*/
interface NavShellCollapsedRail {
/** Items for the icon rail. Defaults to the full rail's `items`. */
items?: NavItem[];
/** Optional header slot (e.g. a home shortcut). */
header?: React.ReactNode;
/** Optional footer slot (dark-mode toggle, logout). */
footer?: React.ReactNode;
/** The `[min, max)` viewport band for the collapsed rail (default `{ min: 768, max: 1024 }`). */
range?: CollapsedRailRange;
}
/**
* Config for the SIDE rail (rendered as a `Sidebar` in `AppShell`'s sidebar
* slot). Used when `layout` is `'side'` or `'both'`.
*/
interface NavShellSideRail {
/** Side-rail nav entries (sections — leaf + expandable). */
items: NavItem[];
/** Localized menu title (heading). */
title: string;
/** Optional header slot rendered above the items (e.g. a Home shortcut). */
header?: React.ReactNode;
/** Optional footer slot rendered after a flex spacer (dark-mode toggle, logout). */
footer?: React.ReactNode;
/** a11y hint shown when an expandable section is collapsed. */
expandHint?: string;
/** a11y hint shown when an expandable section is expanded. */
collapseHint?: string;
/** Optional chevron renderer for expandable sections. */
renderChevron?: (expanded: boolean, color: string, size: number) => React.ReactNode;
/** Extra container style overrides. */
containerStyle?: ViewStyle | ViewStyle[];
/** Optional collapsed (icon-only) rail config → the intermediate tablet tier. */
collapsed?: NavShellCollapsedRail;
/**
* Opt the inner `Sidebar` into its persistent desktop inline-search field.
* Requires `search` labels; when omitted the rail renders exactly as before.
* The ⌘K palette remains the search affordance on narrow viewports
* (see {@link resolveSearchAffordance}). Forwarded to `Sidebar.enableInlineSearch`.
*/
enableInlineSearch?: boolean;
/** Pre-localized inline-search strings — required for `enableInlineSearch`. Forwarded to `Sidebar.search`. */
search?: SidebarSearchConfig;
/**
* Opt the inner `Sidebar` into the ⌘K palette trigger as its narrow-viewport
* search affordance. Mutually exclusive with the inline field by viewport width
* (never both shown). Forwarded to `Sidebar.paletteTrigger`.
*/
paletteTrigger?: SidebarPaletteTriggerConfig;
/** Controlled search query (optional — the Sidebar owns the state when omitted). Forwarded to `Sidebar.searchQuery`. */
searchQuery?: string;
/** Called on each query change (controlled or internal). Forwarded to `Sidebar.onSearchChange`. */
onSearchChange?: (query: string) => void;
}
/**
* `AppShell` props NavShell forwards straight through. It composes the `header`
* and `sidebar` (+ `collapsedSidebar`) regions itself, so those are handled here;
* everything else — including the optional `nav` strip (e.g. a `PillNav`), the
* auth gate, state cards, banner, and width policy — passes through untouched.
*/
type ForwardedShellProps = Omit;
interface NavShellProps extends ForwardedShellProps {
/** Which regions to render: a top bar, a side rail, or both. */
layout: NavShellLayout;
/** Current active route/path — drives active highlighting on both regions. */
pathname: string;
/** Navigation callback — receives a `NavItem.route`. */
onNavigate: (route: string) => void;
/** Localized accessibility label for the navigation landmark(s). */
regionLabel: string;
/** a11y hint for a nav item, given its label. Defaults to the label. */
navigateHint?: (label: string) => string;
/**
* Top-bar config → a `NavBar` in the header slot. Required for `'top'` /
* `'both'`; **ignored for `'side'`** — `layout` decides which regions exist, so
* a `'side'` app that also wants a bar asks for `layout="both"`. A `'side'` app
* that wants its OWN header node uses the `header` escape hatch instead.
*/
topBar?: NavShellTopBar;
/**
* Escape hatch: a ready-made header node (e.g. an app-composed ``).
* When supplied it REPLACES the `topBar`-built `NavBar` — used by `'side'`
* apps that want the structured `Topbar` (language / notification / user
* slots) instead of a `NavBar` header.
*/
header?: React.ReactNode;
/**
* Side-rail config → a `Sidebar` in the sidebar slot. Required for `'side'` /
* `'both'`; ignored for `'top'`.
*/
sideRail?: NavShellSideRail;
children: React.ReactNode;
}
declare const NavShell: ({ layout, pathname, onNavigate, regionLabel, navigateHint, topBar, header, sideRail, children, collapsedSidebar: collapsedSidebarProp, collapsedRailRange: collapsedRailRangeProp, ...shellProps }: NavShellProps) => React.ReactElement | null;
/**
* CollapsedRail — the icon-only vertical rail for the intermediate (tablet) tier
* of `AppShell`'s three-tier responsive layout (the erevna / katalogos collapsed
* rail). It renders the SAME caller-supplied `NavItem[]` as `Sidebar`, but as a
* narrow strip of icon buttons: each item shows its `renderIcon` (or a first-letter
* glyph fallback), labelled for assistive tech, with the active route highlighted.
* Optional `header` / `footer` slots carry app chrome (a home shortcut, a
* dark-mode toggle, logout) top and bottom.
*
* A collapsed rail cannot expand a section inline, so pressing ANY item — leaf or
* parent — navigates to its `route`; an app that wants a parent to open the full
* drawer instead can special-case that in its `onNavigate`.
*
* Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM, router,
* icon-set, or store imports. Labels are pre-localized strings, icons are render
* slots, and every colour is read from the `@dloizides/ui-feedback` theme.
*/
interface CollapsedRailProps {
/** Nav entries — the same role-filtered `NavItem[]` the full `Sidebar` renders. */
items: NavItem[];
/** Current active route/path. */
pathname: string;
/** Navigation callback — receives a `NavItem.route`. */
onNavigate: (route: string) => void;
/** Localized accessibility label for the navigation landmark. */
regionLabel: string;
/** a11y hint for an item, given its label. Defaults to the label. */
navigateHint?: (label: string) => string;
/** Optional header slot rendered above the items (e.g. a home shortcut). */
header?: React.ReactNode;
/** Optional footer slot rendered after a flex spacer (dark-mode toggle, logout). */
footer?: React.ReactNode;
/** Extra container style overrides. */
containerStyle?: ViewStyle | ViewStyle[];
}
declare const CollapsedRail: ({ items, pathname, onNavigate, regionLabel, navigateHint, header, footer, containerStyle, }: CollapsedRailProps) => React.ReactElement;
/**
* PillNav — a horizontal row of fully-rounded "pills", the cross-navigation
* switcher promoted from kefi's coral dashboard nav. It renders the same
* caller-supplied `NavItem[]` as the other nav components, as pills that wrap,
* with the active route wearing the accent pill.
*
* It also folds in kefi's **visibility rule**: the whole nav hides (renders
* `null`) unless there are at least `minItems` entries — a single destination is
* not worth a switcher. Default `minItems` is 1 (always show when there is at
* least one item); kefi passes `2`.
*
* "Coral pills" is STYLE, not structure: the accent colour is the theme
* `palette.primary` (kefi's coral), the resting pill is `surfaceElevated`, and
* `containerStyle` / theme tokens carry any per-app tweak — no layout fork.
*
* Contract discipline (same as the rest of `@dloizides/ui-nav`): NO FM, router,
* icon-set, or store imports. Labels are pre-localized strings, icons are render
* slots, and every colour is read from the `@dloizides/ui-feedback` theme.
*/
interface PillNavProps {
/** Nav entries — the cross-navigation destinations. */
items: NavItem[];
/** Current active route/path. */
pathname: string;
/** Navigation callback — receives a `NavItem.route`. */
onNavigate: (route: string) => void;
/** Localized accessibility label for the navigation landmark. */
regionLabel: string;
/** a11y hint for a pill, given its label. Defaults to the label. */
navigateHint?: (label: string) => string;
/**
* Hide the whole switcher unless there are at least this many items. Default 1
* (show when non-empty); kefi passes 2 (a lone dashboard needs no switcher).
*/
minItems?: number;
/** Extra container style overrides. */
containerStyle?: ViewStyle | ViewStyle[];
}
declare const PillNav: ({ items, pathname, onNavigate, regionLabel, navigateHint, minItems, containerStyle, }: PillNavProps) => React.ReactElement | null;
/**
* DarkModeControl — the shared colour-scheme control promoted from erevna /
* katalogos (their Light / Dark / System toggle + the icon-only "cycle" button).
* It is a CONTROLLED component: the app owns the current `value` and applies the
* chosen scheme; this component only renders the choices and reports changes.
*
* Two variants encode the two behaviours the apps shipped:
* - `"segmented"` (default): a row of segments (Light / Dark / System), the
* active one highlighted — the desktop control.
* - `"cycle"`: a single icon+label button that advances to the NEXT option on
* each press (wrapping) — the compact mobile control.
*
* The options are pre-localized (`label` + `hint`) and value-agnostic (a string),
* so the app maps its own `DarkModePreference` enum to strings without this
* package importing it. Every colour flows through the `@dloizides/ui-feedback`
* theme; STYLE is the only per-app variation.
*/
/** Which control shape to render. */
type DarkModeVariant = 'segmented' | 'cycle';
/** One pre-localized colour-scheme choice (e.g. Light / Dark / System). */
interface DarkModeOption {
/** App-defined value (its own preference enum mapped to a string). */
value: string;
/** Localized visible + accessible label. */
label: string;
/** Localized accessibility hint. */
hint: string;
/** Optional leading icon. Receives the resolved colour + size. */
renderIcon?: (color: string, size: number) => React.ReactNode;
}
interface DarkModeControlProps {
/** Current selected value — must match one of `options[].value`. */
value: string;
/** The pre-localized choices (typically Light / Dark / System). */
options: DarkModeOption[];
/** Change callback — receives the newly-selected value. */
onChange: (value: string) => void;
/** Localized accessibility label for the control group. */
regionLabel: string;
/** Control shape — a segmented row (default) or a single cycling button. */
variant?: DarkModeVariant;
/** testID for the control; segments are `${testID}-${value}`. */
testID?: string;
/** Extra container style overrides. */
containerStyle?: ViewStyle | ViewStyle[];
}
declare const DarkModeControl: ({ value, options, onChange, regionLabel, variant, testID, containerStyle, }: DarkModeControlProps) => React.ReactElement | null;
/**
* NavBarLink — a SINGLE horizontal `NavBar` link, extracted so each link owns its
* own hover/focus state (React hooks can't be per-item inside a map otherwise).
*
* It renders the v1 `.gn-links a` affordance, THEME-DRIVEN (no hard-coded colours
* beyond white ink on the accent pill):
* - rest → muted text (`colors.rest`), no background
* - hover → text brightens to full contrast (`colors.hoverText`) + a subtle
* rounded-pill background (`colors.hoverBg`, e.g. v1 `#16223a`)
* - active → solid accent pill (`colors.activeBg`) with white ink + aria-current
* - focus → a themed keyboard focus ring (`colors.ring`)
* Hover/focus are WEB-only (react-native-web fires `onHoverIn/Out`); on native
* those handlers never fire, so the link stays at its rest style.
*/
/** Theme-resolved colours a link needs, computed once by `NavBar`. */
interface NavBarLinkColors {
/** Muted link text at rest. */
rest: string;
/** Brightened link text on hover. */
hoverText: string;
/** Subtle hover-pill background. */
hoverBg: string;
/** Active-pill background (accent). */
activeBg: string;
/** Focus-ring colour. */
ring: string;
}
/**
* NavOverflowMenu — the "More ▾" trigger + dropdown that holds the `NavBar` items
* which don't fit inline (the priority+ overflow set).
*
* It REUSES the shared `@dloizides/ui-layout` `ModalDropdown` — the exact component
* every other menu in the app uses — so the overflow menu is portalled, keyboard
* navigable, dismissed on outside-click / Escape, and opens on click, looking
* identical to the rest of the app's menus. The overflow entries are the same
* `NavItem`s (label + route + active state); selecting one navigates via the same
* `onNavigate` the inline links use.
*
* The trigger is a rounded pill matching a `NavBarLink`: muted at rest, and — when
* the ACTIVE route lives in the overflow set — it wears the accent pill so the
* active item stays visibly reachable behind the "…". The dropdown marks that same
* item selected (its `value` is the active overflow route). a11y: `ModalDropdown`
* owns the trigger's `role="button"` + `aria-expanded` + caller-supplied label/hint,
* and gives the options keyboard navigation.
*
* Contract discipline (as the rest of `@dloizides/ui-nav`): no FM / router / icon
* set / store; the label + hint are pre-localized strings from the caller and every
* colour comes from the theme (via the `NavBarLinkColors` the `NavBar` computed).
*/
interface NavOverflowMenuProps {
/** The items that spilled out of the inline row. */
items: NavItem[];
/** Current active route/path — decides the active-pill + selected option. */
pathname: string;
/** Navigation callback — receives the chosen item's `route`. */
onNavigate: (route: string) => void;
/** Theme-resolved link colours (same set the inline links use). */
colors: NavBarLinkColors;
/** Localized visible + accessible label for the trigger (e.g. "More"). */
label: string;
/** Localized accessibility hint for the trigger. */
hint: string;
/** testID for the trigger (options default to `` `${testID}-option-${route}` ``). */
testID: string;
/** Force a dropdown variant (default: responsive — inline menu on desktop). */
variant?: DropdownVariant;
}
declare const NavOverflowMenu: ({ items, pathname, onNavigate, colors, label, hint, testID, variant, }: NavOverflowMenuProps) => React.ReactElement;
/**
* Default testIDs for `@dloizides/ui-nav`. Kept as a small central map (mirrors
* `@dloizides/ui-layout`'s `LAYOUT_TEST_IDS`) so the app-agnostic chrome exposes
* stable Playwright selectors. The existing Topbar logout/account buttons keep
* their *caller-supplied* testIDs (via `TopbarAction.testID`) — these constants
* only name the NEW account-state bits and the AppShell region suffixes.
*/
declare const NAV_TEST_IDS: {
/** displayName line in the rich account header (tappable when `onAccount` set). */
readonly accountName: "topbar-account-name";
/** muted tenant-name line under the displayName. */
readonly accountTenant: "topbar-account-tenant";
/** small plan badge (free / pro / enterprise). */
readonly accountPlan: "topbar-account-plan";
/** default testID for the rich header's upgrade action (overridable per-action). */
readonly accountUpgrade: "topbar-account-upgrade";
/** responsive hamburger toggle in the horizontal NavBar (shown below the breakpoint). */
readonly navBarToggle: "navbar-toggle";
/** links container in the horizontal NavBar. */
readonly navBarLinks: "navbar-links";
/** the "More ▾" priority+ overflow trigger in the horizontal NavBar (shown when items spill). */
readonly navBarOverflow: "navbar-overflow";
};
/** Suffixes appended to an `AppShell`'s required `testID` to name its regions. */
declare const APP_SHELL_SUFFIX: {
readonly content: "-content";
readonly header: "-header";
readonly nav: "-nav";
/** The persistent left rail of the back-office layout (only when `sidebar` is supplied). */
readonly sidebar: "-sidebar";
/** The intermediate persistent COLLAPSED (icon-only) rail (3-tier layout, tablet band). */
readonly collapsedSidebar: "-collapsed-sidebar";
/** Mobile hamburger that opens the nav drawer (back-office layout, below the breakpoint). */
readonly menuToggle: "-menu-toggle";
/** The mobile nav drawer panel (holds the `sidebar` slot when opened on a narrow viewport). */
readonly drawer: "-drawer";
/** The mobile nav drawer scrim (tap to close). */
readonly scrim: "-drawer-scrim";
readonly banner: "-banner";
readonly pending: "-pending";
readonly loading: "-loading";
readonly error: "-error";
readonly forbidden: "-forbidden";
};
/**
* NavExpandableItem — a single sidebar entry that is either a leaf (navigates)
* or an expandable section (toggles nested children). Ported from the twin
* erevna/katalogos `Sidebar/NavExpandableItem`, made config-driven: labels are
* pre-localized strings, icons are render slots, colours come from `useUi`.
*
* Docs-sidebar polish (v1.16): each row carries a PRIMARY-TINTED overlay whose
* opacity eases 0 → hover → active (a smooth ~160ms cross-fade on web, gated by
* reduced motion), over the transparent left-accent gutter every leaf reserves —
* so the active leaf reads as a tinted pill with a coloured left bar, and any row
* lights up subtly on hover, matching the aml-screening docs rail. Expandable
* groups get a built-in rotating {@link SidebarChevron} when the caller supplies
* no `renderChevron`.
*/
interface NavExpandableItemProps {
item: NavItem;
pathname: string;
onNavigate: (route: string) => void;
/**
* The single winning active leaf route for the whole nav model, resolved once by
* the parent via `resolveActiveRoute` (most-specific/longest match wins). A leaf
* is active iff `item.route === activeRoute`, so at most one leaf ever carries
* `aria-current="page"` — the fix for `/accounting` co-activating with
* `/accounting/journals`. When omitted (standalone use) each leaf falls back to
* the per-item prefix predicate, preserving the pre-1.18 behaviour.
*/
activeRoute?: string;
/** a11y hint for a leaf item, given its label. */
navigateHint: (label: string) => string;
/** a11y hint shown when the section is collapsed (press expands). */
expandHint: string;
/** a11y hint shown when the section is expanded (press collapses). */
collapseHint: string;
/**
* Optional chevron icon renderer for expandable sections. When omitted, a
* built-in rotating {@link SidebarChevron} is drawn — so grouped rails animate
* their disclosure caret without the caller supplying an icon set.
*/
renderChevron?: (expanded: boolean, color: string, size: number) => React.ReactNode;
depth?: number;
}
declare const NavExpandableItem: ({ item, pathname, onNavigate, navigateHint, expandHint, collapseHint, renderChevron, activeRoute, depth, }: NavExpandableItemProps) => React.ReactElement;
/**
* Pure matching logic for the {@link CommandPalette}. Kept framework-free so the
* ranking/filtering is unit-testable without rendering: a command matches when
* every whitespace-separated token of the query appears (case-insensitively) in
* its haystack (label + hint + keywords). Requiring ALL tokens lets an operator
* narrow with "pay dash" the way a fuzzy launcher would, without a fuzzy lib.
*/
/** One actionable destination/command shown in the palette. */
interface CommandItem {
/** Stable key + default testID. */
key: string;
/** Optional testID override (defaults to `key`). */
testID?: string;
/** Localized display label (what the operator reads). */
label: string;
/** Optional localized secondary line (e.g. the module it lives in). */
hint?: string;
/** Optional extra search terms not shown but matched (synonyms, route words). */
keywords?: readonly string[];
/** Invoked when the item is chosen (the consumer navigates / runs the action). */
onSelect: () => void;
/** Optional leading icon slot, same contract as `NavItem.renderIcon`. */
renderIcon?: (color: string, size: number) => React.ReactNode;
}
/**
* Filter `items` to those matching `query`. An empty/whitespace query returns
* the list unchanged (the palette opens showing everything). Order is preserved
* — the caller supplies items in a meaningful order (nav order).
*/
declare function filterCommands(items: readonly CommandItem[], query: string): CommandItem[];
/**
* CommandPalette — the shared ⌘K launcher for `@dloizides/ui-nav`. An overlay
* (scrim + centred panel) with a search input over a keyboard-navigable list of
* {@link CommandItem}s — jump to any nav destination (or run any action) without
* hunting the rail. Web-first (RN-web), but degrades safely: when `open` is
* false it renders nothing.
*
* Package discipline (as the rest of ui-nav): NO FM / router / store / icon-set
* imports. Labels are pre-localized strings; icons are render slots; every colour
* comes from the `@dloizides/ui-feedback` UiProvider theme. The consumer owns the
* `open` state (pair with {@link useCommandPaletteHotkey}) and each item's
* `onSelect` (navigate / run) — the palette just closes after a choice.
*/
/** Pre-localized labels for the palette chrome. */
interface CommandPaletteLabels {
/** Search input placeholder. */
placeholder: string;
/** Accessible name of the scrim (tap to close). */
closeLabel: string;
/** Accessible hint of the scrim (tap to close). */
closeHint: string;
/** Shown when the query matches nothing. */
emptyText: string;
/** Accessible label for the palette dialog landmark. */
regionLabel: string;
}
interface CommandPaletteProps {
/** Whether the palette overlay is shown. */
open: boolean;
/** The commands to search over, in a meaningful (nav) order. */
items: readonly CommandItem[];
/** Close the palette (scrim tap / Escape / after a selection). */
onClose: () => void;
/** Pre-localized chrome labels. */
labels: CommandPaletteLabels;
/** testID for the panel (defaults to `command-palette`). */
testID?: string;
}
/** The overlay palette. Renders null when closed. */
declare const CommandPalette: ({ open, items, onClose, labels, testID }: CommandPaletteProps) => React.ReactElement | null;
/**
* CommandPaletteTrigger — the VISIBLE, discoverable affordance that opens the
* {@link CommandPalette}. A ⌘K shortcut alone is invisible; a docs-style search
* pill (muted placeholder on the left, a keyboard-shortcut badge on the right)
* tells operators the palette exists and is one click away. Place it in a shell
* header / sidebar; wire `onPress` to the same open handler as the ⌘K hotkey.
*
* Self-contained on purpose: the shared icon set has no magnifier, so this uses
* text + a shortcut badge (the recognizable "Search … ⌘K" pattern) rather than a
* render-slot icon — one less thing every consumer must supply. Colours come from
* the `@dloizides/ui-feedback` UiProvider theme; the label/shortcut are
* pre-localized strings (package discipline: no FM/router imports).
*/
interface CommandPaletteTriggerProps {
/** Placeholder-style label, e.g. "Search". */
label: string;
/** a11y hint describing what activating it does. */
hint: string;
/** The shortcut badge text, e.g. "⌘K" (already chosen per-platform by the caller). */
shortcut: string;
/** Open the palette (same handler as the ⌘K hotkey). */
onPress: () => void;
/** testID for the trigger. */
testID: string;
}
/** The visible "Search … ⌘K" pill that opens the command palette. */
declare const CommandPaletteTrigger: ({ label, hint, shortcut, onPress, testID, }: CommandPaletteTriggerProps) => React.ReactElement;
/**
* Call `onOpen` when the operator presses ⌘K (macOS) or Ctrl-K (Windows/Linux),
* anywhere in the document. `enabled=false` unbinds (e.g. when the shell has no
* palette configured), so the shortcut never fires without a handler behind it.
*/
declare function useCommandPaletteHotkey(onOpen: () => void, enabled?: boolean): void;
/**
* Role-gated nav helpers — build a `NavItem[]` from a user's roles, reusing the
* existing `resolveAccessibleRoutes` from `@dloizides/auth-web` (the same helper
* the multi-role dashboard nav uses) rather than reimplementing role filtering.
*
* The app supplies its ordered `RoleRouteTable` (role → route + optional
* `labelKey`) and a `translate` fn (its `FM`); this returns the ordered set of
* `NavItem`s the user's roles unlock — most privileged first, empty when none.
*/
/** The user shape `resolveAccessibleRoutes` accepts (derived — no extra dep). */
type NavUser = Parameters[0];
/** Map already-resolved role routes to nav items, localizing each `labelKey`. */
declare function roleRoutesToNavItems(routes: RoleRoute[], translate: (key: string) => string): NavItem[];
/**
* Resolve the nav items a user can reach, in table (priority) order. Wraps
* `resolveAccessibleRoutes` so role gating lives in one place.
*/
declare function accessibleNavItems(user: NavUser, table: RoleRouteTable, translate: (key: string) => string): NavItem[];
declare const ACTIVE_BORDER_RADIUS = 4;
/** Corner radius (px) of a sidebar row's hover/active pill — the docs-rail softness. */
declare const NAV_ROW_RADIUS = 8;
/**
* Opacity of the primary-tinted overlay behind the ACTIVE sidebar leaf — a soft
* brand pill (the aml-screening docs `.active` fill, flattened from its gradient).
*/
declare const ACTIVE_TINT_OPACITY = 0.14;
/** Opacity of the same overlay on HOVER — the subtle docs-rail hover wash. */
declare const HOVER_TINT_OPACITY = 0.06;
/**
* Horizontal gap (px) between adjacent inline items in the `NavBar` links row.
* Single source of truth: it is both the row's `columnGap` AND the gap the
* priority+ overflow fit ({@link computeVisibleCount}) reasons about, so the two
* can never drift apart.
*/
declare const NAV_LINK_GAP = 4;
declare const navStyles: {
sidebarContainer: {
width: number;
paddingTop: number;
paddingHorizontal: number;
borderRightWidth: number;
height: "100%";
};
sidebarTitle: {
fontWeight: "700";
marginBottom: number;
};
sidebarItem: {
paddingVertical: number;
paddingHorizontal: number;
borderRadius: number;
};
sidebarItemText: {
fontSize: number;
};
sidebarSpacer: {
flex: number;
};
sidebarTriggerSlot: {
marginBottom: number;
};
sidebarEmpty: {
fontSize: number;
paddingVertical: number;
paddingHorizontal: number;
};
topbarContainer: {
height: number;
paddingHorizontal: number;
borderBottomWidth: number;
flexDirection: "row";
alignItems: "center";
justifyContent: "space-between";
};
topbarLeft: {
flex: number;
};
topbarRight: {
flexDirection: "row";
alignItems: "center";
};
topbarRowItem: {
marginHorizontal: number;
alignItems: "center";
justifyContent: "center";
minHeight: number;
minWidth: number;
};
topbarLabel: {
fontSize: number;
};
userBlock: {
marginHorizontal: number;
alignItems: "flex-end";
};
userName: {
fontWeight: "600";
};
accountNameBtn: {
minHeight: number;
minWidth: number;
justifyContent: "center";
alignItems: "flex-end";
};
userEmail: {
fontSize: number;
};
accountBtn: {
marginLeft: number;
paddingHorizontal: number;
paddingVertical: number;
borderRadius: number;
minHeight: number;
minWidth: number;
justifyContent: "center";
};
accountText: {
fontWeight: "600";
};
accountTenant: {
fontSize: number;
};
planBadge: {
marginTop: number;
paddingHorizontal: number;
paddingVertical: number;
borderRadius: number;
alignSelf: "flex-end";
};
planBadgeText: {
fontSize: number;
fontWeight: "700";
};
};
declare const pillNavStyles: {
container: {
flexDirection: "row";
flexWrap: "wrap";
alignItems: "center";
columnGap: number;
rowGap: number;
paddingVertical: number;
};
pill: {
paddingHorizontal: number;
paddingVertical: number;
borderRadius: number;
minHeight: number;
flexDirection: "row";
alignItems: "center";
columnGap: number;
justifyContent: "center";
};
pillText: {
fontSize: number;
fontWeight: "600";
};
};
declare const darkModeStyles: {
segmentRow: {
flexDirection: "row";
alignItems: "center";
borderWidth: number;
borderRadius: number;
overflow: "hidden";
alignSelf: "flex-start";
};
segment: {
paddingHorizontal: number;
paddingVertical: number;
minHeight: number;
minWidth: number;
flexDirection: "row";
alignItems: "center";
columnGap: number;
justifyContent: "center";
};
segmentText: {
fontSize: number;
fontWeight: "600";
};
cycle: {
paddingHorizontal: number;
paddingVertical: number;
minHeight: number;
minWidth: number;
borderRadius: number;
flexDirection: "row";
alignItems: "center";
columnGap: number;
justifyContent: "center";
};
cycleText: {
fontSize: number;
fontWeight: "600";
};
};
declare const collapsedRailStyles: {
container: {
width: number;
paddingTop: number;
paddingHorizontal: number;
borderRightWidth: number;
height: "100%";
alignItems: "center";
};
item: {
width: number;
height: number;
marginVertical: number;
borderRadius: number;
alignItems: "center";
justifyContent: "center";
};
glyph: {
fontSize: number;
fontWeight: "700";
};
spacer: {
flex: number;
};
slot: {
alignItems: "center";
};
};
declare const expandableStyles: {
childItem: {
position: "relative";
overflow: "hidden";
borderRadius: number;
flexDirection: "row";
alignItems: "center";
paddingVertical: number;
minHeight: number;
borderLeftWidth: number;
borderLeftColor: string;
};
childItemText: {
fontSize: number;
};
childItemTextWithIcon: {
fontSize: number;
marginLeft: number;
};
chevron: {
marginLeft: "auto";
};
childrenContainer: {
overflow: "hidden";
marginBottom: number;
};
tintOverlay: {
position: "absolute";
top: number;
left: number;
right: number;
bottom: number;
borderRadius: number;
pointerEvents: "none";
};
header: {
position: "relative";
overflow: "hidden";
borderRadius: number;
flexDirection: "row";
alignItems: "center";
paddingVertical: number;
minHeight: number;
};
sectionHeader: {
marginTop: number;
};
headerText: {
fontSize: number;
fontWeight: "600";
marginLeft: number;
};
sectionHeaderText: {
fontSize: number;
fontWeight: "700";
marginLeft: number;
textTransform: "uppercase";
letterSpacing: number;
};
iconWrapper: {
width: number;
alignItems: "center";
};
};
/** Base indent applied per nesting depth in the expandable item. */
declare const BASE_INDENT = 12;
/** Default icon size for nav item icons. */
declare const NAV_ICON_SIZE = 14;
/** Chevron icon size for expandable sections. */
declare const CHEVRON_ICON_SIZE = 12;
export { ACTIVE_BORDER_RADIUS, ACTIVE_TINT_OPACITY, APP_SHELL_SUFFIX, type AccountPlan, type AccountState, AppShell, type AppShellProps, type AppShellWidth, BASE_INDENT, CHEVRON_ICON_SIZE, CollapsedRail, type CollapsedRailProps, type CollapsedRailRange, type CommandItem, CommandPalette, type CommandPaletteLabels, type CommandPaletteProps, CommandPaletteTrigger, type CommandPaletteTriggerProps, DEFAULT_COLLAPSED_RAIL_MAX, DEFAULT_SEARCH_BREAKPOINT, DarkModeControl, type DarkModeControlProps, type DarkModeOption, type DarkModeVariant, HOVER_TINT_OPACITY, NAV_ICON_SIZE, NAV_LINK_GAP, NAV_ROW_RADIUS, NAV_TEST_IDS, Nav, NavBar, type NavBarProps, NavExpandableItem, type NavExpandableItemProps, type NavItem, type NavOrientation, NavOverflowMenu, type NavOverflowMenuProps, type NavProps, NavShell, type NavShellCollapsedRail, type NavShellLayout, type NavShellProps, type NavShellSideRail, type NavShellTopBar, type NavUser, PillNav, type PillNavProps, RAIL_FULL_BREAKPOINT, type RailMode, type ResolveRailModeArgs, type SearchAffordance, type ShellMessage, Sidebar, SidebarChevron, type SidebarChevronProps, type SidebarPaletteTriggerConfig, type SidebarProps, SidebarSearch, type SidebarSearchConfig, type SidebarSearchLabels, type SidebarSearchProps, Topbar, type TopbarAction, type TopbarProps, type TopbarUser, accessibleNavItems, collapsedRailStyles, darkModeStyles, expandableStyles, filterCommands, filterNavItems, isFilterActive, isRouteActive, navStyles, pillNavStyles, resolveActiveRoute, resolveContentMaxWidth, resolveRailMode, resolveSearchAffordance, roleRoutesToNavItems, searchTokens, useCommandPaletteHotkey, useContentMaxWidth };