@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
@@ -1,12 +1,16 @@
1
- import { Divider, Icon, Popover, Text, ThemeProvider } from '@servicetitan/anvil2';
1
+ import { Button, Divider, Icon, Popover, Text, ThemeProvider } from '@servicetitan/anvil2';
2
2
  import SvgClose from '@servicetitan/anvil2/assets/icons/material/round/close.svg';
3
+ import SvgCollapseAll from '@servicetitan/anvil2/assets/icons/material/round/unfold_less.svg';
4
+ import SvgExpandAll from '@servicetitan/anvil2/assets/icons/material/round/unfold_more.svg';
3
5
  import SvgCollapse from '@servicetitan/anvil2/assets/icons/st/gnav_menu_collapse.svg';
4
6
  import SvgExpand from '@servicetitan/anvil2/assets/icons/st/gnav_menu_expand.svg';
5
7
  import classNames from 'classnames';
6
8
  import { Children, FC, Fragment, ReactElement, isValidElement } from 'react';
7
9
  import { NavigationItemData, NavigationSubmenuData } from '../../utils/navigation';
8
10
  import { NavLinkComponentProps } from '../../utils/navigation-context';
11
+ import { getModuleNotificationCount } from '../../utils/navigation-notifications';
9
12
  import { getCounterTag, getSubmenuGroupTag } from '../../utils/side-nav';
13
+ import { PinnedNavigationConfig } from './interface';
10
14
  import { NavigationComponentProps } from './interface-internal';
11
15
  import { LayoutPlacementContext, useTitanLayoutContext } from './layout-context';
12
16
  import {
@@ -15,13 +19,30 @@ import {
15
19
  InternalSideNavigationLink,
16
20
  } from './layout-sidebar-links-internal';
17
21
  import * as Styles from './layout-sidebar.module.less';
22
+ import { NavigationCatalogTrigger } from './navigation-catalog';
23
+ import { NavigationScrollIndicators } from './navigation-scroll-indicators';
24
+ import { useNavigationScroll } from './use-navigation-scroll';
18
25
  import { withTooltip } from './with-tooltip';
19
26
 
20
27
  export interface LayoutSidebarProps {
21
28
  className?: string;
22
29
  top?: ReactElement[];
30
+
31
+ /**
32
+ * Destinations rendered above the divider, ahead of `top`.
33
+ *
34
+ * Separate from `top` rather than widening it: that one takes elements a host builds itself
35
+ * and renders them as-is, which is what a bespoke control needs. This takes navigation items
36
+ * and puts them through the same renderer as `mainItems`, so one with pages beneath it is an
37
+ * expandable group instead of being flattened to a bare link.
38
+ */
39
+ topItems?: NavigationItemData[];
40
+
41
+ /** Whether the rail shows the scroll and overflow-notification indicators. */
42
+ scrollIndicators?: boolean;
23
43
  bottom?: ReactElement;
24
44
  mainItems?: NavigationItemData[];
45
+ pinnedNavigation?: PinnedNavigationConfig;
25
46
  barExpanded: boolean;
26
47
  submenusOpened: string[] | undefined;
27
48
  drawerOpened: boolean;
@@ -31,6 +52,7 @@ export interface LayoutSidebarProps {
31
52
  onBarExpandChange(expanded: boolean): void;
32
53
  onDrawerOpenChange(expanded: boolean): void;
33
54
  onSubmenuOpenChange(id: string, opened: boolean): void;
55
+ onSubmenusOpenChange?(ids: string[], opened: boolean): void;
34
56
  onNavigationLinkClick?(itemId: string, submenuItemId?: string): void;
35
57
  }
36
58
 
@@ -43,13 +65,19 @@ export const LayoutSidebar: FC<LayoutSidebarProps> = ({
43
65
  drawerOpened,
44
66
  onBarExpandChange,
45
67
  onSubmenuOpenChange,
68
+ onSubmenusOpenChange,
46
69
  onDrawerOpenChange,
47
70
  onNavigationLinkClick,
48
71
  mainItems,
72
+ pinnedNavigation,
49
73
  top,
74
+ topItems,
75
+ scrollIndicators,
50
76
  bottom,
51
77
  navigationComponent,
52
78
  }) => {
79
+ const { regionRef, scrollRef, revealNotification, ...scrollOverflow } =
80
+ useNavigationScroll(scrollIndicators);
53
81
  const closeDrawer = () => {
54
82
  if (mobile) {
55
83
  onDrawerOpenChange?.(false);
@@ -57,6 +85,90 @@ export const LayoutSidebar: FC<LayoutSidebarProps> = ({
57
85
  };
58
86
 
59
87
  const effectiveExpanded = mobile ? drawerOpened : barExpanded;
88
+ /*
89
+ * Both regions, because a destination above the divider expands exactly like one below it.
90
+ * Counting only `mainItems` would let Expand All skip those groups — and hide the control
91
+ * entirely on a rail whose only expandable modules sit up there.
92
+ */
93
+ const expandableIds = [...(topItems ?? []), ...(mainItems ?? [])]
94
+ .filter(item => item.submenu?.groups.some(group => group.links.length > 0))
95
+ .map(item => item.id);
96
+ const hasOpenSubmenus = expandableIds.some(id => submenusOpened?.includes(id));
97
+ const toggleAllLabel = hasOpenSubmenus ? 'Collapse All' : 'Expand All';
98
+ const toggleAll =
99
+ effectiveExpanded && expandableIds.length > 0 && onSubmenusOpenChange
100
+ ? withTooltip(
101
+ <Button
102
+ appearance="secondary"
103
+ size="small"
104
+ icon={hasOpenSubmenus ? SvgCollapseAll : SvgExpandAll}
105
+ aria-label={toggleAllLabel}
106
+ aria-expanded={hasOpenSubmenus}
107
+ data-cy="navigation-toggle-all"
108
+ data-pendo="navigation-toggle-all"
109
+ onClick={event => {
110
+ event.stopPropagation();
111
+ onSubmenusOpenChange(expandableIds, !hasOpenSubmenus);
112
+ }}
113
+ />,
114
+ toggleAllLabel,
115
+ { placement: 'top' }
116
+ )
117
+ : undefined;
118
+
119
+ const scrollHeader = !mobile && !barExpanded;
120
+ const navigationHeader = (
121
+ <div className={Styles.navHeader}>
122
+ {mobile && (
123
+ <div className={Styles.navCloseWrapper}>
124
+ <div className={Styles.navClose} onClick={() => onBarExpandChange(false)}>
125
+ <Icon svg={SvgClose} size="large" />
126
+ </div>
127
+ </div>
128
+ )}
129
+ {pinnedNavigation && (
130
+ <NavigationCatalogTrigger
131
+ expanded={effectiveExpanded}
132
+ {...pinnedNavigation}
133
+ trailingAction={toggleAll}
134
+ />
135
+ )}
136
+ {!pinnedNavigation && toggleAll && <div className={Styles.navTools}>{toggleAll}</div>}
137
+ </div>
138
+ );
139
+
140
+ /*
141
+ * Shared by the region above the divider and the list below it, so a destination renders the
142
+ * same way wherever the host puts it. Two copies of this branch would drift.
143
+ */
144
+ const renderItems = (items: NavigationItemData[] | undefined) =>
145
+ items?.map(item =>
146
+ item.submenu ? (
147
+ <SideNavigationGroupItem
148
+ key={item.id}
149
+ item={item}
150
+ barExpanded={effectiveExpanded}
151
+ submenuOpened={!!item.id && !!submenusOpened?.includes(item.id)}
152
+ onSubmenuOpen={onSubmenuOpenChange}
153
+ navigationComponent={navigationComponent}
154
+ onNavigationLinkClick={onNavigationLinkClick}
155
+ touchDevice={touchDevice}
156
+ />
157
+ ) : (
158
+ <InternalSideNavigationLink
159
+ key={item.id}
160
+ id={item.id}
161
+ to={item.to}
162
+ title={effectiveExpanded ? item.title : (item.collapsedTitle ?? item.title)}
163
+ isActive={item.isActive}
164
+ icon={item.icon}
165
+ iconActive={item.iconActive}
166
+ className={item.className}
167
+ tag={getSubmenuGroupTag(item.submenu, getCounterTag(item.counter, item.tag))}
168
+ navigationComponent={navigationComponent}
169
+ />
170
+ )
171
+ );
60
172
 
61
173
  return (
62
174
  <LayoutPlacementContext.Provider value="side">
@@ -74,69 +186,46 @@ export const LayoutSidebar: FC<LayoutSidebarProps> = ({
74
186
  data-cy="side-navigation"
75
187
  onClick={closeDrawer}
76
188
  >
77
- <ThemeProvider mode="dark" className={Styles.navMain}>
78
- {mobile && (
79
- <div className={Styles.navCloseWrapper}>
80
- <div
81
- className={Styles.navClose}
82
- onClick={() => onBarExpandChange(false)}
83
- >
84
- <Icon svg={SvgClose} size="large" />
189
+ <ThemeProvider mode="dark" className={Styles.navContent}>
190
+ {!scrollHeader && navigationHeader}
191
+ <div ref={regionRef} className={Styles.navScrollRegion}>
192
+ <div ref={scrollRef} className={Styles.navMain} data-cy="navigation-scroll">
193
+ <div>
194
+ {scrollHeader && navigationHeader}
195
+ {(!!top?.length || !!topItems?.length) && (
196
+ <SidebarTop>
197
+ {renderItems(topItems)}
198
+ {top}
199
+ </SidebarTop>
200
+ )}
201
+
202
+ <div className={Styles.navItems} data-cy="navigation-items">
203
+ {renderItems(mainItems)}
204
+ </div>
205
+
206
+ {!!bottom && <SidebarBottom>{bottom}</SidebarBottom>}
85
207
  </div>
86
208
  </div>
87
- )}
88
- {!!top?.length && <SidebarTop>{top}</SidebarTop>}
89
-
90
- <div data-cy="navigation-items">
91
- {mainItems?.map(item =>
92
- item.submenu ? (
93
- <SideNavigationGroupItem
94
- key={item.id}
95
- item={item}
96
- barExpanded={effectiveExpanded}
97
- submenuOpened={!!item.id && !!submenusOpened?.includes(item.id)}
98
- onSubmenuOpen={onSubmenuOpenChange}
99
- navigationComponent={navigationComponent}
100
- onNavigationLinkClick={onNavigationLinkClick}
101
- touchDevice={touchDevice}
102
- />
103
- ) : (
104
- <InternalSideNavigationLink
105
- key={item.id}
106
- id={item.id}
107
- to={item.to}
108
- title={
109
- effectiveExpanded
110
- ? item.title
111
- : (item.collapsedTitle ?? item.title)
112
- }
113
- isActive={item.isActive}
114
- icon={item.icon}
115
- iconActive={item.iconActive}
116
- className={item.className}
117
- tag={getSubmenuGroupTag(
118
- item.submenu,
119
- getCounterTag(item.counter, item.tag)
120
- )}
121
- navigationComponent={navigationComponent}
122
- />
123
- )
209
+ {scrollIndicators && (
210
+ <NavigationScrollIndicators
211
+ {...scrollOverflow}
212
+ expanded={effectiveExpanded}
213
+ onReveal={revealNotification}
214
+ />
124
215
  )}
125
216
  </div>
126
-
127
- {!!bottom && <SidebarBottom>{bottom}</SidebarBottom>}
128
217
  </ThemeProvider>
129
218
 
130
219
  {!mobile && (
131
- <div className={Styles.navFooter}>
220
+ <ThemeProvider mode="dark" className={Styles.navFooter}>
132
221
  <div className={Styles.divider} />
133
222
  <div className={Styles.toggleWrapper}>
134
223
  <SideNavigationOptionsToggle
135
- appearance={barExpanded ? 'collapse-button' : 'expand'}
224
+ expanded={barExpanded}
136
225
  onExpandedChange={() => onBarExpandChange(!barExpanded)}
137
226
  />
138
227
  </div>
139
- </div>
228
+ </ThemeProvider>
140
229
  )}
141
230
  </div>
142
231
  </LayoutPlacementContext.Provider>
@@ -146,43 +235,22 @@ LayoutSidebar.displayName = 'LayoutSidebar';
146
235
 
147
236
  /** Side Navigation options toggle */
148
237
  const SideNavigationOptionsToggle: FC<{
149
- appearance: 'expand' | 'collapse' | 'collapse-button';
238
+ expanded: boolean;
150
239
  onExpandedChange?(expanded: boolean): void;
151
- }> = ({ appearance, onExpandedChange }) =>
240
+ }> = ({ expanded, onExpandedChange }) =>
152
241
  withTooltip(
153
- <div
154
- role="button"
155
- tabIndex={0}
242
+ <Button
243
+ appearance="ghost"
244
+ size="small"
245
+ icon={expanded ? SvgCollapse : SvgExpand}
156
246
  data-cy="navigation-left-options"
157
247
  data-pendo="navigation-left-options"
158
- aria-label={appearance === 'expand' ? 'Expand Menu' : 'Collapse Menu'}
159
- className={classNames(Styles.toggle)}
160
- onClick={() => onExpandedChange?.(appearance === 'expand')}
161
- onKeyDown={e => {
162
- if (e.key === 'Enter' || e.key === ' ') {
163
- e.preventDefault();
164
- onExpandedChange?.(appearance === 'expand');
165
- }
166
- }}
167
- >
168
- <div className={Styles.toggleContent}>
169
- <div className={Styles.toggleIconWrapper}>
170
- <Icon
171
- className={Styles.toggleIcon}
172
- svg={appearance === 'expand' ? SvgExpand : SvgCollapse}
173
- />
174
- </div>
175
-
176
- {appearance === 'collapse-button' && (
177
- <span className={Styles.toggleText}>Collapse Menu</span>
178
- )}
179
- </div>
180
- </div>,
181
- appearance === 'expand'
182
- ? 'Expand Menu'
183
- : appearance === 'collapse'
184
- ? 'Collapse Menu'
185
- : undefined,
248
+ aria-label={expanded ? 'Collapse Menu' : 'Expand Menu'}
249
+ aria-expanded={expanded}
250
+ className={Styles.toggle}
251
+ onClick={() => onExpandedChange?.(!expanded)}
252
+ />,
253
+ expanded ? 'Collapse Menu' : 'Expand Menu',
186
254
  { placement: 'right' }
187
255
  );
188
256
 
@@ -222,6 +290,7 @@ const SideNavigationGroupItem: FC<{
222
290
  iconActive={item.iconActive}
223
291
  className={item.className}
224
292
  tag={tag}
293
+ notificationCount={getModuleNotificationCount(item, submenuOpened)}
225
294
  submenuOpened={submenuOpened}
226
295
  onSubmenuOpen={onSubmenuOpen}
227
296
  navigationComponent={navigationComponent}
@@ -234,7 +303,13 @@ const SideNavigationGroupItem: FC<{
234
303
  />
235
304
  </InternalSideNavigationGroup>
236
305
  ) : (
237
- <Popover placement="right-start" openOnHover={!touchDevice} delay={200} noPadding>
306
+ <Popover
307
+ placement="right-start"
308
+ openOnHover={!touchDevice}
309
+ delay={200}
310
+ noPadding
311
+ disableCaret
312
+ >
238
313
  <Popover.Trigger>
239
314
  {(triggerProps: any) => (
240
315
  <div {...triggerProps}>
@@ -250,6 +325,7 @@ const SideNavigationGroupItem: FC<{
250
325
  touchDevice && Styles.pointerEventsNone
251
326
  )}
252
327
  tag={tag}
328
+ notificationCount={getModuleNotificationCount(item, false)}
253
329
  navigationComponent={navigationComponent}
254
330
  />
255
331
  </div>
@@ -291,43 +367,42 @@ const SideNavigationGroupContent: FC<
291
367
  > = ({ groups, parentId, navigationComponent, onNavigationLinkClick }) => {
292
368
  return (
293
369
  <Fragment>
294
- {groups.reduce((out, group, index) => {
370
+ {groups.map((group, index) => {
295
371
  if (!group.links.length) {
296
- return out;
372
+ return null;
297
373
  }
298
374
 
299
375
  const title = group.title?.trim() ?? '';
300
376
  /* eslint-disable react/no-array-index-key */
301
- if (title) {
302
- out.push(
303
- <div
304
- key={`:group:${parentId}:${index}:title`}
305
- className={Styles.submenuGroupHeader}
306
- >
307
- {title}
308
- </div>
309
- );
310
- }
311
- out.push(
312
- ...group.links.map((link, index) => (
313
- <InternalSideNavigationGroupLink
314
- key={`:${parentId}:${link.id}:${index}`}
315
- id={link.id}
316
- to={link.to}
317
- title={link.title}
318
- isActive={link.isActive}
319
- className={undefined}
320
- tag={getCounterTag(link.counter, link.tag)}
321
- parentId={parentId}
322
- navigationComponent={navigationComponent}
323
- onLinkClick={onNavigationLinkClick}
324
- />
325
- ))
377
+ return (
378
+ <div key={`:group:${parentId}:${index}`} className={Styles.submenuGroup}>
379
+ {title && (
380
+ <Text
381
+ variant="eyebrow"
382
+ size="small"
383
+ className={Styles.submenuGroupHeader}
384
+ >
385
+ {title}
386
+ </Text>
387
+ )}
388
+ {group.links.map((link, index) => (
389
+ <InternalSideNavigationGroupLink
390
+ key={`:${parentId}:${link.id}:${index}`}
391
+ id={link.id}
392
+ to={link.to}
393
+ title={link.title}
394
+ isActive={link.isActive}
395
+ className={undefined}
396
+ tag={getCounterTag(link.counter, link.tag)}
397
+ parentId={parentId}
398
+ navigationComponent={navigationComponent}
399
+ onLinkClick={onNavigationLinkClick}
400
+ />
401
+ ))}
402
+ </div>
326
403
  );
327
404
  /* eslint-enable react/no-array-index-key */
328
-
329
- return out;
330
- }, [] as ReactElement[])}
405
+ })}
331
406
  </Fragment>
332
407
  );
333
408
  };
@@ -338,8 +413,9 @@ function SidebarTop({ children }: any) {
338
413
  });
339
414
  return list?.length ? (
340
415
  <ThemeProvider mode="dark" className={Styles.navTop} data-cy="navigation-items-top">
341
- {list}
342
- <div className={Styles.divider} />
416
+ <div className={Styles.navTopItems}>{list}</div>
417
+ {/* Anvil 2 spacing 3 resolves to the design's 12px above and below. */}
418
+ <Divider spacing={3} className={Styles.topDivider} />
343
419
  </ThemeProvider>
344
420
  ) : null;
345
421
  }
@@ -0,0 +1,2 @@
1
+ export * from './navigation-catalog';
2
+ export * from './navigation-catalog-trigger';
@@ -0,0 +1,88 @@
1
+ import { Button, Dialog, Text } from '@servicetitan/anvil2';
2
+ import IconUndo from '@servicetitan/anvil2/assets/icons/material/round/undo.svg';
3
+ import { FC, Fragment, useEffect, useId, useRef, useState } from 'react';
4
+ import * as styles from './navigation-catalog.module.less';
5
+
6
+ interface NavigationCatalogFooterProps {
7
+ /** Present only when the current favorites differ from the host's defaults. */
8
+ onRestore?: () => void;
9
+ }
10
+
11
+ export const NavigationCatalogFooter: FC<NavigationCatalogFooterProps> = ({ onRestore }) => {
12
+ const [confirming, setConfirming] = useState(false);
13
+ const cancelRef = useRef<HTMLButtonElement>(null);
14
+ const restoreRef = useRef<HTMLButtonElement>(null);
15
+ const returnFocus = useRef(false);
16
+ const confirmationId = useId();
17
+
18
+ useEffect(() => {
19
+ if (confirming) {
20
+ cancelRef.current?.focus();
21
+ } else if (returnFocus.current) {
22
+ restoreRef.current?.focus();
23
+ returnFocus.current = false;
24
+ }
25
+ }, [confirming]);
26
+
27
+ useEffect(() => {
28
+ if (!onRestore) {
29
+ setConfirming(false);
30
+ }
31
+ }, [onRestore]);
32
+
33
+ return (
34
+ <Dialog.Footer className={styles.footer} data-cy="navigation-catalog-footer">
35
+ {confirming && onRestore ? (
36
+ <Fragment>
37
+ <Text size="small" id={confirmationId}>
38
+ This action replaces your current favorites. You can favorite items again
39
+ anytime.
40
+ </Text>
41
+ <div className={styles.footerActions}>
42
+ <Button
43
+ ref={cancelRef}
44
+ appearance="secondary"
45
+ size="xsmall"
46
+ aria-describedby={confirmationId}
47
+ onClick={() => {
48
+ returnFocus.current = true;
49
+ setConfirming(false);
50
+ }}
51
+ >
52
+ Cancel
53
+ </Button>
54
+ <Button
55
+ appearance="primary"
56
+ size="xsmall"
57
+ aria-describedby={confirmationId}
58
+ onClick={() => {
59
+ setConfirming(false);
60
+ onRestore();
61
+ }}
62
+ >
63
+ Restore
64
+ </Button>
65
+ </div>
66
+ </Fragment>
67
+ ) : (
68
+ <Fragment>
69
+ <Text size="small" subdued>
70
+ Add or remove items to your navigation by clicking the star icon to the
71
+ right
72
+ </Text>
73
+ {onRestore && (
74
+ <Button
75
+ ref={restoreRef}
76
+ appearance="secondary"
77
+ size="xsmall"
78
+ icon={IconUndo}
79
+ onClick={() => setConfirming(true)}
80
+ >
81
+ Restore Default Favorites
82
+ </Button>
83
+ )}
84
+ </Fragment>
85
+ )}
86
+ </Dialog.Footer>
87
+ );
88
+ };
@@ -0,0 +1,89 @@
1
+ @import (reference) '@servicetitan/tokens/core/tokens.less';
2
+
3
+ /*
4
+ * Scoped to `.anvil2.anvil2` so these rules outrank anvil2's own.
5
+ *
6
+ * anvil2 nests every component rule under `.anvil2`, giving it two classes of specificity,
7
+ * which a bare class here loses to. Doubling the scope wins outright and leaves the selectors
8
+ * inside it plain, rather than each one having to defend itself with `!important`.
9
+ */
10
+ :global(.anvil2.anvil2) {
11
+ .container {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 12px;
15
+ padding: @spacing-1 @spacing-1 @spacing-2;
16
+
17
+ > :last-child:not(:first-child) {
18
+ flex-shrink: 0;
19
+ }
20
+ }
21
+
22
+ /*
23
+ * The rail stretches its children to its own width, so the button follows rather than sizing to
24
+ * its label. Everything else about the control — surface, border, radius, focus ring, hover — is
25
+ * the anvil2 secondary button's own.
26
+ */
27
+ .trigger {
28
+ width: 100%;
29
+ min-width: 0;
30
+ flex: 1;
31
+ }
32
+
33
+ // The reference uses the core subdued swatch (#bcbcbd).
34
+ .triggerLabel {
35
+ color: @color-neutral-70;
36
+ line-height: @spacing-2;
37
+ white-space: nowrap;
38
+ }
39
+
40
+ /*
41
+ * Held back from the label so the chord reads as an aside rather than as part of the name, and
42
+ * kept from wrapping when the rail is at its narrowest expanded width.
43
+ */
44
+ .shortcut {
45
+ --a2-mod-chip-min-height: @spacing-2;
46
+ --a2-mod-chip-inner-padding-block: 1px;
47
+
48
+ white-space: nowrap;
49
+ flex-shrink: 0;
50
+ }
51
+
52
+ .containerCollapsed {
53
+ // Together with the button's 6px bottom inset, keep 12px before Dashboard's icon box.
54
+ padding: @spacing-0 @spacing-0 6px;
55
+ }
56
+
57
+ .triggerCollapsed {
58
+ width: 60px;
59
+ min-width: 0;
60
+ min-height: 48px;
61
+ box-sizing: border-box;
62
+ padding: 5px @spacing-half;
63
+ flex-direction: column;
64
+ gap: 2px;
65
+ color: @color-white;
66
+
67
+ [data-anv='icon'] {
68
+ width: 24px;
69
+ height: 24px;
70
+ flex-shrink: 0;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ }
75
+
76
+ svg {
77
+ width: 20px;
78
+ height: 20px;
79
+ }
80
+ }
81
+
82
+ .collapsedLabel {
83
+ font-family: @base-font-family;
84
+ font-size: @typescale-0;
85
+ font-weight: @font-weight-regular;
86
+ line-height: 10px;
87
+ white-space: nowrap;
88
+ }
89
+ }
@@ -0,0 +1,9 @@
1
+ export const __esModule: true;
2
+ export const collapsedLabel: string;
3
+ export const container: string;
4
+ export const containerCollapsed: string;
5
+ export const shortcut: string;
6
+ export const trigger: string;
7
+ export const triggerCollapsed: string;
8
+ export const triggerLabel: string;
9
+