@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.
- package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts +2 -0
- package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts.map +1 -0
- package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts +2 -0
- package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts.map +1 -0
- package/dist/components/titan-layout/interface.d.ts +66 -0
- package/dist/components/titan-layout/interface.d.ts.map +1 -1
- package/dist/components/titan-layout/interface.js.map +1 -1
- package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts +3 -0
- package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts.map +1 -1
- package/dist/components/titan-layout/layout-sidebar-links-internal.js +58 -13
- package/dist/components/titan-layout/layout-sidebar-links-internal.js.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.d.ts +14 -0
- package/dist/components/titan-layout/layout-sidebar.d.ts.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.js +169 -97
- package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.module.less +230 -144
- package/dist/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
- package/dist/components/titan-layout/navigation-catalog/index.d.ts +3 -0
- package/dist/components/titan-layout/navigation-catalog/index.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/index.js +4 -0
- package/dist/components/titan-layout/navigation-catalog/index.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts +8 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js +88 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts +21 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +138 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts +14 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +343 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less +379 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +26 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.d.ts +12 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.d.ts.map +1 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.js +48 -0
- package/dist/components/titan-layout/navigation-scroll-indicators.js.map +1 -0
- package/dist/components/titan-layout/titan-layout.d.ts +29 -1
- package/dist/components/titan-layout/titan-layout.d.ts.map +1 -1
- package/dist/components/titan-layout/titan-layout.js +46 -6
- package/dist/components/titan-layout/titan-layout.js.map +1 -1
- package/dist/components/titan-layout/titan-layout.module.less +2 -1
- package/dist/components/titan-layout/use-navigation-scroll.d.ts +20 -0
- package/dist/components/titan-layout/use-navigation-scroll.d.ts.map +1 -0
- package/dist/components/titan-layout/use-navigation-scroll.js +140 -0
- package/dist/components/titan-layout/use-navigation-scroll.js.map +1 -0
- package/dist/utils/__tests__/catalog-groups.test.d.ts +2 -0
- package/dist/utils/__tests__/catalog-groups.test.d.ts.map +1 -0
- package/dist/utils/__tests__/navigation-notifications.test.d.ts +2 -0
- package/dist/utils/__tests__/navigation-notifications.test.d.ts.map +1 -0
- package/dist/utils/__tests__/submenu-state.test.d.ts +2 -0
- package/dist/utils/__tests__/submenu-state.test.d.ts.map +1 -0
- package/dist/utils/catalog-groups.d.ts +11 -0
- package/dist/utils/catalog-groups.d.ts.map +1 -0
- package/dist/utils/catalog-groups.js +66 -0
- package/dist/utils/catalog-groups.js.map +1 -0
- package/dist/utils/navigation-notifications.d.ts +21 -0
- package/dist/utils/navigation-notifications.d.ts.map +1 -0
- package/dist/utils/navigation-notifications.js +39 -0
- package/dist/utils/navigation-notifications.js.map +1 -0
- package/dist/utils/side-nav.js +3 -1
- package/dist/utils/side-nav.js.map +1 -1
- package/dist/utils/submenu-state.d.ts +3 -0
- package/dist/utils/submenu-state.d.ts.map +1 -0
- package/dist/utils/submenu-state.js +15 -0
- package/dist/utils/submenu-state.js.map +1 -0
- package/package.json +2 -2
- package/src/components/titan-layout/__tests__/layout-sidebar-links-internal.test.tsx +225 -0
- package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +190 -0
- package/src/components/titan-layout/interface.ts +73 -0
- package/src/components/titan-layout/layout-sidebar-links-internal.tsx +86 -14
- package/src/components/titan-layout/layout-sidebar.module.less +230 -144
- package/src/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
- package/src/components/titan-layout/layout-sidebar.tsx +193 -117
- package/src/components/titan-layout/navigation-catalog/index.ts +2 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-footer.tsx +88 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +170 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +379 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +26 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +430 -0
- package/src/components/titan-layout/navigation-scroll-indicators.tsx +80 -0
- package/src/components/titan-layout/titan-layout.module.less +2 -1
- package/src/components/titan-layout/titan-layout.tsx +81 -5
- package/src/components/titan-layout/use-navigation-scroll.ts +145 -0
- package/src/utils/__tests__/catalog-groups.test.ts +97 -0
- package/src/utils/__tests__/navigation-notifications.test.ts +70 -0
- package/src/utils/__tests__/submenu-state.test.ts +39 -0
- package/src/utils/catalog-groups.ts +57 -0
- package/src/utils/navigation-notifications.ts +59 -0
- package/src/utils/side-nav.ts +1 -1
- 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
|
+
}
|
package/src/utils/side-nav.ts
CHANGED
|
@@ -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
|
+
};
|