@conduction/nextcloud-vue 2.63.0 → 2.65.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 (111) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +253 -10
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +139 -46
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
  15. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  17. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  18. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  19. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  20. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  21. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  22. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +55 -2
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +153 -133
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +125 -14
  32. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +87 -40
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +101 -7
  36. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  37. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
  38. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  40. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  43. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  44. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  46. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  48. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  49. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  50. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  51. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  52. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  53. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +74 -5
  54. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +15 -8
  56. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  58. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  60. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  61. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  62. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  63. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  64. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  65. package/dist/esm/l10n/en.json.js +2 -1
  66. package/dist/esm/l10n/en.json.js.map +1 -1
  67. package/dist/esm/l10n/nl.json.js +2 -1
  68. package/dist/esm/l10n/nl.json.js.map +1 -1
  69. package/dist/esm/nextcloud-vue.css +495 -81
  70. package/dist/esm/store/useObjectStore.js +118 -33
  71. package/dist/esm/store/useObjectStore.js.map +1 -1
  72. package/dist/esm/utils/actionsDispatcher.js +14 -0
  73. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  74. package/dist/esm/utils/schemaRefSlug.js +19 -3
  75. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  76. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  77. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  78. package/dist/nextcloud-vue.cjs.js +7388 -6021
  79. package/dist/nextcloud-vue.cjs.js.map +2 -2
  80. package/dist/nextcloud-vue.css +495 -81
  81. package/l10n/en.json +2 -0
  82. package/l10n/nl.json +2 -0
  83. package/package.json +1 -1
  84. package/src/components/CnAppNav/CnAppNav.vue +441 -14
  85. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  86. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  87. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  88. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  89. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  90. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  91. package/src/components/CnDashboardPage/CnDashboardPage.vue +72 -4
  92. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  93. package/src/components/CnDetailPage/CnDetailPage.md +8 -0
  94. package/src/components/CnDetailPage/CnDetailPage.vue +169 -2
  95. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +147 -9
  96. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  97. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  98. package/src/components/CnStagesWidget/CnStagesWidget.vue +189 -2
  99. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  100. package/src/components/CnStatWidget/CnStatWidget.vue +89 -7
  101. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  102. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  103. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  104. package/src/css/detail-page.css +32 -4
  105. package/src/css/kpi-card.css +71 -1
  106. package/src/css/table.css +18 -6
  107. package/src/schemas/app-manifest-v2.schema.json +134 -3
  108. package/src/store/useObjectStore.js +118 -33
  109. package/src/utils/actionsDispatcher.js +14 -0
  110. package/src/utils/schemaRefSlug.js +19 -3
  111. package/src/utils/validateManifestV2.compiled.js +2 -2
package/l10n/en.json CHANGED
@@ -48,6 +48,8 @@
48
48
  "No integrations available yet.": "No integrations available yet.",
49
49
  "This integration is not available.": "This integration is not available.",
50
50
  "Integrations": "Integrations",
51
+ "View": "View",
52
+ "current step": "current step",
51
53
  "{app} not available": "{app} not available",
52
54
  "{app} not configured": "{app} not configured",
53
55
  "This integration is not available or not configured yet.": "This integration is not available or not configured yet.",
package/l10n/nl.json CHANGED
@@ -48,6 +48,8 @@
48
48
  "No integrations available yet.": "Nog geen integraties beschikbaar.",
49
49
  "This integration is not available.": "Deze integratie is niet beschikbaar.",
50
50
  "Integrations": "Integraties",
51
+ "View": "Weergave",
52
+ "current step": "huidige stap",
51
53
  "{app} not available": "{app} niet beschikbaar",
52
54
  "{app} not configured": "{app} niet geconfigureerd",
53
55
  "This integration is not available or not configured yet.": "Deze integratie is nog niet beschikbaar of niet geconfigureerd.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.63.0",
3
+ "version": "2.65.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -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 || showsHelp" #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,24 @@
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
286
+ v-if="footerItems.length > 0 || showsHelp"
287
+ class="cn-app-nav__footer-list"
288
+ :class="{ 'cn-app-nav__footer-list--after-settings': settingsFirst }">
289
+ <!-- The help entry (`nav.help`): a link to the app's own help
290
+ with a help icon, above the footer entries (or after them,
291
+ when a declared `nav.footer` names it last). -->
292
+ <NcAppNavigationItem
293
+ v-if="showsHelp && !helpAfterItems"
294
+ :name="resolvedHelp.label"
295
+ :to="resolvedHelp.to"
296
+ :href="resolvedHelp.href"
297
+ :target="resolvedHelp.href ? '_blank' : undefined"
298
+ data-testid="cn-nav-help">
299
+ <template #icon>
300
+ <HelpCircleOutline :size="20" />
301
+ </template>
302
+ </NcAppNavigationItem>
230
303
  <NcAppNavigationItem
231
304
  v-for="item in footerItems"
232
305
  :key="item.id"
@@ -249,6 +322,17 @@
249
322
  :active="isActive(item)" />
250
323
  </template>
251
324
  </NcAppNavigationItem>
325
+ <NcAppNavigationItem
326
+ v-if="showsHelp && helpAfterItems"
327
+ :name="resolvedHelp.label"
328
+ :to="resolvedHelp.to"
329
+ :href="resolvedHelp.href"
330
+ :target="resolvedHelp.href ? '_blank' : undefined"
331
+ data-testid="cn-nav-help">
332
+ <template #icon>
333
+ <HelpCircleOutline :size="20" />
334
+ </template>
335
+ </NcAppNavigationItem>
252
336
  </NcAppNavigationList>
253
337
  <!-- Settings foldout (section: "settings" items). NC-native
254
338
  gear-icon button that slides open a panel; the first entry
@@ -257,6 +341,7 @@
257
341
  <NcAppNavigationSettings
258
342
  v-if="showSettingsFoldout"
259
343
  :name="settingsFoldoutLabel"
344
+ :class="{ 'cn-app-nav__settings--first': settingsFirst }"
260
345
  data-testid="cn-nav-settings">
261
346
  <ul class="cn-app-nav__settings-list">
262
347
  <NcAppNavigationItem
@@ -360,6 +445,7 @@ import CnMenuItemIcon from '../CnMenuWidget/CnMenuItemIcon.vue'
360
445
  import { isAppInstalled } from '../../utils/appInstalled.js'
361
446
  import { isSvgPath } from '../../utils/iconUtils.js'
362
447
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
448
+ import { CnActionButtons } from '../CnActionButtons/index.js'
363
449
  // The legacy `icon-*` → MDI bridge lives beside CnIcon now, so the menu EDITOR
364
450
  // resolves a seeded `icon-comment` the same way this nav does. It used to be a
365
451
  // map local to this file, which is why the nav drew a proper glyph and the
@@ -444,6 +530,7 @@ export default {
444
530
  NcAppNavigationSettings,
445
531
  NcButton,
446
532
  NcCounterBubble,
533
+ CnActionButtons,
447
534
  CnMenuItemIcon,
448
535
  Cog,
449
536
  HelpCircleOutline,
@@ -490,6 +577,16 @@ export default {
490
577
  * @type {{ [register: string]: { [schema: string]: number } }}
491
578
  */
492
579
  cnMenuCounts: { default: () => ({}) },
580
+ /**
581
+ * Provided by CnAppRoot — reactive `{ [menuItemId]: number }` totals
582
+ * for every menu entry whose `count` is an object
583
+ * (`{ register, schema, filter? }`): the total of that filtered
584
+ * list, fetched under the entry's own key so an index page's
585
+ * whole-schema total is left alone. Empty outside a CnAppRoot.
586
+ *
587
+ * @type {{ [itemId: string]: number }}
588
+ */
589
+ cnMenuItemCounts: { default: () => ({}) },
493
590
  },
494
591
 
495
592
  props: {
@@ -613,7 +710,7 @@ export default {
613
710
  },
614
711
  },
615
712
 
616
- emits: ['primary-action', 'primary-action-click'],
713
+ emits: ['primary-action', 'primary-action-click', 'primary-action-created', 'card-action'],
617
714
 
618
715
  /**
619
716
  * The navigation's own state: which groups are open, and which entry
@@ -674,11 +771,70 @@ export default {
674
771
  const text = (value) => (typeof value === 'string' && value !== '' ? this.effectiveTranslate(value) : '')
675
772
  const brand = {
676
773
  logo: typeof declared.logo === 'string' ? declared.logo : '',
774
+ // A URL, or `true` for the theme's emblem (--nldesign-emblem-url).
775
+ emblem: declared.emblem === true ? true : (typeof declared.emblem === 'string' ? declared.emblem : ''),
677
776
  name: text(declared.name),
678
777
  caption: text(declared.caption),
679
778
  alt: text(declared.alt),
680
779
  }
681
- return (brand.logo || brand.name || brand.caption) ? brand : null
780
+ return (brand.logo || brand.emblem || brand.name || brand.caption) ? brand : null
781
+ },
782
+
783
+ /**
784
+ * The card above the footer entries (`nav.card`): `{ title, text?,
785
+ * link?: { label, route?, params?, href?, action? } }`. Title, text
786
+ * and link label go through the translate function. The link is a
787
+ * router link for a `route` (with a router), an anchor for an
788
+ * `href`, and a button emitting `card-action` with the `action` id
789
+ * otherwise. Null without a title.
790
+ *
791
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
792
+ * @return {object|null}
793
+ */
794
+ resolvedCard() {
795
+ const declared = this.effectiveManifest?.nav?.card
796
+ if (!declared || typeof declared !== 'object' || typeof declared.title !== 'string' || declared.title === '') {
797
+ return null
798
+ }
799
+ const text = (value) => (typeof value === 'string' && value !== '' ? this.effectiveTranslate(value) : '')
800
+ const card = { title: text(declared.title), text: text(declared.text), linkTag: '', linkAttrs: {}, linkLabel: '', action: '' }
801
+ const link = declared.link
802
+ if (link && typeof link === 'object' && typeof link.label === 'string' && link.label !== '') {
803
+ card.linkLabel = text(link.label)
804
+ if (link.route && this.$router) {
805
+ card.linkTag = 'router-link'
806
+ card.linkAttrs = { to: { name: link.route, ...(link.params && typeof link.params === 'object' ? { params: link.params } : {}) } }
807
+ } else if (typeof link.href === 'string' && link.href !== '') {
808
+ card.linkTag = 'a'
809
+ card.linkAttrs = { href: link.href }
810
+ } else if (typeof link.action === 'string' && link.action !== '') {
811
+ card.linkTag = 'button'
812
+ card.linkAttrs = { type: 'button' }
813
+ card.action = link.action
814
+ }
815
+ }
816
+ return card
817
+ },
818
+
819
+ /**
820
+ * The help entry (`nav.help`): `{ label, route?, href? }` rendered as
821
+ * a footer entry with a help icon. Null without a label or a target.
822
+ *
823
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
824
+ * @return {{label: string, to?: object, href?: string}|null}
825
+ */
826
+ resolvedHelp() {
827
+ const declared = this.effectiveManifest?.nav?.help
828
+ if (!declared || typeof declared !== 'object' || typeof declared.label !== 'string' || declared.label === '') {
829
+ return null
830
+ }
831
+ if (declared.route && this.$router) {
832
+ return { label: this.effectiveTranslate(declared.label), to: { name: declared.route }, href: undefined }
833
+ }
834
+ if (typeof declared.href === 'string' && declared.href !== '') {
835
+ return { label: this.effectiveTranslate(declared.label), to: undefined, href: declared.href }
836
+ }
837
+ return null
682
838
  },
683
839
 
684
840
  /**
@@ -699,20 +855,30 @@ export default {
699
855
  * 2. `manifest.nav.primaryAction` as app-wide default
700
856
  * 3. `null` — no primary-action button renders
701
857
  *
702
- * Page-scoped declarations always win over the nav-root default.
858
+ * Page-scoped declarations always win over the nav-root default. An
859
+ * action declaring `permission` or `visibleIf` is gated like a menu
860
+ * entry.
703
861
  *
862
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
704
863
  * @return {object|null}
705
864
  */
706
865
  activePrimaryAction() {
707
866
  const pages = this.effectiveManifest?.pages ?? []
708
867
  const routeName = this.$route?.name
868
+ // The same `permission` / `visibleIf` gate a menu entry has: an
869
+ // action the reader may not take, or that a condition hides, is
870
+ // not drawn. A gated-out page action falls back to the nav one.
871
+ const gated = (action) => (action && this.passesPermission(action) && this.passesVisibleIf(action) ? action : null)
709
872
  if (routeName) {
710
873
  const page = pages.find((p) => p.id === routeName)
711
874
  if (page && page.primaryAction) {
712
- return page.primaryAction
875
+ const pageAction = gated(page.primaryAction)
876
+ if (pageAction) {
877
+ return pageAction
878
+ }
713
879
  }
714
880
  }
715
- return this.effectiveManifest?.nav?.primaryAction ?? null
881
+ return gated(this.effectiveManifest?.nav?.primaryAction ?? null)
716
882
  },
717
883
 
718
884
  /**
@@ -726,6 +892,31 @@ export default {
726
892
  return this.mdiIconComponent(this.activePrimaryAction) ?? Plus
727
893
  },
728
894
 
895
+ /**
896
+ * The CnActionButtons entry for a primary action that carries an
897
+ * `action` (`$defs/action`: open-form, navigate, api-call, ...):
898
+ * the action with the primary action's `id`, `label` and `icon`
899
+ * folded in and `variant: "primary"`. Null for a plain primary
900
+ * action, which keeps its link or button.
901
+ *
902
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
903
+ * @return {object|null}
904
+ */
905
+ primaryDispatchEntry() {
906
+ const action = this.activePrimaryAction
907
+ const inner = action && action.action
908
+ if (!inner || typeof inner !== 'object' || typeof inner.type !== 'string') {
909
+ return null
910
+ }
911
+ return {
912
+ icon: action.icon || 'Plus',
913
+ ...inner,
914
+ id: inner.id || action.id || 'primary-action',
915
+ label: action.label,
916
+ variant: 'primary',
917
+ }
918
+ },
919
+
729
920
  /**
730
921
  * Link props for the primary action's button: an `href` opens in a
731
922
  * new tab, a `route` is a router link. Null when the action is
@@ -783,17 +974,113 @@ export default {
783
974
  * Documentation, Features & Roadmap, About.
784
975
  */
785
976
  footerItems() {
786
- return this.visibleItems.filter((item) => item.section === 'footer')
977
+ const footer = this.visibleItems.filter((item) => item.section === 'footer')
978
+ const declared = this.declaredFooter
979
+ if (declared === null) {
980
+ return footer
981
+ }
982
+ // A declared footer (`nav.footer`) keeps only the entries it names,
983
+ // in the order it names them.
984
+ return declared
985
+ .map((id) => footer.find((item) => item.id === id))
986
+ .filter(Boolean)
987
+ },
988
+
989
+ /**
990
+ * The footer the manifest declares (`nav.footer`): an ordered list of
991
+ * menu entry ids plus the reserved ids `help` (the `nav.help` entry)
992
+ * and `settings` (the settings foldout). Null when the manifest
993
+ * declares none, which keeps today's footer: the help entry, then
994
+ * every `section: "footer"` entry, then the foldout.
995
+ *
996
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-the-navigation-footer-can-be-declared
997
+ * @return {Array<string>|null}
998
+ */
999
+ declaredFooter() {
1000
+ const declared = this.effectiveManifest?.nav?.footer
1001
+ if (!Array.isArray(declared)) {
1002
+ return null
1003
+ }
1004
+ return declared.filter((id) => typeof id === 'string' && id !== '')
1005
+ },
1006
+
1007
+ /**
1008
+ * Footer-section entries a declared footer leaves out. They move into
1009
+ * the settings foldout rather than disappearing, so a page such as
1010
+ * Store or Reports stays reachable. Empty without `nav.footer`.
1011
+ *
1012
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-the-navigation-footer-can-be-declared
1013
+ * @return {Array<object>}
1014
+ */
1015
+ undeclaredFooterItems() {
1016
+ const declared = this.declaredFooter
1017
+ if (declared === null) {
1018
+ return []
1019
+ }
1020
+ return this.visibleItems.filter((item) => item.section === 'footer' && !declared.includes(item.id))
1021
+ },
1022
+
1023
+ /**
1024
+ * Whether the help entry renders: whenever it resolves, unless a
1025
+ * declared footer leaves `help` out.
1026
+ *
1027
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-the-navigation-footer-can-be-declared
1028
+ * @return {boolean}
1029
+ */
1030
+ showsHelp() {
1031
+ if (!this.resolvedHelp) {
1032
+ return false
1033
+ }
1034
+ return this.declaredFooter === null || this.declaredFooter.includes('help')
1035
+ },
1036
+
1037
+ /**
1038
+ * Whether the help entry goes AFTER the footer entries. Only a declared
1039
+ * footer that names an entry before `help` puts it there; by default
1040
+ * it leads the list.
1041
+ *
1042
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-the-navigation-footer-can-be-declared
1043
+ * @return {boolean}
1044
+ */
1045
+ helpAfterItems() {
1046
+ const declared = this.declaredFooter
1047
+ if (declared === null) {
1048
+ return false
1049
+ }
1050
+ const help = declared.indexOf('help')
1051
+ return this.footerItems.some((item) => declared.indexOf(item.id) < help)
1052
+ },
1053
+
1054
+ /**
1055
+ * Whether the settings foldout goes ABOVE the footer list: only when a
1056
+ * declared footer names `settings` before every other entry
1057
+ * (`["settings", "help"]`, the board's "Instellingen" then "Hulp en
1058
+ * uitleg"). Done with CSS `order` on the two siblings, so the markup
1059
+ * of both stays as it is.
1060
+ *
1061
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-the-navigation-footer-can-be-declared
1062
+ * @return {boolean}
1063
+ */
1064
+ settingsFirst() {
1065
+ const declared = this.declaredFooter
1066
+ return declared !== null && declared.indexOf('settings') === 0
787
1067
  },
788
1068
 
789
1069
  /**
790
1070
  * Items that render INSIDE the NcAppNavigationSettings foldout
791
1071
  * (section: "settings"). The foldout is the NC-native gear-icon
792
1072
  * button that slides a panel open; these entries are app-level
793
- * configuration pages (Forms, Pipelines, Automations, …).
1073
+ * configuration pages (Forms, Pipelines, Automations, …). A declared
1074
+ * footer (`nav.footer`) adds the footer entries it leaves out.
1075
+ *
1076
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-the-navigation-footer-can-be-declared
1077
+ * @return {Array<object>}
794
1078
  */
795
1079
  settingsItems() {
796
- return this.visibleItems.filter((item) => item.section === 'settings')
1080
+ return [
1081
+ ...this.visibleItems.filter((item) => item.section === 'settings'),
1082
+ ...this.undeclaredFooterItems,
1083
+ ]
797
1084
  },
798
1085
 
799
1086
  /**
@@ -1590,6 +1877,9 @@ export default {
1590
1877
  * `NcAppNavigationItem`).
1591
1878
  *
1592
1879
  * - Literal positive integer in `item.count` → return as-is.
1880
+ * - `item.count` is an object (`{ register, schema, filter? }`) →
1881
+ * `cnMenuItemCounts[item.id] ?? null`, the filtered total CnAppRoot
1882
+ * fetched for this entry.
1593
1883
  * - `item.count === "auto"` → look up the entry's resolved page;
1594
1884
  * when that page is `type: "index"` with a `register`/`schema`
1595
1885
  * in its `config`, return
@@ -1613,6 +1903,11 @@ export default {
1613
1903
  if (typeof raw === 'number') {
1614
1904
  return raw > 0 ? raw : null
1615
1905
  }
1906
+ if (raw && typeof raw === 'object') {
1907
+ // A filtered count: CnAppRoot fetched it per entry id.
1908
+ const value = this.cnMenuItemCounts?.[item.id]
1909
+ return (typeof value === 'number' && value > 0) ? value : null
1910
+ }
1616
1911
  if (raw !== 'auto') {
1617
1912
  return null
1618
1913
  }
@@ -1923,6 +2218,39 @@ export default {
1923
2218
  *
1924
2219
  * @return {void}
1925
2220
  */
2221
+ /**
2222
+ * Re-emit the object an `open-form` primary action created, so the
2223
+ * host can refresh a list or open the record.
2224
+ *
2225
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
2226
+ * @param {object} created The created object.
2227
+ * @return {void}
2228
+ */
2229
+ onPrimaryActionCreated(created) {
2230
+ /**
2231
+ * @event primary-action-created Emitted after an `open-form` primary action saves. Payload: the created object.
2232
+ */
2233
+ this.$emit('primary-action-created', created)
2234
+ },
2235
+
2236
+ /**
2237
+ * A card link that is a button (an `action` id) emits `card-action`
2238
+ * with that id for the host to run; links and anchors navigate on
2239
+ * their own.
2240
+ *
2241
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
2242
+ * @return {void}
2243
+ */
2244
+ onCardLinkClick() {
2245
+ const card = this.resolvedCard
2246
+ if (card && card.action) {
2247
+ /**
2248
+ * @event card-action Emitted when the nav card's link is an action button. Payload: the action id.
2249
+ */
2250
+ this.$emit('card-action', card.action)
2251
+ }
2252
+ },
2253
+
1926
2254
  onPrimaryActionClick() {
1927
2255
  const action = this.activePrimaryAction
1928
2256
  if (!action) {
@@ -1952,6 +2280,76 @@ export default {
1952
2280
  </script>
1953
2281
 
1954
2282
  <style>
2283
+ /* The emblem beside the app name (`nav.brand.emblem`). A theme sets
2284
+ --nldesign-emblem-url for `emblem: true`, and --cn-nav-emblem-size for both. */
2285
+ .cn-app-nav__brand-emblem {
2286
+ flex: none;
2287
+ height: var(--cn-nav-emblem-size, 34px);
2288
+ width: auto;
2289
+ object-fit: contain;
2290
+ }
2291
+
2292
+ .cn-app-nav__brand-emblem--theme {
2293
+ display: inline-block;
2294
+ width: var(--cn-nav-emblem-size, 34px);
2295
+ background: var(--nldesign-emblem-url) center / contain no-repeat;
2296
+ }
2297
+
2298
+ /* The solid primary action (`solid: true`, or an action that dispatches):
2299
+ one full-width primary button, as the board draws "New case". */
2300
+ .cn-app-nav__primary-action--solid .cn-action-buttons,
2301
+ .cn-app-nav__primary-action--solid .cn-action-buttons .button-vue,
2302
+ .cn-app-nav__primary-action--solid .button-vue {
2303
+ width: 100%;
2304
+ justify-content: center;
2305
+ }
2306
+
2307
+ /* The card above the footer entries (`nav.card`). Theme hooks:
2308
+ --cn-nav-card-background and --cn-nav-card-radius. */
2309
+ .cn-app-nav__card {
2310
+ display: flex;
2311
+ flex-direction: column;
2312
+ gap: calc(2 * var(--default-grid-baseline));
2313
+ margin: calc(2 * var(--default-grid-baseline));
2314
+ padding: calc(4 * var(--default-grid-baseline));
2315
+ border-radius: var(--cn-nav-card-radius, var(--border-radius-large));
2316
+ background: var(--cn-nav-card-background, var(--color-background-hover));
2317
+ }
2318
+
2319
+ .cn-app-nav__card-title {
2320
+ color: var(--color-main-text);
2321
+ font-weight: 700;
2322
+ }
2323
+
2324
+ .cn-app-nav__card-text {
2325
+ color: var(--color-text-maxcontrast);
2326
+ line-height: 1.4;
2327
+ }
2328
+
2329
+ .cn-app-nav__card-link {
2330
+ align-self: flex-start;
2331
+ margin: 0;
2332
+ padding: 0;
2333
+ border: 0;
2334
+ background: none;
2335
+ color: var(--color-primary-element);
2336
+ font: inherit;
2337
+ font-weight: 600;
2338
+ text-decoration: none;
2339
+ cursor: pointer;
2340
+ }
2341
+
2342
+ .cn-app-nav__card-link:hover,
2343
+ .cn-app-nav__card-link:focus-visible {
2344
+ text-decoration: underline;
2345
+ }
2346
+
2347
+ .cn-app-nav__card-link:focus-visible {
2348
+ outline: 2px solid var(--color-primary-element);
2349
+ outline-offset: 2px;
2350
+ border-radius: var(--border-radius-small, 4px);
2351
+ }
2352
+
1955
2353
  /* The brand block at the top of the navigation. */
1956
2354
  .cn-app-nav__brand {
1957
2355
  align-items: center;
@@ -2008,6 +2406,21 @@ export default {
2008
2406
  padding: calc(var(--default-grid-baseline, 4px) * 2);
2009
2407
  }
2010
2408
 
2409
+ /*
2410
+ * The primary action never shrinks out of sight. NcAppNavigation renders it in
2411
+ * `.app-navigation__body`, a flex child with `overflow-y: scroll` (so its
2412
+ * minimum height is 0) beside a list of `height: 100%` and the footer. With a
2413
+ * card and footer entries below the list, the column overflows and the body
2414
+ * gives up its share of the shrink: "New case" showed as a 24px sliver of a
2415
+ * 42px button. All three forms carry `.app-navigation-new` (the solid and
2416
+ * link wrappers mirror NcAppNavigationNew's class), and the rule is scoped to
2417
+ * this component's navigation, so a navigation without a primary action lays
2418
+ * out exactly as before.
2419
+ */
2420
+ .app-navigation[data-testid="cn-nav"] .app-navigation__body:has(> .app-navigation-new) {
2421
+ flex-shrink: 0;
2422
+ }
2423
+
2011
2424
  /*
2012
2425
  * Footer-section items (section: "footer") render in NcAppNavigation's
2013
2426
  * #footer slot, outside the scroll container, so they stay visible above
@@ -2031,6 +2444,20 @@ export default {
2031
2444
  overflow: visible !important;
2032
2445
  }
2033
2446
 
2447
+ /*
2448
+ * A declared footer that names `settings` first (`nav.footer`) puts the
2449
+ * foldout above the footer list. The two are siblings in NcAppNavigation's
2450
+ * flex column, so `order` moves them without changing either's markup; the
2451
+ * card above keeps order 0 and stays first.
2452
+ */
2453
+ .cn-app-nav__settings--first {
2454
+ order: 1;
2455
+ }
2456
+
2457
+ .cn-app-nav__footer-list--after-settings {
2458
+ order: 2;
2459
+ }
2460
+
2034
2461
  /*
2035
2462
  * Align the settings foldout's items with the main/footer list items,
2036
2463
  * whose icons sit at a 16px inset. The foldout's items live in a bare
@@ -203,3 +203,7 @@ Static-manifest apps use `useAppManifest('myapp', bundledManifest)`, which fetch
203
203
  | `softDependencyNotices` | Boolean | `false` | **Deprecated.** Restores the in-shell orange notices, one per unresolved optional dependency. Off by default: they stacked one per optional leaf and pushed the app's own content below the fold, for an audience who cannot act on them. Mount `CnLeafDependencySettings` in admin settings instead. Slated for removal. |
204
204
 
205
205
  `unresolvedSoftDependencies` is unchanged and still exposed, so an app rendering its own surface from it keeps working.
206
+
207
+ ### Without a navigation (`hideMenu`)
208
+
209
+ `hideMenu` (default `false`) renders neither the default CnAppNav nor the `#menu` slot, so the content starts at the left edge.