@servicetitan/navigation 14.2.0 → 14.4.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 (97) 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 +343 -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 +379 -0
  37. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +26 -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__/catalog-groups.test.d.ts +2 -0
  52. package/dist/utils/__tests__/catalog-groups.test.d.ts.map +1 -0
  53. package/dist/utils/__tests__/navigation-notifications.test.d.ts +2 -0
  54. package/dist/utils/__tests__/navigation-notifications.test.d.ts.map +1 -0
  55. package/dist/utils/__tests__/submenu-state.test.d.ts +2 -0
  56. package/dist/utils/__tests__/submenu-state.test.d.ts.map +1 -0
  57. package/dist/utils/catalog-groups.d.ts +11 -0
  58. package/dist/utils/catalog-groups.d.ts.map +1 -0
  59. package/dist/utils/catalog-groups.js +66 -0
  60. package/dist/utils/catalog-groups.js.map +1 -0
  61. package/dist/utils/navigation-notifications.d.ts +21 -0
  62. package/dist/utils/navigation-notifications.d.ts.map +1 -0
  63. package/dist/utils/navigation-notifications.js +39 -0
  64. package/dist/utils/navigation-notifications.js.map +1 -0
  65. package/dist/utils/side-nav.js +3 -1
  66. package/dist/utils/side-nav.js.map +1 -1
  67. package/dist/utils/submenu-state.d.ts +3 -0
  68. package/dist/utils/submenu-state.d.ts.map +1 -0
  69. package/dist/utils/submenu-state.js +15 -0
  70. package/dist/utils/submenu-state.js.map +1 -0
  71. package/package.json +2 -2
  72. package/src/components/titan-layout/__tests__/layout-sidebar-links-internal.test.tsx +225 -0
  73. package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +190 -0
  74. package/src/components/titan-layout/interface.ts +73 -0
  75. package/src/components/titan-layout/layout-sidebar-links-internal.tsx +86 -14
  76. package/src/components/titan-layout/layout-sidebar.module.less +230 -144
  77. package/src/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
  78. package/src/components/titan-layout/layout-sidebar.tsx +193 -117
  79. package/src/components/titan-layout/navigation-catalog/index.ts +2 -0
  80. package/src/components/titan-layout/navigation-catalog/navigation-catalog-footer.tsx +88 -0
  81. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
  82. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
  83. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +170 -0
  84. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +379 -0
  85. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +26 -0
  86. package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +430 -0
  87. package/src/components/titan-layout/navigation-scroll-indicators.tsx +80 -0
  88. package/src/components/titan-layout/titan-layout.module.less +2 -1
  89. package/src/components/titan-layout/titan-layout.tsx +81 -5
  90. package/src/components/titan-layout/use-navigation-scroll.ts +145 -0
  91. package/src/utils/__tests__/catalog-groups.test.ts +97 -0
  92. package/src/utils/__tests__/navigation-notifications.test.ts +70 -0
  93. package/src/utils/__tests__/submenu-state.test.ts +39 -0
  94. package/src/utils/catalog-groups.ts +57 -0
  95. package/src/utils/navigation-notifications.ts +59 -0
  96. package/src/utils/side-nav.ts +1 -1
  97. package/src/utils/submenu-state.ts +16 -0
@@ -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,97 @@
1
+ import {
2
+ filterCatalogGroups,
3
+ getCatalogChildren,
4
+ getCatalogSubmenuGroups,
5
+ } from '../catalog-groups';
6
+ import { NavigationItemData } from '../navigation';
7
+
8
+ const marketing: NavigationItemData = {
9
+ id: 'marketing',
10
+ title: 'Marketing',
11
+ to: '/marketing',
12
+ icon: undefined,
13
+ iconActive: undefined,
14
+ submenu: {
15
+ groups: [
16
+ {
17
+ title: 'Analytics',
18
+ links: [
19
+ { id: 'overview', title: 'Marketing Overview', to: '/marketing/overview' },
20
+ { id: 'one-time', title: 'One-Time Campaigns', to: '/marketing/one-time' },
21
+ ],
22
+ },
23
+ {
24
+ title: 'Campaigns',
25
+ links: [
26
+ { id: 'manager', title: 'Campaign Manager', to: '/marketing/manager' },
27
+ { id: 'templates', title: 'Email Templates', to: '/marketing/templates' },
28
+ ],
29
+ },
30
+ ],
31
+ },
32
+ };
33
+ const catalog = [{ title: 'Demand', items: [marketing] }];
34
+
35
+ describe('catalog submenu groups', () => {
36
+ it('keeps matching pages under their original headings and in source order', () => {
37
+ const result = filterCatalogGroups(catalog, ' CAMPAIGN ');
38
+ expect(result[0].items[0].submenu?.groups).toEqual([
39
+ { title: 'Analytics', links: [marketing.submenu!.groups[0].links[1]] },
40
+ { title: 'Campaigns', links: [marketing.submenu!.groups[1].links[0]] },
41
+ ]);
42
+ });
43
+
44
+ it('omits groups with no matches without changing the full module used for bulk favorites', () => {
45
+ const result = filterCatalogGroups(catalog, 'email');
46
+ expect(result[0].items[0].submenu?.groups).toEqual([
47
+ { title: 'Campaigns', links: [marketing.submenu!.groups[1].links[1]] },
48
+ ]);
49
+ expect(getCatalogChildren(marketing).map(child => child.id)).toEqual([
50
+ 'overview',
51
+ 'one-time',
52
+ 'manager',
53
+ 'templates',
54
+ ]);
55
+ expect(marketing.submenu!.groups.map(group => group.links.length)).toEqual([2, 2]);
56
+ });
57
+
58
+ it('renders duplicate pages once and omits groups left empty by deduplication', () => {
59
+ const groups = getCatalogSubmenuGroups({
60
+ ...marketing,
61
+ submenu: {
62
+ groups: [
63
+ { ...marketing.submenu!.groups[0], title: ' Analytics ' },
64
+ { title: 'Duplicate', links: marketing.submenu!.groups[0].links },
65
+ { title: 'Empty', links: [] },
66
+ marketing.submenu!.groups[1],
67
+ ],
68
+ },
69
+ });
70
+ expect(groups).toEqual(marketing.submenu!.groups);
71
+ });
72
+
73
+ it('retains ungrouped pages without introducing a blank heading', () => {
74
+ const [group] = getCatalogSubmenuGroups({
75
+ ...marketing,
76
+ submenu: { groups: [{ title: ' ', links: marketing.submenu!.groups[0].links }] },
77
+ });
78
+ expect(group.title).toBe('');
79
+ expect(group.links).toEqual(marketing.submenu!.groups[0].links);
80
+ });
81
+
82
+ it('keeps module-only matches without adding unrelated child pages', () => {
83
+ const result = filterCatalogGroups(catalog, 'Marketing Overview');
84
+ expect(result[0].items[0].submenu?.groups[0].title).toBe('Analytics');
85
+ const moduleOnly = filterCatalogGroups(
86
+ [{ title: 'Demand', items: [{ ...marketing, title: 'Marketing Pro' }] }],
87
+ 'Marketing Pro'
88
+ );
89
+ expect(moduleOnly[0].items[0].submenu).toBeUndefined();
90
+ });
91
+
92
+ it('restores the full catalog on clear and removes empty results', () => {
93
+ expect(filterCatalogGroups(catalog, ' ')).toBe(catalog);
94
+ expect(filterCatalogGroups(catalog, 'no such destination')).toEqual([]);
95
+ expect(getCatalogSubmenuGroups({ ...marketing, submenu: undefined })).toEqual([]);
96
+ });
97
+ });
@@ -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,57 @@
1
+ import { NavigationCatalogGroup } from '../components/titan-layout/interface';
2
+ import { NavigationItemData } from './navigation';
3
+
4
+ /** Preserve submenu headings while offering each page only once within its module. */
5
+ export const getCatalogSubmenuGroups = (item: NavigationItemData) => {
6
+ const seen = new Set<string>();
7
+
8
+ return (item.submenu?.groups ?? []).flatMap(group => {
9
+ const links = (group.links ?? []).filter(link => {
10
+ if (seen.has(link.id)) {
11
+ return false;
12
+ }
13
+
14
+ seen.add(link.id);
15
+ return true;
16
+ });
17
+
18
+ return links.length ? [{ ...group, title: group.title?.trim() ?? '', links }] : [];
19
+ });
20
+ };
21
+
22
+ export const getCatalogChildren = (item: NavigationItemData) =>
23
+ getCatalogSubmenuGroups(item).flatMap(group => group.links);
24
+
25
+ /** Match destination titles literally, retaining the headings of matching child pages. */
26
+ export const filterCatalogGroups = (groups: NavigationCatalogGroup[], query: string) => {
27
+ const needle = query.trim().toLowerCase();
28
+
29
+ if (!needle) {
30
+ return groups;
31
+ }
32
+
33
+ return groups.reduce<NavigationCatalogGroup[]>((out, group) => {
34
+ const items = group.items.reduce<NavigationItemData[]>((kept, item) => {
35
+ const matchingGroups = getCatalogSubmenuGroups(item).flatMap(submenuGroup => {
36
+ const links = submenuGroup.links.filter(child =>
37
+ child.title.toLowerCase().includes(needle)
38
+ );
39
+ return links.length ? [{ ...submenuGroup, links }] : [];
40
+ });
41
+
42
+ if (matchingGroups.length > 0) {
43
+ kept.push({ ...item, submenu: { groups: matchingGroups } });
44
+ } else if (item.title.toLowerCase().includes(needle)) {
45
+ kept.push({ ...item, submenu: undefined });
46
+ }
47
+
48
+ return kept;
49
+ }, []);
50
+
51
+ if (items.length > 0) {
52
+ out.push({ ...group, items });
53
+ }
54
+
55
+ return out;
56
+ }, []);
57
+ };
@@ -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
+ };