@conduction/nextcloud-vue 2.62.0 → 2.64.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 (209) 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 +425 -39
  8. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  9. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +119 -42
  10. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  11. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +186 -9
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  13. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +65 -42
  14. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  15. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
  16. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  17. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  18. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
  19. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  20. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
  21. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  22. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  23. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  24. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  25. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  26. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +53 -37
  27. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -0
  29. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  30. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +152 -135
  31. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  32. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js +10 -0
  33. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js.map +1 -0
  34. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +293 -0
  35. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -0
  36. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +189 -0
  37. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -0
  38. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +253 -9
  39. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  40. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +126 -50
  41. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  42. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +66 -14
  43. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  44. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +23 -14
  45. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  46. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  47. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +2 -1
  48. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  49. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js +3 -1
  50. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js.map +1 -1
  51. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js +1 -1
  52. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js.map +1 -1
  53. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +234 -9
  54. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  55. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +96 -57
  56. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  57. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +81 -4
  58. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
  60. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  61. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +81 -2
  62. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  63. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +7 -3
  64. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  65. package/dist/esm/components/CnIndexPage/selfModeActions.js +3 -3
  66. package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
  67. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +41 -2
  68. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  69. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +8 -3
  70. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  71. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js +121 -12
  72. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js.map +1 -1
  73. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js +3 -1
  74. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js.map +1 -1
  75. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +326 -1
  76. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  77. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +112 -26
  78. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  79. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  80. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  81. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  82. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  83. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  84. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  85. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +52 -2
  86. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  87. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -6
  88. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  89. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  90. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  91. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  92. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  93. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js +10 -0
  94. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js.map +1 -0
  95. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +337 -0
  96. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -0
  97. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js +102 -0
  98. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -0
  99. package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js +34 -0
  100. package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js.map +1 -0
  101. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +131 -8
  102. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  103. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js +40 -38
  104. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  105. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +8 -6
  106. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
  107. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  108. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  109. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  110. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  111. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  112. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  113. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
  114. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  115. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
  116. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  117. package/dist/esm/composables/useDependencyCheck.js +65 -0
  118. package/dist/esm/composables/useDependencyCheck.js.map +1 -0
  119. package/dist/esm/composables/useWalkthrough.js +218 -6
  120. package/dist/esm/composables/useWalkthrough.js.map +1 -1
  121. package/dist/esm/index.js +2 -0
  122. package/dist/esm/index.js.map +1 -1
  123. package/dist/esm/l10n/en.json.js +45 -1
  124. package/dist/esm/l10n/en.json.js.map +1 -1
  125. package/dist/esm/l10n/nl.json.js +45 -1
  126. package/dist/esm/l10n/nl.json.js.map +1 -1
  127. package/dist/esm/nextcloud-vue.css +780 -81
  128. package/dist/esm/store/useObjectStore.js +8 -0
  129. package/dist/esm/store/useObjectStore.js.map +1 -1
  130. package/dist/esm/utils/columnFilters.js +311 -0
  131. package/dist/esm/utils/columnFilters.js.map +1 -0
  132. package/dist/esm/utils/groupAutocomplete.js +123 -0
  133. package/dist/esm/utils/groupAutocomplete.js.map +1 -0
  134. package/dist/esm/utils/libraryWidgetKeys.js +2 -0
  135. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  136. package/dist/esm/utils/pickerOptions.js +198 -0
  137. package/dist/esm/utils/pickerOptions.js.map +1 -0
  138. package/dist/esm/utils/schema.js +83 -7
  139. package/dist/esm/utils/schema.js.map +1 -1
  140. package/dist/esm/utils/schemaRefSlug.js +19 -3
  141. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  142. package/dist/esm/utils/timelineEvents.js +174 -0
  143. package/dist/esm/utils/timelineEvents.js.map +1 -0
  144. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  145. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  146. package/dist/esm/utils/walkthroughSignals.js +88 -0
  147. package/dist/esm/utils/walkthroughSignals.js.map +1 -0
  148. package/dist/nextcloud-vue.cjs.js +13088 -7937
  149. package/dist/nextcloud-vue.cjs.js.map +2 -2
  150. package/dist/nextcloud-vue.css +780 -81
  151. package/l10n/en.json +45 -0
  152. package/l10n/nl.json +45 -0
  153. package/package.json +1 -1
  154. package/src/components/CnAdminActionCard/CnAdminActionCard.vue +234 -0
  155. package/src/components/CnAdminActionCard/index.js +4 -0
  156. package/src/components/CnAppNav/CnAppNav.vue +568 -43
  157. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  158. package/src/components/CnAppRoot/CnAppRoot.vue +223 -16
  159. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  160. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  161. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  162. package/src/components/CnChoiceCards/CnChoiceCards.vue +56 -31
  163. package/src/components/CnDashboardPage/CnDashboardPage.vue +45 -4
  164. package/src/components/CnDataTable/CnColumnFilterPopover.vue +524 -0
  165. package/src/components/CnDataTable/CnDataTable.md +3 -0
  166. package/src/components/CnDataTable/CnDataTable.vue +305 -5
  167. package/src/components/CnDetailPage/CnDetailPage.md +4 -0
  168. package/src/components/CnDetailPage/CnDetailPage.vue +60 -1
  169. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +2 -2
  170. package/src/components/CnFieldHelper/CnFieldHelper.vue +4 -2
  171. package/src/components/CnFormDialog/CnFormDialog.vue +290 -31
  172. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +116 -6
  173. package/src/components/CnIndexPage/CnIndexPage.vue +87 -4
  174. package/src/components/CnIndexPage/selfModeActions.js +3 -3
  175. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +46 -2
  176. package/src/components/CnResourceSelect/CnResourceSelect.vue +123 -12
  177. package/src/components/CnSetupWizard/CnSetupWizard.vue +392 -3
  178. package/src/components/CnStagesWidget/CnStagesWidget.vue +180 -2
  179. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  180. package/src/components/CnStatWidget/CnStatWidget.vue +57 -3
  181. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  182. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +477 -0
  183. package/src/components/CnTimelineWidget/dashboardRegistration.js +32 -0
  184. package/src/components/CnTimelineWidget/index.js +4 -0
  185. package/src/components/CnWalkthrough/CnWalkthrough.vue +143 -10
  186. package/src/components/CnWidgetGrid/builtInWidgets.js +2 -0
  187. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  188. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  189. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  190. package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
  191. package/src/components/index.js +2 -0
  192. package/src/composables/useDependencyCheck.js +61 -0
  193. package/src/composables/useWalkthrough.js +217 -5
  194. package/src/css/dashboard.css +49 -0
  195. package/src/css/detail-page.css +29 -4
  196. package/src/css/kpi-card.css +24 -1
  197. package/src/css/table.css +99 -6
  198. package/src/index.js +2 -0
  199. package/src/schemas/app-manifest-v2.schema.json +135 -3
  200. package/src/store/useObjectStore.js +8 -0
  201. package/src/utils/columnFilters.js +324 -0
  202. package/src/utils/groupAutocomplete.js +119 -0
  203. package/src/utils/libraryWidgetKeys.js +2 -0
  204. package/src/utils/pickerOptions.js +194 -0
  205. package/src/utils/schema.js +83 -7
  206. package/src/utils/schemaRefSlug.js +19 -3
  207. package/src/utils/timelineEvents.js +171 -0
  208. package/src/utils/validateManifestV2.compiled.js +2 -2
  209. package/src/utils/walkthroughSignals.js +94 -0
@@ -92,8 +92,23 @@
92
92
  <!-- Decorative beside a name: the name already says whose
93
93
  app this is, and an alt text would say it twice. A logo
94
94
  on its own carries the name as its alt text. -->
95
+ <!-- An emblem (`nav.brand.emblem`) stands in for the logo:
96
+ the municipality's mark beside the app name, distinct
97
+ from the wordmark in the top bar. `true` draws the
98
+ theme's emblem from --nldesign-emblem-url. -->
95
99
  <img
96
- v-if="resolvedBrand.logo"
100
+ v-if="typeof resolvedBrand.emblem === 'string' && resolvedBrand.emblem !== ''"
101
+ class="cn-app-nav__brand-emblem"
102
+ data-testid="cn-nav-brand-emblem"
103
+ :src="resolvedBrand.emblem"
104
+ :alt="resolvedBrand.name ? '' : resolvedBrand.alt">
105
+ <span
106
+ v-else-if="resolvedBrand.emblem === true"
107
+ class="cn-app-nav__brand-emblem cn-app-nav__brand-emblem--theme"
108
+ data-testid="cn-nav-brand-emblem"
109
+ aria-hidden="true" />
110
+ <img
111
+ v-else-if="resolvedBrand.logo"
97
112
  class="cn-app-nav__brand-logo"
98
113
  :src="resolvedBrand.logo"
99
114
  :alt="resolvedBrand.name ? '' : resolvedBrand.alt">
@@ -118,17 +133,33 @@
118
133
  OR nav root). Omitted entirely when neither the slot nor any
119
134
  resolvable primaryAction is present. -->
120
135
  <slot name="primary-action">
136
+ <!-- A primary action that carries an `action` runs a page action
137
+ (open-form opens the create dialog, navigate pushes a route)
138
+ through CnActionButtons, so "New case" can live in the
139
+ navigation without a create page. Drawn as one solid,
140
+ full-width primary button. -->
141
+ <div
142
+ v-if="activePrimaryAction && primaryDispatchEntry"
143
+ class="app-navigation-new cn-app-nav__primary-action cn-app-nav__primary-action--solid"
144
+ data-testid="cn-nav-primary-action">
145
+ <CnActionButtons
146
+ :actions="[primaryDispatchEntry]"
147
+ data-testid="cn-nav-primary-action-dispatch"
148
+ @created="onPrimaryActionCreated" />
149
+ </div>
121
150
  <!-- A primary action with an href or route is a real link.
122
151
  NcAppNavigationNew cannot render one, so this mirrors its
123
- wrapper and button. -->
152
+ wrapper and button. `solid: true` takes this same solid,
153
+ full-width button for an action with neither. -->
124
154
  <div
125
- v-if="activePrimaryAction && primaryActionLink"
155
+ v-else-if="activePrimaryAction && (primaryActionLink || activePrimaryAction.solid === true)"
126
156
  class="app-navigation-new cn-app-nav__primary-action"
157
+ :class="{ 'cn-app-nav__primary-action--solid': activePrimaryAction.solid === true }"
127
158
  data-testid="cn-nav-primary-action">
128
159
  <NcButton
129
160
  variant="primary"
130
161
  wide
131
- v-bind="primaryActionLink"
162
+ v-bind="primaryActionLink || {}"
132
163
  @click="onPrimaryActionClick">
133
164
  <template #icon>
134
165
  <component :is="primaryActionIconComponent" :size="20" />
@@ -215,7 +246,32 @@
215
246
  </NcAppNavigationItem>
216
247
  </template>
217
248
  </template>
218
- <template v-if="footerItems.length > 0 || showSettingsFoldout" #footer>
249
+ <template v-if="footerItems.length > 0 || showSettingsFoldout || resolvedCard || resolvedHelp" #footer>
250
+ <!--
251
+ @slot card
252
+ @description Replace the card above the footer entries
253
+ (`nav.card`). Default content: a title, a line of text and one
254
+ link. Renders nothing when neither the slot nor `nav.card` is set.
255
+ @binding {object|null} card The resolved card, or null.
256
+ -->
257
+ <slot name="card" :card="resolvedCard">
258
+ <div
259
+ v-if="resolvedCard"
260
+ class="cn-app-nav__card"
261
+ data-testid="cn-nav-card">
262
+ <strong class="cn-app-nav__card-title">{{ resolvedCard.title }}</strong>
263
+ <span v-if="resolvedCard.text" class="cn-app-nav__card-text">{{ resolvedCard.text }}</span>
264
+ <component
265
+ :is="resolvedCard.linkTag"
266
+ v-if="resolvedCard.linkTag"
267
+ class="cn-app-nav__card-link"
268
+ data-testid="cn-nav-card-link"
269
+ v-bind="resolvedCard.linkAttrs"
270
+ @click="onCardLinkClick">
271
+ {{ resolvedCard.linkLabel }}
272
+ </component>
273
+ </div>
274
+ </slot>
219
275
  <!-- Footer-section entries (Documentation, Features & Roadmap,
220
276
  About) live in NcAppNavigation's #footer slot — OUTSIDE the
221
277
  scrollable list — so they stay visible above the settings
@@ -226,7 +282,20 @@
226
282
  hover highlight are scoped to itself, so they survive being
227
283
  slotted. The main list's inset comes from NcAppNavigation's
228
284
  scope, which slot content does not carry. -->
229
- <NcAppNavigationList v-if="footerItems.length > 0" class="cn-app-nav__footer-list">
285
+ <NcAppNavigationList v-if="footerItems.length > 0 || resolvedHelp" class="cn-app-nav__footer-list">
286
+ <!-- The help entry (`nav.help`): a link to the app's own help
287
+ with a help icon, above the footer entries. -->
288
+ <NcAppNavigationItem
289
+ v-if="resolvedHelp"
290
+ :name="resolvedHelp.label"
291
+ :to="resolvedHelp.to"
292
+ :href="resolvedHelp.href"
293
+ :target="resolvedHelp.href ? '_blank' : undefined"
294
+ data-testid="cn-nav-help">
295
+ <template #icon>
296
+ <HelpCircleOutline :size="20" />
297
+ </template>
298
+ </NcAppNavigationItem>
230
299
  <NcAppNavigationItem
231
300
  v-for="item in footerItems"
232
301
  :key="item.id"
@@ -360,6 +429,7 @@ import CnMenuItemIcon from '../CnMenuWidget/CnMenuItemIcon.vue'
360
429
  import { isAppInstalled } from '../../utils/appInstalled.js'
361
430
  import { isSvgPath } from '../../utils/iconUtils.js'
362
431
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
432
+ import { CnActionButtons } from '../CnActionButtons/index.js'
363
433
  // The legacy `icon-*` → MDI bridge lives beside CnIcon now, so the menu EDITOR
364
434
  // resolves a seeded `icon-comment` the same way this nav does. It used to be a
365
435
  // map local to this file, which is why the nav drew a proper glyph and the
@@ -394,6 +464,44 @@ function byManifestOrder(a, b) {
394
464
  return a.order - b.order
395
465
  }
396
466
 
467
+ /** A `:name` page-path segment: name, optional `(regexp)`, optional modifier. */
468
+ const PARAM_SEGMENT = /^:(\w+)(\([^)]*\))?([?*+])?$/
469
+
470
+ /**
471
+ * Whether a `PARAM_SEGMENT` match may be left out (`?` or `*`).
472
+ *
473
+ * @param {Array<string>} param The match.
474
+ * @return {boolean} True for an optional segment.
475
+ */
476
+ function isOptionalParam(param) {
477
+ return param[3] === '?' || param[3] === '*'
478
+ }
479
+
480
+ /**
481
+ * Whether a route param value fills its segment.
482
+ *
483
+ * @param {unknown} value The value.
484
+ * @return {boolean} False for undefined, null and the empty string.
485
+ */
486
+ function isFilled(value) {
487
+ return value !== undefined && value !== null && value !== ''
488
+ }
489
+
490
+ /**
491
+ * Decode one percent-encoded path segment, or keep it as is when it is not
492
+ * valid percent-encoding.
493
+ *
494
+ * @param {string} segment The segment.
495
+ * @return {string} The decoded segment.
496
+ */
497
+ function decodeSegment(segment) {
498
+ try {
499
+ return decodeURIComponent(segment)
500
+ } catch {
501
+ return segment
502
+ }
503
+ }
504
+
397
505
  export default {
398
506
  name: 'CnAppNav',
399
507
 
@@ -406,6 +514,7 @@ export default {
406
514
  NcAppNavigationSettings,
407
515
  NcButton,
408
516
  NcCounterBubble,
517
+ CnActionButtons,
409
518
  CnMenuItemIcon,
410
519
  Cog,
411
520
  HelpCircleOutline,
@@ -452,6 +561,16 @@ export default {
452
561
  * @type {{ [register: string]: { [schema: string]: number } }}
453
562
  */
454
563
  cnMenuCounts: { default: () => ({}) },
564
+ /**
565
+ * Provided by CnAppRoot — reactive `{ [menuItemId]: number }` totals
566
+ * for every menu entry whose `count` is an object
567
+ * (`{ register, schema, filter? }`): the total of that filtered
568
+ * list, fetched under the entry's own key so an index page's
569
+ * whole-schema total is left alone. Empty outside a CnAppRoot.
570
+ *
571
+ * @type {{ [itemId: string]: number }}
572
+ */
573
+ cnMenuItemCounts: { default: () => ({}) },
455
574
  },
456
575
 
457
576
  props: {
@@ -575,7 +694,7 @@ export default {
575
694
  },
576
695
  },
577
696
 
578
- emits: ['primary-action', 'primary-action-click'],
697
+ emits: ['primary-action', 'primary-action-click', 'primary-action-created', 'card-action'],
579
698
 
580
699
  /**
581
700
  * The navigation's own state: which groups are open, and which entry
@@ -636,11 +755,70 @@ export default {
636
755
  const text = (value) => (typeof value === 'string' && value !== '' ? this.effectiveTranslate(value) : '')
637
756
  const brand = {
638
757
  logo: typeof declared.logo === 'string' ? declared.logo : '',
758
+ // A URL, or `true` for the theme's emblem (--nldesign-emblem-url).
759
+ emblem: declared.emblem === true ? true : (typeof declared.emblem === 'string' ? declared.emblem : ''),
639
760
  name: text(declared.name),
640
761
  caption: text(declared.caption),
641
762
  alt: text(declared.alt),
642
763
  }
643
- return (brand.logo || brand.name || brand.caption) ? brand : null
764
+ return (brand.logo || brand.emblem || brand.name || brand.caption) ? brand : null
765
+ },
766
+
767
+ /**
768
+ * The card above the footer entries (`nav.card`): `{ title, text?,
769
+ * link?: { label, route?, params?, href?, action? } }`. Title, text
770
+ * and link label go through the translate function. The link is a
771
+ * router link for a `route` (with a router), an anchor for an
772
+ * `href`, and a button emitting `card-action` with the `action` id
773
+ * otherwise. Null without a title.
774
+ *
775
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
776
+ * @return {object|null}
777
+ */
778
+ resolvedCard() {
779
+ const declared = this.effectiveManifest?.nav?.card
780
+ if (!declared || typeof declared !== 'object' || typeof declared.title !== 'string' || declared.title === '') {
781
+ return null
782
+ }
783
+ const text = (value) => (typeof value === 'string' && value !== '' ? this.effectiveTranslate(value) : '')
784
+ const card = { title: text(declared.title), text: text(declared.text), linkTag: '', linkAttrs: {}, linkLabel: '', action: '' }
785
+ const link = declared.link
786
+ if (link && typeof link === 'object' && typeof link.label === 'string' && link.label !== '') {
787
+ card.linkLabel = text(link.label)
788
+ if (link.route && this.$router) {
789
+ card.linkTag = 'router-link'
790
+ card.linkAttrs = { to: { name: link.route, ...(link.params && typeof link.params === 'object' ? { params: link.params } : {}) } }
791
+ } else if (typeof link.href === 'string' && link.href !== '') {
792
+ card.linkTag = 'a'
793
+ card.linkAttrs = { href: link.href }
794
+ } else if (typeof link.action === 'string' && link.action !== '') {
795
+ card.linkTag = 'button'
796
+ card.linkAttrs = { type: 'button' }
797
+ card.action = link.action
798
+ }
799
+ }
800
+ return card
801
+ },
802
+
803
+ /**
804
+ * The help entry (`nav.help`): `{ label, route?, href? }` rendered as
805
+ * a footer entry with a help icon. Null without a label or a target.
806
+ *
807
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
808
+ * @return {{label: string, to?: object, href?: string}|null}
809
+ */
810
+ resolvedHelp() {
811
+ const declared = this.effectiveManifest?.nav?.help
812
+ if (!declared || typeof declared !== 'object' || typeof declared.label !== 'string' || declared.label === '') {
813
+ return null
814
+ }
815
+ if (declared.route && this.$router) {
816
+ return { label: this.effectiveTranslate(declared.label), to: { name: declared.route }, href: undefined }
817
+ }
818
+ if (typeof declared.href === 'string' && declared.href !== '') {
819
+ return { label: this.effectiveTranslate(declared.label), to: undefined, href: declared.href }
820
+ }
821
+ return null
644
822
  },
645
823
 
646
824
  /**
@@ -661,20 +839,30 @@ export default {
661
839
  * 2. `manifest.nav.primaryAction` as app-wide default
662
840
  * 3. `null` — no primary-action button renders
663
841
  *
664
- * Page-scoped declarations always win over the nav-root default.
842
+ * Page-scoped declarations always win over the nav-root default. An
843
+ * action declaring `permission` or `visibleIf` is gated like a menu
844
+ * entry.
665
845
  *
846
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
666
847
  * @return {object|null}
667
848
  */
668
849
  activePrimaryAction() {
669
850
  const pages = this.effectiveManifest?.pages ?? []
670
851
  const routeName = this.$route?.name
852
+ // The same `permission` / `visibleIf` gate a menu entry has: an
853
+ // action the reader may not take, or that a condition hides, is
854
+ // not drawn. A gated-out page action falls back to the nav one.
855
+ const gated = (action) => (action && this.passesPermission(action) && this.passesVisibleIf(action) ? action : null)
671
856
  if (routeName) {
672
857
  const page = pages.find((p) => p.id === routeName)
673
858
  if (page && page.primaryAction) {
674
- return page.primaryAction
859
+ const pageAction = gated(page.primaryAction)
860
+ if (pageAction) {
861
+ return pageAction
862
+ }
675
863
  }
676
864
  }
677
- return this.effectiveManifest?.nav?.primaryAction ?? null
865
+ return gated(this.effectiveManifest?.nav?.primaryAction ?? null)
678
866
  },
679
867
 
680
868
  /**
@@ -688,6 +876,31 @@ export default {
688
876
  return this.mdiIconComponent(this.activePrimaryAction) ?? Plus
689
877
  },
690
878
 
879
+ /**
880
+ * The CnActionButtons entry for a primary action that carries an
881
+ * `action` (`$defs/action`: open-form, navigate, api-call, ...):
882
+ * the action with the primary action's `id`, `label` and `icon`
883
+ * folded in and `variant: "primary"`. Null for a plain primary
884
+ * action, which keeps its link or button.
885
+ *
886
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
887
+ * @return {object|null}
888
+ */
889
+ primaryDispatchEntry() {
890
+ const action = this.activePrimaryAction
891
+ const inner = action && action.action
892
+ if (!inner || typeof inner !== 'object' || typeof inner.type !== 'string') {
893
+ return null
894
+ }
895
+ return {
896
+ icon: action.icon || 'Plus',
897
+ ...inner,
898
+ id: inner.id || action.id || 'primary-action',
899
+ label: action.label,
900
+ variant: 'primary',
901
+ }
902
+ },
903
+
691
904
  /**
692
905
  * Link props for the primary action's button: an `href` opens in a
693
906
  * new tab, a `route` is a router link. Null when the action is
@@ -924,7 +1137,9 @@ export default {
924
1137
  * `/expenses/:id`) light up — and auto-expand — their index entry
925
1138
  * even though the detail route name is not in the menu. Longest
926
1139
  * prefix disambiguates nested namespaces (e.g. `/pos` vs
927
- * `/pos/refunds`). Returns null when nothing matches.
1140
+ * `/pos/refunds`). An entry with `params` matches by its page path
1141
+ * with those params filled in (see `pageMatchDepth`). Returns null
1142
+ * when nothing matches.
928
1143
  *
929
1144
  * @return {string|null}
930
1145
  */
@@ -945,20 +1160,15 @@ export default {
945
1160
  return routeName ?? null
946
1161
  }
947
1162
  let best = null
948
- let bestLen = -1
1163
+ let bestDepth = 0
949
1164
  for (const it of flat) {
950
1165
  if (!it.route) {
951
1166
  continue
952
1167
  }
953
- const pagePath = this.pageForItem(it)?.route
954
- if (!pagePath || pagePath === '/' || pagePath.includes(':')) {
955
- continue
956
- }
957
- if (path === pagePath || path.startsWith(pagePath + '/')) {
958
- if (pagePath.length > bestLen) {
959
- best = it.route
960
- bestLen = pagePath.length
961
- }
1168
+ const depth = this.pageMatchDepth(it, path)
1169
+ if (depth > bestDepth) {
1170
+ best = it.route
1171
+ bestDepth = depth
962
1172
  }
963
1173
  }
964
1174
  return best ?? routeName ?? null
@@ -1007,7 +1217,9 @@ export default {
1007
1217
  * that route, else the first in menu order.
1008
1218
  *
1009
1219
  * Null whenever the existing rule already marks an entry, so a list
1010
- * with an entry without a `query` behaves as it did.
1220
+ * with an entry without a `query` behaves as it did. An entry with
1221
+ * `params` is a candidate only when the current path sits below its
1222
+ * own filled-in page path, so another param value is never marked.
1011
1223
  *
1012
1224
  * @return {object|null} The entry to mark, or null.
1013
1225
  * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
@@ -1017,7 +1229,9 @@ export default {
1017
1229
  if (!active || this.$route?.name === active) {
1018
1230
  return null
1019
1231
  }
1020
- const entries = this.flatEntries.filter((entry) => !entry.href && entry.route === active)
1232
+ const path = this.$route?.path
1233
+ const entries = this.flatEntries.filter((entry) => !entry.href && entry.route === active
1234
+ && (!this.hasParams(entry) || this.pageMatchDepth(entry, path) > 0))
1021
1235
  if (entries.length === 0 || entries.some((entry) => this.isActiveByRule(entry))) {
1022
1236
  return null
1023
1237
  }
@@ -1067,6 +1281,8 @@ export default {
1067
1281
  // stays out of `data()`: a reactive Set mutated inside render would
1068
1282
  // re-trigger the render effect.
1069
1283
  this._autoCountWarned = new Set()
1284
+ // Same latch, for warnUnfilledRouteParam() (`itemTo()` runs during render).
1285
+ this._unfilledParamWarned = new Set()
1070
1286
  },
1071
1287
 
1072
1288
  methods: {
@@ -1275,7 +1491,19 @@ export default {
1275
1491
  .sort(byManifestOrder)
1276
1492
  },
1277
1493
 
1494
+ /**
1495
+ * The label an entry shows: run through the translate function, or
1496
+ * as written when the entry sets `translateLabel: false` (a
1497
+ * user-authored title that may equal a translation key). Every
1498
+ * place the nav shows an entry's label reads it from here.
1499
+ *
1500
+ * @param {object} item Menu entry.
1501
+ * @return {string} The label to render as text.
1502
+ */
1278
1503
  resolveLabel(item) {
1504
+ if (item.translateLabel === false) {
1505
+ return item.label
1506
+ }
1279
1507
  return this.effectiveTranslate(item.label)
1280
1508
  },
1281
1509
 
@@ -1286,7 +1514,10 @@ export default {
1286
1514
  * work" and "Queue" on one list). The route name alone lit both. An
1287
1515
  * entry with a `query` is active only when the address carries that
1288
1516
  * query; an entry without one steps back when a sibling's query
1289
- * matches, so exactly one of them is marked.
1517
+ * matches, so exactly one of them is marked. `params` work the
1518
+ * same way against the route's params, so of several entries on a
1519
+ * `/items/:slug` route only the one whose `slug` is in the address
1520
+ * is marked.
1290
1521
  *
1291
1522
  * On a page below a list where that rule marks nothing, the one
1292
1523
  * entry `subRouteParent` picks is active instead.
@@ -1305,9 +1536,9 @@ export default {
1305
1536
  },
1306
1537
 
1307
1538
  /**
1308
- * The route and query rule on its own, without the fallback for a
1309
- * page below a list. `subRouteParent` asks this to see whether the
1310
- * rule already marks an entry.
1539
+ * The route, query and params rule on its own, without the fallback
1540
+ * for a page below a list. `subRouteParent` asks this to see whether
1541
+ * the rule already marks an entry.
1311
1542
  *
1312
1543
  * @param {object} item Menu entry.
1313
1544
  * @return {boolean} True when the rule marks the entry.
@@ -1321,17 +1552,155 @@ export default {
1321
1552
  if (item.route !== this.activeRouteName) {
1322
1553
  return false
1323
1554
  }
1324
- if (item.query && typeof item.query === 'object') {
1325
- return this.queryMatches(item)
1326
- }
1327
- const siblings = []
1328
- for (const entry of this.visibleItems) {
1329
- siblings.push(entry, ...this.visibleChildren(entry))
1555
+ if (this.isNarrowed(item)) {
1556
+ return this.narrowingMatches(item)
1330
1557
  }
1331
- return !siblings.some((entry) => entry !== item
1558
+ return !this.flatEntries.some((entry) => entry !== item
1332
1559
  && entry.route === item.route
1333
- && entry.query && typeof entry.query === 'object'
1334
- && this.queryMatches(entry))
1560
+ && this.isNarrowed(entry)
1561
+ && this.narrowingMatches(entry))
1562
+ },
1563
+
1564
+ /**
1565
+ * Whether an entry narrows its route with a `query` or `params`.
1566
+ *
1567
+ * @param {object} item Menu entry.
1568
+ * @return {boolean} True when the entry declares either.
1569
+ */
1570
+ isNarrowed(item) {
1571
+ return Boolean(item.query && typeof item.query === 'object') || this.hasParams(item)
1572
+ },
1573
+
1574
+ /**
1575
+ * Whether the address carries every `query` key and every `params`
1576
+ * key the entry declares.
1577
+ *
1578
+ * @param {object} item Menu entry that narrows its route.
1579
+ * @return {boolean} True when everything it declares matches.
1580
+ */
1581
+ narrowingMatches(item) {
1582
+ const queryOk = !(item.query && typeof item.query === 'object') || this.queryMatches(item)
1583
+ const paramsOk = !this.hasParams(item) || this.paramsMatch(item)
1584
+ return queryOk && paramsOk
1585
+ },
1586
+
1587
+ /**
1588
+ * Whether an entry declares a non-empty `params` object.
1589
+ *
1590
+ * @param {object} item Menu entry.
1591
+ * @return {boolean} True when it has route params.
1592
+ */
1593
+ hasParams(item) {
1594
+ return Boolean(item?.params) && typeof item.params === 'object' && Object.keys(item.params).length > 0
1595
+ },
1596
+
1597
+ /**
1598
+ * Whether the route's params carry every key of an entry's
1599
+ * `params`. Values are compared as strings.
1600
+ *
1601
+ * @param {object} item Menu entry with `params`.
1602
+ * @return {boolean} True when every declared key matches.
1603
+ */
1604
+ paramsMatch(item) {
1605
+ const current = this.$route?.params ?? {}
1606
+ return Object.keys(item.params).every((key) => String(current[key] ?? '') === String(item.params[key]))
1607
+ },
1608
+
1609
+ /**
1610
+ * The segments of an entry's page path with its `params` filled in,
1611
+ * as plain values. A missing optional `:name?` segment is dropped;
1612
+ * null when a required one has no param, or without a page.
1613
+ *
1614
+ * @param {object} item Menu entry with `params`.
1615
+ * @return {Array<string>|null} The segments, or null.
1616
+ */
1617
+ filledPageSegments(item) {
1618
+ const pagePath = this.pageForItem(item)?.route
1619
+ if (!pagePath) {
1620
+ return null
1621
+ }
1622
+ const segments = []
1623
+ for (const part of pagePath.split('/').filter(Boolean)) {
1624
+ const param = PARAM_SEGMENT.exec(part)
1625
+ if (!param) {
1626
+ segments.push(part)
1627
+ continue
1628
+ }
1629
+ const value = item.params?.[param[1]]
1630
+ if (isFilled(value)) {
1631
+ segments.push(String(value))
1632
+ } else if (!isOptionalParam(param)) {
1633
+ return null
1634
+ }
1635
+ }
1636
+ return segments
1637
+ },
1638
+
1639
+ /**
1640
+ * The required route param the router cannot fill when it resolves
1641
+ * an entry's link on its manifest page. vue-router fills a named
1642
+ * link's required params from the current route, then overlays every
1643
+ * key the entry declares, empty or null included. So a declared key
1644
+ * decides on its own value, and only an undeclared one falls back
1645
+ * to the current route. Null without a known page, or when every
1646
+ * required segment is filled. Reads `$route`, so it follows
1647
+ * navigation.
1648
+ *
1649
+ * @param {object} item Menu entry.
1650
+ * @return {string|null} The param name, or null.
1651
+ */
1652
+ unfilledRouteParam(item) {
1653
+ const pagePath = this.pageForItem(item)?.route
1654
+ if (!pagePath) {
1655
+ return null
1656
+ }
1657
+ const declared = item.params && typeof item.params === 'object' ? item.params : {}
1658
+ const current = this.$route?.params ?? {}
1659
+ for (const part of pagePath.split('/')) {
1660
+ const param = PARAM_SEGMENT.exec(part)
1661
+ if (!param || isOptionalParam(param)) {
1662
+ continue
1663
+ }
1664
+ const value = param[1] in declared ? declared[param[1]] : current[param[1]]
1665
+ if (!isFilled(value)) {
1666
+ return param[1]
1667
+ }
1668
+ }
1669
+ return null
1670
+ },
1671
+
1672
+ /**
1673
+ * How deep a path sits on an entry's page path: the page path's
1674
+ * segment count when the path is that page or below it, else 0.
1675
+ * A `/` page path, or one with an unfilled `:name`, never matches.
1676
+ *
1677
+ * Without `params` the page path is compared as written. With
1678
+ * `params`, segments are compared decoded, because the router
1679
+ * percent-encodes param values in the path and the entry holds them
1680
+ * plain.
1681
+ *
1682
+ * @param {object} item Menu entry.
1683
+ * @param {string|undefined} path The path to test.
1684
+ * @return {number} The depth of the match, or 0.
1685
+ */
1686
+ pageMatchDepth(item, path) {
1687
+ if (!path) {
1688
+ return 0
1689
+ }
1690
+ if (!this.hasParams(item)) {
1691
+ const pagePath = this.pageForItem(item)?.route
1692
+ if (!pagePath || pagePath === '/' || pagePath.includes(':')) {
1693
+ return 0
1694
+ }
1695
+ const below = path === pagePath || path.startsWith(pagePath + '/')
1696
+ return below ? pagePath.split('/').filter(Boolean).length : 0
1697
+ }
1698
+ const segments = this.filledPageSegments(item)
1699
+ if (!segments || segments.length === 0) {
1700
+ return 0
1701
+ }
1702
+ const current = path.split('/').filter(Boolean).map(decodeSegment)
1703
+ return segments.every((segment, index) => current[index] === segment) ? segments.length : 0
1335
1704
  },
1336
1705
 
1337
1706
  /**
@@ -1396,6 +1765,9 @@ export default {
1396
1765
  * `NcAppNavigationItem`).
1397
1766
  *
1398
1767
  * - Literal positive integer in `item.count` → return as-is.
1768
+ * - `item.count` is an object (`{ register, schema, filter? }`) →
1769
+ * `cnMenuItemCounts[item.id] ?? null`, the filtered total CnAppRoot
1770
+ * fetched for this entry.
1399
1771
  * - `item.count === "auto"` → look up the entry's resolved page;
1400
1772
  * when that page is `type: "index"` with a `register`/`schema`
1401
1773
  * in its `config`, return
@@ -1419,6 +1791,11 @@ export default {
1419
1791
  if (typeof raw === 'number') {
1420
1792
  return raw > 0 ? raw : null
1421
1793
  }
1794
+ if (raw && typeof raw === 'object') {
1795
+ // A filtered count: CnAppRoot fetched it per entry id.
1796
+ const value = this.cnMenuItemCounts?.[item.id]
1797
+ return (typeof value === 'number' && value > 0) ? value : null
1798
+ }
1422
1799
  if (raw !== 'auto') {
1423
1800
  return null
1424
1801
  }
@@ -1454,6 +1831,27 @@ export default {
1454
1831
  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.`)
1455
1832
  },
1456
1833
 
1834
+ /**
1835
+ * One-shot `console.warn` per menu entry whose link cannot fill a
1836
+ * required route param. Keyed by `id`, else route and label, so
1837
+ * entries without an id each get their own warning.
1838
+ *
1839
+ * @param {{ id?: string, route: string, label?: string }} item Menu entry descriptor.
1840
+ * @param {string} param The unfilled param name.
1841
+ * @return {void}
1842
+ * @private
1843
+ */
1844
+ warnUnfilledRouteParam(item, param) {
1845
+ const key = item.id ?? `${item.route}|${item.label ?? ''}`
1846
+ if (this._unfilledParamWarned.has(key)) {
1847
+ return
1848
+ }
1849
+ this._unfilledParamWarned.add(key)
1850
+ const name = item.id ?? item.label ?? item.route
1851
+ // eslint-disable-next-line no-console
1852
+ 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.`)
1853
+ },
1854
+
1457
1855
  /**
1458
1856
  * Look up an item's resolved page (`pages[]` entry whose `id`
1459
1857
  * matches the menu item's `route`) — used to decide whether the
@@ -1476,11 +1874,17 @@ export default {
1476
1874
  * items (`action: "user-settings"`) and `href` items return
1477
1875
  * `null` so the entry is NOT a vue-router link: action items
1478
1876
  * fall through to the click handler, `href` items render a real
1479
- * anchor via `itemHref`. Route items return a named route.
1877
+ * anchor via `itemHref`. Route items return a named route that
1878
+ * carries the item's `params` and `query` when it declares them.
1879
+ *
1880
+ * An item whose manifest page has a required segment that neither
1881
+ * its `params` nor the current route fill also returns null, with a
1882
+ * one-time warning: the router throws resolving such a link, which
1883
+ * would take the whole navigation down rather than that one entry.
1480
1884
  *
1481
1885
  * @param {object} item Menu item being rendered.
1482
- * @return {object|null} A `{ name }` route object, or null for
1483
- * action / href / route-less items.
1886
+ * @return {object|null} A `{ name, params?, query? }` route object,
1887
+ * or null for action / href / route-less / unfillable items.
1484
1888
  */
1485
1889
  itemTo(item) {
1486
1890
  if (item.action) {
@@ -1492,9 +1896,22 @@ export default {
1492
1896
  if (!item.route) {
1493
1897
  return null
1494
1898
  }
1495
- // Carry optional query params so a nav entry can deep-link to a
1496
- // pre-filtered index page (e.g. one entry per case type → Cases?caseType=…).
1497
- return item.query ? { name: item.route, query: item.query } : { name: item.route }
1899
+ const unfilled = this.unfilledRouteParam(item)
1900
+ if (unfilled) {
1901
+ this.warnUnfilledRouteParam(item, unfilled)
1902
+ return null
1903
+ }
1904
+ const to = { name: item.route }
1905
+ // Params fill a parameterised route (one entry per catalog →
1906
+ // /catalogs/:slug); query deep-links to a pre-filtered index page
1907
+ // (one entry per case type → Cases?caseType=…).
1908
+ if (this.hasParams(item)) {
1909
+ to.params = item.params
1910
+ }
1911
+ if (item.query) {
1912
+ to.query = item.query
1913
+ }
1914
+ return to
1498
1915
  },
1499
1916
 
1500
1917
  /**
@@ -1646,6 +2063,11 @@ export default {
1646
2063
  this.cnReplayWalkthrough(item.tourId)
1647
2064
  return
1648
2065
  }
2066
+ // An entry left without a link (see itemTo) renders a `#` anchor;
2067
+ // following it would change the address, so the click goes nowhere.
2068
+ if (!item.href && this.unfilledRouteParam(item) && event && typeof event.preventDefault === 'function') {
2069
+ event.preventDefault()
2070
+ }
1649
2071
  if (this.visibleChildren(item).length === 0) {
1650
2072
  return
1651
2073
  }
@@ -1684,6 +2106,39 @@ export default {
1684
2106
  *
1685
2107
  * @return {void}
1686
2108
  */
2109
+ /**
2110
+ * Re-emit the object an `open-form` primary action created, so the
2111
+ * host can refresh a list or open the record.
2112
+ *
2113
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
2114
+ * @param {object} created The created object.
2115
+ * @return {void}
2116
+ */
2117
+ onPrimaryActionCreated(created) {
2118
+ /**
2119
+ * @event primary-action-created Emitted after an `open-form` primary action saves. Payload: the created object.
2120
+ */
2121
+ this.$emit('primary-action-created', created)
2122
+ },
2123
+
2124
+ /**
2125
+ * A card link that is a button (an `action` id) emits `card-action`
2126
+ * with that id for the host to run; links and anchors navigate on
2127
+ * their own.
2128
+ *
2129
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
2130
+ * @return {void}
2131
+ */
2132
+ onCardLinkClick() {
2133
+ const card = this.resolvedCard
2134
+ if (card && card.action) {
2135
+ /**
2136
+ * @event card-action Emitted when the nav card's link is an action button. Payload: the action id.
2137
+ */
2138
+ this.$emit('card-action', card.action)
2139
+ }
2140
+ },
2141
+
1687
2142
  onPrimaryActionClick() {
1688
2143
  const action = this.activePrimaryAction
1689
2144
  if (!action) {
@@ -1713,6 +2168,76 @@ export default {
1713
2168
  </script>
1714
2169
 
1715
2170
  <style>
2171
+ /* The emblem beside the app name (`nav.brand.emblem`). A theme sets
2172
+ --nldesign-emblem-url for `emblem: true`, and --cn-nav-emblem-size for both. */
2173
+ .cn-app-nav__brand-emblem {
2174
+ flex: none;
2175
+ height: var(--cn-nav-emblem-size, 34px);
2176
+ width: auto;
2177
+ object-fit: contain;
2178
+ }
2179
+
2180
+ .cn-app-nav__brand-emblem--theme {
2181
+ display: inline-block;
2182
+ width: var(--cn-nav-emblem-size, 34px);
2183
+ background: var(--nldesign-emblem-url) center / contain no-repeat;
2184
+ }
2185
+
2186
+ /* The solid primary action (`solid: true`, or an action that dispatches):
2187
+ one full-width primary button, as the board draws "New case". */
2188
+ .cn-app-nav__primary-action--solid .cn-action-buttons,
2189
+ .cn-app-nav__primary-action--solid .cn-action-buttons .button-vue,
2190
+ .cn-app-nav__primary-action--solid .button-vue {
2191
+ width: 100%;
2192
+ justify-content: center;
2193
+ }
2194
+
2195
+ /* The card above the footer entries (`nav.card`). Theme hooks:
2196
+ --cn-nav-card-background and --cn-nav-card-radius. */
2197
+ .cn-app-nav__card {
2198
+ display: flex;
2199
+ flex-direction: column;
2200
+ gap: calc(2 * var(--default-grid-baseline));
2201
+ margin: calc(2 * var(--default-grid-baseline));
2202
+ padding: calc(4 * var(--default-grid-baseline));
2203
+ border-radius: var(--cn-nav-card-radius, var(--border-radius-large));
2204
+ background: var(--cn-nav-card-background, var(--color-background-hover));
2205
+ }
2206
+
2207
+ .cn-app-nav__card-title {
2208
+ color: var(--color-main-text);
2209
+ font-weight: 700;
2210
+ }
2211
+
2212
+ .cn-app-nav__card-text {
2213
+ color: var(--color-text-maxcontrast);
2214
+ line-height: 1.4;
2215
+ }
2216
+
2217
+ .cn-app-nav__card-link {
2218
+ align-self: flex-start;
2219
+ margin: 0;
2220
+ padding: 0;
2221
+ border: 0;
2222
+ background: none;
2223
+ color: var(--color-primary-element);
2224
+ font: inherit;
2225
+ font-weight: 600;
2226
+ text-decoration: none;
2227
+ cursor: pointer;
2228
+ }
2229
+
2230
+ .cn-app-nav__card-link:hover,
2231
+ .cn-app-nav__card-link:focus-visible {
2232
+ text-decoration: underline;
2233
+ }
2234
+
2235
+ .cn-app-nav__card-link:focus-visible {
2236
+ outline: 2px solid var(--color-primary-element);
2237
+ outline-offset: 2px;
2238
+ border-radius: var(--border-radius-small, 4px);
2239
+ }
2240
+
1716
2241
  /* The brand block at the top of the navigation. */
1717
2242
  .cn-app-nav__brand {
1718
2243
  align-items: center;