@conduction/nextcloud-vue 2.64.0 → 2.66.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 (84) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +99 -3
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +23 -7
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  6. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  7. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  8. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +125 -4
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +439 -381
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
  14. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
  16. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
  17. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +142 -26
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +195 -124
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
  22. package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
  23. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
  24. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  25. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  26. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +84 -11
  27. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  28. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +3 -2
  29. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  30. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
  31. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
  32. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
  33. package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
  34. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  36. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +22 -3
  37. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  38. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +10 -3
  39. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  40. package/dist/esm/l10n/en.json.js +3 -0
  41. package/dist/esm/l10n/en.json.js.map +1 -1
  42. package/dist/esm/l10n/nl.json.js +3 -0
  43. package/dist/esm/l10n/nl.json.js.map +1 -1
  44. package/dist/esm/mixins/pageViews.js +400 -0
  45. package/dist/esm/mixins/pageViews.js.map +1 -0
  46. package/dist/esm/nextcloud-vue.css +182 -0
  47. package/dist/esm/store/useObjectStore.js +118 -33
  48. package/dist/esm/store/useObjectStore.js.map +1 -1
  49. package/dist/esm/utils/actionsDispatcher.js +25 -2
  50. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  51. package/dist/esm/utils/columnFilters.js +27 -3
  52. package/dist/esm/utils/columnFilters.js.map +1 -1
  53. package/dist/esm/utils/schema.js +40 -2
  54. package/dist/esm/utils/schema.js.map +1 -1
  55. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  56. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  57. package/dist/nextcloud-vue.cjs.js +2001 -684
  58. package/dist/nextcloud-vue.cjs.js.map +2 -2
  59. package/dist/nextcloud-vue.css +182 -0
  60. package/l10n/en.json +3 -0
  61. package/l10n/nl.json +3 -0
  62. package/package.json +1 -1
  63. package/src/components/CnAppNav/CnAppNav.vue +148 -7
  64. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  65. package/src/components/CnDashboardPage/CnDashboardPage.vue +194 -10
  66. package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
  67. package/src/components/CnDetailPage/CnDetailPage.md +5 -1
  68. package/src/components/CnDetailPage/CnDetailPage.vue +208 -11
  69. package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
  70. package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
  71. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +100 -13
  72. package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
  73. package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
  74. package/src/components/CnStagesWidget/CnStagesWidget.vue +9 -0
  75. package/src/components/CnStatWidget/CnStatWidget.vue +33 -5
  76. package/src/css/detail-page.css +27 -0
  77. package/src/css/kpi-card.css +47 -0
  78. package/src/mixins/pageViews.js +396 -0
  79. package/src/schemas/app-manifest-v2.schema.json +51 -2
  80. package/src/store/useObjectStore.js +118 -33
  81. package/src/utils/actionsDispatcher.js +25 -2
  82. package/src/utils/columnFilters.js +29 -2
  83. package/src/utils/schema.js +40 -2
  84. 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, 'cn-header-widget--with-views': viewOptions.length > 0 }" :style="wrapperStyle">
7
+ <div class="cn-header-widget" :class="{ 'cn-header-widget--plain': isPlain, 'cn-header-widget--ground': isGround, 'cn-header-widget--with-views': viewOptions.length > 0 }" :style="wrapperStyle">
8
8
  <div
9
9
  v-if="hasOverlay"
10
10
  class="cn-header-widget__overlay"
@@ -35,12 +35,15 @@
35
35
  </a>
36
36
  </div>
37
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. -->
38
+ work / My team" as a segmented control whose options are routes,
39
+ or views of the page the widget sits on (`option.view`); a view
40
+ option controls the page's view region. -->
39
41
  <div v-if="viewOptions.length > 0" class="cn-header-widget__views">
40
42
  <CnSegmentedControl
41
43
  :options="viewOptions"
42
44
  :modelValue="activeView"
43
45
  :ariaLabel="viewsLabel"
46
+ :controls="viewsRegionId"
44
47
  data-testid="cn-header-widget-views"
45
48
  @update:modelValue="onViewChange" />
46
49
  </div>
@@ -66,6 +69,9 @@ const ALLOWED_TEXT_ALIGN = ['left', 'center', 'right']
66
69
  const ALLOWED_VERTICAL_ALIGN = ['top', 'middle', 'bottom']
67
70
  const ALLOWED_CTA_STYLES = ['primary', 'secondary', 'ghost']
68
71
 
72
+ /** Marks a switch option that selects a page view rather than a route. */
73
+ const VIEW_OPTION_PREFIX = 'view:'
74
+
69
75
  const HEIGHT_PIXELS = Object.freeze({
70
76
  small: 120,
71
77
  medium: 200,
@@ -121,6 +127,12 @@ export default {
121
127
  * untranslated key renders as itself.
122
128
  */
123
129
  cnTranslate: { default: () => (key) => key },
130
+ /**
131
+ * The views of the page this widget sits on, provided by
132
+ * CnDashboardPage / CnDetailPage (`views`). An option with a `view`
133
+ * selects one of them. Null outside such a page.
134
+ */
135
+ cnPageViews: { default: null },
124
136
  },
125
137
 
126
138
  props: {
@@ -128,12 +140,18 @@ export default {
128
140
  * Persisted widget content: `{title, subtitle, backgroundImageUrl,
129
141
  * backgroundImageFileId, backgroundColor, overlayMode, overlayColor,
130
142
  * overlayOpacity, textColor, textAlign, verticalAlign, height, cta,
131
- * greeting, showDate, plain, views}`. All fields are optional except
143
+ * greeting, showDate, plain, ground, views}`. All fields are optional except
132
144
  * `title` (or `greeting`); unknown enum values collapse to documented
133
145
  * 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.
146
+ * `{ ariaLabel?, options: [{ label, route, params? } | { label, view }] }`:
147
+ * a segmented control at the right of the heading whose checked option
148
+ * is the current route; choosing another pushes its route. An option
149
+ * with `view` selects that view of the page the widget sits on (the
150
+ * page's `views`) instead, and the page then draws no switch of its
151
+ * own. `ground: true`
152
+ * draws the greeting on the page ground: the plain look with no
153
+ * padding, a 32px heading and the date line 6px above it (a dashboard
154
+ * also drops the widget's card for it).
137
155
  *
138
156
  * @type {object}
139
157
  */
@@ -226,12 +244,45 @@ export default {
226
244
  */
227
245
  viewOptions() {
228
246
  const raw = this.content && this.content.views && this.content.views.options
229
- if (!Array.isArray(raw) || !this.$router) {
247
+ if (!Array.isArray(raw)) {
230
248
  return []
231
249
  }
250
+ const pageViewIds = this.pageViewIds
232
251
  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) }))
252
+ .filter((o) => o && typeof o.label === 'string' && o.label !== '')
253
+ .map((o) => {
254
+ if (typeof o.view === 'string' && o.view !== '') {
255
+ return pageViewIds.includes(o.view) ? { value: VIEW_OPTION_PREFIX + o.view, label: this.cnTranslate(o.label) } : null
256
+ }
257
+ if (typeof o.route === 'string' && o.route !== '' && this.$router) {
258
+ return { value: o.route, label: this.cnTranslate(o.label) }
259
+ }
260
+ return null
261
+ })
262
+ .filter(Boolean)
263
+ },
264
+
265
+ /**
266
+ * The ids of the page's views, empty outside a page with views.
267
+ *
268
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-greeting-header-can-switch-the-pages-views
269
+ * @return {string[]}
270
+ */
271
+ pageViewIds() {
272
+ const views = this.cnPageViews && Array.isArray(this.cnPageViews.views) ? this.cnPageViews.views : []
273
+ return views.map((view) => view.id)
274
+ },
275
+
276
+ /**
277
+ * The id of the page's view region when an option selects a view,
278
+ * for the options' `aria-controls`; '' otherwise.
279
+ *
280
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
281
+ * @return {string}
282
+ */
283
+ viewsRegionId() {
284
+ const hasViewOption = this.viewOptions.some((o) => o.value.startsWith(VIEW_OPTION_PREFIX))
285
+ return hasViewOption && this.cnPageViews ? (this.cnPageViews.regionId || '') : ''
235
286
  },
236
287
 
237
288
  /**
@@ -246,6 +297,11 @@ export default {
246
297
  if (options.length === 0) {
247
298
  return null
248
299
  }
300
+ const activeView = this.cnPageViews ? this.cnPageViews.activeId : null
301
+ const viewMatch = activeView ? options.find((o) => o.value === VIEW_OPTION_PREFIX + activeView) : null
302
+ if (viewMatch) {
303
+ return viewMatch.value
304
+ }
249
305
  const current = this.$route && this.$route.name
250
306
  const match = options.find((o) => o.value === current)
251
307
  return match ? match.value : options[0].value
@@ -260,7 +316,7 @@ export default {
260
316
  */
261
317
  viewsLabel() {
262
318
  const label = this.content && this.content.views && this.content.views.ariaLabel
263
- return label ? this.cnTranslate(label) : t('nextcloud-vue', 'View')
319
+ return label ? this.cnTranslate(label) : t('nextcloud-vue', 'Views')
264
320
  },
265
321
 
266
322
  /**
@@ -269,7 +325,20 @@ export default {
269
325
  * @return {boolean}
270
326
  */
271
327
  isPlain() {
272
- return Boolean(this.content && this.content.plain === true)
328
+ return Boolean(this.content && (this.content.plain === true || this.content.ground === true))
329
+ },
330
+
331
+ /**
332
+ * Whether the greeting sits on the page ground (`content.ground`):
333
+ * the plain look without the card padding, so the date line and the
334
+ * heading align with the page edge as the board draws them. Off by
335
+ * default.
336
+ *
337
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-greeting-can-sit-on-the-page-ground
338
+ * @return {boolean}
339
+ */
340
+ isGround() {
341
+ return Boolean(this.content && this.content.ground === true)
273
342
  },
274
343
 
275
344
  /**
@@ -565,9 +634,10 @@ export default {
565
634
  'justify-content': VERTICAL_ALIGN_FLEX[this.verticalAlign],
566
635
  // Plain drops the coloured background, not the card padding:
567
636
  // at 0 the greeting sat flush against the card's left edge.
568
- padding: this.isPlain ? '16px' : '16px 24px',
637
+ // Ground has no card, so it has no padding either.
638
+ padding: this.isGround ? '0' : (this.isPlain ? '16px' : '16px 24px'),
569
639
  'box-sizing': 'border-box',
570
- gap: '8px',
640
+ gap: this.isGround ? '6px' : '8px',
571
641
  'text-align': this.textAlign,
572
642
  }
573
643
  },
@@ -680,6 +750,12 @@ export default {
680
750
  * @return {void}
681
751
  */
682
752
  onViewChange(route) {
753
+ if (typeof route === 'string' && route.startsWith(VIEW_OPTION_PREFIX)) {
754
+ if (this.cnPageViews && typeof this.cnPageViews.select === 'function') {
755
+ this.cnPageViews.select(route.slice(VIEW_OPTION_PREFIX.length))
756
+ }
757
+ return
758
+ }
683
759
  if (!this.$router || !route || route === (this.$route && this.$route.name)) {
684
760
  return
685
761
  }
@@ -765,6 +841,17 @@ export default {
765
841
  padding: 16px;
766
842
  }
767
843
 
844
+ /* On the page ground (`content.ground`): no card, so no inset around the
845
+ view switch, and the heading at the board's 32px. */
846
+ .cn-header-widget--ground .cn-header-widget__views {
847
+ padding: 0;
848
+ }
849
+
850
+ .cn-header-widget--ground .cn-header-widget__title {
851
+ font-size: var(--cn-header-ground-title-size, 32px);
852
+ letter-spacing: -0.01em;
853
+ }
854
+
768
855
  .cn-header-widget__date {
769
856
  font-size: 14px;
770
857
  line-height: 1.4;
@@ -43,4 +43,4 @@ export default {
43
43
  </script>
44
44
  ```
45
45
 
46
- Props: `options` lists the choices, `modelValue` is the chosen value (use `v-model`), `aria-label` names the group (or point `aria-labelledby` at a visible heading), and `stretch` fills the width.
46
+ Props: `options` lists the choices, `modelValue` is the chosen value (use `v-model`), `aria-label` names the group (or point `aria-labelledby` at a visible heading), `controls` names the id of the element the options switch (set as `aria-controls` on every option), and `stretch` fills the width.
@@ -20,6 +20,7 @@
20
20
  :aria-checked="isChecked(option) ? 'true' : 'false'"
21
21
  :tabindex="index === focusableIndex ? 0 : -1"
22
22
  :disabled="option.disabled || null"
23
+ :aria-controls="controls || null"
23
24
  :data-value="option.key"
24
25
  @click="select(option)">
25
26
  <!-- @slot option Replaces the content of one option. -->
@@ -95,6 +96,17 @@ export default {
95
96
  default: '',
96
97
  },
97
98
 
99
+ /**
100
+ * Id of the element the options switch, such as the region holding a
101
+ * page view's widgets. Set as `aria-controls` on every option.
102
+ *
103
+ * @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
104
+ */
105
+ controls: {
106
+ type: String,
107
+ default: '',
108
+ },
109
+
98
110
  /** Stretch the control to the full width, with options of equal width. */
99
111
  stretch: {
100
112
  type: Boolean,
@@ -1489,7 +1489,16 @@ export default {
1489
1489
  white-space: nowrap;
1490
1490
  }
1491
1491
 
1492
+ /* A clickable label is a <button>, and Nextcloud's core stylesheet gives
1493
+ every `button:not(.button-vue)` a min-height of the clickable area (34 to
1494
+ 44px) with the text centred in it. That pushed each reachable stage's label
1495
+ about 12px below the current one, which is a <span>. Zeroed here so all
1496
+ labels sit 8px under their bar, as the board draws them. The visually
1497
+ hidden "(current step)" is absolutely positioned and takes no space. */
1492
1498
  button.cn-stages-widget__bar-label {
1499
+ min-height: 0;
1500
+ height: auto;
1501
+ line-height: inherit;
1493
1502
  cursor: pointer;
1494
1503
  }
1495
1504
 
@@ -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" />
@@ -95,13 +95,22 @@
95
95
  </span>
96
96
  </template>
97
97
  <span
98
- v-if="!displayLoading && !displayError && resolvedCaption"
98
+ v-if="!isStacked && !displayLoading && !displayError && resolvedCaption"
99
99
  class="cn-kpi-card__label cn-stat-widget__caption"
100
100
  :class="captionVariantClass"
101
101
  data-testid="cn-stat-widget-caption">
102
102
  {{ resolvedCaption }}
103
103
  </span>
104
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>
105
114
  </div>
106
115
  </component>
107
116
  </template>
@@ -1242,12 +1251,31 @@ export default {
1242
1251
  /**
1243
1252
  * Card orientation. Horizontal (icon beside the number) is the
1244
1253
  * canonical KPI card; `content.layout: 'vertical'` stacks the icon
1245
- * 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`).
1246
1256
  *
1247
- * @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'}
1248
1259
  */
1249
1260
  cardLayout() {
1250
- 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'
1251
1279
  },
1252
1280
 
1253
1281
  /**
@@ -39,6 +39,28 @@
39
39
  min-width: 0;
40
40
  }
41
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
+
42
64
  .cn-detail-page__header-left {
43
65
  display: flex;
44
66
  align-items: center;
@@ -459,3 +481,8 @@
459
481
  font-size: 0.9em;
460
482
  white-space: nowrap;
461
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
+ }
@@ -367,6 +367,53 @@
367
367
  font-size: var(--cn-kpi-value-size-stacked, 2rem);
368
368
  }
369
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
+
370
417
  /* Filled: the card draws its own grey box, for a KPI that is NOT already
371
418
  inside a wrapper that draws one. Opt-in via `content.flat: false`. */
372
419
  .cn-kpi-card--filled {