@servicetitan/navigation 14.2.0 → 14.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts +2 -0
  2. package/dist/components/titan-layout/__tests__/layout-sidebar-links-internal.test.d.ts.map +1 -0
  3. package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts +2 -0
  4. package/dist/components/titan-layout/__tests__/navigation-catalog-reset.test.d.ts.map +1 -0
  5. package/dist/components/titan-layout/interface.d.ts +66 -0
  6. package/dist/components/titan-layout/interface.d.ts.map +1 -1
  7. package/dist/components/titan-layout/interface.js.map +1 -1
  8. package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts +3 -0
  9. package/dist/components/titan-layout/layout-sidebar-links-internal.d.ts.map +1 -1
  10. package/dist/components/titan-layout/layout-sidebar-links-internal.js +58 -13
  11. package/dist/components/titan-layout/layout-sidebar-links-internal.js.map +1 -1
  12. package/dist/components/titan-layout/layout-sidebar.d.ts +14 -0
  13. package/dist/components/titan-layout/layout-sidebar.d.ts.map +1 -1
  14. package/dist/components/titan-layout/layout-sidebar.js +169 -97
  15. package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
  16. package/dist/components/titan-layout/layout-sidebar.module.less +230 -144
  17. package/dist/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
  18. package/dist/components/titan-layout/navigation-catalog/index.d.ts +3 -0
  19. package/dist/components/titan-layout/navigation-catalog/index.d.ts.map +1 -0
  20. package/dist/components/titan-layout/navigation-catalog/index.js +4 -0
  21. package/dist/components/titan-layout/navigation-catalog/index.js.map +1 -0
  22. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts +8 -0
  23. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.d.ts.map +1 -0
  24. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js +88 -0
  25. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-footer.js.map +1 -0
  26. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts +21 -0
  27. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.d.ts.map +1 -0
  28. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +138 -0
  29. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -0
  30. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
  31. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
  32. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts +14 -0
  33. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -0
  34. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +382 -0
  35. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -0
  36. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
  37. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
  38. package/dist/components/titan-layout/navigation-scroll-indicators.d.ts +12 -0
  39. package/dist/components/titan-layout/navigation-scroll-indicators.d.ts.map +1 -0
  40. package/dist/components/titan-layout/navigation-scroll-indicators.js +48 -0
  41. package/dist/components/titan-layout/navigation-scroll-indicators.js.map +1 -0
  42. package/dist/components/titan-layout/titan-layout.d.ts +29 -1
  43. package/dist/components/titan-layout/titan-layout.d.ts.map +1 -1
  44. package/dist/components/titan-layout/titan-layout.js +46 -6
  45. package/dist/components/titan-layout/titan-layout.js.map +1 -1
  46. package/dist/components/titan-layout/titan-layout.module.less +2 -1
  47. package/dist/components/titan-layout/use-navigation-scroll.d.ts +20 -0
  48. package/dist/components/titan-layout/use-navigation-scroll.d.ts.map +1 -0
  49. package/dist/components/titan-layout/use-navigation-scroll.js +140 -0
  50. package/dist/components/titan-layout/use-navigation-scroll.js.map +1 -0
  51. package/dist/utils/__tests__/navigation-notifications.test.d.ts +2 -0
  52. package/dist/utils/__tests__/navigation-notifications.test.d.ts.map +1 -0
  53. package/dist/utils/__tests__/submenu-state.test.d.ts +2 -0
  54. package/dist/utils/__tests__/submenu-state.test.d.ts.map +1 -0
  55. package/dist/utils/navigation-notifications.d.ts +21 -0
  56. package/dist/utils/navigation-notifications.d.ts.map +1 -0
  57. package/dist/utils/navigation-notifications.js +39 -0
  58. package/dist/utils/navigation-notifications.js.map +1 -0
  59. package/dist/utils/side-nav.js +3 -1
  60. package/dist/utils/side-nav.js.map +1 -1
  61. package/dist/utils/submenu-state.d.ts +3 -0
  62. package/dist/utils/submenu-state.d.ts.map +1 -0
  63. package/dist/utils/submenu-state.js +15 -0
  64. package/dist/utils/submenu-state.js.map +1 -0
  65. package/package.json +2 -2
  66. package/src/components/titan-layout/__tests__/layout-sidebar-links-internal.test.tsx +225 -0
  67. package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +186 -0
  68. package/src/components/titan-layout/interface.ts +73 -0
  69. package/src/components/titan-layout/layout-sidebar-links-internal.tsx +86 -14
  70. package/src/components/titan-layout/layout-sidebar.module.less +230 -144
  71. package/src/components/titan-layout/layout-sidebar.module.less.d.ts +16 -5
  72. package/src/components/titan-layout/layout-sidebar.tsx +193 -117
  73. package/src/components/titan-layout/navigation-catalog/index.ts +2 -0
  74. package/src/components/titan-layout/navigation-catalog/navigation-catalog-footer.tsx +88 -0
  75. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less +89 -0
  76. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.module.less.d.ts +9 -0
  77. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +170 -0
  78. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +436 -0
  79. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +24 -0
  80. package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +458 -0
  81. package/src/components/titan-layout/navigation-scroll-indicators.tsx +80 -0
  82. package/src/components/titan-layout/titan-layout.module.less +2 -1
  83. package/src/components/titan-layout/titan-layout.tsx +81 -5
  84. package/src/components/titan-layout/use-navigation-scroll.ts +145 -0
  85. package/src/utils/__tests__/navigation-notifications.test.ts +70 -0
  86. package/src/utils/__tests__/submenu-state.test.ts +39 -0
  87. package/src/utils/navigation-notifications.ts +59 -0
  88. package/src/utils/side-nav.ts +1 -1
  89. package/src/utils/submenu-state.ts +16 -0
@@ -0,0 +1,458 @@
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 { NavigationItemData } from '../../../utils/navigation';
8
+ import { getCounterTag } from '../../../utils/side-nav';
9
+ import { BadgeTag } from '../../badge-tag';
10
+ import { NavigationCatalogGroup, PinnedNavigationConfig } from '../interface';
11
+ import { useTitanLayoutContext } from '../layout-context';
12
+ import { NavigationCatalogFooter } from './navigation-catalog-footer';
13
+ import * as styles from './navigation-catalog.module.less';
14
+
15
+ /** The pages beneath an item, flattened out of its submenu groups. */
16
+ const childrenOf = (item: NavigationItemData) => {
17
+ const seen = new Set<string>();
18
+
19
+ return (item.submenu?.groups ?? []).flatMap(group =>
20
+ (group.links ?? []).filter(link => {
21
+ if (seen.has(link.id)) {
22
+ return false;
23
+ }
24
+
25
+ seen.add(link.id);
26
+ return true;
27
+ })
28
+ );
29
+ };
30
+
31
+ /** Shown in place of the pin control for a destination the user may not unpin. */
32
+ const LockedPin: FC = () => (
33
+ <Tooltip>
34
+ <Tooltip.Trigger>
35
+ <Icon svg={IconLock} className={styles.lock} />
36
+ </Tooltip.Trigger>
37
+ <Tooltip.Content>Cannot be removed from Favorites</Tooltip.Content>
38
+ </Tooltip>
39
+ );
40
+
41
+ interface PinToggleProps {
42
+ pinned: boolean;
43
+ label: string;
44
+ onToggle: () => void;
45
+ }
46
+
47
+ const PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {
48
+ const toggle = (event: MouseEvent) => {
49
+ // The control sits inside the row but is not a navigation affordance.
50
+ event.preventDefault();
51
+ event.stopPropagation();
52
+ onToggle();
53
+ };
54
+
55
+ return (
56
+ <Tooltip delay={300}>
57
+ <Tooltip.Trigger>
58
+ <Button
59
+ appearance="ghost"
60
+ size="small"
61
+ icon={pinned ? IconStar : IconStarBorder}
62
+ // Only a control that covers nothing but pinned rows is lit.
63
+ className={classNames(styles.star, pinned && styles.starActive)}
64
+ aria-label={label}
65
+ aria-pressed={pinned}
66
+ onClick={toggle}
67
+ />
68
+ </Tooltip.Trigger>
69
+ <Tooltip.Content>
70
+ {pinned ? 'Remove from Favorites' : 'Add to Favorites'}
71
+ </Tooltip.Content>
72
+ </Tooltip>
73
+ );
74
+ };
75
+
76
+ interface CatalogRowsProps {
77
+ item: NavigationItemData;
78
+ /**
79
+ * Every id the module's own control covers, taken from the unfiltered item.
80
+ *
81
+ * `item` may have been narrowed by the search, so deriving this from it would let the control
82
+ * act on only the pages that happen to be visible — silently stranding the rest.
83
+ */
84
+ scopeIds: string[];
85
+ isPinned: (id: string) => boolean;
86
+ isLocked: (id: string) => boolean;
87
+ /** `ownerId` is the module the page belongs to; omitted for the module's own row. */
88
+ onTogglePin: (id: string, ownerId?: string) => void;
89
+ onToggleAll: (ids: string[], shouldPin: boolean) => void;
90
+ onNavigate: () => void;
91
+ }
92
+
93
+ const CatalogRows: FC<CatalogRowsProps> = ({
94
+ item,
95
+ scopeIds,
96
+ isPinned,
97
+ isLocked,
98
+ onTogglePin,
99
+ onToggleAll,
100
+ onNavigate,
101
+ }) => {
102
+ const { NavigationComponent } = useTitanLayoutContext();
103
+ const children = childrenOf(item);
104
+
105
+ /*
106
+ * Minus anything locked — locked destinations are always present, so the module's control
107
+ * neither reports on them nor acts on them.
108
+ */
109
+ const everything = scopeIds.filter(id => !isLocked(id));
110
+ const anyPinned = everything.some(isPinned);
111
+ const tag = getCounterTag(item.counter, item.tag);
112
+
113
+ return (
114
+ <div className={styles.moduleGroup}>
115
+ <div className={styles.row}>
116
+ <NavigationComponent
117
+ to={item.to}
118
+ className={styles.link}
119
+ onClick={onNavigate}
120
+ aria-label={item.title}
121
+ >
122
+ {item.icon && (
123
+ <Icon svg={item.icon} size="small" className={styles.moduleIcon} />
124
+ )}
125
+ <Text inline className={styles.moduleTitle}>
126
+ {item.title}
127
+ </Text>
128
+ {/*
129
+ * The same counter the rail shows, so a destination that is asking for
130
+ * attention still says so here — the catalog is the only place an unfavourited
131
+ * one is visible at all.
132
+ */}
133
+ {!!tag && <BadgeTag data={tag} className={styles.badge} />}
134
+ </NavigationComponent>
135
+ {/*
136
+ * One control for the whole module: favouriting it takes its pages with it, and
137
+ * unfavouriting it takes them away again.
138
+ *
139
+ * Lit when *any* page under it is favourited, because that is exactly when the
140
+ * module is on the rail — the star reports whether the module is there, not
141
+ * whether every page under it is. Reporting "all" instead would leave an unlit
142
+ * star on a module the user can plainly see in the navigation.
143
+ */}
144
+ {isLocked(item.id) ? (
145
+ <LockedPin />
146
+ ) : (
147
+ <PinToggle
148
+ pinned={anyPinned}
149
+ label={
150
+ anyPinned
151
+ ? `Unpin ${item.title} from the navigation`
152
+ : `Pin ${item.title} to the navigation`
153
+ }
154
+ onToggle={() => onToggleAll(everything, !anyPinned)}
155
+ />
156
+ )}
157
+ </div>
158
+ {children.map(child => {
159
+ /*
160
+ * Page titles are only unique within their item — "Settings" and "Reports" recur
161
+ * across the catalog — and the row that tells them apart is a separate element, so
162
+ * it is not part of any of these names. Anything reading the rows on their own, a
163
+ * screen reader running through the links or a voice command naming one, would
164
+ * otherwise be given the same name several times.
165
+ */
166
+ const qualifiedTitle = `${item.title}: ${child.title}`;
167
+ const childPinned = isPinned(child.id);
168
+ const childTag = getCounterTag(child.counter, child.tag);
169
+
170
+ return (
171
+ <div key={child.id} className={styles.row}>
172
+ <NavigationComponent
173
+ to={child.to}
174
+ className={classNames(styles.link, styles.childLink)}
175
+ onClick={onNavigate}
176
+ aria-label={qualifiedTitle}
177
+ >
178
+ <Text inline className={styles.childTitle}>
179
+ {child.title}
180
+ </Text>
181
+ {!!childTag && <BadgeTag data={childTag} className={styles.badge} />}
182
+ </NavigationComponent>
183
+ {isLocked(child.id) ? (
184
+ <LockedPin />
185
+ ) : (
186
+ <PinToggle
187
+ pinned={childPinned}
188
+ label={
189
+ childPinned
190
+ ? `Unpin ${qualifiedTitle} from the navigation`
191
+ : `Pin ${qualifiedTitle} to the navigation`
192
+ }
193
+ onToggle={() => onTogglePin(child.id, item.id)}
194
+ />
195
+ )}
196
+ </div>
197
+ );
198
+ })}
199
+ </div>
200
+ );
201
+ };
202
+
203
+ /**
204
+ * Narrows the catalog to the entries matching `query`.
205
+ *
206
+ * Matching is literal and never cascades: a row survives only when its own title matches. An item
207
+ * whose title matches is kept as a bare row without the pages beneath it, and a section is kept
208
+ * only for whatever survived under it, so the list only ever shrinks as the query grows.
209
+ */
210
+ const filterGroups = (groups: NavigationCatalogGroup[], query: string) => {
211
+ const needle = query.trim().toLowerCase();
212
+
213
+ if (!needle) {
214
+ return groups;
215
+ }
216
+
217
+ return groups.reduce<NavigationCatalogGroup[]>((out, group) => {
218
+ const items = group.items.reduce<NavigationItemData[]>((kept, item) => {
219
+ const matchingChildren = childrenOf(item).filter(child =>
220
+ child.title.toLowerCase().includes(needle)
221
+ );
222
+
223
+ if (matchingChildren.length > 0) {
224
+ kept.push({
225
+ ...item,
226
+ submenu: { groups: [{ title: '', links: matchingChildren }] },
227
+ });
228
+ } else if (item.title.toLowerCase().includes(needle)) {
229
+ kept.push({ ...item, submenu: undefined });
230
+ }
231
+
232
+ return kept;
233
+ }, []);
234
+
235
+ if (items.length > 0) {
236
+ out.push({ ...group, items });
237
+ }
238
+
239
+ return out;
240
+ }, []);
241
+ };
242
+
243
+ export interface NavigationCatalogProps extends PinnedNavigationConfig {
244
+ open: boolean;
245
+ onClose: () => void;
246
+ }
247
+
248
+ /**
249
+ * Every destination the host offers, grouped into sections, with a star on every row.
250
+ *
251
+ * The catalog shows what it is given and nothing more — the host has already resolved what this
252
+ * user may reach, so the catalog cannot offer a destination they do not have.
253
+ */
254
+ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
255
+ open,
256
+ onClose,
257
+ groups,
258
+ pinnedIds,
259
+ lockedIds,
260
+ onPinnedIdsChange,
261
+ onResetToDefaults,
262
+ }) => {
263
+ const [query, setQuery] = useState('');
264
+ const searchRef = useRef<HTMLInputElement>(null);
265
+
266
+ /*
267
+ * Resolved here rather than by the host, so a catalog that costs requests to enumerate is paid
268
+ * for on opening the dialog and not on every page load. This component is mounted only while
269
+ * open, so the call happens once per open unless the host changes the function.
270
+ */
271
+ const resolvedGroups = useMemo(() => groups(), [groups]);
272
+
273
+ const visibleGroups = useMemo(
274
+ () => filterGroups(resolvedGroups, query),
275
+ [resolvedGroups, query]
276
+ );
277
+
278
+ /*
279
+ * The pin scope of every item, keyed by id and built before the search narrows anything, so a
280
+ * module's control still covers the pages a query is currently hiding.
281
+ */
282
+ const scopeById = useMemo(
283
+ () =>
284
+ new Map(
285
+ resolvedGroups
286
+ .flatMap(group => group.items)
287
+ .map(item => [item.id, [item.id, ...childrenOf(item).map(child => child.id)]])
288
+ ),
289
+ [resolvedGroups]
290
+ );
291
+ const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);
292
+ const lockedSet = useMemo(() => new Set(lockedIds ?? []), [lockedIds]);
293
+
294
+ const isPinned = (id: string) => pinnedSet.has(id);
295
+ const isLocked = (id: string) => lockedSet.has(id);
296
+
297
+ /**
298
+ * Favourites or unfavourites one row, carrying its module with it.
299
+ *
300
+ * A page is reached through its module, so the two travel together: favouriting a page brings
301
+ * the module along when it is not already there, and unfavouriting the last one takes the
302
+ * module with it. Leaving the module behind on its own would not read as an emptier
303
+ * navigation — it would put the module back carrying every page it has.
304
+ *
305
+ * Locked ids never reach a proposal. The controls that would move them are not rendered, so
306
+ * that part is belt and braces — but the host's list is the thing that gets persisted, and a
307
+ * locked destination has no business appearing in or disappearing from it.
308
+ */
309
+ const togglePin = (id: string, ownerId?: string) => {
310
+ if (lockedSet.has(id)) {
311
+ return;
312
+ }
313
+
314
+ /** The module, when it is a separate row that this page can legitimately move. */
315
+ const owner = ownerId !== id && ownerId && !lockedSet.has(ownerId) ? ownerId : undefined;
316
+
317
+ if (pinnedSet.has(id)) {
318
+ const removing = new Set([id]);
319
+
320
+ if (owner) {
321
+ const siblings = (scopeById.get(owner) ?? []).filter(
322
+ scoped => scoped !== owner && scoped !== id
323
+ );
324
+
325
+ if (!siblings.some(sibling => pinnedSet.has(sibling))) {
326
+ removing.add(owner);
327
+ }
328
+ }
329
+
330
+ onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
331
+ return;
332
+ }
333
+
334
+ // Module first, so it reads ahead of its own page in the saved order.
335
+ const additions = owner && !pinnedSet.has(owner) ? [owner, id] : [id];
336
+
337
+ onPinnedIdsChange([...pinnedIds, ...additions]);
338
+ };
339
+
340
+ /*
341
+ * One proposal for the whole set rather than one per row, so a host that persists on every
342
+ * change writes once, and every intermediate list — states the user never asked for — never
343
+ * exists.
344
+ */
345
+ const toggleAll = (ids: string[], shouldPin: boolean) => {
346
+ const actionable = ids.filter(id => !lockedSet.has(id));
347
+
348
+ if (!shouldPin) {
349
+ const removing = new Set(actionable);
350
+
351
+ onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
352
+ return;
353
+ }
354
+
355
+ const additions = actionable.filter(id => !pinnedSet.has(id));
356
+
357
+ if (additions.length > 0) {
358
+ onPinnedIdsChange([...pinnedIds, ...additions]);
359
+ }
360
+ };
361
+
362
+ const close = () => {
363
+ setQuery('');
364
+ onClose();
365
+ };
366
+
367
+ return (
368
+ <Dialog
369
+ open={open}
370
+ onClose={close}
371
+ size="xlarge"
372
+ aria-label="All Navigation"
373
+ className={styles.dialog}
374
+ data-cy="navigation-catalog-dialog"
375
+ >
376
+ <Dialog.Content className={styles.content}>
377
+ <div className={styles.body}>
378
+ <SearchField
379
+ ref={searchRef}
380
+ size="small"
381
+ className={styles.search}
382
+ placeholder="Go to ..."
383
+ value={query}
384
+ onChange={event => setQuery(event.target.value)}
385
+ onClear={() => setQuery('')}
386
+ aria-label="Go to"
387
+ />
388
+ <div className={styles.results}>
389
+ {visibleGroups.length === 0 ? (
390
+ <div className={styles.empty}>
391
+ {/*
392
+ * Two different nothings: a search that matched no row, and a host
393
+ * that offered no rows at all. The second is a real state — the
394
+ * catalog is reachable whether or not there is anything in it — and
395
+ * quoting an empty query at the user does not describe it.
396
+ */}
397
+ <Text subdued>
398
+ {query.trim() ? (
399
+ <Fragment>
400
+ No navigation items match &ldquo;{query}&rdquo;.
401
+ </Fragment>
402
+ ) : (
403
+ 'There are no navigation items to show.'
404
+ )}
405
+ </Text>
406
+ </div>
407
+ ) : (
408
+ <div className={styles.columns}>
409
+ {visibleGroups.map(group => (
410
+ <div key={group.title} className={styles.group}>
411
+ {/*
412
+ * `medium` is 0.75rem, a step below the destinations under
413
+ * it. A heading that matches or beats its contents in size
414
+ * competes with them, and this one does not need the room:
415
+ * it is already set apart by case, tracking and a grey that
416
+ * nothing else on the surface uses. `small` is 0.625rem,
417
+ * too small to take in at a glance once uppercased and
418
+ * letter-spaced.
419
+ */}
420
+ <Text
421
+ variant="eyebrow"
422
+ size="medium"
423
+ className={styles.groupTitle}
424
+ >
425
+ {group.title}
426
+ </Text>
427
+ {group.items.map(item => (
428
+ <CatalogRows
429
+ key={item.id}
430
+ item={item}
431
+ scopeIds={scopeById.get(item.id) ?? [item.id]}
432
+ isPinned={isPinned}
433
+ isLocked={isLocked}
434
+ onTogglePin={togglePin}
435
+ onToggleAll={toggleAll}
436
+ onNavigate={close}
437
+ />
438
+ ))}
439
+ </div>
440
+ ))}
441
+ </div>
442
+ )}
443
+ </div>
444
+ </div>
445
+ </Dialog.Content>
446
+ <NavigationCatalogFooter
447
+ onRestore={
448
+ onResetToDefaults &&
449
+ (() => {
450
+ onResetToDefaults();
451
+ // The control disappears with the host's reset; keep focus in the dialog.
452
+ searchRef.current?.focus();
453
+ })
454
+ }
455
+ />
456
+ </Dialog>
457
+ );
458
+ };
@@ -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 {