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: * *