@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,39 @@
1
+ export const getNotificationCount = (value)=>value === true ? 1 : typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
2
+ /** A closed module summarizes its pages; an open module lets each page count itself. */ export function getModuleNotificationCount(item, submenuOpened) {
3
+ var _ref, _item_counter;
4
+ var _item_submenu, _item_tag;
5
+ const childCount = ((_ref = (_item_submenu = item.submenu) === null || _item_submenu === void 0 ? void 0 : _item_submenu.groups) !== null && _ref !== void 0 ? _ref : []).reduce((total, group)=>total + group.links.reduce((sum, link)=>{
6
+ var _link_counter;
7
+ var _link_tag;
8
+ return sum + getNotificationCount((_link_counter = link.counter) !== null && _link_counter !== void 0 ? _link_counter : (_link_tag = link.tag) === null || _link_tag === void 0 ? void 0 : _link_tag.value);
9
+ }, 0), 0);
10
+ return childCount > 0 ? submenuOpened ? 0 : childCount : getNotificationCount((_item_counter = item.counter) !== null && _item_counter !== void 0 ? _item_counter : (_item_tag = item.tag) === null || _item_tag === void 0 ? void 0 : _item_tag.value);
11
+ }
12
+ /** Only fully offscreen rows contribute; partially visible notifications are still discoverable. */ export function getOverflowNotifications(rows, viewport) {
13
+ let above = 0;
14
+ let below = 0;
15
+ let aboveIndex = -1;
16
+ let belowIndex = -1;
17
+ rows.forEach((row, index)=>{
18
+ if (row.count <= 0 || row.bottom <= row.top) {
19
+ return;
20
+ }
21
+ if (row.bottom <= viewport.top) {
22
+ above += row.count;
23
+ aboveIndex = index;
24
+ } else if (row.top >= viewport.bottom) {
25
+ below += row.count;
26
+ if (belowIndex === -1) {
27
+ belowIndex = index;
28
+ }
29
+ }
30
+ });
31
+ return {
32
+ above,
33
+ below,
34
+ aboveIndex,
35
+ belowIndex
36
+ };
37
+ }
38
+
39
+ //# sourceMappingURL=navigation-notifications.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/navigation-notifications.ts"],"sourcesContent":["import { CounterTagValue } from './counter-tag';\nimport { NavigationItemData } from './navigation';\n\nexport const getNotificationCount = (value: CounterTagValue | undefined): number =>\n value === true\n ? 1\n : typeof value === 'number' && Number.isFinite(value) && value > 0\n ? value\n : 0;\n\n/** A closed module summarizes its pages; an open module lets each page count itself. */\nexport function getModuleNotificationCount(item: NavigationItemData, submenuOpened: boolean) {\n const childCount = (item.submenu?.groups ?? []).reduce(\n (total, group) =>\n total +\n group.links.reduce(\n (sum, link) => sum + getNotificationCount(link.counter ?? link.tag?.value),\n 0\n ),\n 0\n );\n return childCount > 0\n ? submenuOpened\n ? 0\n : childCount\n : getNotificationCount(item.counter ?? item.tag?.value);\n}\n\nexport interface NotificationRowBounds {\n top: number;\n bottom: number;\n count: number;\n}\n\n/** Only fully offscreen rows contribute; partially visible notifications are still discoverable. */\nexport function getOverflowNotifications(\n rows: NotificationRowBounds[],\n viewport: { top: number; bottom: number }\n) {\n let above = 0;\n let below = 0;\n let aboveIndex = -1;\n let belowIndex = -1;\n rows.forEach((row, index) => {\n if (row.count <= 0 || row.bottom <= row.top) {\n return;\n }\n if (row.bottom <= viewport.top) {\n above += row.count;\n aboveIndex = index;\n } else if (row.top >= viewport.bottom) {\n below += row.count;\n if (belowIndex === -1) {\n belowIndex = index;\n }\n }\n });\n return { above, below, aboveIndex, belowIndex };\n}\n"],"names":["getNotificationCount","value","Number","isFinite","getModuleNotificationCount","item","submenuOpened","childCount","submenu","groups","reduce","total","group","links","sum","link","counter","tag","getOverflowNotifications","rows","viewport","above","below","aboveIndex","belowIndex","forEach","row","index","count","bottom","top"],"mappings":"AAGA,OAAO,MAAMA,uBAAuB,CAACC,QACjCA,UAAU,OACJ,IACA,OAAOA,UAAU,YAAYC,OAAOC,QAAQ,CAACF,UAAUA,QAAQ,IAC7DA,QACA,EAAE;AAEd,sFAAsF,GACtF,OAAO,SAASG,2BAA2BC,IAAwB,EAAEC,aAAsB;cAc5DD;QAbPA,eAauBA;IAb3C,MAAME,aAAa,UAACF,gBAAAA,KAAKG,OAAO,cAAZH,oCAAAA,cAAcI,MAAM,uCAAI,EAAE,EAAEC,MAAM,CAClD,CAACC,OAAOC,QACJD,QACAC,MAAMC,KAAK,CAACH,MAAM,CACd,CAACI,KAAKC;gBAAoCA;gBAAgBA;mBAA3CD,MAAMd,sBAAqBe,gBAAAA,KAAKC,OAAO,cAAZD,2BAAAA,iBAAgBA,YAAAA,KAAKE,GAAG,cAARF,gCAAAA,UAAUd,KAAK;WACzE,IAER;IAEJ,OAAOM,aAAa,IACdD,gBACI,IACAC,aACJP,sBAAqBK,gBAAAA,KAAKW,OAAO,cAAZX,2BAAAA,iBAAgBA,YAAAA,KAAKY,GAAG,cAARZ,gCAAAA,UAAUJ,KAAK;AAC9D;AAQA,kGAAkG,GAClG,OAAO,SAASiB,yBACZC,IAA6B,EAC7BC,QAAyC;IAEzC,IAAIC,QAAQ;IACZ,IAAIC,QAAQ;IACZ,IAAIC,aAAa,CAAC;IAClB,IAAIC,aAAa,CAAC;IAClBL,KAAKM,OAAO,CAAC,CAACC,KAAKC;QACf,IAAID,IAAIE,KAAK,IAAI,KAAKF,IAAIG,MAAM,IAAIH,IAAII,GAAG,EAAE;YACzC;QACJ;QACA,IAAIJ,IAAIG,MAAM,IAAIT,SAASU,GAAG,EAAE;YAC5BT,SAASK,IAAIE,KAAK;YAClBL,aAAaI;QACjB,OAAO,IAAID,IAAII,GAAG,IAAIV,SAASS,MAAM,EAAE;YACnCP,SAASI,IAAIE,KAAK;YAClB,IAAIJ,eAAe,CAAC,GAAG;gBACnBA,aAAaG;YACjB;QACJ;IACJ;IACA,OAAO;QAAEN;QAAOC;QAAOC;QAAYC;IAAW;AAClD"}
@@ -5,7 +5,9 @@ export function getSubmenuGroupTag(submenu, defaultTag) {
5
5
  let tagValue = undefined;
6
6
  for (const group of submenu.groups){
7
7
  for (const link of group.links){
8
- const ltv = link.counter;
8
+ var _link_counter;
9
+ var _link_tag;
10
+ const ltv = (_link_counter = link.counter) !== null && _link_counter !== void 0 ? _link_counter : (_link_tag = link.tag) === null || _link_tag === void 0 ? void 0 : _link_tag.value;
9
11
  if (ltv) {
10
12
  if (typeof ltv === 'number') {
11
13
  if (typeof tagValue !== 'number') {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/side-nav.ts"],"sourcesContent":["import { CounterTagProps } from '../components/counter-tag';\nimport { CounterTagData, CounterTagValue } from './counter-tag';\nimport { NavigationSubmenuData } from './navigation';\n\nexport function getSubmenuGroupTag(\n submenu: NavigationSubmenuData | undefined,\n defaultTag: CounterTagProps | undefined\n): CounterTagProps | undefined {\n if (!submenu) {\n return defaultTag;\n }\n\n let tagValue: number | boolean | undefined = undefined;\n\n for (const group of submenu.groups) {\n for (const link of group.links) {\n const ltv: number | boolean | undefined = link.counter;\n\n if (ltv) {\n if (typeof ltv === 'number') {\n if (typeof tagValue !== 'number') {\n tagValue = 0;\n }\n tagValue += ltv;\n } else if (typeof tagValue !== 'number') {\n tagValue = true;\n }\n }\n }\n }\n\n return tagValue ? getCounterTag(tagValue) : defaultTag;\n}\n\nexport const getCounterTag = (\n counter: CounterTagValue | undefined,\n tag?: CounterTagData\n): CounterTagProps | undefined => {\n const value = counter ?? tag?.value;\n\n return value ? { value, className: '' } : undefined;\n};\n"],"names":["getSubmenuGroupTag","submenu","defaultTag","tagValue","undefined","group","groups","link","links","ltv","counter","getCounterTag","tag","value","className"],"mappings":"AAIA,OAAO,SAASA,mBACZC,OAA0C,EAC1CC,UAAuC;IAEvC,IAAI,CAACD,SAAS;QACV,OAAOC;IACX;IAEA,IAAIC,WAAyCC;IAE7C,KAAK,MAAMC,SAASJ,QAAQK,MAAM,CAAE;QAChC,KAAK,MAAMC,QAAQF,MAAMG,KAAK,CAAE;YAC5B,MAAMC,MAAoCF,KAAKG,OAAO;YAEtD,IAAID,KAAK;gBACL,IAAI,OAAOA,QAAQ,UAAU;oBACzB,IAAI,OAAON,aAAa,UAAU;wBAC9BA,WAAW;oBACf;oBACAA,YAAYM;gBAChB,OAAO,IAAI,OAAON,aAAa,UAAU;oBACrCA,WAAW;gBACf;YACJ;QACJ;IACJ;IAEA,OAAOA,WAAWQ,cAAcR,YAAYD;AAChD;AAEA,OAAO,MAAMS,gBAAgB,CACzBD,SACAE;IAEA,MAAMC,QAAQH,oBAAAA,qBAAAA,UAAWE,gBAAAA,0BAAAA,IAAKC,KAAK;IAEnC,OAAOA,QAAQ;QAAEA;QAAOC,WAAW;IAAG,IAAIV;AAC9C,EAAE"}
1
+ {"version":3,"sources":["../../src/utils/side-nav.ts"],"sourcesContent":["import { CounterTagProps } from '../components/counter-tag';\nimport { CounterTagData, CounterTagValue } from './counter-tag';\nimport { NavigationSubmenuData } from './navigation';\n\nexport function getSubmenuGroupTag(\n submenu: NavigationSubmenuData | undefined,\n defaultTag: CounterTagProps | undefined\n): CounterTagProps | undefined {\n if (!submenu) {\n return defaultTag;\n }\n\n let tagValue: number | boolean | undefined = undefined;\n\n for (const group of submenu.groups) {\n for (const link of group.links) {\n const ltv: number | boolean | undefined = link.counter ?? link.tag?.value;\n\n if (ltv) {\n if (typeof ltv === 'number') {\n if (typeof tagValue !== 'number') {\n tagValue = 0;\n }\n tagValue += ltv;\n } else if (typeof tagValue !== 'number') {\n tagValue = true;\n }\n }\n }\n }\n\n return tagValue ? getCounterTag(tagValue) : defaultTag;\n}\n\nexport const getCounterTag = (\n counter: CounterTagValue | undefined,\n tag?: CounterTagData\n): CounterTagProps | undefined => {\n const value = counter ?? tag?.value;\n\n return value ? { value, className: '' } : undefined;\n};\n"],"names":["getSubmenuGroupTag","submenu","defaultTag","tagValue","undefined","group","groups","link","links","ltv","counter","tag","value","getCounterTag","className"],"mappings":"AAIA,OAAO,SAASA,mBACZC,OAA0C,EAC1CC,UAAuC;IAEvC,IAAI,CAACD,SAAS;QACV,OAAOC;IACX;IAEA,IAAIC,WAAyCC;IAE7C,KAAK,MAAMC,SAASJ,QAAQK,MAAM,CAAE;QAChC,KAAK,MAAMC,QAAQF,MAAMG,KAAK,CAAE;gBACcD;gBAAgBA;YAA1D,MAAME,OAAoCF,gBAAAA,KAAKG,OAAO,cAAZH,2BAAAA,iBAAgBA,YAAAA,KAAKI,GAAG,cAARJ,gCAAAA,UAAUK,KAAK;YAEzE,IAAIH,KAAK;gBACL,IAAI,OAAOA,QAAQ,UAAU;oBACzB,IAAI,OAAON,aAAa,UAAU;wBAC9BA,WAAW;oBACf;oBACAA,YAAYM;gBAChB,OAAO,IAAI,OAAON,aAAa,UAAU;oBACrCA,WAAW;gBACf;YACJ;QACJ;IACJ;IAEA,OAAOA,WAAWU,cAAcV,YAAYD;AAChD;AAEA,OAAO,MAAMW,gBAAgB,CACzBH,SACAC;IAEA,MAAMC,QAAQF,oBAAAA,qBAAAA,UAAWC,gBAAAA,0BAAAA,IAAKC,KAAK;IAEnC,OAAOA,QAAQ;QAAEA;QAAOE,WAAW;IAAG,IAAIV;AAC9C,EAAE"}
@@ -0,0 +1,3 @@
1
+ /** Update only the requested modules, preserving the state of modules outside the action. */
2
+ export declare const updateOpenSubmenus: (current: readonly string[], moduleIds: readonly string[], opened: boolean) => string[];
3
+ //# sourceMappingURL=submenu-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submenu-state.d.ts","sourceRoot":"","sources":["../../src/utils/submenu-state.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,eAAO,MAAM,kBAAkB,GAC3B,SAAS,SAAS,MAAM,EAAE,EAC1B,WAAW,SAAS,MAAM,EAAE,EAC5B,QAAQ,OAAO,KAChB,MAAM,EAUR,CAAC"}
@@ -0,0 +1,15 @@
1
+ /** Update only the requested modules, preserving the state of modules outside the action. */ export const updateOpenSubmenus = (current, moduleIds, opened)=>{
2
+ const next = new Set(current);
3
+ moduleIds.forEach((id)=>{
4
+ if (opened) {
5
+ next.add(id);
6
+ } else {
7
+ next.delete(id);
8
+ }
9
+ });
10
+ return [
11
+ ...next
12
+ ];
13
+ };
14
+
15
+ //# sourceMappingURL=submenu-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/submenu-state.ts"],"sourcesContent":["/** Update only the requested modules, preserving the state of modules outside the action. */\nexport const updateOpenSubmenus = (\n current: readonly string[],\n moduleIds: readonly string[],\n opened: boolean\n): string[] => {\n const next = new Set(current);\n moduleIds.forEach(id => {\n if (opened) {\n next.add(id);\n } else {\n next.delete(id);\n }\n });\n return [...next];\n};\n"],"names":["updateOpenSubmenus","current","moduleIds","opened","next","Set","forEach","id","add","delete"],"mappings":"AAAA,2FAA2F,GAC3F,OAAO,MAAMA,qBAAqB,CAC9BC,SACAC,WACAC;IAEA,MAAMC,OAAO,IAAIC,IAAIJ;IACrBC,UAAUI,OAAO,CAACC,CAAAA;QACd,IAAIJ,QAAQ;YACRC,KAAKI,GAAG,CAACD;QACb,OAAO;YACHH,KAAKK,MAAM,CAACF;QAChB;IACJ;IACA,OAAO;WAAIH;KAAK;AACpB,EAAE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/navigation",
3
- "version": "14.2.0",
3
+ "version": "14.4.0",
4
4
  "description": "Navigation components",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,5 +35,5 @@
35
35
  "less": true,
36
36
  "webpack": false
37
37
  },
38
- "gitHead": "d1f4e9cbaf1ec2c270dc2d3fdb21b862ae5ee1f7"
38
+ "gitHead": "b64111bc6e95671da2480fa57b27a09c85520c4b"
39
39
  }
@@ -0,0 +1,225 @@
1
+ import { ButtonHTMLAttributes, useState } from 'react';
2
+ import { Root, createRoot } from 'react-dom/client';
3
+ import { act } from 'react-dom/test-utils';
4
+ import { NavLinkComponentProps } from '../../../utils/navigation-context';
5
+ import {
6
+ InternalSideNavigationGroup,
7
+ InternalSideNavigationGroupLink,
8
+ } from '../layout-sidebar-links-internal';
9
+
10
+ jest.mock('@servicetitan/anvil2', () => ({
11
+ Button: ({
12
+ appearance,
13
+ size,
14
+ icon,
15
+ ...props
16
+ }: ButtonHTMLAttributes<HTMLButtonElement> & {
17
+ appearance?: string;
18
+ size?: string;
19
+ icon?: unknown;
20
+ }) => <button {...props} />,
21
+ Icon: () => null,
22
+ }));
23
+
24
+ jest.mock('../with-tooltip', () => ({ withTooltip: (element: unknown) => element }));
25
+
26
+ let container: HTMLDivElement;
27
+ let root: Root;
28
+ const actEnvironment = globalThis as typeof globalThis &
29
+ Record<'IS_REACT_ACT_ENVIRONMENT', boolean | undefined>;
30
+ const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
31
+
32
+ beforeAll(() => {
33
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
34
+ });
35
+
36
+ afterAll(() => {
37
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
38
+ });
39
+
40
+ beforeEach(() => {
41
+ container = document.createElement('div');
42
+ document.body.appendChild(container);
43
+ root = createRoot(container);
44
+ });
45
+
46
+ afterEach(() => {
47
+ act(() => root.unmount());
48
+ container.remove();
49
+ });
50
+
51
+ function click(element: Element) {
52
+ act(() => {
53
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
54
+ });
55
+ }
56
+
57
+ function pressKey(element: Element, key: string) {
58
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
59
+ act(() => {
60
+ element.dispatchEvent(event);
61
+ });
62
+ return event;
63
+ }
64
+
65
+ const getChildLink = () =>
66
+ container.querySelector('[data-cy="navigation-item-pricebook--services"]');
67
+
68
+ function renderGroup(to: string | undefined = '/pricebook', initialOpened = false) {
69
+ const navigate = jest.fn();
70
+ const closeDrawer = jest.fn();
71
+ const onSubmenuOpen = jest.fn();
72
+
73
+ // Like a host router, this link owns its click handler and does not consult defaultPrevented.
74
+ const NavigationLink = ({
75
+ to: destination,
76
+ isActive,
77
+ activeClassName,
78
+ ...props
79
+ }: NavLinkComponentProps) => (
80
+ <a
81
+ {...props}
82
+ href={destination}
83
+ onClick={event => {
84
+ event.preventDefault();
85
+ navigate(destination);
86
+ }}
87
+ />
88
+ );
89
+
90
+ function Fixture() {
91
+ const [opened, setOpened] = useState(initialOpened);
92
+ return (
93
+ <div onClick={closeDrawer}>
94
+ <InternalSideNavigationGroup
95
+ id="pricebook"
96
+ to={to}
97
+ title="Pricebook"
98
+ isActive={undefined}
99
+ icon={undefined}
100
+ iconActive={undefined}
101
+ className={undefined}
102
+ tag={undefined}
103
+ submenuOpened={opened}
104
+ onSubmenuOpen={(id, nextOpened) => {
105
+ onSubmenuOpen(id, nextOpened);
106
+ setOpened(nextOpened);
107
+ }}
108
+ navigationComponent={NavigationLink}
109
+ >
110
+ <InternalSideNavigationGroupLink
111
+ id="services"
112
+ parentId="pricebook"
113
+ to="/pricebook/services"
114
+ title="Services"
115
+ isActive={undefined}
116
+ className={undefined}
117
+ tag={undefined}
118
+ navigationComponent={NavigationLink}
119
+ />
120
+ </InternalSideNavigationGroup>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ act(() => root.render(<Fixture />));
126
+ const moduleTrigger = container.querySelector<HTMLElement>(
127
+ '[data-cy="navigation-item-pricebook"]'
128
+ )!;
129
+ return { moduleTrigger, navigate, closeDrawer, onSubmenuOpen };
130
+ }
131
+
132
+ it.each(['/pricebook', '/pricebook/materials'])(
133
+ 'opens on the module name and navigates to the configured %p route',
134
+ to => {
135
+ const { moduleTrigger, navigate, closeDrawer, onSubmenuOpen } = renderGroup(to);
136
+
137
+ click(moduleTrigger);
138
+ expect(getChildLink()).toBeTruthy();
139
+ expect(moduleTrigger.getAttribute('href')).toBe(to);
140
+ expect(moduleTrigger.getAttribute('role')).toBeNull();
141
+ expect(moduleTrigger.getAttribute('aria-expanded')).toBe('true');
142
+ expect(document.getElementById(moduleTrigger.getAttribute('aria-controls')!)).toBeTruthy();
143
+
144
+ // The chevron is the only control that collapses an open module.
145
+ click(container.querySelector('button[aria-label="Collapse Pricebook submenu"]')!);
146
+ expect(getChildLink()).toBeNull();
147
+ expect(moduleTrigger.getAttribute('aria-expanded')).toBe('false');
148
+
149
+ click(container.querySelector('button[aria-label="Expand Pricebook submenu"]')!);
150
+ expect(getChildLink()).toBeTruthy();
151
+ expect(onSubmenuOpen.mock.calls).toEqual([
152
+ ['pricebook', true],
153
+ ['pricebook', false],
154
+ ['pricebook', true],
155
+ ]);
156
+ expect(navigate.mock.calls).toEqual([[to]]);
157
+ expect(closeDrawer).not.toHaveBeenCalled();
158
+ }
159
+ );
160
+
161
+ it.each(['/pricebook', '/pricebook/materials'])(
162
+ 'keeps an open module open and still navigates to %p from its name',
163
+ to => {
164
+ const { moduleTrigger, navigate, onSubmenuOpen } = renderGroup(to, true);
165
+
166
+ expect(getChildLink()).toBeTruthy();
167
+
168
+ click(moduleTrigger);
169
+ expect(getChildLink()).toBeTruthy();
170
+ expect(moduleTrigger.getAttribute('aria-expanded')).toBe('true');
171
+ expect(navigate.mock.calls).toEqual([[to]]);
172
+ expect(onSubmenuOpen).not.toHaveBeenCalled();
173
+
174
+ click(moduleTrigger);
175
+ expect(getChildLink()).toBeTruthy();
176
+ expect(navigate.mock.calls).toEqual([[to], [to]]);
177
+ expect(onSubmenuOpen).not.toHaveBeenCalled();
178
+ }
179
+ );
180
+
181
+ it.each(['Enter', ' '])('opens and navigates to the module destination with the %p key', key => {
182
+ const { moduleTrigger, navigate, closeDrawer, onSubmenuOpen } = renderGroup();
183
+
184
+ moduleTrigger.focus();
185
+ expect(pressKey(moduleTrigger, key).defaultPrevented).toBe(true);
186
+ expect(getChildLink()).toBeTruthy();
187
+ expect(document.activeElement).toBe(moduleTrigger);
188
+
189
+ pressKey(moduleTrigger, key);
190
+ expect(getChildLink()).toBeTruthy();
191
+ expect(onSubmenuOpen).toHaveBeenCalledTimes(1);
192
+ expect(navigate.mock.calls).toEqual([['/pricebook'], ['/pricebook']]);
193
+ expect(closeDrawer).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it('still navigates and closes the drawer when a child page is selected', () => {
197
+ const { moduleTrigger, navigate, closeDrawer, onSubmenuOpen } = renderGroup();
198
+
199
+ click(moduleTrigger);
200
+ navigate.mockClear();
201
+ click(getChildLink()!);
202
+
203
+ expect(navigate).toHaveBeenCalledWith('/pricebook/services');
204
+ expect(navigate).toHaveBeenCalledTimes(1);
205
+ expect(closeDrawer).toHaveBeenCalledTimes(1);
206
+ expect(onSubmenuOpen).toHaveBeenCalledTimes(1);
207
+ });
208
+
209
+ // A group with no destination has nothing to navigate to, so its name stays a plain toggle.
210
+ it('still lets groups without a destination toggle from their name', () => {
211
+ const { moduleTrigger, onSubmenuOpen } = renderGroup('');
212
+
213
+ expect(moduleTrigger.tabIndex).toBe(0);
214
+ click(moduleTrigger);
215
+
216
+ expect(getChildLink()).toBeTruthy();
217
+ expect(onSubmenuOpen).toHaveBeenCalledTimes(1);
218
+
219
+ click(moduleTrigger);
220
+ expect(getChildLink()).toBeNull();
221
+ expect(onSubmenuOpen.mock.calls).toEqual([
222
+ ['pricebook', true],
223
+ ['pricebook', false],
224
+ ]);
225
+ });
@@ -0,0 +1,190 @@
1
+ import { ComponentPropsWithoutRef, PropsWithChildren } from 'react';
2
+ import { Root, createRoot } from 'react-dom/client';
3
+ import { act } from 'react-dom/test-utils';
4
+ import {
5
+ NavigationCatalog,
6
+ NavigationCatalogProps,
7
+ } from '../navigation-catalog/navigation-catalog';
8
+
9
+ // Keep the real catalog/confirmation logic; substitute only Anvil's browser-dependent primitives.
10
+ jest.mock('@servicetitan/anvil2', () => {
11
+ const { forwardRef } = jest.requireActual<typeof import('react')>('react');
12
+ const Box = ({ children }: PropsWithChildren) => <div>{children}</div>;
13
+
14
+ return {
15
+ Dialog: Object.assign(({ children }: PropsWithChildren) => <div>{children}</div>, {
16
+ Content: Box,
17
+ Footer: Box,
18
+ }),
19
+ Text: Box,
20
+ Icon: () => null,
21
+ Tooltip: Object.assign(Box, { Trigger: Box, Content: () => null }),
22
+ Button: forwardRef<
23
+ HTMLButtonElement,
24
+ ComponentPropsWithoutRef<'button'> & {
25
+ appearance?: string;
26
+ size?: string;
27
+ icon?: unknown;
28
+ }
29
+ >(function MockButton({ appearance, size, icon, ...props }, ref) {
30
+ return <button ref={ref} {...props} />;
31
+ }),
32
+ SearchField: forwardRef<
33
+ HTMLInputElement,
34
+ Omit<ComponentPropsWithoutRef<'input'>, 'size'> & {
35
+ size?: string;
36
+ onClear?: () => void;
37
+ }
38
+ >(function MockSearchField({ size, onClear, ...props }, ref) {
39
+ return <input ref={ref} {...props} />;
40
+ }),
41
+ };
42
+ });
43
+
44
+ let container: HTMLDivElement;
45
+ let root: Root;
46
+ const actEnvironment = globalThis as typeof globalThis &
47
+ Record<'IS_REACT_ACT_ENVIRONMENT', boolean | undefined>;
48
+ const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
49
+
50
+ beforeAll(() => {
51
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
52
+ });
53
+ afterAll(() => {
54
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
55
+ });
56
+ beforeEach(() => {
57
+ container = document.createElement('div');
58
+ document.body.appendChild(container);
59
+ root = createRoot(container);
60
+ });
61
+ afterEach(() => {
62
+ act(() => root.unmount());
63
+ container.remove();
64
+ });
65
+
66
+ const pinned = ['marketing', 'overview'];
67
+ const groups: NavigationCatalogProps['groups'] = () => [
68
+ {
69
+ title: 'Demand',
70
+ items: [
71
+ {
72
+ id: 'marketing',
73
+ title: 'Marketing',
74
+ to: '/marketing',
75
+ icon: undefined,
76
+ iconActive: undefined,
77
+ submenu: {
78
+ groups: [
79
+ {
80
+ title: 'Analytics',
81
+ links: [
82
+ { id: 'overview', title: 'Overview', to: '/marketing/overview' },
83
+ { id: 'campaigns', title: 'Campaigns', to: '/marketing/campaigns' },
84
+ ],
85
+ },
86
+ ],
87
+ },
88
+ },
89
+ ],
90
+ },
91
+ ];
92
+
93
+ const renderCatalog = (overrides: Partial<NavigationCatalogProps> = {}) => {
94
+ const props = {
95
+ open: true,
96
+ onClose: jest.fn(),
97
+ groups,
98
+ pinnedIds: pinned,
99
+ onPinnedIdsChange: jest.fn(),
100
+ onResetToDefaults: jest.fn(),
101
+ ...overrides,
102
+ };
103
+ act(() => root.render(<NavigationCatalog {...props} />));
104
+ return props;
105
+ };
106
+ const button = (text: string) =>
107
+ [...container.querySelectorAll('button')].find(element => element.textContent === text);
108
+ const click = (element: Element) => {
109
+ act(() => element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
110
+ };
111
+ const marketingStar = () => container.querySelector('button[aria-label*="Marketing"]')!;
112
+
113
+ it('reports only a confirmed reset, without proposing a list of its own', () => {
114
+ const props = renderCatalog();
115
+ click(button('Restore Default Favorites')!);
116
+ expect(props.onResetToDefaults).not.toHaveBeenCalled();
117
+ expect(document.activeElement).toBe(button('Cancel'));
118
+
119
+ click(button('Cancel')!);
120
+ expect(props.onResetToDefaults).not.toHaveBeenCalled();
121
+ expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
122
+ expect(document.activeElement).toBe(button('Restore Default Favorites'));
123
+
124
+ click(button('Restore Default Favorites')!);
125
+ click(button('Restore')!);
126
+ expect(props.onResetToDefaults).toHaveBeenCalledTimes(1);
127
+ expect(props.onResetToDefaults).toHaveBeenCalledWith();
128
+ expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
129
+ expect(document.activeElement).toBe(container.querySelector('input'));
130
+ });
131
+
132
+ it('leaves favorites untouched until the host applies the reset', () => {
133
+ const props = renderCatalog();
134
+ click(button('Restore Default Favorites')!);
135
+ click(button('Restore')!);
136
+ /*
137
+ * Fully controlled: the catalog has proposed nothing, so the rows still read as they did —
138
+ * `mixed` here, because two of Marketing's three ids are favourited and not the third.
139
+ */
140
+ expect(marketingStar().getAttribute('aria-pressed')).toBe('mixed');
141
+ expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('keeps star edits on onPinnedIdsChange without calling the reset handler', () => {
145
+ const props = renderCatalog();
146
+ // A partly favourited module completes itself rather than clearing: campaigns joins the list.
147
+ click(marketingStar());
148
+ expect(props.onPinnedIdsChange).toHaveBeenCalledWith(['marketing', 'overview', 'campaigns']);
149
+ expect(props.onResetToDefaults).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it('hides restore when the host does not offer one', () => {
153
+ renderCatalog({ onResetToDefaults: undefined });
154
+ expect(button('Restore Default Favorites')).toBeUndefined();
155
+ });
156
+
157
+ it('drops a pending confirmation when the host withdraws the handler', () => {
158
+ const props = renderCatalog();
159
+ click(button('Restore Default Favorites')!);
160
+ renderCatalog({ ...props, onResetToDefaults: undefined });
161
+ expect(button('Restore')).toBeUndefined();
162
+ expect(props.onResetToDefaults).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it('calls the handler the host supplies now, not the one it confirmed against', () => {
166
+ const previous = renderCatalog();
167
+ click(button('Restore Default Favorites')!);
168
+ const current = renderCatalog({ ...previous, onResetToDefaults: jest.fn() });
169
+ click(button('Restore')!);
170
+ expect(current.onResetToDefaults).toHaveBeenCalledTimes(1);
171
+ expect(previous.onResetToDefaults).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it('reports reset intent while search hides all destinations, preserving the query', () => {
175
+ const props = renderCatalog();
176
+ const input = container.querySelector('input')!;
177
+ act(() => {
178
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
179
+ input,
180
+ 'no matching page'
181
+ );
182
+ input.dispatchEvent(new Event('input', { bubbles: true }));
183
+ });
184
+ expect(container.textContent).toContain('No navigation items match');
185
+ click(button('Restore Default Favorites')!);
186
+ click(button('Restore')!);
187
+ expect(props.onResetToDefaults).toHaveBeenCalledTimes(1);
188
+ expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
189
+ expect(input.value).toBe('no matching page');
190
+ });
@@ -1,6 +1,7 @@
1
1
  import { IconProps } from '@servicetitan/anvil2';
2
2
  import { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
3
3
  import { CounterTagData, CounterTagValue } from '../../utils/counter-tag';
4
+ import { NavigationItemData } from '../../utils/navigation';
4
5
  import { TitanLayoutSidebarContextType } from './layout-context';
5
6
 
6
7
  export interface TitanLayoutLinkWrapperProps {
@@ -11,6 +12,78 @@ export interface TitanLayoutLinkWrapperProps {
11
12
 
12
13
  export type TitanLayoutLinkWrapper = FC<TitanLayoutLinkWrapperProps>;
13
14
 
15
+ /** A titled section of the navigation catalog. */
16
+ export interface NavigationCatalogGroup {
17
+ /** section heading */
18
+ title: string;
19
+
20
+ /**
21
+ * The destinations in this section.
22
+ *
23
+ * `id` is treated as an opaque pin id and is compared verbatim against `pinnedIds`, so the host
24
+ * is responsible for handing over ids in whatever form it stores them. Any `submenu` links are
25
+ * offered as separately pinnable pages beneath their item, and their `id`s are pin ids too.
26
+ */
27
+ items: NavigationItemData[];
28
+ }
29
+
30
+ /**
31
+ * Turns the navigation catalog on.
32
+ *
33
+ * Supplying this renders a catalog trigger in the sidebar and the dialog behind it; omitting it
34
+ * leaves the navigation exactly as it was. The layout owns the dialog, its open state and the
35
+ * keyboard shortcut, and nothing else about pinning: it proposes a new list, or reports that the
36
+ * user confirmed a reset, and the host decides what to do with either.
37
+ */
38
+ export interface PinnedNavigationConfig {
39
+ /**
40
+ * Every destination on offer, resolved on demand.
41
+ *
42
+ * A function rather than an array because resolving a catalog is not free for every host: one
43
+ * whose submenus are dynamic pays a request per module to enumerate its pages. The dialog is
44
+ * mounted only while open, so taking the groups as a thunk keeps that cost — and any failure
45
+ * in it — inside the dialog instead of on every page load of the shell.
46
+ *
47
+ * Called once per open, and again only if the function's identity changes, so a host should
48
+ * memoize it against whatever its resolution actually depends on.
49
+ */
50
+ groups: () => NavigationCatalogGroup[];
51
+
52
+ /** The pinned ids, in display order. */
53
+ pinnedIds: string[];
54
+
55
+ /**
56
+ * Ids the user may not pin or unpin.
57
+ *
58
+ * A locked destination is always present in the navigation whether or not it appears in
59
+ * `pinnedIds`, so the catalog shows a lock where the pin control would be and never proposes a
60
+ * list that adds or removes one.
61
+ */
62
+ lockedIds?: string[];
63
+
64
+ /**
65
+ * The list the user just asked for through a star control, in full.
66
+ *
67
+ * Fully controlled: nothing changes until `pinnedIds` comes back different. A host that
68
+ * declines the proposal simply does not apply it, and the control returns to its previous
69
+ * state on its own. Confirming a reset calls `onResetToDefaults` instead.
70
+ */
71
+ onPinnedIdsChange: (pinnedIds: string[]) => void;
72
+
73
+ /**
74
+ * Called once the user confirms Restore Default Favorites.
75
+ *
76
+ * The catalog reports the intent and proposes nothing: what the defaults are, whether locked
77
+ * destinations survive and how the reset is persisted are all the host's. Favourites move only
78
+ * when the host sends new `pinnedIds` down, and the catalog does not also call
79
+ * `onPinnedIdsChange`.
80
+ *
81
+ * Omit it to hide the restore control — including when the host offers defaults but the user
82
+ * has not strayed from them, which only the host can know.
83
+ */
84
+ onResetToDefaults?: () => void;
85
+ }
86
+
14
87
  export interface TitanLayoutLinkExtraProps {
15
88
  /** link's title should be shown (for extra links we show icon only by default) */
16
89
  showTitle?: boolean;