@servicetitan/navigation 14.2.0 → 14.3.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.
Files changed (89) hide show
  1. package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts +2 -0
  2. package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts.map +1 -0
  3. package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts +2 -0
  4. package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts.map +1 -0
  5. package/dist/components/titan-layout/interface.d.ts +66 -0
  6. package/dist/components/titan-layout/interface.d.ts.map +1 -1
  7. package/dist/components/titan-layout/interface.js.map +1 -1
  8. package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts +3 -0
  9. package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts.map +1 -1
  10. package/dist/components/titan-layout/layout-sidebar-links-internal.js +58 -13
  11. package/dist/components/titan-layout/layout-sidebar-links-internal.js.map +1 -1
  12. package/dist/components/titan-layout/layout-sidebar.d.ts +14 -0
  13. package/dist/components/titan-layout/layout-sidebar.d.ts.map +1 -1
  14. package/dist/components/titan-layout/layout-sidebar.js +169 -97
  15. package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
  16. package/dist/components/titan-layout/layout-sidebar.module.less +230 -144
  17. package/dist/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
  18. package/dist/components/titan-layout/navigation-catalog/index.d.ts +3 -0
  19. package/dist/components/titan-layout/navigation-catalog/index.d.ts.map +1 -0
  20. package/dist/components/titan-layout/navigation-catalog/index.js +4 -0
  21. package/dist/components/titan-layout/navigation-catalog/index.js.map +1 -0
  22. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts +8 -0
  23. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts.map +1 -0
  24. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js +88 -0
  25. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js.map +1 -0
  26. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts +21 -0
  27. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts.map +1 -0
  28. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +138 -0
  29. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -0
  30. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
  31. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
  32. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts +14 -0
  33. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -0
  34. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +382 -0
  35. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -0
  36. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
  37. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
  38. package/dist/components/titan-layout/navigation-scroll-indicators.d.ts +12 -0
  39. package/dist/components/titan-layout/navigation-scroll-indicators.d.ts.map +1 -0
  40. package/dist/components/titan-layout/navigation-scroll-indicators.js +48 -0
  41. package/dist/components/titan-layout/navigation-scroll-indicators.js.map +1 -0
  42. package/dist/components/titan-layout/titan-layout.d.ts +29 -1
  43. package/dist/components/titan-layout/titan-layout.d.ts.map +1 -1
  44. package/dist/components/titan-layout/titan-layout.js +46 -6
  45. package/dist/components/titan-layout/titan-layout.js.map +1 -1
  46. package/dist/components/titan-layout/titan-layout.module.less +2 -1
  47. package/dist/components/titan-layout/use-navigation-scroll.d.ts +20 -0
  48. package/dist/components/titan-layout/use-navigation-scroll.d.ts.map +1 -0
  49. package/dist/components/titan-layout/use-navigation-scroll.js +140 -0
  50. package/dist/components/titan-layout/use-navigation-scroll.js.map +1 -0
  51. package/dist/utils/__tests__/navigation-notifications.test.d.ts +2 -0
  52. package/dist/utils/__tests__/navigation-notifications.test.d.ts.map +1 -0
  53. package/dist/utils/__tests__/submenu-state.test.d.ts +2 -0
  54. package/dist/utils/__tests__/submenu-state.test.d.ts.map +1 -0
  55. package/dist/utils/navigation-notifications.d.ts +21 -0
  56. package/dist/utils/navigation-notifications.d.ts.map +1 -0
  57. package/dist/utils/navigation-notifications.js +39 -0
  58. package/dist/utils/navigation-notifications.js.map +1 -0
  59. package/dist/utils/side-nav.js +3 -1
  60. package/dist/utils/side-nav.js.map +1 -1
  61. package/dist/utils/submenu-state.d.ts +3 -0
  62. package/dist/utils/submenu-state.d.ts.map +1 -0
  63. package/dist/utils/submenu-state.js +15 -0
  64. package/dist/utils/submenu-state.js.map +1 -0
  65. package/package.json +2 -2
  66. package/src/components/titan-layout/__tests__/layout-sidebar-links-internal.test.tsx +225 -0
  67. package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +186 -0
  68. package/src/components/titan-layout/interface.ts +73 -0
  69. package/src/components/titan-layout/layout-sidebar-links-internal.tsx +86 -14
  70. package/src/components/titan-layout/layout-sidebar.module.less +230 -144
  71. package/src/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
  72. package/src/components/titan-layout/layout-sidebar.tsx +193 -117
  73. package/src/components/titan-layout/navigation-catalog/index.ts +2 -0
  74. package/src/components/titan-layout/navigation-catalog/navigation-catalog-footer.tsx +88 -0
  75. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
  76. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
  77. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +170 -0
  78. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
  79. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
  80. package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +458 -0
  81. package/src/components/titan-layout/navigation-scroll-indicators.tsx +80 -0
  82. package/src/components/titan-layout/titan-layout.module.less +2 -1
  83. package/src/components/titan-layout/titan-layout.tsx +81 -5
  84. package/src/components/titan-layout/use-navigation-scroll.ts +145 -0
  85. package/src/utils/__tests__/navigation-notifications.test.ts +70 -0
  86. package/src/utils/__tests__/submenu-state.test.ts +39 -0
  87. package/src/utils/navigation-notifications.ts +59 -0
  88. package/src/utils/side-nav.ts +1 -1
  89. package/src/utils/submenu-state.ts +16 -0
@@ -15,7 +15,9 @@ import {
15
15
  } from 'react';
16
16
  import { NavigationItemData } from '../../utils/navigation';
17
17
  import { DefaultNavLinkComponent, NavLinkComponentProps } from '../../utils/navigation-context';
18
+ import { updateOpenSubmenus } from '../../utils/submenu-state';
18
19
  import { useTitanBreakpoint } from '../../utils/use-breakpoint';
20
+ import { PinnedNavigationConfig } from './interface';
19
21
  import {
20
22
  LayoutContext,
21
23
  LayoutPlacementContext,
@@ -50,6 +52,12 @@ export type TitanLayoutProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'
50
52
  /** data for main navigation links */
51
53
  navigationMainItems?: NavigationItemData[];
52
54
 
55
+ /**
56
+ * Turns the navigation catalog on: renders a trigger in the sidebar and the dialog behind it.
57
+ * Omit it and the navigation is unchanged.
58
+ */
59
+ pinnedNavigation?: PinnedNavigationConfig;
60
+
53
61
  /** data for overflow navigation links (used only with top variant) */
54
62
  navigationOverflowItems?: NavigationItemData[];
55
63
 
@@ -65,6 +73,13 @@ export type TitanLayoutProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'
65
73
  openSubmenus?: string[];
66
74
  /** submenu accordion open state change handler */
67
75
  onOpenSubmenuChange?: (id: string, opened: boolean) => void;
76
+ /**
77
+ * Receives the complete next set of open modules for individual and bulk actions.
78
+ * Preferred over onOpenSubmenuChange when provided, so bulk updates are atomic.
79
+ * Required for the rail's `Expand All` / `Collapse All` control, which is hidden
80
+ * for hosts that supply only the per-module `onOpenSubmenuChange`.
81
+ */
82
+ onOpenSubmenusChange?: (ids: string[]) => void;
68
83
 
69
84
  /** navigation link click handler */
70
85
  onNavigationLinkClick?: (itemId: string, submenuItemId?: string) => void;
@@ -78,6 +93,23 @@ export type TitanLayoutProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'
78
93
  /** top links for side navigation */
79
94
  sideTop?: ReactElement[];
80
95
 
96
+ /**
97
+ * Destinations pinned above the sidebar's divider, ahead of `sideTop`.
98
+ *
99
+ * Rendered with the same machinery as `navigationMainItems`, so one with pages beneath it is
100
+ * an expandable group rather than a bare link. `sideTop` is unchanged and still takes elements
101
+ * the host builds itself; the two can be used together.
102
+ */
103
+ sideTopItems?: NavigationItemData[];
104
+
105
+ /**
106
+ * Whether the sidebar shows the scroll and overflow-notification indicators.
107
+ *
108
+ * Defaults on, so a host that says nothing keeps them. Navigation versions that predate the
109
+ * indicators pass `false`.
110
+ */
111
+ navigationScrollIndicators?: boolean;
112
+
81
113
  /**
82
114
  * profile element for layout
83
115
  * @see ProfileDropdown
@@ -165,7 +197,9 @@ function TitanLayoutComponent({
165
197
  logo,
166
198
  onExpandedChange,
167
199
  onOpenSubmenuChange,
200
+ onOpenSubmenusChange,
168
201
  navigationMainItems,
202
+ pinnedNavigation,
169
203
  navigationOverflowItems,
170
204
  onNavigationLinkClick,
171
205
  extraLinks,
@@ -174,6 +208,8 @@ function TitanLayoutComponent({
174
208
  minContentWidth,
175
209
  noResponsive,
176
210
  sideTop,
211
+ sideTopItems,
212
+ navigationScrollIndicators = true,
177
213
  }: TitanLayoutProps) {
178
214
  const breakpoint = useTitanBreakpoint(noResponsive);
179
215
  const context: TitanLayoutContextType = useMemo(
@@ -194,7 +230,10 @@ function TitanLayoutComponent({
194
230
  const hasSideBar =
195
231
  !contentOnly &&
196
232
  (navVariant === 'left' || (navVariant === 'top' && isMobile)) &&
197
- (!!navigationMainItems?.length || !!sideTop?.length);
233
+ (!!navigationMainItems?.length ||
234
+ !!sideTop?.length ||
235
+ !!sideTopItems?.length ||
236
+ !!pinnedNavigation);
198
237
  const hasTopBar = !contentOnly;
199
238
 
200
239
  useEffect(() => {
@@ -230,9 +269,26 @@ function TitanLayoutComponent({
230
269
  );
231
270
  const onSubmenuOpenChange = useCallback(
232
271
  (id: string, opened: boolean) => {
233
- onOpenSubmenuChange?.(id, opened);
272
+ if (onOpenSubmenusChange) {
273
+ onOpenSubmenusChange(updateOpenSubmenus(openSubmenus ?? [], [id], opened));
274
+ } else {
275
+ onOpenSubmenuChange?.(id, opened);
276
+ }
277
+ },
278
+ [onOpenSubmenuChange, onOpenSubmenusChange, openSubmenus]
279
+ );
280
+ /*
281
+ * Bulk actions require `onOpenSubmenusChange`, which takes the whole next set at once.
282
+ * They are deliberately not emulated with `onOpenSubmenuChange`: that contract is one
283
+ * call per user action, so a host deriving its next list from the current one would see
284
+ * the same stale list on every call of a loop and keep only the last module. `Expand All`
285
+ * is hidden entirely when a host supplies only the per-module callback.
286
+ */
287
+ const onSubmenusOpenChange = useCallback(
288
+ (ids: string[], opened: boolean) => {
289
+ onOpenSubmenusChange?.(updateOpenSubmenus(openSubmenus ?? [], ids, opened));
234
290
  },
235
- [onOpenSubmenuChange]
291
+ [onOpenSubmenusChange, openSubmenus]
236
292
  );
237
293
  const sidebarNavItems = useMemo(() => {
238
294
  if (!hasSideBar) {
@@ -246,10 +302,24 @@ function TitanLayoutComponent({
246
302
  return [...(navigationMainItems ?? []), ...(navigationOverflowItems ?? [])];
247
303
  }, [hasSideBar, navigationMainItems, navigationOverflowItems, navVariant]);
248
304
 
305
+ /*
306
+ * The burger badge speaks for the whole drawer, so it has to count what sits above the
307
+ * divider as well. A permanent destination asking for attention would otherwise go unseen
308
+ * while the drawer is closed.
309
+ */
310
+ const badgeNavItems = useMemo(
311
+ /*
312
+ * Gated on `hasSideBar`, not `sidebarNavItems`: on the left variant a rail that exists
313
+ * only for `sideTopItems` still has `sidebarNavItems` (i.e. `navigationMainItems`) undefined.
314
+ */
315
+ () => (hasSideBar ? [...(sideTopItems ?? []), ...(sidebarNavItems ?? [])] : undefined),
316
+ [hasSideBar, sideTopItems, sidebarNavItems]
317
+ );
318
+
249
319
  const hasMenuNotifications = useMemo(() => {
250
320
  try {
251
321
  return (
252
- sidebarNavItems?.some(item => {
322
+ badgeNavItems?.some(item => {
253
323
  if (item.counter || item.tag?.value) {
254
324
  return true;
255
325
  } else if (item.submenu) {
@@ -263,7 +333,7 @@ function TitanLayoutComponent({
263
333
  } catch {
264
334
  return false;
265
335
  }
266
- }, [sidebarNavItems]);
336
+ }, [badgeNavItems]);
267
337
 
268
338
  const limitContentWidth = useMemo(() => {
269
339
  if (view.isAnvil2 || !minContentWidth) {
@@ -395,9 +465,15 @@ function TitanLayoutComponent({
395
465
  onBarExpandChange={onBarExpandChange}
396
466
  submenusOpened={openSubmenus}
397
467
  onSubmenuOpenChange={onSubmenuOpenChange}
468
+ onSubmenusOpenChange={
469
+ onOpenSubmenusChange ? onSubmenusOpenChange : undefined
470
+ }
398
471
  drawerOpened={mobileDrawerOpened}
399
472
  onDrawerOpenChange={setMobileDrawerOpened}
400
473
  top={sideTop}
474
+ topItems={sideTopItems}
475
+ scrollIndicators={navigationScrollIndicators}
476
+ pinnedNavigation={pinnedNavigation}
401
477
  mainItems={sidebarNavItems}
402
478
  navigationComponent={context.NavigationComponent}
403
479
  onNavigationLinkClick={onNavigationLinkClick}
@@ -0,0 +1,145 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getOverflowNotifications } from '../../utils/navigation-notifications';
3
+
4
+ /**
5
+ * Measures what the rail's scroller is hiding, for the overflow notification pills.
6
+ *
7
+ * `enabled` is false on navigation versions that do not show the indicators. The refs are still
8
+ * handed back so the caller's markup is unchanged, but nothing is observed and no state is kept —
9
+ * this effect measures on every scroll and resize, which is not work to do for a feature that is
10
+ * switched off.
11
+ */
12
+ export function useNavigationScroll(enabled = true) {
13
+ const regionRef = useRef<HTMLDivElement>(null);
14
+ const scrollRef = useRef<HTMLDivElement>(null);
15
+ const aboveIndicatorRef = useRef<HTMLDivElement>(null);
16
+ const belowIndicatorRef = useRef<HTMLDivElement>(null);
17
+ const targets = useRef<{ above?: HTMLElement; below?: HTMLElement }>({});
18
+ const [overflow, setOverflow] = useState({
19
+ above: 0,
20
+ below: 0,
21
+ hasContentAbove: false,
22
+ hasContentBelow: false,
23
+ });
24
+
25
+ /*
26
+ * The counted area, inset from the scrolling box by whichever notification pills are on
27
+ * screen. The pills float over the rail's own edges, so a row one covers is hidden even
28
+ * though it still sits inside the scroller — without the inset the rail can report a count
29
+ * of zero while a pill is physically covering the row it should be counting. Only the pills
30
+ * inset: the scroll shadow is a separate element precisely so its 12px gradient, which
31
+ * hides nothing, does not shrink the area.
32
+ */
33
+ const getCountedViewport = useCallback((scroller: HTMLElement) => {
34
+ const { top, bottom } = scroller.getBoundingClientRect();
35
+ return {
36
+ top: top + (aboveIndicatorRef.current?.offsetHeight ?? 0),
37
+ bottom: bottom - (belowIndicatorRef.current?.offsetHeight ?? 0),
38
+ };
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ const scroller = scrollRef.current;
43
+ const region = regionRef.current;
44
+ if (!enabled || !scroller || !region) {
45
+ return;
46
+ }
47
+ let frame = 0;
48
+ const measure = () => {
49
+ frame = 0;
50
+ const viewport = getCountedViewport(scroller);
51
+ const rows = Array.from(
52
+ scroller.querySelectorAll<HTMLElement>('[data-navigation-notification-count]')
53
+ );
54
+ const { above, below, aboveIndex, belowIndex } = getOverflowNotifications(
55
+ rows.map(row => {
56
+ const { top, bottom } = row.getBoundingClientRect();
57
+ return {
58
+ top,
59
+ bottom,
60
+ count: Number(row.dataset.navigationNotificationCount) || 0,
61
+ };
62
+ }),
63
+ viewport
64
+ );
65
+ targets.current = { above: rows[aboveIndex], below: rows[belowIndex] };
66
+ const hasContentAbove = scroller.scrollTop > 1;
67
+ const hasContentBelow =
68
+ scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 1;
69
+ setOverflow(previous =>
70
+ previous.above === above &&
71
+ previous.below === below &&
72
+ previous.hasContentAbove === hasContentAbove &&
73
+ previous.hasContentBelow === hasContentBelow
74
+ ? previous
75
+ : { above, below, hasContentAbove, hasContentBelow }
76
+ );
77
+ };
78
+ const scheduleMeasure = () => {
79
+ if (!frame) {
80
+ frame = requestAnimationFrame(measure);
81
+ }
82
+ };
83
+ const resizeObserver = new ResizeObserver(scheduleMeasure);
84
+ resizeObserver.observe(scroller);
85
+ if (scroller.firstElementChild) {
86
+ resizeObserver.observe(scroller.firstElementChild);
87
+ }
88
+ const mutationObserver = new MutationObserver(scheduleMeasure);
89
+ /*
90
+ * The whole region rather than just the scroller: the pills mount and unmount as siblings
91
+ * of the scrolling element, and their height feeds back into the counted area above. The
92
+ * pass this schedules settles immediately, since a pill's height does not change with the
93
+ * number it shows (the label cannot wrap).
94
+ */
95
+ mutationObserver.observe(region, {
96
+ childList: true,
97
+ subtree: true,
98
+ attributes: true,
99
+ attributeFilter: ['data-navigation-notification-count', 'class', 'style'],
100
+ });
101
+ scroller.addEventListener('scroll', scheduleMeasure, { passive: true });
102
+ scheduleMeasure();
103
+ return () => {
104
+ cancelAnimationFrame(frame);
105
+ resizeObserver.disconnect();
106
+ mutationObserver.disconnect();
107
+ scroller.removeEventListener('scroll', scheduleMeasure);
108
+ };
109
+ }, [enabled, getCountedViewport]);
110
+
111
+ const revealNotification = useCallback(
112
+ (direction: 'above' | 'below') => {
113
+ const scroller = scrollRef.current;
114
+ const target = targets.current[direction];
115
+ if (!scroller || !target) {
116
+ return;
117
+ }
118
+ // Centred within the counted area, so the row lands clear of both pills.
119
+ const viewport = getCountedViewport(scroller);
120
+ const row = target.getBoundingClientRect();
121
+ // Reveal the item without activating its link or changing the selected destination.
122
+ target.focus({ preventScroll: true });
123
+ scroller.scrollTo({
124
+ top:
125
+ scroller.scrollTop +
126
+ row.top -
127
+ viewport.top -
128
+ (viewport.bottom - viewport.top - row.height) / 2,
129
+ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches
130
+ ? 'auto'
131
+ : 'smooth',
132
+ });
133
+ },
134
+ [getCountedViewport]
135
+ );
136
+
137
+ return {
138
+ regionRef,
139
+ scrollRef,
140
+ aboveIndicatorRef,
141
+ belowIndicatorRef,
142
+ ...overflow,
143
+ revealNotification,
144
+ };
145
+ }
@@ -0,0 +1,70 @@
1
+ import { NavigationItemData } from '../navigation';
2
+ import {
3
+ getModuleNotificationCount,
4
+ getNotificationCount,
5
+ getOverflowNotifications,
6
+ } from '../navigation-notifications';
7
+
8
+ describe('navigation notification counts', () => {
9
+ it('counts dot badges and positive counters, ignoring empty and invalid counters', () => {
10
+ expect(getNotificationCount(true)).toBe(1);
11
+ expect(getNotificationCount(123)).toBe(123);
12
+ [undefined, false, 0, -1, NaN, Infinity].forEach(value => {
13
+ expect(getNotificationCount(value)).toBe(0);
14
+ });
15
+ });
16
+
17
+ it('counts child counters and tags once as a module opens and closes', () => {
18
+ const module: NavigationItemData = {
19
+ id: 'accounting',
20
+ title: 'Accounting',
21
+ to: '/accounting',
22
+ icon: undefined,
23
+ iconActive: undefined,
24
+ counter: 3,
25
+ submenu: {
26
+ groups: [
27
+ {
28
+ title: 'Financials',
29
+ links: [
30
+ { id: 'inbox', title: 'Inbox', to: '/inbox', counter: 2 },
31
+ { id: 'bills', title: 'Bills', to: '/bills', tag: { value: true } },
32
+ ],
33
+ },
34
+ ],
35
+ },
36
+ };
37
+ expect(getModuleNotificationCount(module, false)).toBe(3);
38
+ expect(getModuleNotificationCount(module, true)).toBe(0);
39
+ expect(getModuleNotificationCount({ ...module, submenu: undefined }, true)).toBe(3);
40
+ });
41
+
42
+ it('sums fully hidden rows and selects the closest notification on either side', () => {
43
+ expect(
44
+ getOverflowNotifications(
45
+ [
46
+ { top: 0, bottom: 20, count: 2 },
47
+ { top: 20, bottom: 40, count: 1 },
48
+ { top: 60, bottom: 80, count: 3 },
49
+ { top: 120, bottom: 140, count: 1 },
50
+ { top: 140, bottom: 160, count: 2 },
51
+ ],
52
+ { top: 40, bottom: 120 }
53
+ )
54
+ ).toEqual({ above: 3, below: 3, aboveIndex: 1, belowIndex: 3 });
55
+ });
56
+
57
+ it('excludes partially visible, hidden, and cleared notification rows', () => {
58
+ expect(
59
+ getOverflowNotifications(
60
+ [
61
+ { top: 20, bottom: 60, count: 1 },
62
+ { top: 100, bottom: 140, count: 1 },
63
+ { top: 0, bottom: 0, count: 5 },
64
+ { top: 140, bottom: 180, count: 0 },
65
+ ],
66
+ { top: 40, bottom: 120 }
67
+ )
68
+ ).toEqual({ above: 0, below: 0, aboveIndex: -1, belowIndex: -1 });
69
+ });
70
+ });
@@ -0,0 +1,39 @@
1
+ import { updateOpenSubmenus } from '../submenu-state';
2
+
3
+ describe('updateOpenSubmenus', () => {
4
+ it('expands a partially open rail without duplicating ids or mutating host state', () => {
5
+ const current = ['accounting', 'hidden-module'];
6
+ expect(updateOpenSubmenus(current, ['accounting', 'dispatch', 'inventory'], true)).toEqual([
7
+ 'accounting',
8
+ 'hidden-module',
9
+ 'dispatch',
10
+ 'inventory',
11
+ ]);
12
+ expect(current).toEqual(['accounting', 'hidden-module']);
13
+ });
14
+
15
+ it('collapses only the visible modules and preserves unpinned module state', () => {
16
+ expect(
17
+ updateOpenSubmenus(
18
+ ['accounting', 'hidden-module', 'dispatch'],
19
+ ['accounting', 'dispatch'],
20
+ false
21
+ )
22
+ ).toEqual(['hidden-module']);
23
+ });
24
+
25
+ it('keeps individual and bulk actions consistent across repeated toggles', () => {
26
+ const visible = ['accounting', 'dispatch'];
27
+ const expanded = updateOpenSubmenus([], visible, true);
28
+ const partial = updateOpenSubmenus(expanded, ['accounting'], false);
29
+ expect(partial).toEqual(['dispatch']);
30
+ const collapsed = updateOpenSubmenus(partial, visible, false);
31
+ expect(collapsed).toEqual([]);
32
+ expect(updateOpenSubmenus(collapsed, visible, true)).toEqual(visible);
33
+ });
34
+
35
+ it('leaves state unchanged when no visible modules have child pages', () => {
36
+ expect(updateOpenSubmenus(['hidden-module'], [], false)).toEqual(['hidden-module']);
37
+ expect(updateOpenSubmenus([], [], true)).toEqual([]);
38
+ });
39
+ });
@@ -0,0 +1,59 @@
1
+ import { CounterTagValue } from './counter-tag';
2
+ import { NavigationItemData } from './navigation';
3
+
4
+ export const getNotificationCount = (value: CounterTagValue | undefined): number =>
5
+ value === true
6
+ ? 1
7
+ : typeof value === 'number' && Number.isFinite(value) && value > 0
8
+ ? value
9
+ : 0;
10
+
11
+ /** A closed module summarizes its pages; an open module lets each page count itself. */
12
+ export function getModuleNotificationCount(item: NavigationItemData, submenuOpened: boolean) {
13
+ const childCount = (item.submenu?.groups ?? []).reduce(
14
+ (total, group) =>
15
+ total +
16
+ group.links.reduce(
17
+ (sum, link) => sum + getNotificationCount(link.counter ?? link.tag?.value),
18
+ 0
19
+ ),
20
+ 0
21
+ );
22
+ return childCount > 0
23
+ ? submenuOpened
24
+ ? 0
25
+ : childCount
26
+ : getNotificationCount(item.counter ?? item.tag?.value);
27
+ }
28
+
29
+ export interface NotificationRowBounds {
30
+ top: number;
31
+ bottom: number;
32
+ count: number;
33
+ }
34
+
35
+ /** Only fully offscreen rows contribute; partially visible notifications are still discoverable. */
36
+ export function getOverflowNotifications(
37
+ rows: NotificationRowBounds[],
38
+ viewport: { top: number; bottom: number }
39
+ ) {
40
+ let above = 0;
41
+ let below = 0;
42
+ let aboveIndex = -1;
43
+ let belowIndex = -1;
44
+ rows.forEach((row, index) => {
45
+ if (row.count <= 0 || row.bottom <= row.top) {
46
+ return;
47
+ }
48
+ if (row.bottom <= viewport.top) {
49
+ above += row.count;
50
+ aboveIndex = index;
51
+ } else if (row.top >= viewport.bottom) {
52
+ below += row.count;
53
+ if (belowIndex === -1) {
54
+ belowIndex = index;
55
+ }
56
+ }
57
+ });
58
+ return { above, below, aboveIndex, belowIndex };
59
+ }
@@ -14,7 +14,7 @@ export function getSubmenuGroupTag(
14
14
 
15
15
  for (const group of submenu.groups) {
16
16
  for (const link of group.links) {
17
- const ltv: number | boolean | undefined = link.counter;
17
+ const ltv: number | boolean | undefined = link.counter ?? link.tag?.value;
18
18
 
19
19
  if (ltv) {
20
20
  if (typeof ltv === 'number') {
@@ -0,0 +1,16 @@
1
+ /** Update only the requested modules, preserving the state of modules outside the action. */
2
+ export const updateOpenSubmenus = (
3
+ current: readonly string[],
4
+ moduleIds: readonly string[],
5
+ opened: boolean
6
+ ): string[] => {
7
+ const next = new Set(current);
8
+ moduleIds.forEach(id => {
9
+ if (opened) {
10
+ next.add(id);
11
+ } else {
12
+ next.delete(id);
13
+ }
14
+ });
15
+ return [...next];
16
+ };