@conduction/nextcloud-vue 2.56.0-beta.11 → 2.56.0-beta.13

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 (138) 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 +154 -4
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +555 -480
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  28. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  30. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  32. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  34. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +187 -19
  36. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +215 -135
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  40. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  44. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +157 -7
  45. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  46. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +23 -5
  47. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  48. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  49. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  53. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  54. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  55. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  56. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  57. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  58. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  59. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  60. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  61. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  62. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  63. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  64. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  65. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  66. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +74 -5
  67. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  68. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +15 -8
  69. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  70. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  71. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  72. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  73. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  74. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  75. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  76. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  77. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  78. package/dist/esm/l10n/en.json.js +5 -1
  79. package/dist/esm/l10n/en.json.js.map +1 -1
  80. package/dist/esm/l10n/nl.json.js +5 -1
  81. package/dist/esm/l10n/nl.json.js.map +1 -1
  82. package/dist/esm/mixins/pageViews.js +400 -0
  83. package/dist/esm/mixins/pageViews.js.map +1 -0
  84. package/dist/esm/nextcloud-vue.css +513 -81
  85. package/dist/esm/store/useObjectStore.js +118 -33
  86. package/dist/esm/store/useObjectStore.js.map +1 -1
  87. package/dist/esm/utils/actionsDispatcher.js +25 -2
  88. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  89. package/dist/esm/utils/columnFilters.js +27 -3
  90. package/dist/esm/utils/columnFilters.js.map +1 -1
  91. package/dist/esm/utils/schema.js +40 -2
  92. package/dist/esm/utils/schema.js.map +1 -1
  93. package/dist/esm/utils/schemaRefSlug.js +19 -3
  94. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  95. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  96. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  97. package/dist/nextcloud-vue.cjs.js +8858 -6566
  98. package/dist/nextcloud-vue.cjs.js.map +2 -2
  99. package/dist/nextcloud-vue.css +513 -81
  100. package/l10n/en.json +5 -0
  101. package/l10n/nl.json +5 -0
  102. package/package.json +1 -1
  103. package/src/components/CnAppNav/CnAppNav.vue +441 -14
  104. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  105. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  106. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  107. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  108. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  109. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  110. package/src/components/CnDashboardPage/CnDashboardPage.vue +237 -12
  111. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  112. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  113. package/src/components/CnDetailPage/CnDetailPage.md +8 -0
  114. package/src/components/CnDetailPage/CnDetailPage.vue +263 -7
  115. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  116. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  117. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +206 -9
  118. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  119. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  120. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  121. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  122. package/src/components/CnStagesWidget/CnStagesWidget.vue +189 -2
  123. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  124. package/src/components/CnStatWidget/CnStatWidget.vue +89 -7
  125. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  126. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  127. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  128. package/src/css/detail-page.css +37 -4
  129. package/src/css/kpi-card.css +71 -1
  130. package/src/css/table.css +18 -6
  131. package/src/mixins/pageViews.js +396 -0
  132. package/src/schemas/app-manifest-v2.schema.json +162 -3
  133. package/src/store/useObjectStore.js +118 -33
  134. package/src/utils/actionsDispatcher.js +25 -2
  135. package/src/utils/columnFilters.js +29 -2
  136. package/src/utils/schema.js +40 -2
  137. package/src/utils/schemaRefSlug.js +19 -3
  138. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -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,19 @@
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
+ or views of the page the widget sits on (`option.view`); a view
40
+ option controls the page's view region. -->
41
+ <div v-if="viewOptions.length > 0" class="cn-header-widget__views">
42
+ <CnSegmentedControl
43
+ :options="viewOptions"
44
+ :modelValue="activeView"
45
+ :ariaLabel="viewsLabel"
46
+ :controls="viewsRegionId"
47
+ data-testid="cn-header-widget-views"
48
+ @update:modelValue="onViewChange" />
49
+ </div>
37
50
  <img
38
51
  v-if="hasBackgroundImage"
39
52
  class="cn-header-widget__probe"
@@ -47,6 +60,7 @@
47
60
  <script>
48
61
  import { getCurrentUser } from '@nextcloud/auth'
49
62
  import { getCanonicalLocale, translate as t } from '@nextcloud/l10n'
63
+ import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
50
64
  import { resolveImageUrl } from '../../utils/resolveImageUrl.js'
51
65
 
52
66
  const ALLOWED_OVERLAY_MODES = ['none', 'tint', 'gradient-bottom']
@@ -55,6 +69,9 @@ const ALLOWED_TEXT_ALIGN = ['left', 'center', 'right']
55
69
  const ALLOWED_VERTICAL_ALIGN = ['top', 'middle', 'bottom']
56
70
  const ALLOWED_CTA_STYLES = ['primary', 'secondary', 'ghost']
57
71
 
72
+ /** Marks a switch option that selects a page view rather than a route. */
73
+ const VIEW_OPTION_PREFIX = 'view:'
74
+
58
75
  const HEIGHT_PIXELS = Object.freeze({
59
76
  small: 120,
60
77
  medium: 200,
@@ -99,6 +116,8 @@ const VERTICAL_ALIGN_FLEX = Object.freeze({
99
116
  export default {
100
117
  name: 'CnHeaderWidget',
101
118
 
119
+ components: { CnSegmentedControl },
120
+
102
121
  inject: {
103
122
  /**
104
123
  * Host translate function provided by CnAppRoot as
@@ -108,6 +127,12 @@ export default {
108
127
  * untranslated key renders as itself.
109
128
  */
110
129
  cnTranslate: { default: () => (key) => key },
130
+ /**
131
+ * The views of the page this widget sits on, provided by
132
+ * CnDashboardPage / CnDetailPage (`views`). An option with a `view`
133
+ * selects one of them. Null outside such a page.
134
+ */
135
+ cnPageViews: { default: null },
111
136
  },
112
137
 
113
138
  props: {
@@ -115,9 +140,18 @@ export default {
115
140
  * Persisted widget content: `{title, subtitle, backgroundImageUrl,
116
141
  * backgroundImageFileId, backgroundColor, overlayMode, overlayColor,
117
142
  * 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.
143
+ * greeting, showDate, plain, ground, views}`. All fields are optional except
144
+ * `title` (or `greeting`); unknown enum values collapse to documented
145
+ * defaults and the renderer never throws. `views` is
146
+ * `{ ariaLabel?, options: [{ label, route, params? } | { label, view }] }`:
147
+ * a segmented control at the right of the heading whose checked option
148
+ * is the current route; choosing another pushes its route. An option
149
+ * with `view` selects that view of the page the widget sits on (the
150
+ * page's `views`) instead, and the page then draws no switch of its
151
+ * own. `ground: true`
152
+ * draws the greeting on the page ground: the plain look with no
153
+ * padding, a 32px heading and the date line 6px above it (a dashboard
154
+ * also drops the widget's card for it).
121
155
  *
122
156
  * @type {object}
123
157
  */
@@ -198,13 +232,113 @@ export default {
198
232
  return this.now instanceof Date ? this.now : new Date()
199
233
  },
200
234
 
235
+ /**
236
+ * The view switch's options from `content.views.options`: each
237
+ * `{ label, route, params? }` with a non-empty label and route becomes
238
+ * a segmented-control option keyed on its route name (the label goes
239
+ * through the host translate function). Empty without a router, so
240
+ * the switch cannot offer routes it cannot follow.
241
+ *
242
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
243
+ * @return {Array<{value: string, label: string}>}
244
+ */
245
+ viewOptions() {
246
+ const raw = this.content && this.content.views && this.content.views.options
247
+ if (!Array.isArray(raw)) {
248
+ return []
249
+ }
250
+ const pageViewIds = this.pageViewIds
251
+ return raw
252
+ .filter((o) => o && typeof o.label === 'string' && o.label !== '')
253
+ .map((o) => {
254
+ if (typeof o.view === 'string' && o.view !== '') {
255
+ return pageViewIds.includes(o.view) ? { value: VIEW_OPTION_PREFIX + o.view, label: this.cnTranslate(o.label) } : null
256
+ }
257
+ if (typeof o.route === 'string' && o.route !== '' && this.$router) {
258
+ return { value: o.route, label: this.cnTranslate(o.label) }
259
+ }
260
+ return null
261
+ })
262
+ .filter(Boolean)
263
+ },
264
+
265
+ /**
266
+ * The ids of the page's views, empty outside a page with views.
267
+ *
268
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-greeting-header-can-switch-the-pages-views
269
+ * @return {string[]}
270
+ */
271
+ pageViewIds() {
272
+ const views = this.cnPageViews && Array.isArray(this.cnPageViews.views) ? this.cnPageViews.views : []
273
+ return views.map((view) => view.id)
274
+ },
275
+
276
+ /**
277
+ * The id of the page's view region when an option selects a view,
278
+ * for the options' `aria-controls`; '' otherwise.
279
+ *
280
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
281
+ * @return {string}
282
+ */
283
+ viewsRegionId() {
284
+ const hasViewOption = this.viewOptions.some((o) => o.value.startsWith(VIEW_OPTION_PREFIX))
285
+ return hasViewOption && this.cnPageViews ? (this.cnPageViews.regionId || '') : ''
286
+ },
287
+
288
+ /**
289
+ * The checked view: the option whose route is the current route, else
290
+ * the first option (a switch always has one checked option).
291
+ *
292
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
293
+ * @return {string|null}
294
+ */
295
+ activeView() {
296
+ const options = this.viewOptions
297
+ if (options.length === 0) {
298
+ return null
299
+ }
300
+ const activeView = this.cnPageViews ? this.cnPageViews.activeId : null
301
+ const viewMatch = activeView ? options.find((o) => o.value === VIEW_OPTION_PREFIX + activeView) : null
302
+ if (viewMatch) {
303
+ return viewMatch.value
304
+ }
305
+ const current = this.$route && this.$route.name
306
+ const match = options.find((o) => o.value === current)
307
+ return match ? match.value : options[0].value
308
+ },
309
+
310
+ /**
311
+ * Accessible name of the view switch: `content.views.ariaLabel`
312
+ * through the host translate function, else "View".
313
+ *
314
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
315
+ * @return {string}
316
+ */
317
+ viewsLabel() {
318
+ const label = this.content && this.content.views && this.content.views.ariaLabel
319
+ return label ? this.cnTranslate(label) : t('nextcloud-vue', 'Views')
320
+ },
321
+
201
322
  /**
202
323
  * Whether the plain presentation is on (no coloured background).
203
324
  *
204
325
  * @return {boolean}
205
326
  */
206
327
  isPlain() {
207
- return Boolean(this.content && this.content.plain === true)
328
+ return Boolean(this.content && (this.content.plain === true || this.content.ground === true))
329
+ },
330
+
331
+ /**
332
+ * Whether the greeting sits on the page ground (`content.ground`):
333
+ * the plain look without the card padding, so the date line and the
334
+ * heading align with the page edge as the board draws them. Off by
335
+ * default.
336
+ *
337
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-greeting-can-sit-on-the-page-ground
338
+ * @return {boolean}
339
+ */
340
+ isGround() {
341
+ return Boolean(this.content && this.content.ground === true)
208
342
  },
209
343
 
210
344
  /**
@@ -490,7 +624,9 @@ export default {
490
624
  return {
491
625
  position: 'relative',
492
626
  'z-index': 1,
493
- width: '100%',
627
+ // Beside a view switch the content shares the row instead of
628
+ // taking the whole width (see .cn-header-widget--with-views).
629
+ width: this.viewOptions.length > 0 ? 'auto' : '100%',
494
630
  height: '100%',
495
631
  display: 'flex',
496
632
  'flex-direction': 'column',
@@ -498,9 +634,10 @@ export default {
498
634
  'justify-content': VERTICAL_ALIGN_FLEX[this.verticalAlign],
499
635
  // Plain drops the coloured background, not the card padding:
500
636
  // at 0 the greeting sat flush against the card's left edge.
501
- padding: this.isPlain ? '16px' : '16px 24px',
637
+ // Ground has no card, so it has no padding either.
638
+ padding: this.isGround ? '0' : (this.isPlain ? '16px' : '16px 24px'),
502
639
  'box-sizing': 'border-box',
503
- gap: '8px',
640
+ gap: this.isGround ? '6px' : '8px',
504
641
  'text-align': this.textAlign,
505
642
  }
506
643
  },
@@ -604,6 +741,32 @@ export default {
604
741
  },
605
742
 
606
743
  methods: {
744
+ /**
745
+ * Follow the chosen view: push its route (with the option's `params`
746
+ * when declared). Nothing happens for the route already shown.
747
+ *
748
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
749
+ * @param {string} route The chosen option's route name.
750
+ * @return {void}
751
+ */
752
+ onViewChange(route) {
753
+ if (typeof route === 'string' && route.startsWith(VIEW_OPTION_PREFIX)) {
754
+ if (this.cnPageViews && typeof this.cnPageViews.select === 'function') {
755
+ this.cnPageViews.select(route.slice(VIEW_OPTION_PREFIX.length))
756
+ }
757
+ return
758
+ }
759
+ if (!this.$router || !route || route === (this.$route && this.$route.name)) {
760
+ return
761
+ }
762
+ const declared = (this.content.views.options || []).find((o) => o && o.route === route) || {}
763
+ const target = { name: route }
764
+ if (declared.params && typeof declared.params === 'object') {
765
+ target.params = declared.params
766
+ }
767
+ this.$router.push(target)
768
+ },
769
+
607
770
  /**
608
771
  * Mark the background image as broken so the renderer falls back to
609
772
  * the solid colour.
@@ -655,6 +818,40 @@ export default {
655
818
  border-radius: 0;
656
819
  }
657
820
 
821
+ /* With a view switch the heading and the switch share one row, the switch
822
+ at the right and bottom-aligned with the heading; on a narrow card the
823
+ switch wraps under it. */
824
+ .cn-header-widget--with-views {
825
+ display: flex;
826
+ flex-wrap: wrap;
827
+ align-items: flex-end;
828
+ justify-content: space-between;
829
+ gap: calc(2 * var(--default-grid-baseline)) calc(4 * var(--default-grid-baseline));
830
+ }
831
+
832
+ .cn-header-widget--with-views .cn-header-widget__content {
833
+ flex: 1 1 320px;
834
+ width: auto;
835
+ }
836
+
837
+ .cn-header-widget__views {
838
+ position: relative;
839
+ z-index: 1;
840
+ flex: 0 0 auto;
841
+ padding: 16px;
842
+ }
843
+
844
+ /* On the page ground (`content.ground`): no card, so no inset around the
845
+ view switch, and the heading at the board's 32px. */
846
+ .cn-header-widget--ground .cn-header-widget__views {
847
+ padding: 0;
848
+ }
849
+
850
+ .cn-header-widget--ground .cn-header-widget__title {
851
+ font-size: var(--cn-header-ground-title-size, 32px);
852
+ letter-spacing: -0.01em;
853
+ }
854
+
658
855
  .cn-header-widget__date {
659
856
  font-size: 14px;
660
857
  line-height: 1.4;
@@ -726,7 +923,7 @@ export default {
726
923
  height: 1px;
727
924
  opacity: 0;
728
925
  pointer-events: none;
729
- left: -9999px;
926
+ inset-inline-start: -9999px;
730
927
  top: -9999px;
731
928
  }
732
929
 
@@ -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,
@@ -43,4 +43,4 @@ export default {
43
43
  </script>
44
44
  ```
45
45
 
46
- Props: `options` lists the choices, `modelValue` is the chosen value (use `v-model`), `aria-label` names the group (or point `aria-labelledby` at a visible heading), and `stretch` fills the width.
46
+ Props: `options` lists the choices, `modelValue` is the chosen value (use `v-model`), `aria-label` names the group (or point `aria-labelledby` at a visible heading), `controls` names the id of the element the options switch (set as `aria-controls` on every option), and `stretch` fills the width.
@@ -20,6 +20,7 @@
20
20
  :aria-checked="isChecked(option) ? 'true' : 'false'"
21
21
  :tabindex="index === focusableIndex ? 0 : -1"
22
22
  :disabled="option.disabled || null"
23
+ :aria-controls="controls || null"
23
24
  :data-value="option.key"
24
25
  @click="select(option)">
25
26
  <!-- @slot option Replaces the content of one option. -->
@@ -95,6 +96,17 @@ export default {
95
96
  default: '',
96
97
  },
97
98
 
99
+ /**
100
+ * Id of the element the options switch, such as the region holding a
101
+ * page view's widgets. Set as `aria-controls` on every option.
102
+ *
103
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
104
+ */
105
+ controls: {
106
+ type: String,
107
+ default: '',
108
+ },
109
+
98
110
  /** Stretch the control to the full width, with options of equal width. */
99
111
  stretch: {
100
112
  type: Boolean,
@@ -35,7 +35,45 @@
35
35
  {{ tr('No stages to show') }}
36
36
  </p>
37
37
  <template v-else>
38
+ <!-- The bars variant (`variant: "bars"`): equal columns, a thin bar
39
+ per step coloured by state, the label under it and an optional
40
+ date line. No description: eight Woo steps with their
41
+ descriptions crowded into five lines and clipped the first
42
+ label. The current step says so for a screen reader. -->
43
+ <ol
44
+ v-if="isBars"
45
+ class="cn-stages-widget__bars"
46
+ :aria-label="ariaLabel"
47
+ data-testid="cn-stages-widget-bars">
48
+ <li
49
+ v-for="stage in barStages"
50
+ :key="stage.id"
51
+ class="cn-stages-widget__bar-step"
52
+ :class="`cn-stages-widget__bar-step--${stage.state}`"
53
+ :aria-current="stage.state === 'current' ? 'step' : null"
54
+ :data-testid="`cn-stages-widget-bar-${stage.id}`">
55
+ <span class="cn-stages-widget__bar" aria-hidden="true" />
56
+ <component
57
+ :is="stage.clickable ? 'button' : 'span'"
58
+ :type="stage.clickable ? 'button' : null"
59
+ class="cn-stages-widget__bar-label"
60
+ :title="stage.hint || stage.label"
61
+ :aria-disabled="stage.disabled && stage.clickable ? 'true' : null"
62
+ :data-testid="`cn-stages-widget-stage-${stage.id}`"
63
+ @click="onBarClick(stage)">
64
+ {{ stage.label }}<span v-if="stage.state === 'current'" class="cn-stages-widget__sr-only"> ({{ tr('current step') }})</span>
65
+ </component>
66
+ <span v-if="stage.date" class="cn-stages-widget__bar-date">{{ stage.date }}</span>
67
+ <span
68
+ v-if="stage.reason"
69
+ :class="stage.reasonVisible ? 'cn-stages-widget__reason' : 'cn-stages-widget__sr-only'"
70
+ :data-testid="`cn-stages-widget-reason-${stage.id}`">
71
+ {{ stage.reason }}
72
+ </span>
73
+ </li>
74
+ </ol>
38
75
  <CnTimelineStages
76
+ v-else
39
77
  :stages="timelineStages"
40
78
  :currentStage="currentStageId"
41
79
  :orientation="orientation"
@@ -310,13 +348,15 @@ export default {
310
348
  * `currentField`, `stagesEndpoint` or `stagesSource`, `transition`
311
349
  * (`{ kind: 'lifecycle' }` by default, or `{ kind: 'field' }`, or
312
350
  * absent for a read-only strip), `unreachableReason`, `orientation`,
313
- * `size`, `ariaLabel` and `label`.
351
+ * `size`, `ariaLabel`, `label` and `variant` (`"bars"` draws a thin
352
+ * coloured bar per step with the label under it and no description;
353
+ * the source's `dateField` adds a date line).
314
354
  *
315
355
  * `ariaLabel` names the strip for a screen reader. It falls back to
316
356
  * `label`, the placement's own title, so a titled card does not have to
317
357
  * repeat itself, and then to "Stages".
318
358
  *
319
- * @type {{currentField?: string, stagesEndpoint?: object, stagesSource?: object, transition?: object, unreachableReason?: string, orientation?: ('horizontal'|'vertical'), size?: ('medium'|'small'), ariaLabel?: string, label?: string}}
359
+ * @type {{currentField?: string, stagesEndpoint?: object, stagesSource?: object, transition?: object, unreachableReason?: string, orientation?: ('horizontal'|'vertical'), size?: ('medium'|'small'), ariaLabel?: string, label?: string, variant?: ('dots'|'bars')}}
320
360
  */
321
361
  content: {
322
362
  type: Object,
@@ -723,6 +763,45 @@ export default {
723
763
  return label ? this.effectiveTranslate(label) : this.tr('Stages')
724
764
  },
725
765
 
766
+ /**
767
+ * Whether the bars variant is on (`content.variant: "bars"`). Off by
768
+ * default: the dot strip renders as it always has.
769
+ *
770
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
771
+ * @return {boolean}
772
+ */
773
+ isBars() {
774
+ return this.content.variant === 'bars'
775
+ },
776
+
777
+ /**
778
+ * The stages for the bars variant: each with its `state` (`done`
779
+ * before the current stage, `current`, `todo` after it, every stage
780
+ * `todo` when none is current), its `date`, and whether it is a
781
+ * button (the strip is interactive and the stage is not the current
782
+ * one).
783
+ *
784
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
785
+ * @return {Array<object>}
786
+ */
787
+ barStages() {
788
+ const current = this.stages.findIndex((s) => s.id === this.currentStageId)
789
+ return this.timelineStages.map((stage, index) => {
790
+ let state = 'todo'
791
+ if (current >= 0 && index < current) {
792
+ state = 'done'
793
+ } else if (index === current) {
794
+ state = 'current'
795
+ }
796
+ return {
797
+ ...stage,
798
+ date: this.stages[index]?.date || '',
799
+ state,
800
+ clickable: this.interactive && state !== 'current',
801
+ }
802
+ })
803
+ },
804
+
726
805
  /** @return {'horizontal'|'vertical'} The layout direction. */
727
806
  orientation() {
728
807
  return this.content.orientation === 'vertical' ? 'vertical' : 'horizontal'
@@ -969,6 +1048,26 @@ export default {
969
1048
  * @param {{stage: {id: string}}} payload The CnTimelineStages event.
970
1049
  * @return {void}
971
1050
  */
1051
+ /**
1052
+ * A click on a bar-variant step: a disabled step reports why (as
1053
+ * CnTimelineStages does through `stage-blocked`), any other goes
1054
+ * through the shared move path.
1055
+ *
1056
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
1057
+ * @param {object} stage The bar step.
1058
+ * @return {void}
1059
+ */
1060
+ onBarClick(stage) {
1061
+ if (!stage.clickable) {
1062
+ return
1063
+ }
1064
+ if (stage.disabled) {
1065
+ this.onStageBlocked({ stage })
1066
+ return
1067
+ }
1068
+ this.onStageClick({ stage })
1069
+ },
1070
+
972
1071
  onStageClick({ stage }) {
973
1072
  if (!this.canMove || !stage) {
974
1073
  return
@@ -1340,6 +1439,94 @@ export default {
1340
1439
  color: var(--color-text-maxcontrast);
1341
1440
  }
1342
1441
 
1442
+ /* The bars variant: equal columns, a thin bar per step. Theme hooks:
1443
+ --cn-stages-bar-height, --cn-stages-bar-radius, --cn-stages-bar-done,
1444
+ --cn-stages-bar-current, --cn-stages-bar-todo. */
1445
+ .cn-stages-widget__bars {
1446
+ display: grid;
1447
+ grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
1448
+ grid-auto-flow: column;
1449
+ gap: 8px;
1450
+ margin: 0;
1451
+ padding: 0;
1452
+ list-style: none;
1453
+ }
1454
+
1455
+ .cn-stages-widget__bar-step {
1456
+ display: flex;
1457
+ flex-direction: column;
1458
+ gap: 8px;
1459
+ min-width: 0;
1460
+ }
1461
+
1462
+ .cn-stages-widget__bar {
1463
+ display: block;
1464
+ height: var(--cn-stages-bar-height, 6px);
1465
+ border-radius: var(--cn-stages-bar-radius, 3px);
1466
+ background: var(--cn-stages-bar-todo, var(--color-border));
1467
+ }
1468
+
1469
+ .cn-stages-widget__bar-step--done .cn-stages-widget__bar {
1470
+ background: var(--cn-stages-bar-done, var(--color-primary-element));
1471
+ }
1472
+
1473
+ .cn-stages-widget__bar-step--current .cn-stages-widget__bar {
1474
+ background: var(--cn-stages-bar-current, var(--color-error));
1475
+ }
1476
+
1477
+ .cn-stages-widget__bar-label {
1478
+ display: block;
1479
+ margin: 0;
1480
+ padding: 0;
1481
+ border: 0;
1482
+ background: none;
1483
+ color: var(--color-text-maxcontrast);
1484
+ font: inherit;
1485
+ font-size: 14px;
1486
+ text-align: start;
1487
+ overflow: hidden;
1488
+ text-overflow: ellipsis;
1489
+ white-space: nowrap;
1490
+ }
1491
+
1492
+ /* A clickable label is a <button>, and Nextcloud's core stylesheet gives
1493
+ every `button:not(.button-vue)` a min-height of the clickable area (34 to
1494
+ 44px) with the text centred in it. That pushed each reachable stage's label
1495
+ about 12px below the current one, which is a <span>. Zeroed here so all
1496
+ labels sit 8px under their bar, as the board draws them. The visually
1497
+ hidden "(current step)" is absolutely positioned and takes no space. */
1498
+ button.cn-stages-widget__bar-label {
1499
+ min-height: 0;
1500
+ height: auto;
1501
+ line-height: inherit;
1502
+ cursor: pointer;
1503
+ }
1504
+
1505
+ button.cn-stages-widget__bar-label[aria-disabled="true"] {
1506
+ cursor: not-allowed;
1507
+ }
1508
+
1509
+ button.cn-stages-widget__bar-label:focus-visible {
1510
+ outline: 2px solid var(--color-primary-element);
1511
+ outline-offset: 2px;
1512
+ border-radius: var(--border-radius-small, 4px);
1513
+ }
1514
+
1515
+ .cn-stages-widget__bar-step--done .cn-stages-widget__bar-label {
1516
+ color: var(--color-main-text);
1517
+ font-weight: 600;
1518
+ }
1519
+
1520
+ .cn-stages-widget__bar-step--current .cn-stages-widget__bar-label {
1521
+ color: var(--color-main-text);
1522
+ font-weight: 700;
1523
+ }
1524
+
1525
+ .cn-stages-widget__bar-date {
1526
+ font-size: 12px;
1527
+ color: var(--color-text-maxcontrast);
1528
+ }
1529
+
1343
1530
  /* Text for screen readers only. */
1344
1531
  .cn-stages-widget__sr-only {
1345
1532
  position: absolute;
@@ -85,8 +85,9 @@ export function isTrue(value) {
85
85
  * the rows keep the order they arrived in.
86
86
  *
87
87
  * @param {Array<object>} rows The stage rows.
88
- * @param {{idField?: string, labelField?: string, descriptionField?: string, orderField?: string, finalField?: string}} [cfg] The field mapping.
89
- * @return {Array<{id: string, label: string, subtitle: string, final: boolean}>} The stages.
88
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
89
+ * @param {{idField?: string, labelField?: string, descriptionField?: string, dateField?: string, orderField?: string, finalField?: string}} [cfg] The field mapping.
90
+ * @return {Array<{id: string, label: string, subtitle: string, date: string, final: boolean}>} The stages.
90
91
  */
91
92
  export function normalizeStages(rows, cfg = {}) {
92
93
  const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r === 'object') : []
@@ -108,6 +109,8 @@ export function normalizeStages(rows, cfg = {}) {
108
109
  id: rowId(row, cfg.idField),
109
110
  label,
110
111
  subtitle: cfg.descriptionField ? labelText(getByPath(row, cfg.descriptionField)) : '',
112
+ // An optional date line (`dateField`), drawn by the bars variant.
113
+ date: cfg.dateField ? labelText(getByPath(row, cfg.dateField)) : '',
111
114
  final: cfg.finalField ? isTrue(getByPath(row, cfg.finalField)) : false,
112
115
  }
113
116
  })