@servicetitan/navigation 14.3.0 → 14.5.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 (35) hide show
  1. package/dist/components/titan-layout/layout-sidebar.d.ts.map +1 -1
  2. package/dist/components/titan-layout/layout-sidebar.js +30 -11
  3. package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
  4. package/dist/components/titan-layout/layout-sidebar.module.less +1 -1
  5. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +1 -1
  6. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -1
  7. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -1
  8. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +69 -107
  9. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -1
  10. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less +70 -127
  11. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +2 -0
  12. package/dist/utils/__tests__/catalog-groups.test.d.ts +2 -0
  13. package/dist/utils/__tests__/catalog-groups.test.d.ts.map +1 -0
  14. package/dist/utils/__tests__/side-nav.test.d.ts +2 -0
  15. package/dist/utils/__tests__/side-nav.test.d.ts.map +1 -0
  16. package/dist/utils/catalog-groups.d.ts +11 -0
  17. package/dist/utils/catalog-groups.d.ts.map +1 -0
  18. package/dist/utils/catalog-groups.js +66 -0
  19. package/dist/utils/catalog-groups.js.map +1 -0
  20. package/dist/utils/side-nav.d.ts +17 -0
  21. package/dist/utils/side-nav.d.ts.map +1 -1
  22. package/dist/utils/side-nav.js +32 -0
  23. package/dist/utils/side-nav.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +7 -3
  26. package/src/components/titan-layout/layout-sidebar.module.less +1 -1
  27. package/src/components/titan-layout/layout-sidebar.tsx +34 -10
  28. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +1 -1
  29. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +70 -127
  30. package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +2 -0
  31. package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +107 -134
  32. package/src/utils/__tests__/catalog-groups.test.ts +97 -0
  33. package/src/utils/__tests__/side-nav.test.ts +77 -0
  34. package/src/utils/catalog-groups.ts +57 -0
  35. package/src/utils/side-nav.ts +42 -0
@@ -4,42 +4,34 @@ import IconStar from '@servicetitan/anvil2/assets/icons/material/round/star.svg'
4
4
  import IconStarBorder from '@servicetitan/anvil2/assets/icons/material/round/star_border.svg';
5
5
  import classNames from 'classnames';
6
6
  import { FC, Fragment, MouseEvent, useMemo, useRef, useState } from 'react';
7
+ import {
8
+ filterCatalogGroups,
9
+ getCatalogChildren,
10
+ getCatalogSubmenuGroups,
11
+ } from '../../../utils/catalog-groups';
7
12
  import { NavigationItemData } from '../../../utils/navigation';
8
13
  import { getCounterTag } from '../../../utils/side-nav';
9
14
  import { BadgeTag } from '../../badge-tag';
10
- import { NavigationCatalogGroup, PinnedNavigationConfig } from '../interface';
15
+ import { PinnedNavigationConfig } from '../interface';
11
16
  import { useTitanLayoutContext } from '../layout-context';
12
17
  import { NavigationCatalogFooter } from './navigation-catalog-footer';
13
18
  import * as styles from './navigation-catalog.module.less';
14
19
 
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
20
  /** Shown in place of the pin control for a destination the user may not unpin. */
32
21
  const LockedPin: FC = () => (
33
22
  <Tooltip>
34
23
  <Tooltip.Trigger>
35
24
  <Icon svg={IconLock} className={styles.lock} />
36
25
  </Tooltip.Trigger>
37
- <Tooltip.Content>Cannot be removed from Favorites</Tooltip.Content>
26
+ <Tooltip.Content style={{ pointerEvents: 'none' }}>
27
+ Cannot be removed from Favorites
28
+ </Tooltip.Content>
38
29
  </Tooltip>
39
30
  );
40
31
 
41
32
  interface PinToggleProps {
42
- pinned: boolean;
33
+ /** Mixed modules stay highlighted but use an outline until all pages are pinned. */
34
+ pinned: boolean | 'mixed';
43
35
  label: string;
44
36
  onToggle: () => void;
45
37
  }
@@ -58,16 +50,20 @@ const PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {
58
50
  <Button
59
51
  appearance="ghost"
60
52
  size="small"
61
- icon={pinned ? IconStar : IconStarBorder}
62
- // Only a control that covers nothing but pinned rows is lit.
53
+ icon={pinned === true ? IconStar : IconStarBorder}
63
54
  className={classNames(styles.star, pinned && styles.starActive)}
64
55
  aria-label={label}
65
56
  aria-pressed={pinned}
57
+ data-pendo="global-nav-favorite-star"
66
58
  onClick={toggle}
67
59
  />
68
60
  </Tooltip.Trigger>
69
- <Tooltip.Content>
70
- {pinned ? 'Remove from Favorites' : 'Add to Favorites'}
61
+ <Tooltip.Content style={{ pointerEvents: 'none' }}>
62
+ {pinned === 'mixed'
63
+ ? 'Add this module to Favorites'
64
+ : pinned
65
+ ? 'Remove from Favorites'
66
+ : 'Add to Favorites'}
71
67
  </Tooltip.Content>
72
68
  </Tooltip>
73
69
  );
@@ -100,7 +96,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
100
96
  onNavigate,
101
97
  }) => {
102
98
  const { NavigationComponent } = useTitanLayoutContext();
103
- const children = childrenOf(item);
99
+ const submenuGroups = getCatalogSubmenuGroups(item);
104
100
 
105
101
  /*
106
102
  * Minus anything locked — locked destinations are always present, so the module's control
@@ -108,6 +104,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
108
104
  */
109
105
  const everything = scopeIds.filter(id => !isLocked(id));
110
106
  const anyPinned = everything.some(isPinned);
107
+ const allPinned = everything.length > 0 && everything.every(isPinned);
111
108
  const tag = getCounterTag(item.counter, item.tag);
112
109
 
113
110
  return (
@@ -120,9 +117,9 @@ const CatalogRows: FC<CatalogRowsProps> = ({
120
117
  aria-label={item.title}
121
118
  >
122
119
  {item.icon && (
123
- <Icon svg={item.icon} size="small" className={styles.moduleIcon} />
120
+ <Icon svg={item.icon} size="medium" className={styles.moduleIcon} />
124
121
  )}
125
- <Text inline className={styles.moduleTitle}>
122
+ <Text inline size="small" className={styles.moduleTitle}>
126
123
  {item.title}
127
124
  </Text>
128
125
  {/*
@@ -136,110 +133,80 @@ const CatalogRows: FC<CatalogRowsProps> = ({
136
133
  * One control for the whole module: favouriting it takes its pages with it, and
137
134
  * unfavouriting it takes them away again.
138
135
  *
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.
136
+ * A gold outline marks a partially favourited module. Fill the star only when
137
+ * its full, unfiltered scope is favourited, including pages hidden by search.
138
+ * Clicking the outline adds the remaining pages; clicking the filled star removes all.
143
139
  */}
144
140
  {isLocked(item.id) ? (
145
141
  <LockedPin />
146
142
  ) : (
147
143
  <PinToggle
148
- pinned={anyPinned}
144
+ pinned={allPinned ? true : anyPinned ? 'mixed' : false}
149
145
  label={
150
- anyPinned
146
+ allPinned
151
147
  ? `Unpin ${item.title} from the navigation`
152
148
  : `Pin ${item.title} to the navigation`
153
149
  }
154
- onToggle={() => onToggleAll(everything, !anyPinned)}
150
+ onToggle={() => onToggleAll(everything, !allPinned)}
155
151
  />
156
152
  )}
157
153
  </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
- })}
154
+ {submenuGroups.map(group => (
155
+ <div key={group.links[0].id}>
156
+ {group.title && (
157
+ <Text variant="eyebrow" size="small" className={styles.submenuTitle}>
158
+ {group.title}
159
+ </Text>
160
+ )}
161
+ {group.links.map(child => {
162
+ /*
163
+ * Page titles are only unique within their item — "Settings" and "Reports" recur
164
+ * across the catalog — and the row that tells them apart is a separate element, so
165
+ * it is not part of any of these names. Anything reading the rows on their own, a
166
+ * screen reader running through the links or a voice command naming one, would
167
+ * otherwise be given the same name several times.
168
+ */
169
+ const qualifiedTitle = `${item.title}: ${child.title}`;
170
+ const childPinned = isPinned(child.id);
171
+ const childTag = getCounterTag(child.counter, child.tag);
172
+
173
+ return (
174
+ <div key={child.id} className={styles.row}>
175
+ <NavigationComponent
176
+ to={child.to}
177
+ className={classNames(styles.link, styles.childLink)}
178
+ onClick={onNavigate}
179
+ aria-label={qualifiedTitle}
180
+ >
181
+ <Text inline className={styles.childTitle}>
182
+ {child.title}
183
+ </Text>
184
+ {!!childTag && (
185
+ <BadgeTag data={childTag} className={styles.badge} />
186
+ )}
187
+ </NavigationComponent>
188
+ {isLocked(child.id) ? (
189
+ <LockedPin />
190
+ ) : (
191
+ <PinToggle
192
+ pinned={childPinned}
193
+ label={
194
+ childPinned
195
+ ? `Unpin ${qualifiedTitle} from the navigation`
196
+ : `Pin ${qualifiedTitle} to the navigation`
197
+ }
198
+ onToggle={() => onTogglePin(child.id, item.id)}
199
+ />
200
+ )}
201
+ </div>
202
+ );
203
+ })}
204
+ </div>
205
+ ))}
199
206
  </div>
200
207
  );
201
208
  };
202
209
 
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
210
  export interface NavigationCatalogProps extends PinnedNavigationConfig {
244
211
  open: boolean;
245
212
  onClose: () => void;
@@ -261,6 +228,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
261
228
  onResetToDefaults,
262
229
  }) => {
263
230
  const [query, setQuery] = useState('');
231
+ const isSearching = query.trim().length > 0;
264
232
  const searchRef = useRef<HTMLInputElement>(null);
265
233
 
266
234
  /*
@@ -271,7 +239,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
271
239
  const resolvedGroups = useMemo(() => groups(), [groups]);
272
240
 
273
241
  const visibleGroups = useMemo(
274
- () => filterGroups(resolvedGroups, query),
242
+ () => filterCatalogGroups(resolvedGroups, query),
275
243
  [resolvedGroups, query]
276
244
  );
277
245
 
@@ -284,7 +252,10 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
284
252
  new Map(
285
253
  resolvedGroups
286
254
  .flatMap(group => group.items)
287
- .map(item => [item.id, [item.id, ...childrenOf(item).map(child => child.id)]])
255
+ .map(item => [
256
+ item.id,
257
+ [item.id, ...getCatalogChildren(item).map(child => child.id)],
258
+ ])
288
259
  ),
289
260
  [resolvedGroups]
290
261
  );
@@ -385,7 +356,9 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
385
356
  onClear={() => setQuery('')}
386
357
  aria-label="Go to"
387
358
  />
388
- <div className={styles.results}>
359
+ <div
360
+ className={classNames(styles.results, isSearching && styles.searchResults)}
361
+ >
389
362
  {visibleGroups.length === 0 ? (
390
363
  <div className={styles.empty}>
391
364
  {/*
@@ -395,7 +368,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
395
368
  * quoting an empty query at the user does not describe it.
396
369
  */}
397
370
  <Text subdued>
398
- {query.trim() ? (
371
+ {isSearching ? (
399
372
  <Fragment>
400
373
  No navigation items match &ldquo;{query}&rdquo;.
401
374
  </Fragment>
@@ -405,25 +378,25 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
405
378
  </Text>
406
379
  </div>
407
380
  ) : (
408
- <div className={styles.columns}>
381
+ <div className={isSearching ? undefined : styles.columns}>
409
382
  {visibleGroups.map(group => (
410
- <div key={group.title} className={styles.group}>
383
+ <div
384
+ key={group.title}
385
+ className={isSearching ? undefined : styles.group}
386
+ >
411
387
  {/*
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.
388
+ * Browse uses titled columns. Search stacks matches at full
389
+ * width, keeping only each module's own submenu headings.
419
390
  */}
420
- <Text
421
- variant="eyebrow"
422
- size="medium"
423
- className={styles.groupTitle}
424
- >
425
- {group.title}
426
- </Text>
391
+ {!isSearching && (
392
+ <Text
393
+ variant="eyebrow"
394
+ size="medium"
395
+ className={styles.groupTitle}
396
+ >
397
+ {group.title}
398
+ </Text>
399
+ )}
427
400
  {group.items.map(item => (
428
401
  <CatalogRows
429
402
  key={item.id}
@@ -0,0 +1,97 @@
1
+ import {
2
+ filterCatalogGroups,
3
+ getCatalogChildren,
4
+ getCatalogSubmenuGroups,
5
+ } from '../catalog-groups';
6
+ import { NavigationItemData } from '../navigation';
7
+
8
+ const marketing: NavigationItemData = {
9
+ id: 'marketing',
10
+ title: 'Marketing',
11
+ to: '/marketing',
12
+ icon: undefined,
13
+ iconActive: undefined,
14
+ submenu: {
15
+ groups: [
16
+ {
17
+ title: 'Analytics',
18
+ links: [
19
+ { id: 'overview', title: 'Marketing Overview', to: '/marketing/overview' },
20
+ { id: 'one-time', title: 'One-Time Campaigns', to: '/marketing/one-time' },
21
+ ],
22
+ },
23
+ {
24
+ title: 'Campaigns',
25
+ links: [
26
+ { id: 'manager', title: 'Campaign Manager', to: '/marketing/manager' },
27
+ { id: 'templates', title: 'Email Templates', to: '/marketing/templates' },
28
+ ],
29
+ },
30
+ ],
31
+ },
32
+ };
33
+ const catalog = [{ title: 'Demand', items: [marketing] }];
34
+
35
+ describe('catalog submenu groups', () => {
36
+ it('keeps matching pages under their original headings and in source order', () => {
37
+ const result = filterCatalogGroups(catalog, ' CAMPAIGN ');
38
+ expect(result[0].items[0].submenu?.groups).toEqual([
39
+ { title: 'Analytics', links: [marketing.submenu!.groups[0].links[1]] },
40
+ { title: 'Campaigns', links: [marketing.submenu!.groups[1].links[0]] },
41
+ ]);
42
+ });
43
+
44
+ it('omits groups with no matches without changing the full module used for bulk favorites', () => {
45
+ const result = filterCatalogGroups(catalog, 'email');
46
+ expect(result[0].items[0].submenu?.groups).toEqual([
47
+ { title: 'Campaigns', links: [marketing.submenu!.groups[1].links[1]] },
48
+ ]);
49
+ expect(getCatalogChildren(marketing).map(child => child.id)).toEqual([
50
+ 'overview',
51
+ 'one-time',
52
+ 'manager',
53
+ 'templates',
54
+ ]);
55
+ expect(marketing.submenu!.groups.map(group => group.links.length)).toEqual([2, 2]);
56
+ });
57
+
58
+ it('renders duplicate pages once and omits groups left empty by deduplication', () => {
59
+ const groups = getCatalogSubmenuGroups({
60
+ ...marketing,
61
+ submenu: {
62
+ groups: [
63
+ { ...marketing.submenu!.groups[0], title: ' Analytics ' },
64
+ { title: 'Duplicate', links: marketing.submenu!.groups[0].links },
65
+ { title: 'Empty', links: [] },
66
+ marketing.submenu!.groups[1],
67
+ ],
68
+ },
69
+ });
70
+ expect(groups).toEqual(marketing.submenu!.groups);
71
+ });
72
+
73
+ it('retains ungrouped pages without introducing a blank heading', () => {
74
+ const [group] = getCatalogSubmenuGroups({
75
+ ...marketing,
76
+ submenu: { groups: [{ title: ' ', links: marketing.submenu!.groups[0].links }] },
77
+ });
78
+ expect(group.title).toBe('');
79
+ expect(group.links).toEqual(marketing.submenu!.groups[0].links);
80
+ });
81
+
82
+ it('keeps module-only matches without adding unrelated child pages', () => {
83
+ const result = filterCatalogGroups(catalog, 'Marketing Overview');
84
+ expect(result[0].items[0].submenu?.groups[0].title).toBe('Analytics');
85
+ const moduleOnly = filterCatalogGroups(
86
+ [{ title: 'Demand', items: [{ ...marketing, title: 'Marketing Pro' }] }],
87
+ 'Marketing Pro'
88
+ );
89
+ expect(moduleOnly[0].items[0].submenu).toBeUndefined();
90
+ });
91
+
92
+ it('restores the full catalog on clear and removes empty results', () => {
93
+ expect(filterCatalogGroups(catalog, ' ')).toBe(catalog);
94
+ expect(filterCatalogGroups(catalog, 'no such destination')).toEqual([]);
95
+ expect(getCatalogSubmenuGroups({ ...marketing, submenu: undefined })).toEqual([]);
96
+ });
97
+ });
@@ -0,0 +1,77 @@
1
+ import { getModuleActiveMatcher, getModuleDestination } from '../side-nav';
2
+
3
+ describe('getModuleDestination', () => {
4
+ it('routes to the first link of the first non-empty group when a submenu exists', () => {
5
+ expect(
6
+ getModuleDestination('/dispatch/default', {
7
+ groups: [
8
+ {
9
+ title: 'Boards',
10
+ links: [{ id: 'daily', title: 'Daily', to: '/dispatch/daily' }],
11
+ },
12
+ ],
13
+ })
14
+ ).toBe('/dispatch/daily');
15
+ });
16
+
17
+ it('skips empty groups to find the first group that actually has links', () => {
18
+ expect(
19
+ getModuleDestination('/reports', {
20
+ groups: [
21
+ { title: 'Empty', links: [] },
22
+ {
23
+ title: 'Reports',
24
+ links: [{ id: 'sales', title: 'Sales', to: '/reports/sales' }],
25
+ },
26
+ ],
27
+ })
28
+ ).toBe('/reports/sales');
29
+ });
30
+
31
+ it('falls back to the host-supplied destination when there is no submenu', () => {
32
+ expect(getModuleDestination('/dashboard', undefined)).toBe('/dashboard');
33
+ });
34
+
35
+ it('falls back to the host-supplied destination when the submenu has no links at all', () => {
36
+ expect(
37
+ getModuleDestination('/dashboard', { groups: [{ title: 'Empty', links: [] }] })
38
+ ).toBe('/dashboard');
39
+ });
40
+ });
41
+
42
+ describe('getModuleActiveMatcher', () => {
43
+ const submenu = {
44
+ groups: [
45
+ {
46
+ title: 'Boards',
47
+ links: [
48
+ { id: 'daily', title: 'Daily', to: '/dispatch/daily' },
49
+ { id: 'weekly', title: 'Weekly', to: '/dispatch/weekly' },
50
+ ],
51
+ },
52
+ ],
53
+ };
54
+
55
+ it('matches a sub-item page even though the row now navigates to a different sub-item', () => {
56
+ const isActive = getModuleActiveMatcher('', submenu)!;
57
+ expect(isActive('/dispatch/weekly')).toBe(true);
58
+ expect(isActive('/dispatch/daily')).toBe(true);
59
+ });
60
+
61
+ it('also matches the module own to, when it has one, alongside its sub-items', () => {
62
+ const isActive = getModuleActiveMatcher('/dispatch', submenu)!;
63
+ expect(isActive('/dispatch/routes')).toBe(true);
64
+ });
65
+
66
+ it('does not match an unrelated page', () => {
67
+ const isActive = getModuleActiveMatcher('', submenu)!;
68
+ expect(isActive('/accounting/invoices')).toBe(false);
69
+ });
70
+
71
+ it('returns undefined when there is nothing to match against', () => {
72
+ expect(getModuleActiveMatcher('', undefined)).toBeUndefined();
73
+ expect(
74
+ getModuleActiveMatcher('', { groups: [{ title: 'Empty', links: [] }] })
75
+ ).toBeUndefined();
76
+ });
77
+ });
@@ -0,0 +1,57 @@
1
+ import { NavigationCatalogGroup } from '../components/titan-layout/interface';
2
+ import { NavigationItemData } from './navigation';
3
+
4
+ /** Preserve submenu headings while offering each page only once within its module. */
5
+ export const getCatalogSubmenuGroups = (item: NavigationItemData) => {
6
+ const seen = new Set<string>();
7
+
8
+ return (item.submenu?.groups ?? []).flatMap(group => {
9
+ const links = (group.links ?? []).filter(link => {
10
+ if (seen.has(link.id)) {
11
+ return false;
12
+ }
13
+
14
+ seen.add(link.id);
15
+ return true;
16
+ });
17
+
18
+ return links.length ? [{ ...group, title: group.title?.trim() ?? '', links }] : [];
19
+ });
20
+ };
21
+
22
+ export const getCatalogChildren = (item: NavigationItemData) =>
23
+ getCatalogSubmenuGroups(item).flatMap(group => group.links);
24
+
25
+ /** Match destination titles literally, retaining the headings of matching child pages. */
26
+ export const filterCatalogGroups = (groups: NavigationCatalogGroup[], query: string) => {
27
+ const needle = query.trim().toLowerCase();
28
+
29
+ if (!needle) {
30
+ return groups;
31
+ }
32
+
33
+ return groups.reduce<NavigationCatalogGroup[]>((out, group) => {
34
+ const items = group.items.reduce<NavigationItemData[]>((kept, item) => {
35
+ const matchingGroups = getCatalogSubmenuGroups(item).flatMap(submenuGroup => {
36
+ const links = submenuGroup.links.filter(child =>
37
+ child.title.toLowerCase().includes(needle)
38
+ );
39
+ return links.length ? [{ ...submenuGroup, links }] : [];
40
+ });
41
+
42
+ if (matchingGroups.length > 0) {
43
+ kept.push({ ...item, submenu: { groups: matchingGroups } });
44
+ } else if (item.title.toLowerCase().includes(needle)) {
45
+ kept.push({ ...item, submenu: undefined });
46
+ }
47
+
48
+ return kept;
49
+ }, []);
50
+
51
+ if (items.length > 0) {
52
+ out.push({ ...group, items });
53
+ }
54
+
55
+ return out;
56
+ }, []);
57
+ };
@@ -2,6 +2,48 @@ import { CounterTagProps } from '../components/counter-tag';
2
2
  import { CounterTagData, CounterTagValue } from './counter-tag';
3
3
  import { NavigationSubmenuData } from './navigation';
4
4
 
5
+ /**
6
+ * A module with sub-items always routes to the first one, in the nav's existing order, rather
7
+ * than whatever destination the host configured for the module row itself (3.5a). A host-supplied
8
+ * `to` only applies to modules with no submenu.
9
+ */
10
+ export function getModuleDestination(
11
+ to: string,
12
+ submenu: NavigationSubmenuData | undefined
13
+ ): string {
14
+ for (const group of submenu?.groups ?? []) {
15
+ if (group.links.length > 0) {
16
+ return group.links[0].to;
17
+ }
18
+ }
19
+
20
+ return to;
21
+ }
22
+
23
+ /**
24
+ * Routing a module row to its first sub-item (see `getModuleDestination`) means the row's `to` no
25
+ * longer identifies the module for a host that has no explicit `isActive` of its own — the default
26
+ * "compare href to pathname" behavior would then only match the first sub-item's page, not the rest
27
+ * of the module. Synthesize an equivalent match across the module's own `to` and every sub-item
28
+ * instead, so the row still highlights on any of the module's pages, not just the first.
29
+ *
30
+ * Returns `undefined` when there's nothing to match, so a host can still supply its own `isActive`
31
+ * and take precedence (this only fills the gap left by the default behavior, it doesn't replace it).
32
+ */
33
+ export function getModuleActiveMatcher(
34
+ to: string,
35
+ submenu: NavigationSubmenuData | undefined
36
+ ): ((pathname: string) => boolean) | undefined {
37
+ const candidates = [
38
+ ...(to ? [to] : []),
39
+ ...(submenu?.groups.flatMap(group => group.links.map(link => link.to)) ?? []),
40
+ ];
41
+
42
+ return candidates.length > 0
43
+ ? (pathname: string) => candidates.some(candidate => pathname.startsWith(candidate))
44
+ : undefined;
45
+ }
46
+
5
47
  export function getSubmenuGroupTag(
6
48
  submenu: NavigationSubmenuData | undefined,
7
49
  defaultTag: CounterTagProps | undefined