@conduction/nextcloud-vue 2.93.0 → 2.94.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 (105) hide show
  1. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +1 -1
  2. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js +2 -1
  4. package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  6. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +28 -8
  7. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  8. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +37 -2
  9. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  10. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
  11. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +139 -3
  12. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js +60 -12
  14. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +91 -36
  17. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  18. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +115 -72
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  20. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +32 -3
  21. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  22. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +7 -7
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +141 -7
  25. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  26. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -2
  27. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js +8 -0
  29. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js.map +1 -0
  30. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js +114 -0
  31. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js.map +1 -0
  32. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js +45 -0
  33. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js.map +1 -0
  34. package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js +38 -2
  35. package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js.map +1 -1
  36. package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js +12 -2
  37. package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js.map +1 -1
  38. package/dist/esm/components/CnPagination/CnPagination.vue2.js.map +1 -1
  39. package/dist/esm/components/CnPagination/CnPagination.vue3.js +56 -51
  40. package/dist/esm/components/CnPagination/CnPagination.vue3.js.map +1 -1
  41. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  42. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +3 -3
  43. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  45. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  46. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +24 -0
  47. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  48. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +3 -1
  49. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  50. package/dist/esm/components/CnTabs/CnTabs.vue.js +1 -1
  51. package/dist/esm/components/CnTabs/CnTabs.vue2.js +39 -2
  52. package/dist/esm/components/CnTabs/CnTabs.vue2.js.map +1 -1
  53. package/dist/esm/components/CnTabs/CnTabs.vue3.js.map +1 -1
  54. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  55. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +85 -8
  56. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  57. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +37 -9
  58. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  59. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue.js +1 -1
  60. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js.map +1 -1
  61. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js +4 -3
  62. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js.map +1 -1
  63. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  64. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +20 -0
  65. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  66. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +31 -20
  67. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  68. package/dist/esm/composables/indexSources.js +9 -0
  69. package/dist/esm/composables/indexSources.js.map +1 -1
  70. package/dist/esm/library-style.js +1 -1
  71. package/dist/esm/nextcloud-vue.css +402 -262
  72. package/dist/esm/utils/dueText.js +49 -8
  73. package/dist/esm/utils/dueText.js.map +1 -1
  74. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  75. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  76. package/dist/nextcloud-vue.cjs.js +5779 -4827
  77. package/dist/nextcloud-vue.cjs.js.map +2 -2
  78. package/dist/nextcloud-vue.css +402 -262
  79. package/package.json +1 -1
  80. package/src/components/CnActionsMenu/CnActionsMenu.vue +3 -1
  81. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +39 -1
  82. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +222 -6
  83. package/src/components/CnDashboardPage/CnDashboardPage.vue +96 -2
  84. package/src/components/CnDataTable/CnDataTable.md +1 -0
  85. package/src/components/CnDataTable/CnDataTable.vue +39 -10
  86. package/src/components/CnIndexPage/CnIndexPage.vue +144 -7
  87. package/src/components/CnPageHeader/CnHeaderTrail.vue +140 -0
  88. package/src/components/CnPageHeader/CnPageHeader.md +11 -0
  89. package/src/components/CnPageHeader/CnPageHeader.vue +42 -0
  90. package/src/components/CnPagination/CnPagination.vue +10 -2
  91. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +3 -3
  92. package/src/components/CnSettingsPage/CnSettingsPage.vue +26 -0
  93. package/src/components/CnTabs/CnTabs.vue +39 -2
  94. package/src/components/CnTabsWidget/CnTabsWidget.vue +106 -13
  95. package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +4 -3
  96. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +8 -0
  97. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +31 -2
  98. package/src/composables/indexSources.js +9 -0
  99. package/src/css/dialog.css +14 -0
  100. package/src/css/look-board-index.css +27 -1
  101. package/src/css/look-board.css +27 -0
  102. package/src/css/page-header.css +16 -0
  103. package/src/schemas/app-manifest-v2.schema.json +22 -15
  104. package/src/utils/dueText.js +49 -8
  105. package/src/utils/validateManifestV2.compiled.js +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.93.0",
3
+ "version": "2.94.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -32,6 +32,8 @@
32
32
  <template #icon>
33
33
  <DotsHorizontal :size="20" />
34
34
  </template>
35
+ <!-- @slot lead-items Items rendered at the very top of the menu, above Refresh: the tabs a board strip lists here instead of drawing them (CnTabsWidget). The caller owns any separator after them. -->
36
+ <slot name="lead-items" />
35
37
  <!-- Stays open while refreshing; onRefreshClick closes the menu once the refresh settles. -->
36
38
  <NcActionButton
37
39
  v-if="showRefresh"
@@ -569,7 +571,7 @@ export default {
569
571
  if (this.showRequestFeature) {
570
572
  return true
571
573
  }
572
- if (this.hasPrimaryItems || this.$slots['primary-items']) {
574
+ if (this.hasPrimaryItems || this.$slots['primary-items'] || this.$slots['lead-items']) {
573
575
  return true
574
576
  }
575
577
  return Boolean(this.$slots['action-items']) || Boolean(this.$slots && this.$slots['action-items'])
@@ -139,7 +139,7 @@
139
139
  <template #footer>
140
140
  <!-- @slot footer Replaces the default support/SLA footer inside the version card. -->
141
141
  <slot name="footer">
142
- <div v-if="showSupport" class="cn-support-info">
142
+ <div v-if="showSupport && !supportAtEnd" class="cn-support-info">
143
143
  <h4>{{ supportTitle }}</h4>
144
144
  <p>
145
145
  {{ supportLine }}
@@ -185,6 +185,24 @@
185
185
  <!-- @slot default The app's own settings sections, rendered below the version card, or above it with `versionCardPosition: "end"`. With `tabs`, only while the first tab is open. -->
186
186
  <slot v-if="!versionCardAtEnd && onFirstTab" />
187
187
 
188
+ <!-- `supportPosition: "end"`: the support lines as their own wide card,
189
+ last on the page (board PqBeheer), not in the version card. -->
190
+ <CnSettingsSection
191
+ v-if="showSupport && supportAtEnd && onFirstTab"
192
+ class="cn-admin-settings-shell__support"
193
+ :name="supportTitle"
194
+ :wide="true"
195
+ data-testid="cn-admin-settings-support">
196
+ <p class="cn-admin-settings-shell__support-line">
197
+ {{ supportLine }}
198
+ <a :href="`mailto:${supportEmail}`">{{ supportEmail }}</a>
199
+ </p>
200
+ <p v-if="slaEmail" class="cn-admin-settings-shell__support-line">
201
+ {{ slaLine }}
202
+ <a :href="`mailto:${slaEmail}`">{{ slaEmail }}</a>
203
+ </p>
204
+ </CnSettingsSection>
205
+
188
206
  <!-- First-time setup wizard, opened from the admin page (ADR-042). -->
189
207
  <CnSetupWizard
190
208
  v-if="showSetup && setupWizardOpen"
@@ -216,6 +234,7 @@ import { normalizeLook, useLook } from '../../composables/useLook.js'
216
234
  import { ensureTranslations } from '../../l10n/index.js'
217
235
  import { buildFeatureRequestUrl } from '../../utils/forge.js'
218
236
  import { prefixUrl } from '../../utils/headers.js'
237
+ import { CnSettingsSection } from '../CnSettingsSection/index.js'
219
238
  import { CnTab, CnTabs } from '../CnTabs/index.js'
220
239
  import { CnVersionInfoCard } from '../CnVersionInfoCard/index.js'
221
240
 
@@ -327,6 +346,7 @@ export default {
327
346
  BookOpenVariant,
328
347
  NcSettingsSection,
329
348
  CnCredentials,
349
+ CnSettingsSection,
330
350
  CnVersionInfoCard,
331
351
  CnSetupWizard,
332
352
  CnTab,
@@ -616,6 +636,19 @@ export default {
616
636
  default: true,
617
637
  },
618
638
 
639
+ /**
640
+ * Where the support lines go: `card`, in the version card's footer (the
641
+ * default), or `end`, as their own wide card after everything else on
642
+ * the first tab (board PqBeheer).
643
+ *
644
+ * @spec openspec/changes/admin-shell-support-end/specs/settings-board-look/spec.md#requirement-support-can-be-its-own-card-at-the-end
645
+ */
646
+ supportPosition: {
647
+ type: String,
648
+ default: 'card',
649
+ validator: (value) => ['card', 'end'].includes(value),
650
+ },
651
+
619
652
  /** Support contact email. */
620
653
  supportEmail: {
621
654
  type: String,
@@ -723,6 +756,11 @@ export default {
723
756
  return this.versionCardPosition === 'end'
724
757
  },
725
758
 
759
+ /** @return {boolean} Whether the support lines are their own card at the end. */
760
+ supportAtEnd() {
761
+ return this.supportPosition === 'end'
762
+ },
763
+
726
764
  /**
727
765
  * The crumbs that have a label.
728
766
  *
@@ -4,8 +4,10 @@
4
4
  -->
5
5
 
6
6
  <template>
7
- <div class="cn-kpi-card cn-countdown-widget" :class="`cn-countdown-widget--${variant}`">
8
- <div class="cn-kpi-card__icon cn-countdown-widget__icon" :style="iconCircleStyle">
7
+ <div
8
+ class="cn-kpi-card cn-countdown-widget"
9
+ :class="[`cn-countdown-widget--${variant}`, { 'cn-kpi-card--board cn-countdown-widget--board': isBoardLook }]">
10
+ <div v-if="!isBoardLook" class="cn-kpi-card__icon cn-countdown-widget__icon" :style="iconCircleStyle">
9
11
  <CnWidgetIcon :name="resolvedIcon" :size="24" />
10
12
  </div>
11
13
 
@@ -16,9 +18,35 @@
16
18
  <div class="cn-kpi-card__value cn-countdown-widget__value" :style="{ color: valueColor }">
17
19
  {{ headline }}
18
20
  </div>
19
- <div v-if="subLabel" class="cn-countdown-widget__sub">
21
+ <div v-if="subLabel" class="cn-countdown-widget__sub" data-testid="cn-countdown-sub">
20
22
  {{ subLabel }}
21
23
  </div>
24
+ <dl v-if="facts.length" class="cn-countdown-widget__facts" data-testid="cn-countdown-facts">
25
+ <template v-for="fact in facts" :key="fact.label">
26
+ <dt>{{ fact.label }}</dt>
27
+ <dd>{{ fact.value }}</dd>
28
+ </template>
29
+ </dl>
30
+ <span
31
+ v-if="progress !== null"
32
+ class="cn-countdown-widget__progress"
33
+ role="meter"
34
+ :aria-label="progressLabel"
35
+ :aria-valuenow="Math.round(progress)"
36
+ aria-valuemin="0"
37
+ aria-valuemax="100"
38
+ data-testid="cn-countdown-progress">
39
+ <span class="cn-countdown-widget__progress-fill" :style="{ width: `${progress}%` }" />
40
+ </span>
41
+ <button
42
+ v-if="actionLabel"
43
+ type="button"
44
+ class="cn-countdown-widget__action"
45
+ :class="{ 'cn-countdown-widget__action--primary': content.action && content.action.variant === 'primary' }"
46
+ data-testid="cn-countdown-action"
47
+ @click="runAction">
48
+ {{ actionLabel }}
49
+ </button>
22
50
  </div>
23
51
  </div>
24
52
  </template>
@@ -28,7 +56,10 @@ import { translate as t } from '@nextcloud/l10n'
28
56
  import { inject } from 'vue'
29
57
  import CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'
30
58
  import { getByPath } from '../../composables/useEndpointSource.js'
59
+ import { normalizeLook } from '../../composables/useLook.js'
60
+ import { dispatchAction } from '../../utils/actionsDispatcher.js'
31
61
  import { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'
62
+ import { resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
32
63
  import { userLocale } from '../../utils/userLocale.js'
33
64
 
34
65
  const MS_PER_DAY = 24 * 60 * 60 * 1000
@@ -106,8 +137,15 @@ export default {
106
137
  * - `showDate` — render the absolute date underneath (default true).
107
138
  * - `settledField` — property on the record that is truthy once the work is done; the tile then reads `settledLabel` and never counts overdue.
108
139
  * - `settledLabel` — the headline of a settled record, run through the host's translate function (default "Closed").
140
+ * - `daysLabel` — `{ one, other }` with `{count}`, the headline while days remain ("27 dagen").
141
+ * - `caption` — the line under the headline instead of the date, with `{date}` (the long date) and `{count}` (days left).
142
+ * - `startField` — property holding the start of the term; a bar then shows how much of it has passed.
143
+ * - `action` — one manifest action under the card (`{ label, type, variant?, ... }`, the headerActions shape; `variant: "primary"` draws it in the primary tint).
144
+ * - `emptyLabel` — the headline while the date is unset (default a dash).
145
+ * - `emptyCaption` — the line under it while the date is unset, with `{date}` read from `emptyDateField`.
146
+ * - `facts` — `[{ label, field }]` rows under the caption.
109
147
  *
110
- * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean, settledField?: string, settledLabel?: string }}
148
+ * @type {{ field?: string, label?: string, icon?: string, thresholds?: {warn?: number, danger?: number}, showDate?: boolean, settledField?: string, settledLabel?: string, daysLabel?: {one?: string, other?: string}, caption?: string, startField?: string, action?: object, emptyLabel?: string, emptyCaption?: string, emptyDateField?: string, facts?: Array<{label: string, field: string}> }}
111
149
  */
112
150
  content: {
113
151
  type: Object,
@@ -131,6 +169,8 @@ export default {
131
169
  cnObjectContextInjected: inject('cnObjectContext', null),
132
170
  cnDetailObjectContextInjected: inject('cnDetailObjectContext', null),
133
171
  cnTranslate: inject('cnTranslate', null),
172
+ cnLook: inject('cnLook', 'nextcloud'),
173
+ cnDispatchAction: inject('cnDispatchAction', null),
134
174
  }
135
175
  },
136
176
 
@@ -222,7 +262,7 @@ export default {
222
262
  }
223
263
  const days = this.daysRemaining
224
264
  if (days === null) {
225
- return '—'
265
+ return this.content?.emptyLabel ? this.translate(this.content.emptyLabel) : '—'
226
266
  }
227
267
  if (days < 0) {
228
268
  const overdue = Math.abs(days)
@@ -233,12 +273,89 @@ export default {
233
273
  if (days === 0) {
234
274
  return t('nextcloud-vue', 'Due today')
235
275
  }
276
+ const labels = this.content?.daysLabel
277
+ if (labels && typeof labels === 'object' && (labels.one || labels.other)) {
278
+ const template = this.translate((days === 1 ? labels.one : labels.other) || labels.other || labels.one)
279
+ return template.replace(/\{count\}/g, String(days))
280
+ }
236
281
  if (days === 1) {
237
282
  return t('nextcloud-vue', '1 day left')
238
283
  }
239
284
  return t('nextcloud-vue', '{count} days left', { count: days })
240
285
  },
241
286
 
287
+ /**
288
+ * Whether the app takes the board look: no icon circle.
289
+ *
290
+ * @return {boolean} True in the board look.
291
+ * @spec openspec/changes/countdown-board-look/specs/countdown-board-look/spec.md#requirement-the-countdown-draws-the-boards-term-card
292
+ */
293
+ isBoardLook() {
294
+ return normalizeLook(this.cnLook) === 'board'
295
+ },
296
+
297
+ /**
298
+ * How much of the term has passed, from `startField` to the date, in
299
+ * whole days and as a percentage (0 to 100), or null without a usable
300
+ * start.
301
+ *
302
+ * @return {number|null} The share passed.
303
+ * @spec openspec/changes/countdown-board-look/specs/countdown-board-look/spec.md#requirement-the-countdown-draws-the-boards-term-card
304
+ */
305
+ progress() {
306
+ const field = this.content?.startField
307
+ if (!field || !this.record || !this.targetDate || this.isSettled) {
308
+ return null
309
+ }
310
+ const raw = getByPath(this.record, field)
311
+ const start = raw === null || raw === undefined || raw === '' ? null : new Date(raw)
312
+ if (!start || Number.isNaN(start.getTime())) {
313
+ return null
314
+ }
315
+ const startOfDay = (d) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
316
+ const span = startOfDay(this.targetDate) - startOfDay(start)
317
+ if (span <= 0) {
318
+ return null
319
+ }
320
+ return Math.min(100, Math.max(0, ((startOfDay(new Date()) - startOfDay(start)) / span) * 100))
321
+ },
322
+
323
+ /**
324
+ * The `facts` rows under the caption (`[{ label, field }]`), with
325
+ * their values read off the record; a row without a value is left out.
326
+ *
327
+ * @return {Array<{label: string, value: string}>} The rows.
328
+ * @spec openspec/changes/countdown-board-look/specs/countdown-board-look/spec.md#requirement-the-countdown-draws-the-boards-term-card
329
+ */
330
+ facts() {
331
+ const rows = Array.isArray(this.content?.facts) ? this.content.facts : []
332
+ return rows
333
+ .filter((row) => row && typeof row.label === 'string' && typeof row.field === 'string')
334
+ .map((row) => {
335
+ const value = this.record ? getByPath(this.record, row.field) : null
336
+ return { label: this.translate(row.label), value: value === null || value === undefined ? '' : this.translate(String(value)) }
337
+ })
338
+ .filter((row) => row.value !== '')
339
+ },
340
+
341
+ /** @return {string} The accessible name of the progress bar. */
342
+ progressLabel() {
343
+ return t('nextcloud-vue', 'Time passed')
344
+ },
345
+
346
+ /**
347
+ * The label of `content.action`, translated, or ''.
348
+ *
349
+ * @return {string} The label.
350
+ * @spec openspec/changes/countdown-board-look/specs/countdown-board-look/spec.md#requirement-the-countdown-draws-the-boards-term-card
351
+ */
352
+ actionLabel() {
353
+ const action = this.content?.action
354
+ return action && typeof action === 'object' && typeof action.label === 'string' && action.label !== ''
355
+ ? this.translate(action.label)
356
+ : ''
357
+ },
358
+
242
359
  /**
243
360
  * The absolute date under the headline, so the tile answers "how long"
244
361
  * and "when" at once.
@@ -246,10 +363,21 @@ export default {
246
363
  * @return {string} The formatted date, or ''.
247
364
  */
248
365
  subLabel() {
366
+ if (!this.targetDate && typeof this.content?.emptyCaption === 'string' && this.content.emptyCaption !== '') {
367
+ const raw = this.content.emptyDateField && this.record ? getByPath(this.record, this.content.emptyDateField) : null
368
+ const when = raw ? new Date(raw) : null
369
+ const date = when && !Number.isNaN(when.getTime()) ? when.toLocaleDateString(userLocale(), { day: 'numeric', month: 'long', year: 'numeric' }) : ''
370
+ return this.translate(this.content.emptyCaption).replace(/\{date\}/g, date)
371
+ }
249
372
  if (!this.targetDate || this.content?.showDate === false) {
250
373
  return ''
251
374
  }
252
- return this.targetDate.toLocaleDateString(userLocale(), { day: 'numeric', month: 'long', year: 'numeric' })
375
+ const date = this.targetDate.toLocaleDateString(userLocale(), { day: 'numeric', month: 'long', year: 'numeric' })
376
+ const caption = this.content?.caption
377
+ if (typeof caption === 'string' && caption !== '') {
378
+ return this.translate(caption).replace(/\{date\}/g, date).replace(/\{count\}/g, String(this.daysRemaining ?? ''))
379
+ }
380
+ return date
253
381
  },
254
382
 
255
383
  /**
@@ -278,6 +406,9 @@ export default {
278
406
 
279
407
  /** @return {string} The value colour for the current band. */
280
408
  valueColor() {
409
+ if (this.isBoardLook && this.variant === 'default') {
410
+ return 'var(--color-main-text)'
411
+ }
281
412
  return VARIANT_COLORS[this.variant] || VARIANT_COLORS.default
282
413
  },
283
414
 
@@ -296,6 +427,39 @@ export default {
296
427
  return { color: this.valueColor }
297
428
  },
298
429
  },
430
+
431
+ methods: {
432
+ /**
433
+ * The host's translate, or the text unchanged.
434
+ *
435
+ * @param {string} text The text.
436
+ * @return {string} The translation.
437
+ */
438
+ translate(text) {
439
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
440
+ },
441
+
442
+ /**
443
+ * Dispatch `content.action` with the record's tokens resolved, through
444
+ * the page's dispatcher when there is one.
445
+ *
446
+ * @return {unknown}
447
+ * @spec openspec/changes/countdown-board-look/specs/countdown-board-look/spec.md#requirement-the-countdown-draws-the-boards-term-card
448
+ */
449
+ runAction() {
450
+ const action = this.content?.action
451
+ if (!action || typeof action !== 'object') {
452
+ return undefined
453
+ }
454
+ const ctx = resolveObjectTokenContext(this.cnObjectContextInjected, this.cnDetailObjectContextInjected) || {}
455
+ const tokenCtx = { ...ctx, object: this.record || ctx.object || null, objectId: this.record?.id ?? ctx.objectId ?? null }
456
+ const resolved = resolveDeepTokens(action, tokenCtx)
457
+ if (typeof this.cnDispatchAction === 'function') {
458
+ return this.cnDispatchAction(resolved)
459
+ }
460
+ return dispatchAction(resolved, { router: this.$router || null, tokenCtx })
461
+ },
462
+ },
299
463
  }
300
464
  </script>
301
465
 
@@ -304,6 +468,58 @@ export default {
304
468
  white-space: nowrap;
305
469
  }
306
470
 
471
+ .cn-countdown-widget__progress {
472
+ background: var(--color-border);
473
+ border-radius: 3px;
474
+ display: block;
475
+ height: 6px;
476
+ margin-block-start: 10px;
477
+ overflow: hidden;
478
+ }
479
+
480
+ .cn-countdown-widget__progress-fill {
481
+ background: var(--color-primary-element);
482
+ display: block;
483
+ height: 100%;
484
+ }
485
+
486
+ .cn-countdown-widget__action {
487
+ align-self: flex-start;
488
+ background: var(--color-background-dark);
489
+ border: 0;
490
+ border-radius: var(--border-radius-pill, 17px);
491
+ color: var(--color-main-text);
492
+ font-weight: 600;
493
+ margin-block-start: 10px;
494
+ min-height: 34px;
495
+ padding: 0 12px;
496
+ }
497
+
498
+ .cn-countdown-widget__action--primary {
499
+ background: var(--color-primary-element-light);
500
+ color: var(--color-primary-element-light-text);
501
+ }
502
+
503
+ .cn-countdown-widget__facts {
504
+ display: grid;
505
+ gap: 8px 16px;
506
+ grid-template-columns: max-content 1fr;
507
+ margin: 10px 0 0;
508
+ }
509
+
510
+ .cn-countdown-widget__facts dt {
511
+ color: var(--color-text-maxcontrast);
512
+ }
513
+
514
+ .cn-countdown-widget__facts dd {
515
+ margin: 0;
516
+ }
517
+
518
+ .cn-countdown-widget__action:hover,
519
+ .cn-countdown-widget__action:focus-visible {
520
+ background: var(--color-background-hover);
521
+ }
522
+
307
523
  .cn-countdown-widget__sub {
308
524
  color: var(--color-text-maxcontrast);
309
525
  font-size: 0.85em;
@@ -34,7 +34,29 @@
34
34
  <!-- @slot title-meta Content on the title's row, right of the title (e.g. a page-wide picker). Fill it from a manifest with `page.slots: { "title-meta": "<RegistryName>" }`. -->
35
35
  <slot name="title-meta" />
36
36
  </div>
37
- <p v-if="subtitleText" class="cn-dashboard-page__description" data-testid="cn-dashboard-page-subtitle">
37
+ <CnHeaderTrail
38
+ :breadcrumb="breadcrumb"
39
+ :current="title ? resolvedTitle : ''"
40
+ :meta="headerMeta ? effectiveTranslate(headerMeta) : ''" />
41
+ <!-- `dateRange.placement: "description"`: the segmented period
42
+ group ends the description line ("Week 41 · alle teams ·
43
+ [Deze week | Dit kwartaal | Dit jaar]"). -->
44
+ <div
45
+ v-if="datePeriodInDescription"
46
+ class="cn-dashboard-page__description cn-dashboard-page__description--with-period"
47
+ data-testid="cn-dashboard-page-subtitle">
48
+ <span v-if="subtitleText">{{ subtitleText }}</span>
49
+ <span v-if="subtitleText" aria-hidden="true">&middot;</span>
50
+ <CnSegmentedControl
51
+ size="compact"
52
+ mode="toggle"
53
+ :options="segmentedOptions"
54
+ :modelValue="segmentedValue"
55
+ :ariaLabel="datePeriodLabel"
56
+ data-testid="cn-dashboard-page-date-segmented"
57
+ @update:modelValue="onSegmentPick" />
58
+ </div>
59
+ <p v-else-if="subtitleText" class="cn-dashboard-page__description" data-testid="cn-dashboard-page-subtitle">
38
60
  {{ subtitleText }}
39
61
  </p>
40
62
  </div>
@@ -185,7 +207,7 @@
185
207
  handling, persistence, and event emission lives in
186
208
  `onDateRangeChange`. -->
187
209
  <div
188
- v-if="showHeaderDateRange"
210
+ v-if="showHeaderDateRange && !datePeriodInDescription"
189
211
  class="cn-dashboard-page__date-range"
190
212
  :class="{
191
213
  'cn-dashboard-page__date-range--pills': dateRangeControl === 'pills',
@@ -436,6 +458,7 @@
436
458
  v-else
437
459
  :title="getWidgetTitle(item)"
438
460
  :eyebrow="getWidgetEyebrow(item)"
461
+ :subtitle="getWidgetSubtitle(item)"
439
462
  :showTitle="false"
440
463
  :showRefresh="false">
441
464
  <div class="cn-dashboard-page__unknown">
@@ -498,6 +521,7 @@
498
521
  v-if="missingRequiredApp(item)"
499
522
  :title="getWidgetTitle(item)"
500
523
  :eyebrow="getWidgetEyebrow(item)"
524
+ :subtitle="getWidgetSubtitle(item)"
501
525
  :showTitle="widgetShowTitle(item)">
502
526
  <!-- Compact on purpose: a two-row tile is about 150px, less
503
527
  a title row. NcEmptyContent's 64px icon, 20px name,
@@ -537,6 +561,7 @@
537
561
  :widgetId="item.widgetId"
538
562
  :title="getWidgetTitle(item)"
539
563
  :eyebrow="getWidgetEyebrow(item)"
564
+ :subtitle="getWidgetSubtitle(item)"
540
565
  :iconUrl="getWidgetIconUrl(item)"
541
566
  :iconClass="getWidgetIconClass(item)"
542
567
  :showTitle="widgetShowTitle(item)"
@@ -619,6 +644,7 @@
619
644
  :class="{ 'cn-dashboard-page__chart-fit': isChartFitted(item) }"
620
645
  :title="getWidgetTitle(item)"
621
646
  :eyebrow="getWidgetEyebrow(item)"
647
+ :subtitle="getWidgetSubtitle(item)"
622
648
  :iconUrl="getWidgetIconUrl(item)"
623
649
  :iconClass="getWidgetIconClass(item)"
624
650
  :showTitle="widgetShowTitle(item)"
@@ -698,6 +724,7 @@
698
724
  :widgetId="item.widgetId"
699
725
  :title="getWidgetTitle(item)"
700
726
  :eyebrow="getWidgetEyebrow(item)"
727
+ :subtitle="getWidgetSubtitle(item)"
701
728
  :iconUrl="getWidgetIconUrl(item)"
702
729
  :iconClass="getWidgetIconClass(item)"
703
730
  :showTitle="widgetShowTitle(item)"
@@ -726,6 +753,7 @@
726
753
  :widgetId="item.widgetId"
727
754
  :title="getWidgetTitle(item)"
728
755
  :eyebrow="getWidgetEyebrow(item)"
756
+ :subtitle="getWidgetSubtitle(item)"
729
757
  :iconUrl="getWidgetIconUrl(item)"
730
758
  :iconClass="getWidgetIconClass(item)"
731
759
  :showTitle="widgetShowTitle(item)"
@@ -766,6 +794,7 @@
766
794
  :widgetId="item.widgetId"
767
795
  :title="getWidgetTitle(item)"
768
796
  :eyebrow="getWidgetEyebrow(item)"
797
+ :subtitle="getWidgetSubtitle(item)"
769
798
  :iconUrl="getWidgetIconUrl(item)"
770
799
  :iconClass="getWidgetIconClass(item)"
771
800
  :showTitle="widgetShowTitle(item)"
@@ -792,6 +821,7 @@
792
821
  :widgetId="item.widgetId"
793
822
  :title="getWidgetTitle(item)"
794
823
  :eyebrow="getWidgetEyebrow(item)"
824
+ :subtitle="getWidgetSubtitle(item)"
795
825
  :showTitle="widgetShowTitle(item)"
796
826
  :showActions="widgetShowActions(item)"
797
827
  :showRefresh="getWidgetShowRefresh(item)"
@@ -893,6 +923,7 @@
893
923
  v-else
894
924
  :title="getWidgetTitle(item)"
895
925
  :eyebrow="getWidgetEyebrow(item)"
926
+ :subtitle="getWidgetSubtitle(item)"
896
927
  :showTitle="widgetShowTitle(item)"
897
928
  :showRefresh="false">
898
929
  <div class="cn-dashboard-page__unknown">
@@ -972,6 +1003,7 @@ import CnDashboardGrid from '../CnDashboardGrid/CnDashboardGrid.vue'
972
1003
  import CnDateRangePicker, { DEFAULT_DATE_RANGE_PRESETS, resolvePresetWindow, translatePresetLabel } from '../CnDateRangePicker/CnDateRangePicker.vue'
973
1004
  import CnEmptyContent from '../CnEmptyContent/CnEmptyContent.vue'
974
1005
  import CnKpiGrid from '../CnKpiGrid/CnKpiGrid.vue'
1006
+ import CnHeaderTrail from '../CnPageHeader/CnHeaderTrail.vue'
975
1007
  import CnSegmentedControl from '../CnSegmentedControl/CnSegmentedControl.vue'
976
1008
  import CnStatsBlockWidget from '../CnStatsBlockWidget/CnStatsBlockWidget.vue'
977
1009
  import CnTileWidget from '../CnTileWidget/CnTileWidget.vue'
@@ -1115,6 +1147,7 @@ export default {
1115
1147
  name: 'CnDashboardPage',
1116
1148
 
1117
1149
  components: {
1150
+ CnHeaderTrail,
1118
1151
  NcActions,
1119
1152
  NcActionButton,
1120
1153
  NcActionInput,
@@ -1214,6 +1247,30 @@ export default {
1214
1247
  default: '',
1215
1248
  },
1216
1249
 
1250
+ /**
1251
+ * A breadcrumb under the title (manifest `config.breadcrumb`): one
1252
+ * crumb `{ label, route?, params?, href?, icon?, separator? }` or a
1253
+ * list; the title follows as the current crumb. Drawn as the detail
1254
+ * header's breadcrumb row. Null (the default) draws none.
1255
+ *
1256
+ * @type {object|Array<object>|null}
1257
+ * @spec openspec/changes/page-header-breadcrumb/specs/page-header/spec.md#requirement-a-list-dashboard-or-settings-page-can-show-a-breadcrumb-and-a-meta-note
1258
+ */
1259
+ breadcrumb: {
1260
+ type: [Object, Array],
1261
+ default: null,
1262
+ },
1263
+
1264
+ /**
1265
+ * A note after the breadcrumb and a middle dot (manifest `config.headerMeta`).
1266
+ *
1267
+ * @spec openspec/changes/page-header-breadcrumb/specs/page-header/spec.md#requirement-a-list-dashboard-or-settings-page-can-show-a-breadcrumb-and-a-meta-note
1268
+ */
1269
+ headerMeta: {
1270
+ type: String,
1271
+ default: '',
1272
+ },
1273
+
1217
1274
  /** Page description (shown below title) */
1218
1275
  description: {
1219
1276
  type: String,
@@ -2261,6 +2318,20 @@ export default {
2261
2318
  return this.dateRangeEnabled && this.dateRange.showHeaderPicker !== false
2262
2319
  },
2263
2320
 
2321
+ /**
2322
+ * Whether the segmented period group sits at the end of the
2323
+ * description line (`dateRange.placement: "description"`) instead of
2324
+ * in a row of its own. Only for the segmented control without a
2325
+ * custom range, whose from/to popover keeps the row of its own.
2326
+ *
2327
+ * @return {boolean}
2328
+ * @spec openspec/changes/page-header-breadcrumb/specs/page-header/spec.md#requirement-a-dashboard-can-show-its-period-on-the-description-line
2329
+ */
2330
+ datePeriodInDescription() {
2331
+ return this.showHeaderDateRange && this.dateRangeControl === 'segmented'
2332
+ && this.dateRange.placement === 'description' && !this.hasCustomPreset
2333
+ },
2334
+
2264
2335
  /**
2265
2336
  * The preset list passed to the picker. Falls back to the
2266
2337
  * `DEFAULT_DATE_RANGE_PRESETS` constant when the consumer
@@ -2972,6 +3043,7 @@ export default {
2972
3043
  widgetId: item.widgetId,
2973
3044
  title: this.getWidgetTitle(item),
2974
3045
  eyebrow: this.getWidgetEyebrow(item),
3046
+ subtitle: this.getWidgetSubtitle(item),
2975
3047
  showTitle: this.widgetShowTitle(item),
2976
3048
  showActions: this.widgetShowActions(item),
2977
3049
  showRefresh: this.getWidgetShowRefresh(item),
@@ -4090,6 +4162,20 @@ export default {
4090
4162
  this.showWidgetConfig = false
4091
4163
  },
4092
4164
 
4165
+ /**
4166
+ * The line under a widget's title (`subtitle` on the widget
4167
+ * definition), translated; empty when it carries none.
4168
+ *
4169
+ * @param {object} item Layout item.
4170
+ * @return {string}
4171
+ * @spec openspec/changes/page-header-breadcrumb/specs/page-header/spec.md#requirement-a-dashboard-widget-card-can-carry-a-line-under-its-title
4172
+ */
4173
+ getWidgetSubtitle(item) {
4174
+ const def = this.getWidgetDef(item.widgetId)
4175
+ const subtitle = def && typeof def.subtitle === 'string' ? def.subtitle : ''
4176
+ return subtitle ? this.effectiveTranslate(subtitle) : ''
4177
+ },
4178
+
4093
4179
  /**
4094
4180
  * The line above a widget's title (`eyebrow` on the widget
4095
4181
  * definition: "Saldo per week" above "Prognose 13 weken"), through the
@@ -4903,6 +4989,14 @@ export default {
4903
4989
  margin-bottom: 16px;
4904
4990
  }
4905
4991
 
4992
+ /* The period group at the end of the description line. */
4993
+ .cn-dashboard-page__description--with-period {
4994
+ align-items: center;
4995
+ display: flex;
4996
+ flex-wrap: wrap;
4997
+ gap: 8px;
4998
+ }
4999
+
4906
5000
  .cn-dashboard-page__date-segmented {
4907
5001
  display: flex;
4908
5002
  align-items: center;
@@ -198,6 +198,7 @@ export default {
198
198
  | `rowIndicatorCap` | Number | `3` | How many declared indicators render on the row before the rest move into the row menu, reachable as `indicatorsFor(row).overflow`. |
199
199
  | `selectAllLabel` | String | `'Select all rows'` | Accessible name (`aria-label`) for the header select-all checkbox, so screen readers announce a named control (WCAG 4.1.2) |
200
200
  | `selectRowLabel` | String | `'Select row'` | Accessible name (`aria-label`) for each per-row select checkbox, so screen readers announce a named control (WCAG 4.1.2) |
201
+ | `showUnread` | Boolean | `true` | Draw an unread record (`@self.unread`) in bold with the unread dot. Off, every row reads alike. CnIndexPage turns it off under the board look unless the page offers the Unread lens. |
201
202
  | `rowTitle` | String | `'link'` | Board look only: how the title column draws its title. `link` underlines the title (not its secondary line). `plain` draws it as 15px bold text without an underline over a 13px muted secondary line, as the PqTickets board does; the row stays clickable. The Nextcloud look ignores it. |
202
203
  | `hideHeader` | Boolean | `false` | Hide the column-header row (`<thead>`) — for compact dashboard list widgets that want a plain bordered-row list without column labels |
203
204
  | `pinnedCount` | Number | `0` | How many data columns are pinned to the start of the table. They, and the selection and icon columns, stay in view when the table scrolls sideways |