@conduction/nextcloud-vue 2.63.0 → 2.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +154 -7
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +119 -42
  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/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  17. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  18. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -0
  20. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +150 -133
  22. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  24. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  26. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +66 -14
  28. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +23 -14
  30. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  31. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +81 -4
  32. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
  34. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  40. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  42. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  43. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  44. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  46. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  48. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  49. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +52 -2
  50. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  51. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -6
  52. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  53. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  54. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  56. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  58. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  59. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  60. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  61. package/dist/esm/l10n/en.json.js +2 -1
  62. package/dist/esm/l10n/en.json.js.map +1 -1
  63. package/dist/esm/l10n/nl.json.js +2 -1
  64. package/dist/esm/l10n/nl.json.js.map +1 -1
  65. package/dist/esm/nextcloud-vue.css +331 -81
  66. package/dist/esm/utils/schemaRefSlug.js +19 -3
  67. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  68. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  69. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  70. package/dist/nextcloud-vue.cjs.js +6933 -5958
  71. package/dist/nextcloud-vue.cjs.js.map +2 -2
  72. package/dist/nextcloud-vue.css +331 -81
  73. package/l10n/en.json +2 -0
  74. package/l10n/nl.json +2 -0
  75. package/package.json +1 -1
  76. package/src/components/CnAppNav/CnAppNav.vue +297 -11
  77. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  78. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  79. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  80. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  81. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  82. package/src/components/CnDashboardPage/CnDashboardPage.vue +43 -2
  83. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  84. package/src/components/CnDetailPage/CnDetailPage.md +4 -0
  85. package/src/components/CnDetailPage/CnDetailPage.vue +60 -1
  86. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +116 -6
  87. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  88. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  89. package/src/components/CnStagesWidget/CnStagesWidget.vue +180 -2
  90. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  91. package/src/components/CnStatWidget/CnStatWidget.vue +57 -3
  92. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  93. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  94. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  95. package/src/css/detail-page.css +10 -4
  96. package/src/css/kpi-card.css +24 -1
  97. package/src/css/table.css +18 -6
  98. package/src/schemas/app-manifest-v2.schema.json +113 -3
  99. package/src/utils/schemaRefSlug.js +19 -3
  100. 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--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,12 @@ 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, 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.
121
137
  *
122
138
  * @type {object}
123
139
  */
@@ -198,6 +214,55 @@ export default {
198
214
  return this.now instanceof Date ? this.now : new Date()
199
215
  },
200
216
 
217
+ /**
218
+ * The view switch's options from `content.views.options`: each
219
+ * `{ label, route, params? }` with a non-empty label and route becomes
220
+ * a segmented-control option keyed on its route name (the label goes
221
+ * through the host translate function). Empty without a router, so
222
+ * the switch cannot offer routes it cannot follow.
223
+ *
224
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
225
+ * @return {Array<{value: string, label: string}>}
226
+ */
227
+ viewOptions() {
228
+ const raw = this.content && this.content.views && this.content.views.options
229
+ if (!Array.isArray(raw) || !this.$router) {
230
+ return []
231
+ }
232
+ return raw
233
+ .filter((o) => o && typeof o.label === 'string' && o.label !== '' && typeof o.route === 'string' && o.route !== '')
234
+ .map((o) => ({ value: o.route, label: this.cnTranslate(o.label) }))
235
+ },
236
+
237
+ /**
238
+ * The checked view: the option whose route is the current route, else
239
+ * the first option (a switch always has one checked option).
240
+ *
241
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
242
+ * @return {string|null}
243
+ */
244
+ activeView() {
245
+ const options = this.viewOptions
246
+ if (options.length === 0) {
247
+ return null
248
+ }
249
+ const current = this.$route && this.$route.name
250
+ const match = options.find((o) => o.value === current)
251
+ return match ? match.value : options[0].value
252
+ },
253
+
254
+ /**
255
+ * Accessible name of the view switch: `content.views.ariaLabel`
256
+ * through the host translate function, else "View".
257
+ *
258
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
259
+ * @return {string}
260
+ */
261
+ viewsLabel() {
262
+ const label = this.content && this.content.views && this.content.views.ariaLabel
263
+ return label ? this.cnTranslate(label) : t('nextcloud-vue', 'View')
264
+ },
265
+
201
266
  /**
202
267
  * Whether the plain presentation is on (no coloured background).
203
268
  *
@@ -490,7 +555,9 @@ export default {
490
555
  return {
491
556
  position: 'relative',
492
557
  'z-index': 1,
493
- width: '100%',
558
+ // Beside a view switch the content shares the row instead of
559
+ // taking the whole width (see .cn-header-widget--with-views).
560
+ width: this.viewOptions.length > 0 ? 'auto' : '100%',
494
561
  height: '100%',
495
562
  display: 'flex',
496
563
  'flex-direction': 'column',
@@ -604,6 +671,26 @@ export default {
604
671
  },
605
672
 
606
673
  methods: {
674
+ /**
675
+ * Follow the chosen view: push its route (with the option's `params`
676
+ * when declared). Nothing happens for the route already shown.
677
+ *
678
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-greeting-header-switches-views
679
+ * @param {string} route The chosen option's route name.
680
+ * @return {void}
681
+ */
682
+ onViewChange(route) {
683
+ if (!this.$router || !route || route === (this.$route && this.$route.name)) {
684
+ return
685
+ }
686
+ const declared = (this.content.views.options || []).find((o) => o && o.route === route) || {}
687
+ const target = { name: route }
688
+ if (declared.params && typeof declared.params === 'object') {
689
+ target.params = declared.params
690
+ }
691
+ this.$router.push(target)
692
+ },
693
+
607
694
  /**
608
695
  * Mark the background image as broken so the renderer falls back to
609
696
  * the solid colour.
@@ -655,6 +742,29 @@ export default {
655
742
  border-radius: 0;
656
743
  }
657
744
 
745
+ /* With a view switch the heading and the switch share one row, the switch
746
+ at the right and bottom-aligned with the heading; on a narrow card the
747
+ switch wraps under it. */
748
+ .cn-header-widget--with-views {
749
+ display: flex;
750
+ flex-wrap: wrap;
751
+ align-items: flex-end;
752
+ justify-content: space-between;
753
+ gap: calc(2 * var(--default-grid-baseline)) calc(4 * var(--default-grid-baseline));
754
+ }
755
+
756
+ .cn-header-widget--with-views .cn-header-widget__content {
757
+ flex: 1 1 320px;
758
+ width: auto;
759
+ }
760
+
761
+ .cn-header-widget__views {
762
+ position: relative;
763
+ z-index: 1;
764
+ flex: 0 0 auto;
765
+ padding: 16px;
766
+ }
767
+
658
768
  .cn-header-widget__date {
659
769
  font-size: 14px;
660
770
  line-height: 1.4;
@@ -726,7 +836,7 @@ export default {
726
836
  height: 1px;
727
837
  opacity: 0;
728
838
  pointer-events: none;
729
- left: -9999px;
839
+ inset-inline-start: -9999px;
730
840
  top: -9999px;
731
841
  }
732
842
 
@@ -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,
@@ -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,85 @@ 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
+ button.cn-stages-widget__bar-label {
1493
+ cursor: pointer;
1494
+ }
1495
+
1496
+ button.cn-stages-widget__bar-label[aria-disabled="true"] {
1497
+ cursor: not-allowed;
1498
+ }
1499
+
1500
+ button.cn-stages-widget__bar-label:focus-visible {
1501
+ outline: 2px solid var(--color-primary-element);
1502
+ outline-offset: 2px;
1503
+ border-radius: var(--border-radius-small, 4px);
1504
+ }
1505
+
1506
+ .cn-stages-widget__bar-step--done .cn-stages-widget__bar-label {
1507
+ color: var(--color-main-text);
1508
+ font-weight: 600;
1509
+ }
1510
+
1511
+ .cn-stages-widget__bar-step--current .cn-stages-widget__bar-label {
1512
+ color: var(--color-main-text);
1513
+ font-weight: 700;
1514
+ }
1515
+
1516
+ .cn-stages-widget__bar-date {
1517
+ font-size: 12px;
1518
+ color: var(--color-text-maxcontrast);
1519
+ }
1520
+
1343
1521
  /* Text for screen readers only. */
1344
1522
  .cn-stages-widget__sr-only {
1345
1523
  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
  })
@@ -94,7 +94,11 @@
94
94
  {{ formattedTrend }}
95
95
  </span>
96
96
  </template>
97
- <span v-if="!displayLoading && !displayError && content.caption" class="cn-kpi-card__label cn-stat-widget__caption">
97
+ <span
98
+ v-if="!displayLoading && !displayError && resolvedCaption"
99
+ class="cn-kpi-card__label cn-stat-widget__caption"
100
+ :class="captionVariantClass"
101
+ data-testid="cn-stat-widget-caption">
98
102
  {{ resolvedCaption }}
99
103
  </span>
100
104
  </div>
@@ -461,7 +465,7 @@ export default {
461
465
  * counted between LOCAL CALENDAR DAYS, not as elapsed milliseconds, so
462
466
  * the time of day on a deadline never moves the answer.
463
467
  *
464
- * @type {{label?: string, icon?: string, iconColor?: string, valueColor?: string, caption?: string, route?: (object|string), clickRoute?: (object|string), link?: string, format?: {style?: string, currency?: string, decimals?: number, prefix?: string, suffix?: string}, source?: {kind?: string, register?: string, schema?: string, metric?: string, field?: string, filter?: object, url?: string, path?: string, params?: object}, endpointSource?: {url: string, method?: string, params?: object, responsePath?: string}, valueField?: string, limitField?: string, limit?: number, dateRange?: {presets?: Array<{id: string, label?: string, from?: string, to?: string}>, default?: string}, previousField?: string, deltaField?: string, goodDirection?: ('up'|'down'), variant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), variantWhen?: Array<{op: string, value: unknown, variant: string, icon?: string}>, objectField?: (string|{field: string, resolve?: {register: string, schema: string, labelField?: string, variantField?: string, variantMap?: {[key: string]: string}}}), display?: ('text'|'badge'|'countdown'), countdown?: {unit?: 'days', warnAt?: number, dangerAt?: number, futureLabel?: string, todayLabel?: string, pastLabel?: string, emptyText?: string}, emptyText?: string, overrides?: Array<{when: {field: string, op?: string, value?: unknown}, label?: string, variant?: string, icon?: string}>}}
468
+ * @type {{label?: string, icon?: string, iconColor?: string, valueColor?: string, caption?: string, route?: (object|string), clickRoute?: (object|string), link?: string, format?: {style?: string, currency?: string, decimals?: number, prefix?: string, suffix?: string}, source?: {kind?: string, register?: string, schema?: string, metric?: string, field?: string, filter?: object, url?: string, path?: string, params?: object}, endpointSource?: {url: string, method?: string, params?: object, responsePath?: string}, valueField?: string, limitField?: string, limit?: number, dateRange?: {presets?: Array<{id: string, label?: string, from?: string, to?: string}>, default?: string}, previousField?: string, deltaField?: string, goodDirection?: ('up'|'down'), variant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), variantWhen?: Array<{op: string, value: unknown, variant: string, icon?: string}>, objectField?: (string|{field: string, resolve?: {register: string, schema: string, labelField?: string, variantField?: string, variantMap?: {[key: string]: string}}}), display?: ('text'|'badge'|'countdown'), countdown?: {unit?: 'days', warnAt?: number, dangerAt?: number, futureLabel?: string, todayLabel?: string, pastLabel?: string, emptyText?: string}, emptyText?: string, captionVariant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), captionVariantWhen?: Array<{op: string, value: unknown, variant: string}>, overrides?: Array<{when: {field: string, op?: string, value?: unknown}, label?: string, variant?: string, icon?: string, caption?: string, captionVariant?: string}>}}
465
469
  */
466
470
  content: {
467
471
  type: Object,
@@ -605,7 +609,10 @@ export default {
605
609
  * @return {string}
606
610
  */
607
611
  resolvedCaption() {
608
- const caption = this.content.caption
612
+ const override = this.activeOverride
613
+ const caption = (override && typeof override.caption === 'string' && override.caption !== '')
614
+ ? override.caption
615
+ : this.content.caption
609
616
  if (!caption) {
610
617
  return ''
611
618
  }
@@ -615,11 +622,58 @@ export default {
615
622
  }
616
623
  const payload = this.endpointMode ? this.epData : null
617
624
  return translated.replace(/\{([A-Za-z0-9_.]+)\}/g, (whole, path) => {
625
+ // `{value}` is the tile's own number, so a caption can read
626
+ // "{value} due today" on a register-counted tile that has no
627
+ // payload to read fields from.
628
+ if (path === 'value' && (payload === null || getByPath(payload, path) === undefined)) {
629
+ return this.formattedValue === undefined || this.formattedValue === null ? '' : String(this.formattedValue)
630
+ }
618
631
  const v = getByPath(payload, path)
619
632
  return (v === undefined || v === null) ? '' : String(v)
620
633
  }).replace(/\s{2,}/g, ' ').trim()
621
634
  },
622
635
 
636
+ /**
637
+ * The first `content.captionVariantWhen` rule the tile's value
638
+ * matches (`[{ op, value, variant }]`, the `variantWhen` shape), or
639
+ * null. Colours the caption on the value without recolouring the
640
+ * tile: "1 due today" turns red while the number stays ink.
641
+ *
642
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule
643
+ * @return {object|null}
644
+ */
645
+ activeCaptionRule() {
646
+ const rules = this.content.captionVariantWhen
647
+ if (!Array.isArray(rules) || rules.length === 0) {
648
+ return null
649
+ }
650
+ const current = this.displayValue
651
+ if (current === null || current === undefined) {
652
+ return null
653
+ }
654
+ return rules.find((r) => r && this.matchesRule(current, r)) || null
655
+ },
656
+
657
+ /**
658
+ * The caption's colour class: the matching override's
659
+ * `captionVariant`, else `content.captionVariant`, as
660
+ * `cn-kpi-card__label--<variant>`. '' for `default` or an unknown
661
+ * variant, so an uncoloured caption is what it always was.
662
+ *
663
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule
664
+ * @return {string}
665
+ */
666
+ captionVariantClass() {
667
+ const override = this.activeOverride
668
+ const variant = (override && override.captionVariant)
669
+ || (this.activeCaptionRule && this.activeCaptionRule.variant)
670
+ || this.content.captionVariant
671
+ if (!variant || variant === 'default' || !Object.hasOwn(VARIANT_COLORS, variant)) {
672
+ return ''
673
+ }
674
+ return `cn-kpi-card__label--${variant === 'danger' ? 'error' : variant}`
675
+ },
676
+
623
677
  /**
624
678
  * The unwrapped detail-page object context for token resolution, or null
625
679
  * on surfaces (dashboards) that don't provide one.
@@ -8,6 +8,7 @@
8
8
  <CnTabs
9
9
  :aria-label="stripLabel"
10
10
  :moreLabel="moreLabel"
11
+ :variant="stripVariant"
11
12
  class="cn-tabs-widget__tabs"
12
13
  @update:activeIndex="onTabChange">
13
14
  <!-- One Actions menu for the whole widget, bound to whichever child
@@ -246,9 +247,10 @@ export default {
246
247
  *
247
248
  * `maxVisibleTabs` caps the strip: later tabs go under "More".
248
249
  * `hideEmpty: true` moves a tab whose count is 0 there too.
249
- * `moreLabel` names that menu.
250
+ * `moreLabel` names that menu. `variant: "segmented"` draws the strip
251
+ * as a pill switch (CnTabs' segmented variant); `line` is the default.
250
252
  *
251
- * @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string }}
253
+ * @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}
252
254
  */
253
255
  content: {
254
256
  type: Object,
@@ -464,6 +466,17 @@ export default {
464
466
  return this.activeTab?.label || ''
465
467
  },
466
468
 
469
+ /**
470
+ * The strip's look: `segmented` when the content asks for it, else
471
+ * CnTabs' default `line`. An unknown value falls back to `line`.
472
+ *
473
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-tabs-widget-renders-a-segmented-strip
474
+ * @return {('line'|'segmented')} The CnTabs variant.
475
+ */
476
+ stripVariant() {
477
+ return this.content?.variant === 'segmented' ? 'segmented' : 'line'
478
+ },
479
+
467
480
  /**
468
481
  * Accessible name for the tab strip.
469
482
  *
@@ -163,3 +163,7 @@ Request a feature / Report a bug / Documentation render on every widget; the sha
163
163
  ## High contrast
164
164
 
165
165
  `high-contrast` declares that this widget wants a high contrast treatment. The flag reaches the theme; the component picks no colour. See [the reference page](../../../docs/components/cn-widget-wrapper.md#high-contrast).
166
+
167
+ ### A text link in the header (`headerLink`)
168
+
169
+ `headerLink` (`{ label, route?, params?, query?, href? }`) draws one text link in the header before the overflow menu, such as "All deadlines".