@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
@@ -41,8 +41,20 @@
41
41
  {{ skipLinkLabel }}
42
42
  </a>
43
43
 
44
+ <!-- Breadcrumb line (manifest `config.breadcrumb`): the list the record
45
+ belongs to, then the record itself as the current crumb. -->
46
+ <CnBreadcrumbs
47
+ v-if="breadcrumbCrumbs.length > 0 && !objectNotFound"
48
+ class="cn-detail-page__breadcrumbs"
49
+ :crumbs="breadcrumbCrumbs"
50
+ :rootText="breadcrumbCrumbs[0].icon === undefined"
51
+ data-testid="cn-detail-page-breadcrumbs" />
44
52
  <!-- Header -->
45
- <div v-if="!objectNotFound" class="cn-detail-page__header" data-testid="cn-detail-page-header">
53
+ <div
54
+ v-if="!objectNotFound"
55
+ class="cn-detail-page__header"
56
+ :class="{ 'cn-detail-page__header--card': headerCard, 'cn-detail-page__header--with-widget': headerWidgetDef !== null }"
57
+ data-testid="cn-detail-page-header">
46
58
  <!-- Header (left block) — overridable via #header slot. Default
47
59
  renders the icon + title + description. The right-hand
48
60
  #actions slot remains separate so headerComponent and
@@ -98,7 +110,7 @@
98
110
  (ADR-062). Only shown once the object resolves to a
99
111
  display name that differs from the type label. -->
100
112
  <p
101
- v-if="typeEyebrow"
113
+ v-if="typeEyebrow && showTypeEyebrow"
102
114
  class="cn-detail-page__type-eyebrow"
103
115
  data-testid="cn-detail-page-type-eyebrow">
104
116
  {{ typeEyebrow }}
@@ -363,6 +375,50 @@
363
375
  </template>
364
376
  </CnActionsMenu>
365
377
  </div>
378
+ <!-- A widget inside the header (manifest `config.headerWidget`), on
379
+ a row of its own under the title and the actions: the stages
380
+ bars of the board's case card. Drawn without a card of its own,
381
+ and taken out of the body grid. Nothing renders without the
382
+ key. -->
383
+ <div
384
+ v-if="headerWidgetDef !== null"
385
+ class="cn-detail-page__header-widget"
386
+ data-testid="cn-detail-page-header-widget">
387
+ <!--
388
+ @slot `widget-${headerWidgetDef.id}`
389
+ @description The same per-widget slot the body grid and the side
390
+ column offer, for the widget placed in the header
391
+ (`headerWidget`). Same name and same bindings.
392
+ -->
393
+ <slot
394
+ :name="`widget-${headerWidgetDef.id}`"
395
+ :item="{ id: headerWidgetDef.id, widgetId: headerWidgetDef.id }"
396
+ :widget="headerWidgetDef"
397
+ :objectId="objectId"
398
+ :object="resolvedObject"
399
+ :objectData="resolvedObject"
400
+ :objectType="resolvedObjectType"
401
+ :register="register"
402
+ :schema="schema">
403
+ <CnDetailWidgetHost
404
+ :widget="headerWidgetDef"
405
+ chrome="bare"
406
+ :objectId="objectId"
407
+ :object="currentObject"
408
+ :objectType="resolvedObjectType"
409
+ :schemaObject="currentSchema"
410
+ :register="register"
411
+ :schema="schema"
412
+ :store="effectiveObjectStore"
413
+ :surface="surface"
414
+ :integrationContext="effectiveIntegrationContext"
415
+ :hideEmpty="hideEmpty"
416
+ :cnRegistry="cnRegistry"
417
+ :availableWidgets="widgets"
418
+ @geoSaved="onGeoSaved"
419
+ @openIntegration="onAutoBodyOpenIntegration" />
420
+ </slot>
421
+ </div>
366
422
  </div>
367
423
 
368
424
  <!-- The locked card, under the title, for ANY active lock — not only a
@@ -1025,6 +1081,7 @@ import {
1025
1081
  widgetTitleOf,
1026
1082
  } from '../../utils/widgetDispatch.js'
1027
1083
  import { CnActionButtons } from '../CnActionButtons/index.js'
1084
+ import { CnBreadcrumbs } from '../CnBreadcrumbs/index.js'
1028
1085
  import { CnIcon } from '../CnIcon/index.js'
1029
1086
  import { CnStatusBadge } from '../CnStatusBadge/index.js'
1030
1087
  import { getWidgetTypeEntry } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
@@ -1172,6 +1229,7 @@ export default {
1172
1229
  CnDashboardGrid,
1173
1230
  CnLifecycleActions,
1174
1231
  CnActionButtons,
1232
+ CnBreadcrumbs,
1175
1233
  CnStatusBadge,
1176
1234
  CnSummaryAggregates,
1177
1235
  CnRelatedCollections,
@@ -2078,6 +2136,37 @@ export default {
2078
2136
  default: null,
2079
2137
  },
2080
2138
 
2139
+ /**
2140
+ * Whether the type eyebrow (the type label above the record name)
2141
+ * renders once the record resolves. `false` (manifest
2142
+ * `config.showTypeEyebrow: false`) drops it for a header that says
2143
+ * the type in a pill instead.
2144
+ *
2145
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
2146
+ */
2147
+ showTypeEyebrow: {
2148
+ type: Boolean,
2149
+ default: true,
2150
+ },
2151
+
2152
+ /**
2153
+ * A breadcrumb line above the header (manifest `config.breadcrumb`):
2154
+ * `{ label, route?, params?, href? }` names the list the record
2155
+ * belongs to; the record's display name follows as the current
2156
+ * crumb. `label` goes through the host translate function and shows
2157
+ * as text; `icon` (an MDI name, e.g. `Home`) draws that icon instead,
2158
+ * with the label as its accessible name. Null (the default) draws no
2159
+ * trail.
2160
+ *
2161
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
2162
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-declared-breadcrumb-label-shows-as-text
2163
+ * @type {{label: string, route?: string, params?: object, href?: string, icon?: string}|null}
2164
+ */
2165
+ breadcrumb: {
2166
+ type: Object,
2167
+ default: null,
2168
+ },
2169
+
2081
2170
  /**
2082
2171
  * A column of cards beside the body (manifest `config.sideColumn`),
2083
2172
  * for the record's facts: deadline, requester, handler, links. Each
@@ -2093,6 +2182,33 @@ export default {
2093
2182
  default: () => [],
2094
2183
  },
2095
2184
 
2185
+ /**
2186
+ * Draw the header as a card (manifest `config.headerCard: true`): the
2187
+ * pills, the title and the actions on the page's surface colour inside
2188
+ * a bordered, rounded box, as the board's case header. Off by default,
2189
+ * which keeps the header on the page ground.
2190
+ *
2191
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
2192
+ */
2193
+ headerCard: {
2194
+ type: Boolean,
2195
+ default: false,
2196
+ },
2197
+
2198
+ /**
2199
+ * The id of a widget declared in `widgets` to render inside the header,
2200
+ * on its own row under the title and the actions (manifest
2201
+ * `config.headerWidget`), e.g. the stages bars. It renders without a
2202
+ * card of its own and leaves the body grid. Empty (the default), or an
2203
+ * id that names no widget, renders nothing extra.
2204
+ *
2205
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
2206
+ */
2207
+ headerWidget: {
2208
+ type: String,
2209
+ default: '',
2210
+ },
2211
+
2096
2212
  /**
2097
2213
  * Whether the current user administers this instance. Shows the
2098
2214
  * `adminOnly` header actions. `null` (the default) reads it from
@@ -2632,6 +2748,21 @@ export default {
2632
2748
  .filter(Boolean)
2633
2749
  },
2634
2750
 
2751
+ /**
2752
+ * The widget the header holds (`headerWidget`): the definition in
2753
+ * `widgets` with that id, or null without the key or a match.
2754
+ *
2755
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
2756
+ * @return {object|null} The widget definition, or null.
2757
+ */
2758
+ headerWidgetDef() {
2759
+ if (!this.headerWidget) {
2760
+ return null
2761
+ }
2762
+ const declared = Array.isArray(this.widgets) ? this.widgets : []
2763
+ return declared.find((widget) => widget && widget.id === this.headerWidget) || null
2764
+ },
2765
+
2635
2766
  /**
2636
2767
  * The side column's widget definitions. A string names a widget
2637
2768
  * declared in `widgets`; an object is a definition of its own.
@@ -3201,6 +3332,33 @@ export default {
3201
3332
  return this.objectDisplayName || this.resolvedTitle
3202
3333
  },
3203
3334
 
3335
+ /**
3336
+ * The breadcrumb trail: the declared crumb (a router target from
3337
+ * `route` + `params`, or an `href`), then the record's display name
3338
+ * as the current crumb. Empty without a `breadcrumb` label. The first
3339
+ * crumb carries `icon` only when the manifest declares one.
3340
+ *
3341
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
3342
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-declared-breadcrumb-label-shows-as-text
3343
+ * @return {Array<{label: string, to?: object, href?: string, icon?: string}>}
3344
+ */
3345
+ breadcrumbCrumbs() {
3346
+ const crumb = this.breadcrumb
3347
+ if (!crumb || typeof crumb.label !== 'string' || crumb.label === '') {
3348
+ return []
3349
+ }
3350
+ const first = { label: this.effectiveTranslate(crumb.label) }
3351
+ if (typeof crumb.icon === 'string' && crumb.icon !== '') {
3352
+ first.icon = crumb.icon
3353
+ }
3354
+ if (crumb.route) {
3355
+ first.to = { name: crumb.route, ...(crumb.params ? { params: crumb.params } : {}) }
3356
+ } else if (crumb.href) {
3357
+ first.href = crumb.href
3358
+ }
3359
+ return [first, { label: this.displayTitle }]
3360
+ },
3361
+
3204
3362
  /**
3205
3363
  * Effective translate function: the injected `cnTranslate` (the host
3206
3364
  * app's bound `t()`), identity by default.
@@ -4652,6 +4810,10 @@ export default {
4652
4810
  * it — CnPageRenderer's split pane forwards no slots, which is exactly
4653
4811
  * that fault and wants fixing, not concealing.
4654
4812
  *
4813
+ * The widget the header holds (`headerWidget`) draws nothing here
4814
+ * either: it is already drawn in the header.
4815
+ *
4816
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-detail-header-can-be-a-card-that-holds-a-widget
4655
4817
  * @param {object} item A layout item.
4656
4818
  * @return {boolean} False only when the item provably draws nothing.
4657
4819
  */
@@ -4662,6 +4824,11 @@ export default {
4662
4824
  if (this.editingBody) {
4663
4825
  return true
4664
4826
  }
4827
+ // The widget the header holds (`headerWidget`) is drawn there, so
4828
+ // its grid cell would be the same widget twice.
4829
+ if (this.headerWidgetDef !== null && item.widgetId === this.headerWidgetDef.id) {
4830
+ return false
4831
+ }
4665
4832
  const widget = this.findWidget(item)
4666
4833
  if (!widget) {
4667
4834
  return true
@@ -4,7 +4,7 @@
4
4
  -->
5
5
 
6
6
  <template>
7
- <div class="cn-header-widget" :class="{ 'cn-header-widget--plain': isPlain }" :style="wrapperStyle">
7
+ <div class="cn-header-widget" :class="{ 'cn-header-widget--plain': isPlain, 'cn-header-widget--ground': isGround, 'cn-header-widget--with-views': viewOptions.length > 0 }" :style="wrapperStyle">
8
8
  <div
9
9
  v-if="hasOverlay"
10
10
  class="cn-header-widget__overlay"
@@ -34,6 +34,16 @@
34
34
  {{ ctaLabel }}
35
35
  </a>
36
36
  </div>
37
+ <!-- A view switch at the right of the heading (`content.views`): "My
38
+ work / My team" as a segmented control whose options are routes. -->
39
+ <div v-if="viewOptions.length > 0" class="cn-header-widget__views">
40
+ <CnSegmentedControl
41
+ :options="viewOptions"
42
+ :modelValue="activeView"
43
+ :ariaLabel="viewsLabel"
44
+ data-testid="cn-header-widget-views"
45
+ @update:modelValue="onViewChange" />
46
+ </div>
37
47
  <img
38
48
  v-if="hasBackgroundImage"
39
49
  class="cn-header-widget__probe"
@@ -47,6 +57,7 @@
47
57
  <script>
48
58
  import { getCurrentUser } from '@nextcloud/auth'
49
59
  import { getCanonicalLocale, translate as t } from '@nextcloud/l10n'
60
+ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
50
61
  import { resolveImageUrl } from '../../utils/resolveImageUrl.js'
51
62
 
52
63
  const ALLOWED_OVERLAY_MODES = ['none', 'tint', 'gradient-bottom']
@@ -99,6 +110,8 @@ const VERTICAL_ALIGN_FLEX = Object.freeze({
99
110
  export default {
100
111
  name: 'CnHeaderWidget',
101
112
 
113
+ components: { CnSegmentedControl },
114
+
102
115
  inject: {
103
116
  /**
104
117
  * Host translate function provided by CnAppRoot as
@@ -115,9 +128,15 @@ export default {
115
128
  * Persisted widget content: `{title, subtitle, backgroundImageUrl,
116
129
  * backgroundImageFileId, backgroundColor, overlayMode, overlayColor,
117
130
  * overlayOpacity, textColor, textAlign, verticalAlign, height, cta,
118
- * greeting, showDate, plain}`. All fields are optional except `title`
119
- * (or `greeting`); unknown enum values collapse to documented defaults
120
- * and the renderer never throws.
131
+ * greeting, showDate, plain, ground, views}`. All fields are optional except
132
+ * `title` (or `greeting`); unknown enum values collapse to documented
133
+ * defaults and the renderer never throws. `views` is
134
+ * `{ ariaLabel?, options: [{ label, route, params? }] }`: a segmented
135
+ * control at the right of the heading whose checked option is the
136
+ * current route; choosing another pushes its route. `ground: true`
137
+ * draws the greeting on the page ground: the plain look with no
138
+ * padding, a 32px heading and the date line 6px above it (a dashboard
139
+ * also drops the widget's card for it).
121
140
  *
122
141
  * @type {object}
123
142
  */
@@ -198,13 +217,75 @@ export default {
198
217
  return this.now instanceof Date ? this.now : new Date()
199
218
  },
200
219
 
220
+ /**
221
+ * The view switch's options from `content.views.options`: each
222
+ * `{ label, route, params? }` with a non-empty label and route becomes
223
+ * a segmented-control option keyed on its route name (the label goes
224
+ * through the host translate function). Empty without a router, so
225
+ * the switch cannot offer routes it cannot follow.
226
+ *
227
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
228
+ * @return {Array<{value: string, label: string}>}
229
+ */
230
+ viewOptions() {
231
+ const raw = this.content && this.content.views && this.content.views.options
232
+ if (!Array.isArray(raw) || !this.$router) {
233
+ return []
234
+ }
235
+ return raw
236
+ .filter((o) => o && typeof o.label === 'string' && o.label !== '' && typeof o.route === 'string' && o.route !== '')
237
+ .map((o) => ({ value: o.route, label: this.cnTranslate(o.label) }))
238
+ },
239
+
240
+ /**
241
+ * The checked view: the option whose route is the current route, else
242
+ * the first option (a switch always has one checked option).
243
+ *
244
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
245
+ * @return {string|null}
246
+ */
247
+ activeView() {
248
+ const options = this.viewOptions
249
+ if (options.length === 0) {
250
+ return null
251
+ }
252
+ const current = this.$route && this.$route.name
253
+ const match = options.find((o) => o.value === current)
254
+ return match ? match.value : options[0].value
255
+ },
256
+
257
+ /**
258
+ * Accessible name of the view switch: `content.views.ariaLabel`
259
+ * through the host translate function, else "View".
260
+ *
261
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
262
+ * @return {string}
263
+ */
264
+ viewsLabel() {
265
+ const label = this.content && this.content.views && this.content.views.ariaLabel
266
+ return label ? this.cnTranslate(label) : t('nextcloud-vue', 'View')
267
+ },
268
+
201
269
  /**
202
270
  * Whether the plain presentation is on (no coloured background).
203
271
  *
204
272
  * @return {boolean}
205
273
  */
206
274
  isPlain() {
207
- return Boolean(this.content && this.content.plain === true)
275
+ return Boolean(this.content && (this.content.plain === true || this.content.ground === true))
276
+ },
277
+
278
+ /**
279
+ * Whether the greeting sits on the page ground (`content.ground`):
280
+ * the plain look without the card padding, so the date line and the
281
+ * heading align with the page edge as the board draws them. Off by
282
+ * default.
283
+ *
284
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-greeting-can-sit-on-the-page-ground
285
+ * @return {boolean}
286
+ */
287
+ isGround() {
288
+ return Boolean(this.content && this.content.ground === true)
208
289
  },
209
290
 
210
291
  /**
@@ -490,7 +571,9 @@ export default {
490
571
  return {
491
572
  position: 'relative',
492
573
  'z-index': 1,
493
- width: '100%',
574
+ // Beside a view switch the content shares the row instead of
575
+ // taking the whole width (see .cn-header-widget--with-views).
576
+ width: this.viewOptions.length > 0 ? 'auto' : '100%',
494
577
  height: '100%',
495
578
  display: 'flex',
496
579
  'flex-direction': 'column',
@@ -498,9 +581,10 @@ export default {
498
581
  'justify-content': VERTICAL_ALIGN_FLEX[this.verticalAlign],
499
582
  // Plain drops the coloured background, not the card padding:
500
583
  // at 0 the greeting sat flush against the card's left edge.
501
- padding: this.isPlain ? '16px' : '16px 24px',
584
+ // Ground has no card, so it has no padding either.
585
+ padding: this.isGround ? '0' : (this.isPlain ? '16px' : '16px 24px'),
502
586
  'box-sizing': 'border-box',
503
- gap: '8px',
587
+ gap: this.isGround ? '6px' : '8px',
504
588
  'text-align': this.textAlign,
505
589
  }
506
590
  },
@@ -604,6 +688,26 @@ export default {
604
688
  },
605
689
 
606
690
  methods: {
691
+ /**
692
+ * Follow the chosen view: push its route (with the option's `params`
693
+ * when declared). Nothing happens for the route already shown.
694
+ *
695
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
696
+ * @param {string} route The chosen option's route name.
697
+ * @return {void}
698
+ */
699
+ onViewChange(route) {
700
+ if (!this.$router || !route || route === (this.$route && this.$route.name)) {
701
+ return
702
+ }
703
+ const declared = (this.content.views.options || []).find((o) => o && o.route === route) || {}
704
+ const target = { name: route }
705
+ if (declared.params && typeof declared.params === 'object') {
706
+ target.params = declared.params
707
+ }
708
+ this.$router.push(target)
709
+ },
710
+
607
711
  /**
608
712
  * Mark the background image as broken so the renderer falls back to
609
713
  * the solid colour.
@@ -655,6 +759,40 @@ export default {
655
759
  border-radius: 0;
656
760
  }
657
761
 
762
+ /* With a view switch the heading and the switch share one row, the switch
763
+ at the right and bottom-aligned with the heading; on a narrow card the
764
+ switch wraps under it. */
765
+ .cn-header-widget--with-views {
766
+ display: flex;
767
+ flex-wrap: wrap;
768
+ align-items: flex-end;
769
+ justify-content: space-between;
770
+ gap: calc(2 * var(--default-grid-baseline)) calc(4 * var(--default-grid-baseline));
771
+ }
772
+
773
+ .cn-header-widget--with-views .cn-header-widget__content {
774
+ flex: 1 1 320px;
775
+ width: auto;
776
+ }
777
+
778
+ .cn-header-widget__views {
779
+ position: relative;
780
+ z-index: 1;
781
+ flex: 0 0 auto;
782
+ padding: 16px;
783
+ }
784
+
785
+ /* On the page ground (`content.ground`): no card, so no inset around the
786
+ view switch, and the heading at the board's 32px. */
787
+ .cn-header-widget--ground .cn-header-widget__views {
788
+ padding: 0;
789
+ }
790
+
791
+ .cn-header-widget--ground .cn-header-widget__title {
792
+ font-size: var(--cn-header-ground-title-size, 32px);
793
+ letter-spacing: -0.01em;
794
+ }
795
+
658
796
  .cn-header-widget__date {
659
797
  font-size: 14px;
660
798
  line-height: 1.4;
@@ -726,7 +864,7 @@ export default {
726
864
  height: 1px;
727
865
  opacity: 0;
728
866
  pointer-events: none;
729
- left: -9999px;
867
+ inset-inline-start: -9999px;
730
868
  top: -9999px;
731
869
  }
732
870
 
@@ -13,12 +13,12 @@
13
13
  <slot
14
14
  name="header"
15
15
  :title="title"
16
- :description="description"
16
+ :description="headerDescription"
17
17
  :icon="resolvedIcon"
18
18
  :showTitle="showTitle">
19
19
  <CnPageHeader
20
20
  :title="title"
21
- :description="description"
21
+ :description="headerDescription"
22
22
  :icon="resolvedIcon"
23
23
  :visuallyHidden="!showTitle" />
24
24
  </slot>
@@ -1177,6 +1177,21 @@ export default {
1177
1177
  default: '',
1178
1178
  },
1179
1179
 
1180
+ /**
1181
+ * A description that carries the collection's total: `{total}` in
1182
+ * the text is replaced by the current pagination total ("{total}
1183
+ * open cases" reads "48 open cases"). Rendered in place of
1184
+ * `description` once a total is known; before that, `description`
1185
+ * shows. Manifest key `config.countSubtitle`. Goes through the host
1186
+ * translate function.
1187
+ *
1188
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-an-index-page-title-is-a-manifest-key
1189
+ */
1190
+ countSubtitle: {
1191
+ type: String,
1192
+ default: '',
1193
+ },
1194
+
1180
1195
  /**
1181
1196
  * Whether to show the page header (icon, title, description) inline.
1182
1197
  * When false (default), only the sidebar header shows the title
@@ -4231,6 +4246,21 @@ export default {
4231
4246
  return resolveDeepTokens(seed, {})
4232
4247
  },
4233
4248
 
4249
+ /**
4250
+ * The header's description line: `countSubtitle` with the total
4251
+ * filled in when one is known, else `description`.
4252
+ *
4253
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-an-index-page-title-is-a-manifest-key
4254
+ * @return {string}
4255
+ */
4256
+ headerDescription() {
4257
+ const total = this.effectivePagination?.total
4258
+ if (this.countSubtitle && typeof total === 'number' && total >= 0) {
4259
+ return this.cnTranslate(this.countSubtitle).replace('{total}', String(total))
4260
+ }
4261
+ return this.description
4262
+ },
4263
+
4234
4264
  /** Add button label — derived from schema.title if not explicitly set */
4235
4265
  resolvedAddLabel() {
4236
4266
  if (this.addLabel) {
@@ -151,6 +151,7 @@
151
151
  :sortKey="localSort.field || null"
152
152
  :sortOrder="localSort.dir || 'asc'"
153
153
  borderless
154
+ :hideHeader="content.hideHeader === true"
154
155
  @rowClick="onRowClick"
155
156
  @rowAuxClick="onRowAuxClick"
156
157
  @select="onSelect"
@@ -172,6 +173,7 @@
172
173
  :sortKey="localSort.field || null"
173
174
  :sortOrder="localSort.dir || 'asc'"
174
175
  borderless
176
+ :hideHeader="content.hideHeader === true"
175
177
  @rowClick="onRowClick"
176
178
  @rowAuxClick="onRowAuxClick"
177
179
  @select="onSelect"
@@ -419,7 +421,11 @@ export default {
419
421
  * those carrying a selected value. Client-side over the fetched page,
420
422
  * like the rest of this widget's row set.
421
423
  *
422
- * @type {{register?: string, schema?: string, filter?: object, sort?: {field?: string, dir?: string}, limit?: number, extend?: Array<string>, columns?: Array, rowActions?: Array<object>, dropZone?: object, upload?: boolean, groupBy?: string, groupLabel?: string, groupLabelResolve?: {register: string, schema: string, labelField?: string}, selectable?: boolean, bulkActions?: Array<object>, sortable?: boolean, facet?: {field: string, label?: string}, rowRoute?: string, prompt?: string, emptyText?: string, viewAllRoute?: string, viewAllQuery?: object}}
424
+ * `hideHeader: true` drops the table's column header row (CnDataTable's
425
+ * `hideHeader`), for a short list that reads as rows, not as a table.
426
+ *
427
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-small-opt-ins-from-the-decidiq-and-learniq-lanes
428
+ * @type {{register?: string, schema?: string, filter?: object, sort?: {field?: string, dir?: string}, limit?: number, extend?: Array<string>, columns?: Array, rowActions?: Array<object>, dropZone?: object, upload?: boolean, groupBy?: string, groupLabel?: string, groupLabelResolve?: {register: string, schema: string, labelField?: string}, selectable?: boolean, bulkActions?: Array<object>, sortable?: boolean, facet?: {field: string, label?: string}, rowRoute?: string, prompt?: string, emptyText?: string, viewAllRoute?: string, viewAllQuery?: object, hideHeader?: boolean}}
423
429
  */
424
430
  content: {
425
431
  type: Object,