@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.
- 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 +382 -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 +436 -0
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -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__/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/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 +186 -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 +436 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +458 -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__/navigation-notifications.test.ts +70 -0
- package/src/utils/__tests__/submenu-state.test.ts +39 -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
|
@@ -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 ||
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
+
}
|
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
|
+
};
|