@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,430 @@
1
+ import { Button, Dialog, Icon, SearchField, Text, Tooltip } from '@servicetitan/anvil2';
2
+ import IconLock from '@servicetitan/anvil2/assets/icons/material/round/lock.svg';
3
+ import IconStar from '@servicetitan/anvil2/assets/icons/material/round/star.svg';
4
+ import IconStarBorder from '@servicetitan/anvil2/assets/icons/material/round/star_border.svg';
5
+ import classNames from 'classnames';
6
+ import { FC, Fragment, MouseEvent, useMemo, useRef, useState } from 'react';
7
+ import {
8
+ filterCatalogGroups,
9
+ getCatalogChildren,
10
+ getCatalogSubmenuGroups,
11
+ } from '../../../utils/catalog-groups';
12
+ import { NavigationItemData } from '../../../utils/navigation';
13
+ import { getCounterTag } from '../../../utils/side-nav';
14
+ import { BadgeTag } from '../../badge-tag';
15
+ import { PinnedNavigationConfig } from '../interface';
16
+ import { useTitanLayoutContext } from '../layout-context';
17
+ import { NavigationCatalogFooter } from './navigation-catalog-footer';
18
+ import * as styles from './navigation-catalog.module.less';
19
+
20
+ /** Shown in place of the pin control for a destination the user may not unpin. */
21
+ const LockedPin: FC = () => (
22
+ <Tooltip>
23
+ <Tooltip.Trigger>
24
+ <Icon svg={IconLock} className={styles.lock} />
25
+ </Tooltip.Trigger>
26
+ <Tooltip.Content style={{ pointerEvents: 'none' }}>
27
+ Cannot be removed from Favorites
28
+ </Tooltip.Content>
29
+ </Tooltip>
30
+ );
31
+
32
+ interface PinToggleProps {
33
+ /** Mixed modules stay highlighted but use an outline until all pages are pinned. */
34
+ pinned: boolean | 'mixed';
35
+ label: string;
36
+ onToggle: () => void;
37
+ }
38
+
39
+ const PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {
40
+ const toggle = (event: MouseEvent) => {
41
+ // The control sits inside the row but is not a navigation affordance.
42
+ event.preventDefault();
43
+ event.stopPropagation();
44
+ onToggle();
45
+ };
46
+
47
+ return (
48
+ <Tooltip delay={300}>
49
+ <Tooltip.Trigger>
50
+ <Button
51
+ appearance="ghost"
52
+ size="small"
53
+ icon={pinned === true ? IconStar : IconStarBorder}
54
+ className={classNames(styles.star, pinned && styles.starActive)}
55
+ aria-label={label}
56
+ aria-pressed={pinned}
57
+ onClick={toggle}
58
+ />
59
+ </Tooltip.Trigger>
60
+ <Tooltip.Content style={{ pointerEvents: 'none' }}>
61
+ {pinned === 'mixed'
62
+ ? 'Add this module to Favorites'
63
+ : pinned
64
+ ? 'Remove from Favorites'
65
+ : 'Add to Favorites'}
66
+ </Tooltip.Content>
67
+ </Tooltip>
68
+ );
69
+ };
70
+
71
+ interface CatalogRowsProps {
72
+ item: NavigationItemData;
73
+ /**
74
+ * Every id the module's own control covers, taken from the unfiltered item.
75
+ *
76
+ * `item` may have been narrowed by the search, so deriving this from it would let the control
77
+ * act on only the pages that happen to be visible — silently stranding the rest.
78
+ */
79
+ scopeIds: string[];
80
+ isPinned: (id: string) => boolean;
81
+ isLocked: (id: string) => boolean;
82
+ /** `ownerId` is the module the page belongs to; omitted for the module's own row. */
83
+ onTogglePin: (id: string, ownerId?: string) => void;
84
+ onToggleAll: (ids: string[], shouldPin: boolean) => void;
85
+ onNavigate: () => void;
86
+ }
87
+
88
+ const CatalogRows: FC<CatalogRowsProps> = ({
89
+ item,
90
+ scopeIds,
91
+ isPinned,
92
+ isLocked,
93
+ onTogglePin,
94
+ onToggleAll,
95
+ onNavigate,
96
+ }) => {
97
+ const { NavigationComponent } = useTitanLayoutContext();
98
+ const submenuGroups = getCatalogSubmenuGroups(item);
99
+
100
+ /*
101
+ * Minus anything locked — locked destinations are always present, so the module's control
102
+ * neither reports on them nor acts on them.
103
+ */
104
+ const everything = scopeIds.filter(id => !isLocked(id));
105
+ const anyPinned = everything.some(isPinned);
106
+ const allPinned = everything.length > 0 && everything.every(isPinned);
107
+ const tag = getCounterTag(item.counter, item.tag);
108
+
109
+ return (
110
+ <div className={styles.moduleGroup}>
111
+ <div className={styles.row}>
112
+ <NavigationComponent
113
+ to={item.to}
114
+ className={styles.link}
115
+ onClick={onNavigate}
116
+ aria-label={item.title}
117
+ >
118
+ {item.icon && (
119
+ <Icon svg={item.icon} size="medium" className={styles.moduleIcon} />
120
+ )}
121
+ <Text inline size="small" className={styles.moduleTitle}>
122
+ {item.title}
123
+ </Text>
124
+ {/*
125
+ * The same counter the rail shows, so a destination that is asking for
126
+ * attention still says so here — the catalog is the only place an unfavourited
127
+ * one is visible at all.
128
+ */}
129
+ {!!tag && <BadgeTag data={tag} className={styles.badge} />}
130
+ </NavigationComponent>
131
+ {/*
132
+ * One control for the whole module: favouriting it takes its pages with it, and
133
+ * unfavouriting it takes them away again.
134
+ *
135
+ * A gold outline marks a partially favourited module. Fill the star only when
136
+ * its full, unfiltered scope is favourited, including pages hidden by search.
137
+ * Clicking the outline adds the remaining pages; clicking the filled star removes all.
138
+ */}
139
+ {isLocked(item.id) ? (
140
+ <LockedPin />
141
+ ) : (
142
+ <PinToggle
143
+ pinned={allPinned ? true : anyPinned ? 'mixed' : false}
144
+ label={
145
+ allPinned
146
+ ? `Unpin ${item.title} from the navigation`
147
+ : `Pin ${item.title} to the navigation`
148
+ }
149
+ onToggle={() => onToggleAll(everything, !allPinned)}
150
+ />
151
+ )}
152
+ </div>
153
+ {submenuGroups.map(group => (
154
+ <div key={group.links[0].id}>
155
+ {group.title && (
156
+ <Text variant="eyebrow" size="small" className={styles.submenuTitle}>
157
+ {group.title}
158
+ </Text>
159
+ )}
160
+ {group.links.map(child => {
161
+ /*
162
+ * Page titles are only unique within their item — "Settings" and "Reports" recur
163
+ * across the catalog — and the row that tells them apart is a separate element, so
164
+ * it is not part of any of these names. Anything reading the rows on their own, a
165
+ * screen reader running through the links or a voice command naming one, would
166
+ * otherwise be given the same name several times.
167
+ */
168
+ const qualifiedTitle = `${item.title}: ${child.title}`;
169
+ const childPinned = isPinned(child.id);
170
+ const childTag = getCounterTag(child.counter, child.tag);
171
+
172
+ return (
173
+ <div key={child.id} className={styles.row}>
174
+ <NavigationComponent
175
+ to={child.to}
176
+ className={classNames(styles.link, styles.childLink)}
177
+ onClick={onNavigate}
178
+ aria-label={qualifiedTitle}
179
+ >
180
+ <Text inline className={styles.childTitle}>
181
+ {child.title}
182
+ </Text>
183
+ {!!childTag && (
184
+ <BadgeTag data={childTag} className={styles.badge} />
185
+ )}
186
+ </NavigationComponent>
187
+ {isLocked(child.id) ? (
188
+ <LockedPin />
189
+ ) : (
190
+ <PinToggle
191
+ pinned={childPinned}
192
+ label={
193
+ childPinned
194
+ ? `Unpin ${qualifiedTitle} from the navigation`
195
+ : `Pin ${qualifiedTitle} to the navigation`
196
+ }
197
+ onToggle={() => onTogglePin(child.id, item.id)}
198
+ />
199
+ )}
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
+ ))}
205
+ </div>
206
+ );
207
+ };
208
+
209
+ export interface NavigationCatalogProps extends PinnedNavigationConfig {
210
+ open: boolean;
211
+ onClose: () => void;
212
+ }
213
+
214
+ /**
215
+ * Every destination the host offers, grouped into sections, with a star on every row.
216
+ *
217
+ * The catalog shows what it is given and nothing more — the host has already resolved what this
218
+ * user may reach, so the catalog cannot offer a destination they do not have.
219
+ */
220
+ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
221
+ open,
222
+ onClose,
223
+ groups,
224
+ pinnedIds,
225
+ lockedIds,
226
+ onPinnedIdsChange,
227
+ onResetToDefaults,
228
+ }) => {
229
+ const [query, setQuery] = useState('');
230
+ const isSearching = query.trim().length > 0;
231
+ const searchRef = useRef<HTMLInputElement>(null);
232
+
233
+ /*
234
+ * Resolved here rather than by the host, so a catalog that costs requests to enumerate is paid
235
+ * for on opening the dialog and not on every page load. This component is mounted only while
236
+ * open, so the call happens once per open unless the host changes the function.
237
+ */
238
+ const resolvedGroups = useMemo(() => groups(), [groups]);
239
+
240
+ const visibleGroups = useMemo(
241
+ () => filterCatalogGroups(resolvedGroups, query),
242
+ [resolvedGroups, query]
243
+ );
244
+
245
+ /*
246
+ * The pin scope of every item, keyed by id and built before the search narrows anything, so a
247
+ * module's control still covers the pages a query is currently hiding.
248
+ */
249
+ const scopeById = useMemo(
250
+ () =>
251
+ new Map(
252
+ resolvedGroups
253
+ .flatMap(group => group.items)
254
+ .map(item => [
255
+ item.id,
256
+ [item.id, ...getCatalogChildren(item).map(child => child.id)],
257
+ ])
258
+ ),
259
+ [resolvedGroups]
260
+ );
261
+ const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);
262
+ const lockedSet = useMemo(() => new Set(lockedIds ?? []), [lockedIds]);
263
+
264
+ const isPinned = (id: string) => pinnedSet.has(id);
265
+ const isLocked = (id: string) => lockedSet.has(id);
266
+
267
+ /**
268
+ * Favourites or unfavourites one row, carrying its module with it.
269
+ *
270
+ * A page is reached through its module, so the two travel together: favouriting a page brings
271
+ * the module along when it is not already there, and unfavouriting the last one takes the
272
+ * module with it. Leaving the module behind on its own would not read as an emptier
273
+ * navigation — it would put the module back carrying every page it has.
274
+ *
275
+ * Locked ids never reach a proposal. The controls that would move them are not rendered, so
276
+ * that part is belt and braces — but the host's list is the thing that gets persisted, and a
277
+ * locked destination has no business appearing in or disappearing from it.
278
+ */
279
+ const togglePin = (id: string, ownerId?: string) => {
280
+ if (lockedSet.has(id)) {
281
+ return;
282
+ }
283
+
284
+ /** The module, when it is a separate row that this page can legitimately move. */
285
+ const owner = ownerId !== id && ownerId && !lockedSet.has(ownerId) ? ownerId : undefined;
286
+
287
+ if (pinnedSet.has(id)) {
288
+ const removing = new Set([id]);
289
+
290
+ if (owner) {
291
+ const siblings = (scopeById.get(owner) ?? []).filter(
292
+ scoped => scoped !== owner && scoped !== id
293
+ );
294
+
295
+ if (!siblings.some(sibling => pinnedSet.has(sibling))) {
296
+ removing.add(owner);
297
+ }
298
+ }
299
+
300
+ onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
301
+ return;
302
+ }
303
+
304
+ // Module first, so it reads ahead of its own page in the saved order.
305
+ const additions = owner && !pinnedSet.has(owner) ? [owner, id] : [id];
306
+
307
+ onPinnedIdsChange([...pinnedIds, ...additions]);
308
+ };
309
+
310
+ /*
311
+ * One proposal for the whole set rather than one per row, so a host that persists on every
312
+ * change writes once, and every intermediate list — states the user never asked for — never
313
+ * exists.
314
+ */
315
+ const toggleAll = (ids: string[], shouldPin: boolean) => {
316
+ const actionable = ids.filter(id => !lockedSet.has(id));
317
+
318
+ if (!shouldPin) {
319
+ const removing = new Set(actionable);
320
+
321
+ onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
322
+ return;
323
+ }
324
+
325
+ const additions = actionable.filter(id => !pinnedSet.has(id));
326
+
327
+ if (additions.length > 0) {
328
+ onPinnedIdsChange([...pinnedIds, ...additions]);
329
+ }
330
+ };
331
+
332
+ const close = () => {
333
+ setQuery('');
334
+ onClose();
335
+ };
336
+
337
+ return (
338
+ <Dialog
339
+ open={open}
340
+ onClose={close}
341
+ size="xlarge"
342
+ aria-label="All Navigation"
343
+ className={styles.dialog}
344
+ data-cy="navigation-catalog-dialog"
345
+ >
346
+ <Dialog.Content className={styles.content}>
347
+ <div className={styles.body}>
348
+ <SearchField
349
+ ref={searchRef}
350
+ size="small"
351
+ className={styles.search}
352
+ placeholder="Go to ..."
353
+ value={query}
354
+ onChange={event => setQuery(event.target.value)}
355
+ onClear={() => setQuery('')}
356
+ aria-label="Go to"
357
+ />
358
+ <div
359
+ className={classNames(styles.results, isSearching && styles.searchResults)}
360
+ >
361
+ {visibleGroups.length === 0 ? (
362
+ <div className={styles.empty}>
363
+ {/*
364
+ * Two different nothings: a search that matched no row, and a host
365
+ * that offered no rows at all. The second is a real state — the
366
+ * catalog is reachable whether or not there is anything in it — and
367
+ * quoting an empty query at the user does not describe it.
368
+ */}
369
+ <Text subdued>
370
+ {isSearching ? (
371
+ <Fragment>
372
+ No navigation items match &ldquo;{query}&rdquo;.
373
+ </Fragment>
374
+ ) : (
375
+ 'There are no navigation items to show.'
376
+ )}
377
+ </Text>
378
+ </div>
379
+ ) : (
380
+ <div className={isSearching ? undefined : styles.columns}>
381
+ {visibleGroups.map(group => (
382
+ <div
383
+ key={group.title}
384
+ className={isSearching ? undefined : styles.group}
385
+ >
386
+ {/*
387
+ * Browse uses titled columns. Search stacks matches at full
388
+ * width, keeping only each module's own submenu headings.
389
+ */}
390
+ {!isSearching && (
391
+ <Text
392
+ variant="eyebrow"
393
+ size="medium"
394
+ className={styles.groupTitle}
395
+ >
396
+ {group.title}
397
+ </Text>
398
+ )}
399
+ {group.items.map(item => (
400
+ <CatalogRows
401
+ key={item.id}
402
+ item={item}
403
+ scopeIds={scopeById.get(item.id) ?? [item.id]}
404
+ isPinned={isPinned}
405
+ isLocked={isLocked}
406
+ onTogglePin={togglePin}
407
+ onToggleAll={toggleAll}
408
+ onNavigate={close}
409
+ />
410
+ ))}
411
+ </div>
412
+ ))}
413
+ </div>
414
+ )}
415
+ </div>
416
+ </div>
417
+ </Dialog.Content>
418
+ <NavigationCatalogFooter
419
+ onRestore={
420
+ onResetToDefaults &&
421
+ (() => {
422
+ onResetToDefaults();
423
+ // The control disappears with the host's reset; keep focus in the dialog.
424
+ searchRef.current?.focus();
425
+ })
426
+ }
427
+ />
428
+ </Dialog>
429
+ );
430
+ };
@@ -0,0 +1,80 @@
1
+ import { Button } from '@servicetitan/anvil2';
2
+ import SvgArrowDown from '@servicetitan/anvil2/assets/icons/material/round/arrow_downward.svg';
3
+ import SvgArrowUp from '@servicetitan/anvil2/assets/icons/material/round/arrow_upward.svg';
4
+ import classNames from 'classnames';
5
+ import { Fragment, RefObject } from 'react';
6
+ import * as Styles from './layout-sidebar.module.less';
7
+
8
+ export function NavigationScrollIndicators({
9
+ expanded,
10
+ above,
11
+ below,
12
+ hasContentAbove,
13
+ hasContentBelow,
14
+ aboveIndicatorRef,
15
+ belowIndicatorRef,
16
+ onReveal,
17
+ }: {
18
+ expanded: boolean;
19
+ above: number;
20
+ below: number;
21
+ hasContentAbove: boolean;
22
+ hasContentBelow: boolean;
23
+ aboveIndicatorRef: RefObject<HTMLDivElement>;
24
+ belowIndicatorRef: RefObject<HTMLDivElement>;
25
+ onReveal(direction: 'above' | 'below'): void;
26
+ }) {
27
+ return (
28
+ <Fragment>
29
+ {(['above', 'below'] as const).map(direction => {
30
+ const isAbove = direction === 'above';
31
+ const count = isAbove ? above : below;
32
+ const hasContent = isAbove ? hasContentAbove : hasContentBelow;
33
+ const label = `${count} ${count === 1 ? 'notification' : 'notifications'}`;
34
+ return (
35
+ <Fragment key={direction}>
36
+ {/*
37
+ * Its own element rather than the pill's background: the pill's height
38
+ * insets the rail's counted area, and this gradient hides nothing, so it
39
+ * must not. See `useNavigationScroll`.
40
+ */}
41
+ {hasContent && (
42
+ <div
43
+ className={classNames(
44
+ Styles.scrollShadow,
45
+ isAbove ? Styles.scrollShadowAbove : Styles.scrollShadowBelow
46
+ )}
47
+ />
48
+ )}
49
+ {count > 0 && (
50
+ <div
51
+ ref={isAbove ? aboveIndicatorRef : belowIndicatorRef}
52
+ className={classNames(
53
+ Styles.scrollIndicator,
54
+ isAbove
55
+ ? Styles.scrollIndicatorAbove
56
+ : Styles.scrollIndicatorBelow
57
+ )}
58
+ >
59
+ <Button
60
+ appearance="secondary"
61
+ size="xsmall"
62
+ icon={{ before: isAbove ? SvgArrowUp : SvgArrowDown }}
63
+ className={Styles.notificationButton}
64
+ data-cy={`navigation-notifications-${direction}`}
65
+ aria-label={`${label} ${direction}. Show nearest notification`}
66
+ onClick={event => {
67
+ event.stopPropagation();
68
+ onReveal(direction);
69
+ }}
70
+ >
71
+ {expanded ? label : count >= 100 ? '99+' : count}
72
+ </Button>
73
+ </div>
74
+ )}
75
+ </Fragment>
76
+ );
77
+ })}
78
+ </Fragment>
79
+ );
80
+ }
@@ -82,7 +82,8 @@
82
82
  }
83
83
 
84
84
  &.layout-desktop.layout-nav-wide {
85
- --nav-offset-left: 212px;
85
+ // Figma 042. Pantheon: the expanded rail is 220 wide (a 204 row column inset 8 each side).
86
+ --nav-offset-left: 220px;
86
87
  }
87
88
 
88
89
  .top {
@@ -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}