@conduction/nextcloud-vue 2.61.0 → 2.63.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 (186) hide show
  1. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue.js +10 -0
  2. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue.js.map +1 -0
  3. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js +189 -0
  4. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js.map +1 -0
  5. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js +72 -0
  6. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js.map +1 -0
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +394 -27
  8. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  9. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  10. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +99 -9
  11. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +39 -18
  13. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  14. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +79 -2
  15. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  16. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js +38 -25
  17. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  18. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js +21 -1
  19. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js.map +1 -1
  20. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js +4 -1
  21. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js.map +1 -1
  22. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  23. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +53 -37
  24. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +30 -6
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +2 -2
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js +10 -0
  30. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js.map +1 -0
  31. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +293 -0
  32. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -0
  33. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +189 -0
  34. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -0
  35. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +256 -23
  36. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +119 -51
  38. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  39. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  40. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +2 -1
  41. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  42. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js +3 -1
  43. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js.map +1 -1
  44. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js +1 -1
  45. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js.map +1 -1
  46. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +234 -9
  47. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  48. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +96 -57
  49. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +51 -2
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  52. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -1
  53. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  54. package/dist/esm/components/CnIndexPage/selfModeActions.js +3 -3
  55. package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
  56. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -1
  57. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -1
  58. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +36 -1
  59. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  60. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -1
  61. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  62. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +53 -0
  63. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  64. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +22 -5
  65. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  66. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  67. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  68. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js +121 -12
  69. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js.map +1 -1
  70. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js +3 -1
  71. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js.map +1 -1
  72. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +326 -1
  73. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  74. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +112 -26
  75. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  76. package/dist/esm/components/CnStorePage/CnStorePage.vue2.js.map +1 -1
  77. package/dist/esm/components/CnStorePage/CnStorePage.vue3.js.map +1 -1
  78. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js +10 -0
  79. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js.map +1 -0
  80. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +337 -0
  81. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -0
  82. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js +102 -0
  83. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -0
  84. package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js +34 -0
  85. package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js.map +1 -0
  86. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +131 -8
  87. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  88. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js +40 -38
  89. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  90. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +8 -6
  91. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
  92. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  93. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  94. package/dist/esm/components/CnWikiPage/CnWikiPage.vue2.js.map +1 -1
  95. package/dist/esm/components/CnWikiPage/CnWikiPage.vue3.js.map +1 -1
  96. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
  97. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  98. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
  99. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  100. package/dist/esm/composables/useDependencyCheck.js +65 -0
  101. package/dist/esm/composables/useDependencyCheck.js.map +1 -0
  102. package/dist/esm/composables/useWalkthrough.js +218 -6
  103. package/dist/esm/composables/useWalkthrough.js.map +1 -1
  104. package/dist/esm/index.js +2 -0
  105. package/dist/esm/index.js.map +1 -1
  106. package/dist/esm/l10n/en.json.js +48 -0
  107. package/dist/esm/l10n/en.json.js.map +1 -1
  108. package/dist/esm/l10n/nl.json.js +48 -0
  109. package/dist/esm/l10n/nl.json.js.map +1 -1
  110. package/dist/esm/nextcloud-vue.css +557 -9
  111. package/dist/esm/store/useObjectStore.js +8 -0
  112. package/dist/esm/store/useObjectStore.js.map +1 -1
  113. package/dist/esm/utils/columnFilters.js +311 -0
  114. package/dist/esm/utils/columnFilters.js.map +1 -0
  115. package/dist/esm/utils/groupAutocomplete.js +123 -0
  116. package/dist/esm/utils/groupAutocomplete.js.map +1 -0
  117. package/dist/esm/utils/libraryWidgetKeys.js +2 -0
  118. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  119. package/dist/esm/utils/pickerOptions.js +198 -0
  120. package/dist/esm/utils/pickerOptions.js.map +1 -0
  121. package/dist/esm/utils/schema.js +83 -7
  122. package/dist/esm/utils/schema.js.map +1 -1
  123. package/dist/esm/utils/timelineEvents.js +174 -0
  124. package/dist/esm/utils/timelineEvents.js.map +1 -0
  125. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  126. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  127. package/dist/esm/utils/walkthroughSignals.js +88 -0
  128. package/dist/esm/utils/walkthroughSignals.js.map +1 -0
  129. package/dist/nextcloud-vue.cjs.js +9779 -5236
  130. package/dist/nextcloud-vue.cjs.js.map +2 -2
  131. package/dist/nextcloud-vue.css +557 -9
  132. package/l10n/en.json +48 -0
  133. package/l10n/nl.json +48 -0
  134. package/package.json +3 -2
  135. package/scripts/manifest-schema-hash.js +41 -0
  136. package/scripts/record-manifest-schema-hash.js +52 -0
  137. package/src/components/CnAdminActionCard/CnAdminActionCard.vue +234 -0
  138. package/src/components/CnAdminActionCard/index.js +4 -0
  139. package/src/components/CnAppNav/CnAppNav.vue +394 -27
  140. package/src/components/CnAppRoot/CnAppRoot.vue +129 -15
  141. package/src/components/CnBannerWidget/CnBannerWidget.vue +113 -2
  142. package/src/components/CnBrandStripe/CnBrandStripe.md +16 -0
  143. package/src/components/CnBrandStripe/CnBrandStripe.vue +58 -5
  144. package/src/components/CnChoiceCards/CnChoiceCards.vue +56 -31
  145. package/src/components/CnDashboardPage/CnDashboardPage.vue +32 -8
  146. package/src/components/CnDataTable/CnColumnFilterPopover.vue +524 -0
  147. package/src/components/CnDataTable/CnDataTable.md +4 -1
  148. package/src/components/CnDataTable/CnDataTable.vue +302 -21
  149. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +2 -2
  150. package/src/components/CnFieldHelper/CnFieldHelper.vue +4 -2
  151. package/src/components/CnFormDialog/CnFormDialog.vue +290 -31
  152. package/src/components/CnIndexPage/CnIndexPage.vue +55 -2
  153. package/src/components/CnIndexPage/selfModeActions.js +3 -3
  154. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +35 -2
  155. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +39 -1
  156. package/src/components/CnObjectSidebar/CnFilesTab.vue +65 -0
  157. package/src/components/CnReportsPage/CnReportsPage.vue +7 -0
  158. package/src/components/CnResourceSelect/CnResourceSelect.vue +123 -12
  159. package/src/components/CnSetupWizard/CnSetupWizard.vue +392 -3
  160. package/src/components/CnStorePage/CnStorePage.vue +7 -0
  161. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +477 -0
  162. package/src/components/CnTimelineWidget/dashboardRegistration.js +32 -0
  163. package/src/components/CnTimelineWidget/index.js +4 -0
  164. package/src/components/CnWalkthrough/CnWalkthrough.vue +143 -10
  165. package/src/components/CnWidgetGrid/builtInWidgets.js +2 -0
  166. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  167. package/src/components/CnWikiPage/CnWikiPage.vue +7 -0
  168. package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
  169. package/src/components/index.js +2 -0
  170. package/src/composables/useDependencyCheck.js +61 -0
  171. package/src/composables/useWalkthrough.js +217 -5
  172. package/src/css/dashboard.css +49 -0
  173. package/src/css/detail-page.css +19 -0
  174. package/src/css/table.css +82 -2
  175. package/src/index.js +2 -0
  176. package/src/public/index.js +5 -0
  177. package/src/schemas/app-manifest-v2.schema.json +23 -1
  178. package/src/store/useObjectStore.js +8 -0
  179. package/src/utils/columnFilters.js +324 -0
  180. package/src/utils/groupAutocomplete.js +119 -0
  181. package/src/utils/libraryWidgetKeys.js +2 -0
  182. package/src/utils/pickerOptions.js +194 -0
  183. package/src/utils/schema.js +83 -7
  184. package/src/utils/timelineEvents.js +171 -0
  185. package/src/utils/validateManifestV2.compiled.js +2 -2
  186. package/src/utils/walkthroughSignals.js +94 -0
@@ -346,6 +346,7 @@
346
346
  import { translate as t } from '@nextcloud/l10n'
347
347
  import { generateUrl } from '@nextcloud/router'
348
348
  import { NcAppNavigation, NcAppNavigationCaption, NcAppNavigationItem, NcAppNavigationList, NcAppNavigationNew, NcAppNavigationSettings, NcButton, NcCounterBubble } from '@nextcloud/vue'
349
+ import { toRaw } from 'vue'
349
350
  import BookOpenVariant from 'vue-material-design-icons/BookOpenVariant.vue'
350
351
  import Cog from 'vue-material-design-icons/Cog.vue'
351
352
  // ADR-077 rule 4: visible fallback for an unresolvable icon name.
@@ -393,6 +394,44 @@ function byManifestOrder(a, b) {
393
394
  return a.order - b.order
394
395
  }
395
396
 
397
+ /** A `:name` page-path segment: name, optional `(regexp)`, optional modifier. */
398
+ const PARAM_SEGMENT = /^:(\w+)(\([^)]*\))?([?*+])?$/
399
+
400
+ /**
401
+ * Whether a `PARAM_SEGMENT` match may be left out (`?` or `*`).
402
+ *
403
+ * @param {Array<string>} param The match.
404
+ * @return {boolean} True for an optional segment.
405
+ */
406
+ function isOptionalParam(param) {
407
+ return param[3] === '?' || param[3] === '*'
408
+ }
409
+
410
+ /**
411
+ * Whether a route param value fills its segment.
412
+ *
413
+ * @param {unknown} value The value.
414
+ * @return {boolean} False for undefined, null and the empty string.
415
+ */
416
+ function isFilled(value) {
417
+ return value !== undefined && value !== null && value !== ''
418
+ }
419
+
420
+ /**
421
+ * Decode one percent-encoded path segment, or keep it as is when it is not
422
+ * valid percent-encoding.
423
+ *
424
+ * @param {string} segment The segment.
425
+ * @return {string} The decoded segment.
426
+ */
427
+ function decodeSegment(segment) {
428
+ try {
429
+ return decodeURIComponent(segment)
430
+ } catch {
431
+ return segment
432
+ }
433
+ }
434
+
396
435
  export default {
397
436
  name: 'CnAppNav',
398
437
 
@@ -576,6 +615,13 @@ export default {
576
615
 
577
616
  emits: ['primary-action', 'primary-action-click'],
578
617
 
618
+ /**
619
+ * The navigation's own state: which groups are open, and which entry
620
+ * of a route the reader last used.
621
+ *
622
+ * @return {object} The state.
623
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
624
+ */
579
625
  data() {
580
626
  return {
581
627
  /**
@@ -589,6 +635,15 @@ export default {
589
635
  */
590
636
  openState: {},
591
637
 
638
+ /**
639
+ * Per route name, the key of the entry that was last active on
640
+ * that route in this session. Read by `subRouteParent` to keep
641
+ * "the list I came from" marked on a page below it. In memory
642
+ * only: a reload starts empty and falls back to menu order.
643
+ *
644
+ * @type {Record<string, string>}
645
+ */
646
+ lastActiveEntryByRoute: {},
592
647
  }
593
648
  },
594
649
 
@@ -907,7 +962,9 @@ export default {
907
962
  * `/expenses/:id`) light up — and auto-expand — their index entry
908
963
  * even though the detail route name is not in the menu. Longest
909
964
  * prefix disambiguates nested namespaces (e.g. `/pos` vs
910
- * `/pos/refunds`). Returns null when nothing matches.
965
+ * `/pos/refunds`). An entry with `params` matches by its page path
966
+ * with those params filled in (see `pageMatchDepth`). Returns null
967
+ * when nothing matches.
911
968
  *
912
969
  * @return {string|null}
913
970
  */
@@ -928,24 +985,84 @@ export default {
928
985
  return routeName ?? null
929
986
  }
930
987
  let best = null
931
- let bestLen = -1
988
+ let bestDepth = 0
932
989
  for (const it of flat) {
933
990
  if (!it.route) {
934
991
  continue
935
992
  }
936
- const pagePath = this.pageForItem(it)?.route
937
- if (!pagePath || pagePath === '/' || pagePath.includes(':')) {
938
- continue
939
- }
940
- if (path === pagePath || path.startsWith(pagePath + '/')) {
941
- if (pagePath.length > bestLen) {
942
- best = it.route
943
- bestLen = pagePath.length
944
- }
993
+ const depth = this.pageMatchDepth(it, path)
994
+ if (depth > bestDepth) {
995
+ best = it.route
996
+ bestDepth = depth
945
997
  }
946
998
  }
947
999
  return best ?? routeName ?? null
948
1000
  },
1001
+
1002
+ /**
1003
+ * Every visible entry and child, flat, in menu order.
1004
+ *
1005
+ * @return {Array<object>} The entries.
1006
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
1007
+ */
1008
+ flatEntries() {
1009
+ const flat = []
1010
+ for (const item of this.visibleItems) {
1011
+ flat.push(item, ...this.visibleChildren(item))
1012
+ }
1013
+ return flat
1014
+ },
1015
+
1016
+ /**
1017
+ * The entry the query rule marks while the reader is on a menu
1018
+ * route itself (not on a page below it). Watched, so the nav can
1019
+ * remember which entry of a route the reader last used.
1020
+ *
1021
+ * @return {object|null} The entry, or null on a page below a route.
1022
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
1023
+ */
1024
+ entryActiveOnOwnRoute() {
1025
+ const routeName = this.$route?.name
1026
+ if (!routeName || routeName !== this.activeRouteName) {
1027
+ return null
1028
+ }
1029
+ return this.flatEntries.find((entry) => this.isActiveByRule(entry)) ?? null
1030
+ },
1031
+
1032
+ /**
1033
+ * The one entry to mark on a page BELOW a menu route when the query
1034
+ * rule marks none.
1035
+ *
1036
+ * On a detail page (`/cases/123`) the active route is found by path
1037
+ * prefix, and the address carries the detail page's query, not the
1038
+ * list's. When every entry on that list has a `query` ("My work",
1039
+ * "Queue"), none of them matched and the menu showed no place at
1040
+ * all. Marking all of them is the defect the query rule ended, so
1041
+ * this picks exactly one: the entry the reader last had active on
1042
+ * that route, else the first in menu order.
1043
+ *
1044
+ * Null whenever the existing rule already marks an entry, so a list
1045
+ * with an entry without a `query` behaves as it did. An entry with
1046
+ * `params` is a candidate only when the current path sits below its
1047
+ * own filled-in page path, so another param value is never marked.
1048
+ *
1049
+ * @return {object|null} The entry to mark, or null.
1050
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
1051
+ */
1052
+ subRouteParent() {
1053
+ const active = this.activeRouteName
1054
+ if (!active || this.$route?.name === active) {
1055
+ return null
1056
+ }
1057
+ const path = this.$route?.path
1058
+ const entries = this.flatEntries.filter((entry) => !entry.href && entry.route === active
1059
+ && (!this.hasParams(entry) || this.pageMatchDepth(entry, path) > 0))
1060
+ if (entries.length === 0 || entries.some((entry) => this.isActiveByRule(entry))) {
1061
+ return null
1062
+ }
1063
+ const remembered = this.lastActiveEntryByRoute[active]
1064
+ return entries.find((entry) => this.entryKey(entry) === remembered) ?? entries[0]
1065
+ },
949
1066
  },
950
1067
 
951
1068
  watch: {
@@ -960,6 +1077,23 @@ export default {
960
1077
  visibleItems() {
961
1078
  this.pinGroupsEntered(this.activeRouteName)
962
1079
  },
1080
+
1081
+ entryActiveOnOwnRoute: {
1082
+ immediate: true,
1083
+ /**
1084
+ * Remember which entry of a route the reader used, for
1085
+ * `subRouteParent`.
1086
+ *
1087
+ * @param {object|null} entry The entry active on its own route.
1088
+ * @return {void}
1089
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
1090
+ */
1091
+ handler(entry) {
1092
+ if (entry && entry.route) {
1093
+ this.lastActiveEntryByRoute[entry.route] = this.entryKey(entry)
1094
+ }
1095
+ },
1096
+ },
963
1097
  },
964
1098
 
965
1099
  created() {
@@ -972,6 +1106,8 @@ export default {
972
1106
  // stays out of `data()`: a reactive Set mutated inside render would
973
1107
  // re-trigger the render effect.
974
1108
  this._autoCountWarned = new Set()
1109
+ // Same latch, for warnUnfilledRouteParam() (`itemTo()` runs during render).
1110
+ this._unfilledParamWarned = new Set()
975
1111
  },
976
1112
 
977
1113
  methods: {
@@ -1180,7 +1316,19 @@ export default {
1180
1316
  .sort(byManifestOrder)
1181
1317
  },
1182
1318
 
1319
+ /**
1320
+ * The label an entry shows: run through the translate function, or
1321
+ * as written when the entry sets `translateLabel: false` (a
1322
+ * user-authored title that may equal a translation key). Every
1323
+ * place the nav shows an entry's label reads it from here.
1324
+ *
1325
+ * @param {object} item Menu entry.
1326
+ * @return {string} The label to render as text.
1327
+ */
1183
1328
  resolveLabel(item) {
1329
+ if (item.translateLabel === false) {
1330
+ return item.label
1331
+ }
1184
1332
  return this.effectiveTranslate(item.label)
1185
1333
  },
1186
1334
 
@@ -1191,30 +1339,204 @@ export default {
1191
1339
  * work" and "Queue" on one list). The route name alone lit both. An
1192
1340
  * entry with a `query` is active only when the address carries that
1193
1341
  * query; an entry without one steps back when a sibling's query
1194
- * matches, so exactly one of them is marked.
1342
+ * matches, so exactly one of them is marked. `params` work the
1343
+ * same way against the route's params, so of several entries on a
1344
+ * `/items/:slug` route only the one whose `slug` is in the address
1345
+ * is marked.
1346
+ *
1347
+ * On a page below a list where that rule marks nothing, the one
1348
+ * entry `subRouteParent` picks is active instead.
1195
1349
  *
1196
1350
  * @param {object} item Menu entry.
1197
1351
  * @return {boolean} True when the entry is the current page.
1198
1352
  * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-menu-entries-that-differ-in-query
1353
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
1199
1354
  */
1200
1355
  isActive(item) {
1356
+ if (this.isActiveByRule(item)) {
1357
+ return true
1358
+ }
1359
+ const parent = this.subRouteParent
1360
+ return parent !== null && toRaw(parent) === toRaw(item)
1361
+ },
1362
+
1363
+ /**
1364
+ * The route, query and params rule on its own, without the fallback
1365
+ * for a page below a list. `subRouteParent` asks this to see whether
1366
+ * the rule already marks an entry.
1367
+ *
1368
+ * @param {object} item Menu entry.
1369
+ * @return {boolean} True when the rule marks the entry.
1370
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-menu-entries-that-differ-in-query
1371
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
1372
+ */
1373
+ isActiveByRule(item) {
1201
1374
  if (item.href || !item.route) {
1202
1375
  return false
1203
1376
  }
1204
1377
  if (item.route !== this.activeRouteName) {
1205
1378
  return false
1206
1379
  }
1207
- if (item.query && typeof item.query === 'object') {
1208
- return this.queryMatches(item)
1209
- }
1210
- const siblings = []
1211
- for (const entry of this.visibleItems) {
1212
- siblings.push(entry, ...this.visibleChildren(entry))
1380
+ if (this.isNarrowed(item)) {
1381
+ return this.narrowingMatches(item)
1213
1382
  }
1214
- return !siblings.some((entry) => entry !== item
1383
+ return !this.flatEntries.some((entry) => entry !== item
1215
1384
  && entry.route === item.route
1216
- && entry.query && typeof entry.query === 'object'
1217
- && this.queryMatches(entry))
1385
+ && this.isNarrowed(entry)
1386
+ && this.narrowingMatches(entry))
1387
+ },
1388
+
1389
+ /**
1390
+ * Whether an entry narrows its route with a `query` or `params`.
1391
+ *
1392
+ * @param {object} item Menu entry.
1393
+ * @return {boolean} True when the entry declares either.
1394
+ */
1395
+ isNarrowed(item) {
1396
+ return Boolean(item.query && typeof item.query === 'object') || this.hasParams(item)
1397
+ },
1398
+
1399
+ /**
1400
+ * Whether the address carries every `query` key and every `params`
1401
+ * key the entry declares.
1402
+ *
1403
+ * @param {object} item Menu entry that narrows its route.
1404
+ * @return {boolean} True when everything it declares matches.
1405
+ */
1406
+ narrowingMatches(item) {
1407
+ const queryOk = !(item.query && typeof item.query === 'object') || this.queryMatches(item)
1408
+ const paramsOk = !this.hasParams(item) || this.paramsMatch(item)
1409
+ return queryOk && paramsOk
1410
+ },
1411
+
1412
+ /**
1413
+ * Whether an entry declares a non-empty `params` object.
1414
+ *
1415
+ * @param {object} item Menu entry.
1416
+ * @return {boolean} True when it has route params.
1417
+ */
1418
+ hasParams(item) {
1419
+ return Boolean(item?.params) && typeof item.params === 'object' && Object.keys(item.params).length > 0
1420
+ },
1421
+
1422
+ /**
1423
+ * Whether the route's params carry every key of an entry's
1424
+ * `params`. Values are compared as strings.
1425
+ *
1426
+ * @param {object} item Menu entry with `params`.
1427
+ * @return {boolean} True when every declared key matches.
1428
+ */
1429
+ paramsMatch(item) {
1430
+ const current = this.$route?.params ?? {}
1431
+ return Object.keys(item.params).every((key) => String(current[key] ?? '') === String(item.params[key]))
1432
+ },
1433
+
1434
+ /**
1435
+ * The segments of an entry's page path with its `params` filled in,
1436
+ * as plain values. A missing optional `:name?` segment is dropped;
1437
+ * null when a required one has no param, or without a page.
1438
+ *
1439
+ * @param {object} item Menu entry with `params`.
1440
+ * @return {Array<string>|null} The segments, or null.
1441
+ */
1442
+ filledPageSegments(item) {
1443
+ const pagePath = this.pageForItem(item)?.route
1444
+ if (!pagePath) {
1445
+ return null
1446
+ }
1447
+ const segments = []
1448
+ for (const part of pagePath.split('/').filter(Boolean)) {
1449
+ const param = PARAM_SEGMENT.exec(part)
1450
+ if (!param) {
1451
+ segments.push(part)
1452
+ continue
1453
+ }
1454
+ const value = item.params?.[param[1]]
1455
+ if (isFilled(value)) {
1456
+ segments.push(String(value))
1457
+ } else if (!isOptionalParam(param)) {
1458
+ return null
1459
+ }
1460
+ }
1461
+ return segments
1462
+ },
1463
+
1464
+ /**
1465
+ * The required route param the router cannot fill when it resolves
1466
+ * an entry's link on its manifest page. vue-router fills a named
1467
+ * link's required params from the current route, then overlays every
1468
+ * key the entry declares, empty or null included. So a declared key
1469
+ * decides on its own value, and only an undeclared one falls back
1470
+ * to the current route. Null without a known page, or when every
1471
+ * required segment is filled. Reads `$route`, so it follows
1472
+ * navigation.
1473
+ *
1474
+ * @param {object} item Menu entry.
1475
+ * @return {string|null} The param name, or null.
1476
+ */
1477
+ unfilledRouteParam(item) {
1478
+ const pagePath = this.pageForItem(item)?.route
1479
+ if (!pagePath) {
1480
+ return null
1481
+ }
1482
+ const declared = item.params && typeof item.params === 'object' ? item.params : {}
1483
+ const current = this.$route?.params ?? {}
1484
+ for (const part of pagePath.split('/')) {
1485
+ const param = PARAM_SEGMENT.exec(part)
1486
+ if (!param || isOptionalParam(param)) {
1487
+ continue
1488
+ }
1489
+ const value = param[1] in declared ? declared[param[1]] : current[param[1]]
1490
+ if (!isFilled(value)) {
1491
+ return param[1]
1492
+ }
1493
+ }
1494
+ return null
1495
+ },
1496
+
1497
+ /**
1498
+ * How deep a path sits on an entry's page path: the page path's
1499
+ * segment count when the path is that page or below it, else 0.
1500
+ * A `/` page path, or one with an unfilled `:name`, never matches.
1501
+ *
1502
+ * Without `params` the page path is compared as written. With
1503
+ * `params`, segments are compared decoded, because the router
1504
+ * percent-encodes param values in the path and the entry holds them
1505
+ * plain.
1506
+ *
1507
+ * @param {object} item Menu entry.
1508
+ * @param {string|undefined} path The path to test.
1509
+ * @return {number} The depth of the match, or 0.
1510
+ */
1511
+ pageMatchDepth(item, path) {
1512
+ if (!path) {
1513
+ return 0
1514
+ }
1515
+ if (!this.hasParams(item)) {
1516
+ const pagePath = this.pageForItem(item)?.route
1517
+ if (!pagePath || pagePath === '/' || pagePath.includes(':')) {
1518
+ return 0
1519
+ }
1520
+ const below = path === pagePath || path.startsWith(pagePath + '/')
1521
+ return below ? pagePath.split('/').filter(Boolean).length : 0
1522
+ }
1523
+ const segments = this.filledPageSegments(item)
1524
+ if (!segments || segments.length === 0) {
1525
+ return 0
1526
+ }
1527
+ const current = path.split('/').filter(Boolean).map(decodeSegment)
1528
+ return segments.every((segment, index) => current[index] === segment) ? segments.length : 0
1529
+ },
1530
+
1531
+ /**
1532
+ * What an entry is remembered by: its `id`, else its label.
1533
+ *
1534
+ * @param {object} item Menu entry.
1535
+ * @return {string} The key.
1536
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
1537
+ */
1538
+ entryKey(item) {
1539
+ return String(item.id ?? item.label ?? '')
1218
1540
  },
1219
1541
 
1220
1542
  /**
@@ -1326,6 +1648,27 @@ export default {
1326
1648
  console.warn(`[CnAppNav] Menu entry "${item.id}" declares count: "auto" but has no resolvable index-type page with register + schema config — no badge will render.`)
1327
1649
  },
1328
1650
 
1651
+ /**
1652
+ * One-shot `console.warn` per menu entry whose link cannot fill a
1653
+ * required route param. Keyed by `id`, else route and label, so
1654
+ * entries without an id each get their own warning.
1655
+ *
1656
+ * @param {{ id?: string, route: string, label?: string }} item Menu entry descriptor.
1657
+ * @param {string} param The unfilled param name.
1658
+ * @return {void}
1659
+ * @private
1660
+ */
1661
+ warnUnfilledRouteParam(item, param) {
1662
+ const key = item.id ?? `${item.route}|${item.label ?? ''}`
1663
+ if (this._unfilledParamWarned.has(key)) {
1664
+ return
1665
+ }
1666
+ this._unfilledParamWarned.add(key)
1667
+ const name = item.id ?? item.label ?? item.route
1668
+ // eslint-disable-next-line no-console
1669
+ console.warn(`[CnAppNav] Menu entry "${name}" cannot fill the required route param "${param}" of page "${item.route}" from its params or the current route — the entry renders without a link.`)
1670
+ },
1671
+
1329
1672
  /**
1330
1673
  * Look up an item's resolved page (`pages[]` entry whose `id`
1331
1674
  * matches the menu item's `route`) — used to decide whether the
@@ -1348,11 +1691,17 @@ export default {
1348
1691
  * items (`action: "user-settings"`) and `href` items return
1349
1692
  * `null` so the entry is NOT a vue-router link: action items
1350
1693
  * fall through to the click handler, `href` items render a real
1351
- * anchor via `itemHref`. Route items return a named route.
1694
+ * anchor via `itemHref`. Route items return a named route that
1695
+ * carries the item's `params` and `query` when it declares them.
1696
+ *
1697
+ * An item whose manifest page has a required segment that neither
1698
+ * its `params` nor the current route fill also returns null, with a
1699
+ * one-time warning: the router throws resolving such a link, which
1700
+ * would take the whole navigation down rather than that one entry.
1352
1701
  *
1353
1702
  * @param {object} item Menu item being rendered.
1354
- * @return {object|null} A `{ name }` route object, or null for
1355
- * action / href / route-less items.
1703
+ * @return {object|null} A `{ name, params?, query? }` route object,
1704
+ * or null for action / href / route-less / unfillable items.
1356
1705
  */
1357
1706
  itemTo(item) {
1358
1707
  if (item.action) {
@@ -1364,9 +1713,22 @@ export default {
1364
1713
  if (!item.route) {
1365
1714
  return null
1366
1715
  }
1367
- // Carry optional query params so a nav entry can deep-link to a
1368
- // pre-filtered index page (e.g. one entry per case type → Cases?caseType=…).
1369
- return item.query ? { name: item.route, query: item.query } : { name: item.route }
1716
+ const unfilled = this.unfilledRouteParam(item)
1717
+ if (unfilled) {
1718
+ this.warnUnfilledRouteParam(item, unfilled)
1719
+ return null
1720
+ }
1721
+ const to = { name: item.route }
1722
+ // Params fill a parameterised route (one entry per catalog →
1723
+ // /catalogs/:slug); query deep-links to a pre-filtered index page
1724
+ // (one entry per case type → Cases?caseType=…).
1725
+ if (this.hasParams(item)) {
1726
+ to.params = item.params
1727
+ }
1728
+ if (item.query) {
1729
+ to.query = item.query
1730
+ }
1731
+ return to
1370
1732
  },
1371
1733
 
1372
1734
  /**
@@ -1518,6 +1880,11 @@ export default {
1518
1880
  this.cnReplayWalkthrough(item.tourId)
1519
1881
  return
1520
1882
  }
1883
+ // An entry left without a link (see itemTo) renders a `#` anchor;
1884
+ // following it would change the address, so the click goes nowhere.
1885
+ if (!item.href && this.unfilledRouteParam(item) && event && typeof event.preventDefault === 'function') {
1886
+ event.preventDefault()
1887
+ }
1521
1888
  if (this.visibleChildren(item).length === 0) {
1522
1889
  return
1523
1890
  }