@dloizides/ui-nav 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.18.0
4
+
5
+ Root-cause fix for sidebar leaf co-activation. Behavioral/additive — no breaking API.
6
+
7
+ - **Fix (two sidebar leaves showed `aria-current="page"` at once).** On the live finreg
8
+ `/accounting/journals`, BOTH "Chart of Accounts" (route `/accounting`) and "Journals"
9
+ (route `/accounting/journals`) were marked active, because the active predicate was purely
10
+ prefix-based (`pathname === route || pathname.startsWith(route + '/')`) — a module-home leaf
11
+ routed at `/accounting` is a prefix of every `/accounting/*` sibling, so it co-activated on all
12
+ of them. (Second occurrence of this class; the first, CRM Contacts at `/crm`, was worked around
13
+ by moving the route.) Now resolution is **most-specific (longest) match wins**: among a nav
14
+ model's LEAF items, at most ONE is active for any path — the leaf whose route is the longest
15
+ prefix match. On `/accounting/journals` only Journals is active; on `/accounting` exactly only
16
+ Chart of Accounts is. For nav models with no parent/child route overlap (the common case)
17
+ behaviour is unchanged.
18
+ - **New export** `resolveActiveRoute(items, pathname)`: flattens the tree to its leaf routes and
19
+ returns the single longest matching route (honouring per-leaf `exact`), or `undefined`.
20
+ - `Sidebar` resolves the winning route once over the full leaf set and passes `activeRoute` to
21
+ each row; `NavExpandableItem` marks a leaf active iff `item.route === activeRoute` (falls back
22
+ to the per-item predicate when used standalone — backward compatible). Group HEADER expansion
23
+ (`hasActiveDescendant`) is a separate concern and is unchanged.
24
+ - The same winner-based resolution now backs `NavBar`, `PillNav`, `CollapsedRail` and
25
+ `NavOverflowMenu`, so the latent prefix co-activation cannot resurface in the horizontal navs.
26
+ - **Additive API**: new optional `NavItem.exact?: boolean` — a leaf active ONLY on its exact
27
+ path (never a nested one). A complementary escape hatch; the longest-match default is the real
28
+ fix, so no consumer needs to set it.
29
+ - `isRouteActive(pathname, route, exact?)` gains an optional third arg; the two-arg call is
30
+ unchanged.
31
+
3
32
  ## 1.17.0
4
33
 
5
34
  Two live-defect fixes to the shared sidebar chrome. Behavioral/additive — no breaking API.
package/dist/index.d.mts CHANGED
@@ -5,12 +5,71 @@ import { DropdownVariant } from '@dloizides/ui-layout';
5
5
  import { resolveAccessibleRoutes, RoleRouteTable, RoleRoute } from '@dloizides/auth-web';
6
6
 
7
7
  /**
8
- * Active-route matcher shared by the sidebar entries. Ported verbatim from the
9
- * twin app sidebars: an item is active when the current pathname equals its
10
- * route or is nested under it (`/foo` matches `/foo` and `/foo/bar`, but `/`
11
- * only matches `/`).
8
+ * Public prop types for the `@dloizides/ui-nav` config-driven navigation shell.
9
+ *
10
+ * The rendering chrome (sidebar + topbar) was byte-identical across erevna-web
11
+ * and katalogos-web; only the *item data* (which routes, how they group) differs
12
+ * per app. So this package renders a caller-supplied `NavItem[]` — labels are
13
+ * pre-localized strings and icons are render slots, keeping the package free of
14
+ * any app's i18n helper, icon set, router, or store.
12
15
  */
13
- declare function isRouteActive(pathname: string, route: string): boolean;
16
+
17
+ /** One navigation entry. Labels are already localized by the caller. */
18
+ interface NavItem {
19
+ /** Stable key + default testID. */
20
+ key: string;
21
+ /** Localized display label. */
22
+ label: string;
23
+ /** Route/path this item navigates to (passed back to `onNavigate`). */
24
+ route: string;
25
+ /** Optional testID override (defaults to `key`). */
26
+ testID?: string;
27
+ /**
28
+ * Optional leading icon. Receives the resolved foreground colour and a size,
29
+ * so the app supplies its own icon component without this package importing
30
+ * an icon set.
31
+ */
32
+ renderIcon?: (color: string, size: number) => React.ReactNode;
33
+ /** Optional nested items — rendered as an expandable section. */
34
+ children?: NavItem[];
35
+ /**
36
+ * Opt this leaf out of prefix matching: when `true` it is active ONLY when the
37
+ * pathname equals its route exactly (never for a nested `route/child` path). A
38
+ * complementary escape hatch — the default most-specific/longest-match resolution
39
+ * (see `resolveActiveRoute`) already prevents a module-home leaf like `/accounting`
40
+ * from co-activating with `/accounting/journals`, so most consumers never need
41
+ * this. Reach for it only when a leaf must NEVER light up on any descendant path
42
+ * even when no longer sibling route exists.
43
+ */
44
+ exact?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Active-route matching for the nav shells.
49
+ *
50
+ * `isRouteActive` is the low-level per-route predicate (prefix-based, ported
51
+ * verbatim from the twin app sidebars): a route is active when the current
52
+ * pathname equals it or is nested under it (`/foo` matches `/foo` and `/foo/bar`,
53
+ * but `/` only matches `/`). With `exact`, only an exact path matches.
54
+ *
55
+ * `resolveActiveRoute` is the CROSS-ITEM resolver that fixes the sidebar
56
+ * co-activation bug: because a module-home leaf (`/accounting`) is a prefix of
57
+ * every sibling (`/accounting/journals`), the naive per-item predicate lights up
58
+ * BOTH on `/accounting/journals`. `resolveActiveRoute` instead returns the ONE
59
+ * most-specific (longest) leaf route that matches — so at most one leaf is ever
60
+ * active, the longest wins on nested paths, and the module-home leaf is active
61
+ * only on its own exact path. For a nav model with no parent/child route overlap
62
+ * (the common case) this is identical to the old behaviour.
63
+ */
64
+
65
+ declare function isRouteActive(pathname: string, route: string, exact?: boolean): boolean;
66
+ /**
67
+ * The single active leaf route for `pathname`: the LONGEST leaf route that
68
+ * matches (most-specific wins). Returns `undefined` when no leaf matches. Marking
69
+ * a leaf active iff `item.route === resolveActiveRoute(items, pathname)` guarantees
70
+ * at most one active leaf — the whack-a-mole prefix co-activation fix.
71
+ */
72
+ declare function resolveActiveRoute(items: NavItem[], pathname: string): string | undefined;
14
73
 
15
74
  /**
16
75
  * SidebarSearch — the persistent, DESKTOP inline search field that lives at the
@@ -51,36 +110,6 @@ interface SidebarSearchProps {
51
110
  /** The inline sidebar search field (desktop) with a clear button. */
52
111
  declare const SidebarSearch: ({ value, onChangeText, onClear, labels, testID, }: SidebarSearchProps) => React.ReactElement;
53
112
 
54
- /**
55
- * Public prop types for the `@dloizides/ui-nav` config-driven navigation shell.
56
- *
57
- * The rendering chrome (sidebar + topbar) was byte-identical across erevna-web
58
- * and katalogos-web; only the *item data* (which routes, how they group) differs
59
- * per app. So this package renders a caller-supplied `NavItem[]` — labels are
60
- * pre-localized strings and icons are render slots, keeping the package free of
61
- * any app's i18n helper, icon set, router, or store.
62
- */
63
-
64
- /** One navigation entry. Labels are already localized by the caller. */
65
- interface NavItem {
66
- /** Stable key + default testID. */
67
- key: string;
68
- /** Localized display label. */
69
- label: string;
70
- /** Route/path this item navigates to (passed back to `onNavigate`). */
71
- route: string;
72
- /** Optional testID override (defaults to `key`). */
73
- testID?: string;
74
- /**
75
- * Optional leading icon. Receives the resolved foreground colour and a size,
76
- * so the app supplies its own icon component without this package importing
77
- * an icon set.
78
- */
79
- renderIcon?: (color: string, size: number) => React.ReactNode;
80
- /** Optional nested items — rendered as an expandable section. */
81
- children?: NavItem[];
82
- }
83
-
84
113
  /**
85
114
  * Sidebar — the config-driven left navigation shell promoted from the
86
115
  * byte-identical erevna-web / katalogos-web `Sidebar`. It renders a caller
@@ -1080,6 +1109,15 @@ interface NavExpandableItemProps {
1080
1109
  item: NavItem;
1081
1110
  pathname: string;
1082
1111
  onNavigate: (route: string) => void;
1112
+ /**
1113
+ * The single winning active leaf route for the whole nav model, resolved once by
1114
+ * the parent via `resolveActiveRoute` (most-specific/longest match wins). A leaf
1115
+ * is active iff `item.route === activeRoute`, so at most one leaf ever carries
1116
+ * `aria-current="page"` — the fix for `/accounting` co-activating with
1117
+ * `/accounting/journals`. When omitted (standalone use) each leaf falls back to
1118
+ * the per-item prefix predicate, preserving the pre-1.18 behaviour.
1119
+ */
1120
+ activeRoute?: string;
1083
1121
  /** a11y hint for a leaf item, given its label. */
1084
1122
  navigateHint: (label: string) => string;
1085
1123
  /** a11y hint shown when the section is collapsed (press expands). */
@@ -1094,7 +1132,7 @@ interface NavExpandableItemProps {
1094
1132
  renderChevron?: (expanded: boolean, color: string, size: number) => React.ReactNode;
1095
1133
  depth?: number;
1096
1134
  }
1097
- declare const NavExpandableItem: ({ item, pathname, onNavigate, navigateHint, expandHint, collapseHint, renderChevron, depth, }: NavExpandableItemProps) => React.ReactElement;
1135
+ declare const NavExpandableItem: ({ item, pathname, onNavigate, navigateHint, expandHint, collapseHint, renderChevron, activeRoute, depth, }: NavExpandableItemProps) => React.ReactElement;
1098
1136
 
1099
1137
  /**
1100
1138
  * Pure matching logic for the {@link CommandPalette}. Kept framework-free so the
@@ -1489,4 +1527,4 @@ declare const NAV_ICON_SIZE = 14;
1489
1527
  /** Chevron icon size for expandable sections. */
1490
1528
  declare const CHEVRON_ICON_SIZE = 12;
1491
1529
 
1492
- 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, resolveContentMaxWidth, resolveRailMode, resolveSearchAffordance, roleRoutesToNavItems, searchTokens, useCommandPaletteHotkey, useContentMaxWidth };
1530
+ 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 };
package/dist/index.d.ts CHANGED
@@ -5,12 +5,71 @@ import { DropdownVariant } from '@dloizides/ui-layout';
5
5
  import { resolveAccessibleRoutes, RoleRouteTable, RoleRoute } from '@dloizides/auth-web';
6
6
 
7
7
  /**
8
- * Active-route matcher shared by the sidebar entries. Ported verbatim from the
9
- * twin app sidebars: an item is active when the current pathname equals its
10
- * route or is nested under it (`/foo` matches `/foo` and `/foo/bar`, but `/`
11
- * only matches `/`).
8
+ * Public prop types for the `@dloizides/ui-nav` config-driven navigation shell.
9
+ *
10
+ * The rendering chrome (sidebar + topbar) was byte-identical across erevna-web
11
+ * and katalogos-web; only the *item data* (which routes, how they group) differs
12
+ * per app. So this package renders a caller-supplied `NavItem[]` — labels are
13
+ * pre-localized strings and icons are render slots, keeping the package free of
14
+ * any app's i18n helper, icon set, router, or store.
12
15
  */
13
- declare function isRouteActive(pathname: string, route: string): boolean;
16
+
17
+ /** One navigation entry. Labels are already localized by the caller. */
18
+ interface NavItem {
19
+ /** Stable key + default testID. */
20
+ key: string;
21
+ /** Localized display label. */
22
+ label: string;
23
+ /** Route/path this item navigates to (passed back to `onNavigate`). */
24
+ route: string;
25
+ /** Optional testID override (defaults to `key`). */
26
+ testID?: string;
27
+ /**
28
+ * Optional leading icon. Receives the resolved foreground colour and a size,
29
+ * so the app supplies its own icon component without this package importing
30
+ * an icon set.
31
+ */
32
+ renderIcon?: (color: string, size: number) => React.ReactNode;
33
+ /** Optional nested items — rendered as an expandable section. */
34
+ children?: NavItem[];
35
+ /**
36
+ * Opt this leaf out of prefix matching: when `true` it is active ONLY when the
37
+ * pathname equals its route exactly (never for a nested `route/child` path). A
38
+ * complementary escape hatch — the default most-specific/longest-match resolution
39
+ * (see `resolveActiveRoute`) already prevents a module-home leaf like `/accounting`
40
+ * from co-activating with `/accounting/journals`, so most consumers never need
41
+ * this. Reach for it only when a leaf must NEVER light up on any descendant path
42
+ * even when no longer sibling route exists.
43
+ */
44
+ exact?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Active-route matching for the nav shells.
49
+ *
50
+ * `isRouteActive` is the low-level per-route predicate (prefix-based, ported
51
+ * verbatim from the twin app sidebars): a route is active when the current
52
+ * pathname equals it or is nested under it (`/foo` matches `/foo` and `/foo/bar`,
53
+ * but `/` only matches `/`). With `exact`, only an exact path matches.
54
+ *
55
+ * `resolveActiveRoute` is the CROSS-ITEM resolver that fixes the sidebar
56
+ * co-activation bug: because a module-home leaf (`/accounting`) is a prefix of
57
+ * every sibling (`/accounting/journals`), the naive per-item predicate lights up
58
+ * BOTH on `/accounting/journals`. `resolveActiveRoute` instead returns the ONE
59
+ * most-specific (longest) leaf route that matches — so at most one leaf is ever
60
+ * active, the longest wins on nested paths, and the module-home leaf is active
61
+ * only on its own exact path. For a nav model with no parent/child route overlap
62
+ * (the common case) this is identical to the old behaviour.
63
+ */
64
+
65
+ declare function isRouteActive(pathname: string, route: string, exact?: boolean): boolean;
66
+ /**
67
+ * The single active leaf route for `pathname`: the LONGEST leaf route that
68
+ * matches (most-specific wins). Returns `undefined` when no leaf matches. Marking
69
+ * a leaf active iff `item.route === resolveActiveRoute(items, pathname)` guarantees
70
+ * at most one active leaf — the whack-a-mole prefix co-activation fix.
71
+ */
72
+ declare function resolveActiveRoute(items: NavItem[], pathname: string): string | undefined;
14
73
 
15
74
  /**
16
75
  * SidebarSearch — the persistent, DESKTOP inline search field that lives at the
@@ -51,36 +110,6 @@ interface SidebarSearchProps {
51
110
  /** The inline sidebar search field (desktop) with a clear button. */
52
111
  declare const SidebarSearch: ({ value, onChangeText, onClear, labels, testID, }: SidebarSearchProps) => React.ReactElement;
53
112
 
54
- /**
55
- * Public prop types for the `@dloizides/ui-nav` config-driven navigation shell.
56
- *
57
- * The rendering chrome (sidebar + topbar) was byte-identical across erevna-web
58
- * and katalogos-web; only the *item data* (which routes, how they group) differs
59
- * per app. So this package renders a caller-supplied `NavItem[]` — labels are
60
- * pre-localized strings and icons are render slots, keeping the package free of
61
- * any app's i18n helper, icon set, router, or store.
62
- */
63
-
64
- /** One navigation entry. Labels are already localized by the caller. */
65
- interface NavItem {
66
- /** Stable key + default testID. */
67
- key: string;
68
- /** Localized display label. */
69
- label: string;
70
- /** Route/path this item navigates to (passed back to `onNavigate`). */
71
- route: string;
72
- /** Optional testID override (defaults to `key`). */
73
- testID?: string;
74
- /**
75
- * Optional leading icon. Receives the resolved foreground colour and a size,
76
- * so the app supplies its own icon component without this package importing
77
- * an icon set.
78
- */
79
- renderIcon?: (color: string, size: number) => React.ReactNode;
80
- /** Optional nested items — rendered as an expandable section. */
81
- children?: NavItem[];
82
- }
83
-
84
113
  /**
85
114
  * Sidebar — the config-driven left navigation shell promoted from the
86
115
  * byte-identical erevna-web / katalogos-web `Sidebar`. It renders a caller
@@ -1080,6 +1109,15 @@ interface NavExpandableItemProps {
1080
1109
  item: NavItem;
1081
1110
  pathname: string;
1082
1111
  onNavigate: (route: string) => void;
1112
+ /**
1113
+ * The single winning active leaf route for the whole nav model, resolved once by
1114
+ * the parent via `resolveActiveRoute` (most-specific/longest match wins). A leaf
1115
+ * is active iff `item.route === activeRoute`, so at most one leaf ever carries
1116
+ * `aria-current="page"` — the fix for `/accounting` co-activating with
1117
+ * `/accounting/journals`. When omitted (standalone use) each leaf falls back to
1118
+ * the per-item prefix predicate, preserving the pre-1.18 behaviour.
1119
+ */
1120
+ activeRoute?: string;
1083
1121
  /** a11y hint for a leaf item, given its label. */
1084
1122
  navigateHint: (label: string) => string;
1085
1123
  /** a11y hint shown when the section is collapsed (press expands). */
@@ -1094,7 +1132,7 @@ interface NavExpandableItemProps {
1094
1132
  renderChevron?: (expanded: boolean, color: string, size: number) => React.ReactNode;
1095
1133
  depth?: number;
1096
1134
  }
1097
- declare const NavExpandableItem: ({ item, pathname, onNavigate, navigateHint, expandHint, collapseHint, renderChevron, depth, }: NavExpandableItemProps) => React.ReactElement;
1135
+ declare const NavExpandableItem: ({ item, pathname, onNavigate, navigateHint, expandHint, collapseHint, renderChevron, activeRoute, depth, }: NavExpandableItemProps) => React.ReactElement;
1098
1136
 
1099
1137
  /**
1100
1138
  * Pure matching logic for the {@link CommandPalette}. Kept framework-free so the
@@ -1489,4 +1527,4 @@ declare const NAV_ICON_SIZE = 14;
1489
1527
  /** Chevron icon size for expandable sections. */
1490
1528
  declare const CHEVRON_ICON_SIZE = 12;
1491
1529
 
1492
- 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, resolveContentMaxWidth, resolveRailMode, resolveSearchAffordance, roleRoutesToNavItems, searchTokens, useCommandPaletteHotkey, useContentMaxWidth };
1530
+ 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 };
package/dist/index.js CHANGED
@@ -128,10 +128,31 @@ function isFilterActive(query) {
128
128
  }
129
129
 
130
130
  // src/isRouteActive.ts
131
- function isRouteActive(pathname, route) {
131
+ function isRouteActive(pathname, route, exact = false) {
132
+ if (exact) return pathname === route;
132
133
  if (route === "/") return pathname === "/";
133
134
  return pathname === route || pathname.startsWith(`${route}/`);
134
135
  }
136
+ function leafRoutes(items) {
137
+ return items.flatMap((item) => {
138
+ const children = item.children ?? [];
139
+ const isGroup = children.length > 0;
140
+ return isGroup ? leafRoutes(children) : [{ route: item.route, exact: item.exact === true }];
141
+ });
142
+ }
143
+ function resolveActiveRoute(items, pathname) {
144
+ let winner;
145
+ let winnerLength = -1;
146
+ for (const leaf of leafRoutes(items)) {
147
+ const matches = isRouteActive(pathname, leaf.route, leaf.exact);
148
+ const isLonger = leaf.route.length > winnerLength;
149
+ if (matches && isLonger) {
150
+ winner = leaf.route;
151
+ winnerLength = leaf.route.length;
152
+ }
153
+ }
154
+ return winner;
155
+ }
135
156
  var ACTIVE_BORDER_RADIUS = 4;
136
157
  var ACTIVE_ACCENT_WIDTH = 3;
137
158
  var NAV_ROW_RADIUS = 8;
@@ -509,6 +530,7 @@ var NavExpandableItem = ({
509
530
  expandHint,
510
531
  collapseHint,
511
532
  renderChevron,
533
+ activeRoute,
512
534
  depth = 0
513
535
  }) => {
514
536
  const [expanded, setExpanded] = React.useState(() => hasActiveDescendant(item, pathname));
@@ -522,7 +544,7 @@ var NavExpandableItem = ({
522
544
  const onHoverOut = React.useCallback(() => setHovered(false), []);
523
545
  const indent = depth * BASE_INDENT;
524
546
  const hasChildren = Array.isArray(item.children) && item.children.length > 0;
525
- const isActive = isRouteActive(pathname, item.route);
547
+ const isActive = activeRoute !== void 0 ? item.route === activeRoute : isRouteActive(pathname, item.route, item.exact === true);
526
548
  const isSectionHeader = hasChildren && depth === 0;
527
549
  const activeAccentStyle = React.useMemo(
528
550
  () => ({ borderLeftWidth: ACTIVE_ACCENT_WIDTH, borderLeftColor: primaryColor }),
@@ -610,6 +632,7 @@ var NavExpandableItem = ({
610
632
  /* @__PURE__ */ jsxRuntime.jsx(uiMotion.Collapse, { open: expanded, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.childrenContainer, children: item.children?.map((child) => /* @__PURE__ */ jsxRuntime.jsx(
611
633
  NavExpandableItem,
612
634
  {
635
+ activeRoute,
613
636
  collapseHint,
614
637
  depth: depth + 1,
615
638
  expandHint,
@@ -760,6 +783,7 @@ var Sidebar = ({
760
783
  const showPaletteTrigger = paletteTrigger !== void 0 && !isInlineAffordance;
761
784
  const visibleItems = showInlineSearch ? filterNavItems(items, query) : items;
762
785
  const showEmpty = showInlineSearch && isFilterActive(query) && visibleItems.length === 0;
786
+ const activeRoute = resolveActiveRoute(items, pathname);
763
787
  return /* @__PURE__ */ jsxRuntime.jsxs(
764
788
  reactNative.View,
765
789
  {
@@ -797,6 +821,7 @@ var Sidebar = ({
797
821
  showEmpty && search !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navStyles.sidebarEmpty, { color: colors.textSecondary }], testID: "sidebar-search-empty", children: search.emptyText }) : visibleItems.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
798
822
  NavExpandableItem,
799
823
  {
824
+ activeRoute,
800
825
  collapseHint,
801
826
  expandHint,
802
827
  item,
@@ -1061,7 +1086,7 @@ var NavOverflowMenu = ({
1061
1086
  }) => {
1062
1087
  const options = React.useMemo(() => items.map((item) => ({ label: item.label, value: item.route })), [items]);
1063
1088
  const activeRoute = React.useMemo(
1064
- () => items.find((item) => isRouteActive(pathname, item.route))?.route ?? NO_ACTIVE_ROUTE,
1089
+ () => resolveActiveRoute(items, pathname) ?? NO_ACTIVE_ROUTE,
1065
1090
  [items, pathname]
1066
1091
  );
1067
1092
  const optionTestID = React.useMemo(() => {
@@ -1194,6 +1219,7 @@ var NavBarInner = ({
1194
1219
  },
1195
1220
  [onNavigate]
1196
1221
  );
1222
+ const activeRoute = resolveActiveRoute(items, pathname);
1197
1223
  const linkColors = React.useMemo(
1198
1224
  () => ({
1199
1225
  rest: colors.textSecondary,
@@ -1241,7 +1267,7 @@ var NavBarInner = ({
1241
1267
  collapsed,
1242
1268
  colors: linkColors,
1243
1269
  iconSize: NAV_ICON_SIZE,
1244
- isActive: isRouteActive(pathname, item.route),
1270
+ isActive: item.route === activeRoute,
1245
1271
  item,
1246
1272
  navigateHint,
1247
1273
  reducedMotion,
@@ -1603,6 +1629,7 @@ var CollapsedRail = ({
1603
1629
  const { theme } = uiFeedback.useUi();
1604
1630
  const colors = theme.colors;
1605
1631
  const primaryColor = theme.palette.primary["500"];
1632
+ const activeRoute = resolveActiveRoute(items, pathname);
1606
1633
  return /* @__PURE__ */ jsxRuntime.jsxs(
1607
1634
  reactNative.View,
1608
1635
  {
@@ -1617,7 +1644,7 @@ var CollapsedRail = ({
1617
1644
  children: [
1618
1645
  header !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: collapsedRailStyles.slot, children: renderTextSlot(header, { color: colors.text }) }) : null,
1619
1646
  items.map((item) => {
1620
- const active = isRouteActive(pathname, item.route);
1647
+ const active = item.route === activeRoute;
1621
1648
  const iconColor = active ? primaryColor : colors.textSecondary;
1622
1649
  return /* @__PURE__ */ jsxRuntime.jsx(
1623
1650
  FocusableTouchable,
@@ -1745,6 +1772,7 @@ var PillNav = ({
1745
1772
  const colors = theme.colors;
1746
1773
  const primaryColor = theme.palette.primary["500"];
1747
1774
  if (items.length < minItems) return null;
1775
+ const activeRoute = resolveActiveRoute(items, pathname);
1748
1776
  return /* @__PURE__ */ jsxRuntime.jsx(
1749
1777
  reactNative.View,
1750
1778
  {
@@ -1753,7 +1781,7 @@ var PillNav = ({
1753
1781
  role: "navigation",
1754
1782
  style: [pillNavStyles.container, containerStyle],
1755
1783
  children: items.map((item) => {
1756
- const active = isRouteActive(pathname, item.route);
1784
+ const active = item.route === activeRoute;
1757
1785
  const textColor = active ? TEXT_ON_PRIMARY4 : colors.textSecondary;
1758
1786
  const pillStyle = active ? { backgroundColor: primaryColor } : { backgroundColor: colors.surfaceElevated };
1759
1787
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -2049,6 +2077,7 @@ exports.isFilterActive = isFilterActive;
2049
2077
  exports.isRouteActive = isRouteActive;
2050
2078
  exports.navStyles = navStyles;
2051
2079
  exports.pillNavStyles = pillNavStyles;
2080
+ exports.resolveActiveRoute = resolveActiveRoute;
2052
2081
  exports.resolveContentMaxWidth = resolveContentMaxWidth;
2053
2082
  exports.resolveRailMode = resolveRailMode;
2054
2083
  exports.resolveSearchAffordance = resolveSearchAffordance;