@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
@@ -25,7 +25,7 @@
25
25
  ]"
26
26
  v-bind="linkAttrs">
27
27
  <div
28
- v-if="resolvedIcon"
28
+ v-if="resolvedIcon && !isStacked"
29
29
  class="cn-kpi-card__icon cn-stat-widget__icon"
30
30
  :style="iconCircleStyle">
31
31
  <CnWidgetIcon :name="resolvedIcon" :size="24" />
@@ -94,10 +94,23 @@
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="!isStacked && !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>
105
+ <!-- Stacked (`content.layout: "stacked"`): the caption is a line of
106
+ its own under the number, as the board's "3 new this week". -->
107
+ <span
108
+ v-if="isStacked && !displayLoading && !displayError && resolvedCaption"
109
+ class="cn-kpi-card__label cn-stat-widget__caption cn-kpi-card__caption-line"
110
+ :class="captionVariantClass"
111
+ data-testid="cn-stat-widget-caption">
112
+ {{ resolvedCaption }}
113
+ </span>
101
114
  </div>
102
115
  </component>
103
116
  </template>
@@ -461,7 +474,7 @@ export default {
461
474
  * counted between LOCAL CALENDAR DAYS, not as elapsed milliseconds, so
462
475
  * the time of day on a deadline never moves the answer.
463
476
  *
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}>}}
477
+ * @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
478
  */
466
479
  content: {
467
480
  type: Object,
@@ -605,7 +618,10 @@ export default {
605
618
  * @return {string}
606
619
  */
607
620
  resolvedCaption() {
608
- const caption = this.content.caption
621
+ const override = this.activeOverride
622
+ const caption = (override && typeof override.caption === 'string' && override.caption !== '')
623
+ ? override.caption
624
+ : this.content.caption
609
625
  if (!caption) {
610
626
  return ''
611
627
  }
@@ -615,11 +631,58 @@ export default {
615
631
  }
616
632
  const payload = this.endpointMode ? this.epData : null
617
633
  return translated.replace(/\{([A-Za-z0-9_.]+)\}/g, (whole, path) => {
634
+ // `{value}` is the tile's own number, so a caption can read
635
+ // "{value} due today" on a register-counted tile that has no
636
+ // payload to read fields from.
637
+ if (path === 'value' && (payload === null || getByPath(payload, path) === undefined)) {
638
+ return this.formattedValue === undefined || this.formattedValue === null ? '' : String(this.formattedValue)
639
+ }
618
640
  const v = getByPath(payload, path)
619
641
  return (v === undefined || v === null) ? '' : String(v)
620
642
  }).replace(/\s{2,}/g, ' ').trim()
621
643
  },
622
644
 
645
+ /**
646
+ * The first `content.captionVariantWhen` rule the tile's value
647
+ * matches (`[{ op, value, variant }]`, the `variantWhen` shape), or
648
+ * null. Colours the caption on the value without recolouring the
649
+ * tile: "1 due today" turns red while the number stays ink.
650
+ *
651
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule
652
+ * @return {object|null}
653
+ */
654
+ activeCaptionRule() {
655
+ const rules = this.content.captionVariantWhen
656
+ if (!Array.isArray(rules) || rules.length === 0) {
657
+ return null
658
+ }
659
+ const current = this.displayValue
660
+ if (current === null || current === undefined) {
661
+ return null
662
+ }
663
+ return rules.find((r) => r && this.matchesRule(current, r)) || null
664
+ },
665
+
666
+ /**
667
+ * The caption's colour class: the matching override's
668
+ * `captionVariant`, else `content.captionVariant`, as
669
+ * `cn-kpi-card__label--<variant>`. '' for `default` or an unknown
670
+ * variant, so an uncoloured caption is what it always was.
671
+ *
672
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule
673
+ * @return {string}
674
+ */
675
+ captionVariantClass() {
676
+ const override = this.activeOverride
677
+ const variant = (override && override.captionVariant)
678
+ || (this.activeCaptionRule && this.activeCaptionRule.variant)
679
+ || this.content.captionVariant
680
+ if (!variant || variant === 'default' || !Object.hasOwn(VARIANT_COLORS, variant)) {
681
+ return ''
682
+ }
683
+ return `cn-kpi-card__label--${variant === 'danger' ? 'error' : variant}`
684
+ },
685
+
623
686
  /**
624
687
  * The unwrapped detail-page object context for token resolution, or null
625
688
  * on surfaces (dashboards) that don't provide one.
@@ -1188,12 +1251,31 @@ export default {
1188
1251
  /**
1189
1252
  * Card orientation. Horizontal (icon beside the number) is the
1190
1253
  * canonical KPI card; `content.layout: 'vertical'` stacks the icon
1191
- * above a centred number for a tile taller than it is wide.
1254
+ * above a centred number for a tile taller than it is wide;
1255
+ * `content.layout: 'stacked'` is the board look (see `isStacked`).
1192
1256
  *
1193
- * @return {'horizontal'|'vertical'}
1257
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-stat-tile-can-take-the-stacked-board-look
1258
+ * @return {'horizontal'|'vertical'|'stacked'}
1194
1259
  */
1195
1260
  cardLayout() {
1196
- return (this.content || {}).layout === 'vertical' ? 'vertical' : 'horizontal'
1261
+ const layout = (this.content || {}).layout
1262
+ if (layout === 'vertical' || layout === 'stacked') {
1263
+ return layout
1264
+ }
1265
+ return 'horizontal'
1266
+ },
1267
+
1268
+ /**
1269
+ * Whether the tile takes the stacked board look (`content.layout:
1270
+ * "stacked"`): a plain muted label, the number at 34px in the text
1271
+ * colour, the caption on a line of its own, and no icon circle. Off by
1272
+ * default.
1273
+ *
1274
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-stat-tile-can-take-the-stacked-board-look
1275
+ * @return {boolean}
1276
+ */
1277
+ isStacked() {
1278
+ return this.cardLayout === 'stacked'
1197
1279
  },
1198
1280
 
1199
1281
  /**
@@ -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".
@@ -69,6 +69,17 @@
69
69
  It used to gate both, so a caller who switched the menu off
70
70
  also lost the Save button for an inline edit, silently. -->
71
71
  <div v-if="actionsAreVisible()" class="cn-widget-wrapper__actions">
72
+ <!-- One text link in the header (`headerLink`): "All deadlines",
73
+ "To the board". A route is a router link, an href a plain
74
+ anchor. Sits before the slot and the overflow menu. -->
75
+ <component
76
+ :is="headerLinkTag"
77
+ v-if="headerLinkTag"
78
+ class="cn-widget-wrapper__header-link"
79
+ data-testid="cn-widget-wrapper-header-link"
80
+ v-bind="headerLinkAttrs">
81
+ {{ headerLinkLabel }}
82
+ </component>
72
83
  <!-- @slot actions Custom action buttons rendered before the
73
84
  built-in overflow menu. -->
74
85
  <slot name="actions" />
@@ -351,6 +362,22 @@ export default {
351
362
  default: () => [],
352
363
  },
353
364
 
365
+ /**
366
+ * One text link in the header's actions group, before the overflow
367
+ * menu: `{ label, route?, params?, query?, href? }`. A `route` is a
368
+ * named router link (needs a router), an `href` a plain anchor. The
369
+ * label goes through the host translate function. Null (the
370
+ * default) draws nothing. Manifest: `headerLink` on the widget
371
+ * definition or its layout entry.
372
+ *
373
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
374
+ * @type {{label: string, route?: string, params?: object, query?: object, href?: string}|null}
375
+ */
376
+ headerLink: {
377
+ type: Object,
378
+ default: null,
379
+ },
380
+
354
381
  /**
355
382
  * Whether the header's overflow action menu (Refresh / Documentation /
356
383
  * Request-a-feature + any `#action-items`) renders. Shown by default;
@@ -634,6 +661,62 @@ export default {
634
661
  return this.translate ?? this.cnTranslate
635
662
  },
636
663
 
664
+ /**
665
+ * The header link's tag: `router-link` for a route (when a router is
666
+ * mounted), `a` for an href, '' when there is no usable link.
667
+ *
668
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
669
+ * @return {string}
670
+ */
671
+ headerLinkTag() {
672
+ const link = this.headerLink
673
+ if (!link || typeof link.label !== 'string' || link.label === '') {
674
+ return ''
675
+ }
676
+ if (link.route && this.$router) {
677
+ return 'router-link'
678
+ }
679
+ if (typeof link.href === 'string' && link.href !== '') {
680
+ return 'a'
681
+ }
682
+ return ''
683
+ },
684
+
685
+ /**
686
+ * The header link's attributes for its tag.
687
+ *
688
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
689
+ * @return {object}
690
+ */
691
+ headerLinkAttrs() {
692
+ const link = this.headerLink || {}
693
+ if (this.headerLinkTag === 'router-link') {
694
+ const to = { name: link.route }
695
+ if (link.params && typeof link.params === 'object') {
696
+ to.params = link.params
697
+ }
698
+ if (link.query && typeof link.query === 'object') {
699
+ to.query = link.query
700
+ }
701
+ return { to }
702
+ }
703
+ if (this.headerLinkTag === 'a') {
704
+ return { href: link.href }
705
+ }
706
+ return {}
707
+ },
708
+
709
+ /**
710
+ * The header link's text, through the host translate function.
711
+ *
712
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
713
+ * @return {string}
714
+ */
715
+ headerLinkLabel() {
716
+ const label = this.headerLink && this.headerLink.label
717
+ return label ? this.effectiveTranslate(label) : ''
718
+ },
719
+
637
720
  /**
638
721
  * The RENDERED widget title — `displayTitle` run through the host
639
722
  * translate function. Kept separate from `displayTitle` on purpose:
@@ -877,7 +960,7 @@ export default {
877
960
  * @return {boolean} True when the group would hold a visible control.
878
961
  */
879
962
  actionsAreVisible() {
880
- return this.showActions || this.actionsSlotRenders()
963
+ return this.showActions || this.actionsSlotRenders() || Boolean(this.headerLinkTag)
881
964
  },
882
965
 
883
966
  /**
@@ -1127,6 +1210,29 @@ export default {
1127
1210
  flex-shrink: 0;
1128
1211
  }
1129
1212
 
1213
+ /* The header's text link (`headerLink`). Theme hooks: --cn-widget-header-link-size
1214
+ and --cn-widget-header-link-weight. */
1215
+ .cn-widget-wrapper__header-link {
1216
+ align-self: center;
1217
+ padding: 0 calc(1 * var(--default-grid-baseline));
1218
+ color: var(--color-primary-element);
1219
+ font-size: var(--cn-widget-header-link-size, 14px);
1220
+ font-weight: var(--cn-widget-header-link-weight, 600);
1221
+ text-decoration: none;
1222
+ white-space: nowrap;
1223
+ }
1224
+
1225
+ .cn-widget-wrapper__header-link:hover,
1226
+ .cn-widget-wrapper__header-link:focus-visible {
1227
+ text-decoration: underline;
1228
+ }
1229
+
1230
+ .cn-widget-wrapper__header-link:focus-visible {
1231
+ outline: 2px solid var(--color-primary-element);
1232
+ outline-offset: 2px;
1233
+ border-radius: var(--border-radius-small, 4px);
1234
+ }
1235
+
1130
1236
  /* Every widget's header icon is coloured — `--cn-widget-icon-color` is set on
1131
1237
  the header from titleIconStyle, and defaults to the theme colour if a
1132
1238
  wrapper is ever rendered without it. Material-design-icon components paint
@@ -8,6 +8,12 @@
8
8
  position: relative;
9
9
  }
10
10
 
11
+ /* The breadcrumb line above the header (`config.breadcrumb`). */
12
+ .cn-detail-page__breadcrumbs {
13
+ margin-bottom: calc(2 * var(--default-grid-baseline));
14
+ color: var(--color-text-maxcontrast);
15
+ }
16
+
11
17
  /* Header */
12
18
  .cn-detail-page__header {
13
19
  display: flex;
@@ -33,6 +39,28 @@
33
39
  min-width: 0;
34
40
  }
35
41
 
42
+ /* A widget in the header (`config.headerWidget`) takes a row of its own under
43
+ the title and the actions. */
44
+ .cn-detail-page__header--with-widget {
45
+ flex-wrap: wrap;
46
+ row-gap: var(--cn-detail-header-row-gap, 22px);
47
+ }
48
+
49
+ .cn-detail-page__header-widget {
50
+ flex: 1 0 100%;
51
+ min-width: 0;
52
+ }
53
+
54
+ /* The header as a card (`config.headerCard`): the board's case header, a
55
+ bordered box on the surface colour. The 56px inset that clears the
56
+ navigation toggle is not needed inside a card that starts below it. */
57
+ .cn-detail-page__header--card {
58
+ padding: var(--cn-detail-header-card-padding, 22px 24px);
59
+ border: 1px solid var(--color-border);
60
+ border-radius: var(--cn-detail-header-card-radius, 12px);
61
+ background: var(--color-main-background);
62
+ }
63
+
36
64
  .cn-detail-page__header-left {
37
65
  display: flex;
38
66
  align-items: center;
@@ -175,7 +203,7 @@
175
203
 
176
204
  .cn-detail-page__stats-table thead th {
177
205
  padding: calc(1.5 * var(--default-grid-baseline)) calc(2 * var(--default-grid-baseline));
178
- text-align: left;
206
+ text-align: start;
179
207
  font-weight: 600;
180
208
  font-size: 0.9em;
181
209
  color: var(--color-text-maxcontrast);
@@ -200,7 +228,7 @@
200
228
  }
201
229
 
202
230
  .cn-detail-page__stats-cell--indented {
203
- padding-left: calc(5 * var(--default-grid-baseline)) !important;
231
+ padding-inline-start: calc(5 * var(--default-grid-baseline)) !important;
204
232
  }
205
233
 
206
234
  .cn-detail-page__stats-cell--center {
@@ -208,7 +236,7 @@
208
236
  }
209
237
 
210
238
  .cn-detail-page__stats-cell--right {
211
- text-align: right;
239
+ text-align: end;
212
240
  }
213
241
 
214
242
  /* Body & content */
@@ -342,7 +370,7 @@
342
370
 
343
371
  .cn-detail-page__sidebar-toggle {
344
372
  position: fixed;
345
- right: calc(5 * var(--default-grid-baseline));
373
+ inset-inline-end: calc(5 * var(--default-grid-baseline));
346
374
  top: 80px;
347
375
  z-index: 10;
348
376
  }
@@ -453,3 +481,8 @@
453
481
  font-size: 0.9em;
454
482
  white-space: nowrap;
455
483
  }
484
+
485
+ /* A page view the switch shows with nothing to draw (view-switch-containers). */
486
+ .cn-detail-page .cn-page-view-region--empty {
487
+ padding: 40px 20px;
488
+ }
@@ -133,7 +133,7 @@
133
133
  padding: var(--cn-kpi-padding-flat, 8px);
134
134
  box-sizing: border-box;
135
135
  overflow: hidden;
136
- text-align: left;
136
+ text-align: start;
137
137
  text-decoration: none;
138
138
  color: inherit;
139
139
  background: transparent;
@@ -248,6 +248,29 @@
248
248
  white-space: nowrap;
249
249
  }
250
250
 
251
+ /* A caption coloured by a rule (`captionVariant` / `overrides[].captionVariant`):
252
+ "1 due today" in the error colour. Foreground tokens, which stay readable in
253
+ dark mode. */
254
+ .cn-kpi-card__label--primary {
255
+ color: var(--color-primary-element);
256
+ font-weight: 600;
257
+ }
258
+
259
+ .cn-kpi-card__label--success {
260
+ color: var(--color-text-success, var(--color-success-text));
261
+ font-weight: 600;
262
+ }
263
+
264
+ .cn-kpi-card__label--warning {
265
+ color: var(--color-element-warning, var(--color-warning-text));
266
+ font-weight: 600;
267
+ }
268
+
269
+ .cn-kpi-card__label--error {
270
+ color: var(--color-text-error, var(--color-error-text));
271
+ font-weight: 600;
272
+ }
273
+
251
274
  /* Semantic variants. Each re-points the ONE accent token, so the icon tint
252
275
  and the number move together by construction — there is no second place to
253
276
  forget. The inline KPI below shares the same modifiers for the same reason:
@@ -344,6 +367,53 @@
344
367
  font-size: var(--cn-kpi-value-size-stacked, 2rem);
345
368
  }
346
369
 
370
+ /* Stacked: the board's tile (`content.layout: 'stacked'`). A plain muted
371
+ label, the number at 34px/700 in the text colour, and the caption on a line
372
+ of its own; CnStatWidget draws no icon circle in this layout. The padding
373
+ adds up with a dashboard card-fit cell's 8px 14px to the board's 18px 20px.
374
+ Every value is a token so a theme can move it. */
375
+ .cn-kpi-card--stacked {
376
+ flex-direction: column;
377
+ align-items: stretch;
378
+ justify-content: center;
379
+ gap: 0;
380
+ padding: var(--cn-kpi-stacked-padding, 10px 6px);
381
+ }
382
+
383
+ .cn-kpi-card--stacked .cn-kpi-card__body {
384
+ gap: var(--cn-kpi-stacked-gap, 6px);
385
+ }
386
+
387
+ .cn-kpi-card--stacked .cn-kpi-card__title {
388
+ font-size: var(--cn-kpi-stacked-title-size, 14px);
389
+ font-weight: var(--cn-kpi-stacked-title-weight, 400);
390
+ color: var(--color-text-maxcontrast);
391
+ }
392
+
393
+ .cn-kpi-card--stacked .cn-kpi-card__value {
394
+ font-size: var(--cn-kpi-stacked-value-size, 34px);
395
+ font-weight: var(--cn-kpi-value-weight, 700);
396
+ line-height: 1;
397
+ letter-spacing: -0.01em;
398
+ color: var(--cn-kpi-stacked-value-color, var(--color-main-text));
399
+ }
400
+
401
+ .cn-kpi-card--stacked .cn-kpi-card__caption-line {
402
+ flex: 0 0 auto;
403
+ max-width: 100%;
404
+ font-size: var(--cn-kpi-label-size, 13px);
405
+ font-weight: 500;
406
+ }
407
+
408
+ /* A linked stacked tile reads as a tile, not as underlined link text. The
409
+ NL Design theme underlines every `a` with `!important`, so the opt-out
410
+ needs it too; it only applies to this opt-in layout. */
411
+ a.cn-kpi-card--stacked,
412
+ a.cn-kpi-card--stacked:hover,
413
+ a.cn-kpi-card--stacked:focus-visible {
414
+ text-decoration: none !important;
415
+ }
416
+
347
417
  /* Filled: the card draws its own grey box, for a KPI that is NOT already
348
418
  inside a wrapper that draws one. Opt-in via `content.flat: false`. */
349
419
  .cn-kpi-card--filled {
package/src/css/table.css CHANGED
@@ -155,7 +155,7 @@
155
155
  .cn-data-table th,
156
156
  .cn-data-table td {
157
157
  padding: calc(3 * var(--default-grid-baseline));
158
- text-align: left;
158
+ text-align: start;
159
159
  border-bottom: 1px solid var(--color-border);
160
160
  vertical-align: middle;
161
161
  max-width: var(--cn-table-cell-max-width, 22rem);
@@ -174,6 +174,18 @@
174
174
  white-space: normal;
175
175
  }
176
176
 
177
+ /* The secondary line of a cell (`columns[].secondary`): muted, smaller, on its
178
+ own line under the value. Theme hooks: --cn-table-secondary-size and
179
+ --cn-table-secondary-color. */
180
+ .cn-data-table .cn-table-cell__secondary {
181
+ display: block;
182
+ color: var(--cn-table-secondary-color, var(--color-text-maxcontrast));
183
+ font-size: var(--cn-table-secondary-size, 0.9em);
184
+ line-height: 1.35;
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ }
188
+
177
189
  /* Back to wrapping, for a column holding prose rather than a label:
178
190
  `columns: [{ key: 'description', cellClass: 'cn-cell--wrap' }]`. */
179
191
  .cn-data-table th.cn-cell--wrap,
@@ -235,7 +247,7 @@
235
247
  }
236
248
 
237
249
  .cn-data-table td.cn-cell--end {
238
- text-align: right;
250
+ text-align: end;
239
251
  white-space: nowrap;
240
252
  }
241
253
 
@@ -273,7 +285,7 @@
273
285
  }
274
286
 
275
287
  .cn-table-sort-indicator {
276
- margin-left: var(--default-grid-baseline);
288
+ margin-inline-start: var(--default-grid-baseline);
277
289
  opacity: 0.6;
278
290
  }
279
291
 
@@ -383,7 +395,7 @@
383
395
  justify-content: center;
384
396
  min-width: 14px;
385
397
  height: 14px;
386
- margin-left: 2px;
398
+ margin-inline-start: 2px;
387
399
  padding: 0 3px;
388
400
  border-radius: 7px;
389
401
  background-color: var(--color-primary-element);
@@ -448,7 +460,7 @@
448
460
 
449
461
  .cn-table-col--icon {
450
462
  width: 40px;
451
- padding-right: 0;
463
+ padding-inline-end: 0;
452
464
  text-align: center;
453
465
  color: var(--color-text-maxcontrast);
454
466
  }
@@ -511,7 +523,7 @@
511
523
 
512
524
  .viewTable { width: 100%; border-collapse: collapse; background-color: var(--color-main-background); }
513
525
 
514
- .viewTable th, .viewTable td { padding: calc(3 * var(--default-grid-baseline)); text-align: left; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
526
+ .viewTable th, .viewTable td { padding: calc(3 * var(--default-grid-baseline)); text-align: start; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
515
527
 
516
528
  .viewTable th { background: var(--color-background-hover); font-weight: 500; color: var(--color-text-maxcontrast); background-color: var(--color-background-dark); }
517
529