@conduction/nextcloud-vue 2.92.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 (199) 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/CnAppRoot/CnAppRoot.vue2.js +57 -4
  11. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +44 -17
  13. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  14. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  15. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +44 -2
  16. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  17. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +140 -59
  18. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  19. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
  20. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +139 -3
  21. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
  22. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js +60 -12
  23. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +111 -42
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +117 -73
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +34 -39
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  31. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +7 -7
  32. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +5 -3
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  35. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  36. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  37. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +6 -4
  38. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  39. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  40. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +46 -0
  41. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
  42. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js +6 -2
  43. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js.map +1 -1
  44. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  45. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +82 -9
  46. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  47. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js +104 -90
  48. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  49. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +9 -0
  50. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js.map +1 -0
  51. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +355 -0
  52. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -0
  53. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +462 -0
  54. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -0
  55. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  56. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +21 -0
  57. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  58. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +402 -391
  59. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  60. package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
  61. package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
  62. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +141 -7
  63. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  64. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -2
  65. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  66. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue.js +1 -1
  67. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js +22 -1
  68. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js.map +1 -1
  69. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js +20 -14
  70. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js.map +1 -1
  71. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue.js +1 -1
  72. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +2 -0
  73. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
  74. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +15 -5
  75. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
  76. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js +8 -0
  77. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js.map +1 -0
  78. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js +114 -0
  79. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js.map +1 -0
  80. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js +45 -0
  81. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js.map +1 -0
  82. package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js +38 -2
  83. package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js.map +1 -1
  84. package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js +12 -2
  85. package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js.map +1 -1
  86. package/dist/esm/components/CnPagination/CnPagination.vue2.js.map +1 -1
  87. package/dist/esm/components/CnPagination/CnPagination.vue3.js +56 -51
  88. package/dist/esm/components/CnPagination/CnPagination.vue3.js.map +1 -1
  89. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  90. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +3 -3
  91. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  92. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  93. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue.js +1 -1
  94. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js +8 -30
  95. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js.map +1 -1
  96. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue3.js.map +1 -1
  97. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  98. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +24 -0
  99. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  100. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +3 -1
  101. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  102. package/dist/esm/components/CnTabs/CnTabs.vue.js +1 -1
  103. package/dist/esm/components/CnTabs/CnTabs.vue2.js +39 -2
  104. package/dist/esm/components/CnTabs/CnTabs.vue2.js.map +1 -1
  105. package/dist/esm/components/CnTabs/CnTabs.vue3.js.map +1 -1
  106. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  107. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +85 -8
  108. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  109. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +37 -9
  110. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  111. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +9 -0
  112. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js.map +1 -0
  113. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +164 -0
  114. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -0
  115. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +93 -0
  116. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -0
  117. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue.js +1 -1
  118. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js.map +1 -1
  119. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js +4 -3
  120. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js.map +1 -1
  121. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  122. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +32 -0
  123. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  124. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +31 -20
  125. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  126. package/dist/esm/composables/indexSources.js +9 -0
  127. package/dist/esm/composables/indexSources.js.map +1 -1
  128. package/dist/esm/l10n/en.json.js +24 -1
  129. package/dist/esm/l10n/en.json.js.map +1 -1
  130. package/dist/esm/l10n/nl.json.js +24 -1
  131. package/dist/esm/l10n/nl.json.js.map +1 -1
  132. package/dist/esm/library-style.js +1 -1
  133. package/dist/esm/nextcloud-vue.css +1190 -410
  134. package/dist/esm/utils/dueText.js +49 -8
  135. package/dist/esm/utils/dueText.js.map +1 -1
  136. package/dist/esm/utils/featureCatalog.js +135 -0
  137. package/dist/esm/utils/featureCatalog.js.map +1 -0
  138. package/dist/esm/utils/fillTemplate.js +74 -3
  139. package/dist/esm/utils/fillTemplate.js.map +1 -1
  140. package/dist/esm/utils/headerMeta.js +62 -12
  141. package/dist/esm/utils/headerMeta.js.map +1 -1
  142. package/dist/esm/utils/roadmapIssues.js +86 -0
  143. package/dist/esm/utils/roadmapIssues.js.map +1 -0
  144. package/dist/esm/utils/userDisplayName.js +47 -0
  145. package/dist/esm/utils/userDisplayName.js.map +1 -0
  146. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  147. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  148. package/dist/nextcloud-vue.cjs.js +16229 -13423
  149. package/dist/nextcloud-vue.cjs.js.map +2 -2
  150. package/dist/nextcloud-vue.css +1190 -410
  151. package/l10n/en.json +24 -1
  152. package/l10n/nl.json +24 -1
  153. package/package.json +1 -1
  154. package/src/components/CnActionsMenu/CnActionsMenu.vue +3 -1
  155. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +39 -1
  156. package/src/components/CnAppRoot/CnAppRoot.md +2 -0
  157. package/src/components/CnAppRoot/CnAppRoot.vue +66 -5
  158. package/src/components/CnChoiceCards/CnChoiceCards.md +27 -0
  159. package/src/components/CnChoiceCards/CnChoiceCards.vue +168 -3
  160. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +222 -6
  161. package/src/components/CnDashboardPage/CnDashboardPage.vue +141 -8
  162. package/src/components/CnDataTable/CnDataTable.md +1 -0
  163. package/src/components/CnDataTable/CnDataTable.vue +41 -46
  164. package/src/components/CnDetailPage/CnDetailPage.vue +5 -3
  165. package/src/components/CnFactsWidget/CnFactsWidget.vue +6 -4
  166. package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +51 -1
  167. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +98 -13
  168. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +1053 -0
  169. package/src/components/CnFormDialog/CnFormDialog.vue +606 -562
  170. package/src/components/CnIndexPage/CnIndexPage.vue +144 -7
  171. package/src/components/CnNcWidgetWidget/CnNcWidgetWidget.vue +23 -2
  172. package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +5 -0
  173. package/src/components/CnPageHeader/CnHeaderTrail.vue +140 -0
  174. package/src/components/CnPageHeader/CnPageHeader.md +11 -0
  175. package/src/components/CnPageHeader/CnPageHeader.vue +42 -0
  176. package/src/components/CnPagination/CnPagination.vue +10 -2
  177. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +3 -3
  178. package/src/components/CnRoadmapTab/CnRoadmapTab.vue +6 -28
  179. package/src/components/CnSettingsPage/CnSettingsPage.vue +26 -0
  180. package/src/components/CnTabs/CnTabs.vue +39 -2
  181. package/src/components/CnTabsWidget/CnTabsWidget.vue +106 -13
  182. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +305 -0
  183. package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +4 -3
  184. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +8 -0
  185. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +43 -2
  186. package/src/composables/indexSources.js +9 -0
  187. package/src/css/dialog.css +61 -0
  188. package/src/css/look-board-detail.css +8 -0
  189. package/src/css/look-board-index.css +27 -1
  190. package/src/css/look-board.css +60 -0
  191. package/src/css/page-header.css +16 -0
  192. package/src/schemas/app-manifest-v2.schema.json +30 -17
  193. package/src/utils/dueText.js +49 -8
  194. package/src/utils/featureCatalog.js +132 -0
  195. package/src/utils/fillTemplate.js +73 -2
  196. package/src/utils/headerMeta.js +62 -12
  197. package/src/utils/roadmapIssues.js +82 -0
  198. package/src/utils/userDisplayName.js +54 -0
  199. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -54,11 +54,14 @@
54
54
  class="cn-widget-wrapper__icon" />
55
55
  <!-- An eyebrow (`eyebrow`) is a muted line above the title:
56
56
  "Saldo per week" above "Prognose 13 weken". -->
57
- <div v-if="resolvedEyebrow" class="cn-widget-wrapper__title-stack">
58
- <span class="cn-widget-wrapper__eyebrow" data-testid="cn-widget-wrapper-eyebrow">{{ resolvedEyebrow }}</span>
57
+ <!-- A subtitle (`subtitle`) is a muted line under the title:
58
+ "Dit kwartaal, beslist binnen de termijn". -->
59
+ <div v-if="resolvedEyebrow || resolvedSubtitle" class="cn-widget-wrapper__title-stack">
60
+ <span v-if="resolvedEyebrow" class="cn-widget-wrapper__eyebrow" data-testid="cn-widget-wrapper-eyebrow">{{ resolvedEyebrow }}</span>
59
61
  <h3 :id="titleId" class="cn-widget-wrapper__title">
60
62
  {{ resolvedTitle }}
61
63
  </h3>
64
+ <span v-if="resolvedSubtitle" class="cn-widget-wrapper__subtitle" data-testid="cn-widget-wrapper-subtitle">{{ resolvedSubtitle }}</span>
62
65
  </div>
63
66
  <h3 v-else :id="titleId" class="cn-widget-wrapper__title">
64
67
  {{ resolvedTitle }}
@@ -225,6 +228,18 @@ export default {
225
228
  CnActionsMenu,
226
229
  },
227
230
 
231
+ provide() {
232
+ return {
233
+ /**
234
+ * Whether this card draws a title row, so a widget inside it can
235
+ * leave out a heading of its own (nc-widget-header).
236
+ *
237
+ * @return {boolean}
238
+ */
239
+ cnWidgetTitled: () => this.titleVisible && Boolean(this.displayTitle),
240
+ }
241
+ },
242
+
228
243
  inject: {
229
244
  /**
230
245
  * Host translate function provided by CnAppRoot as
@@ -252,6 +267,17 @@ export default {
252
267
  default: '',
253
268
  },
254
269
 
270
+ /**
271
+ * A muted line under the title, such as the period a chart covers.
272
+ * Run through the host translate function. Empty draws none.
273
+ *
274
+ * @spec openspec/changes/page-header-breadcrumb/specs/page-header/spec.md#requirement-a-dashboard-widget-card-can-carry-a-line-under-its-title
275
+ */
276
+ subtitle: {
277
+ type: String,
278
+ default: '',
279
+ },
280
+
255
281
  /** Whether to show the header with title */
256
282
  showTitle: {
257
283
  type: Boolean,
@@ -721,6 +747,15 @@ export default {
721
747
  return this.eyebrow ? this.effectiveTranslate(this.eyebrow) : ''
722
748
  },
723
749
 
750
+ /**
751
+ * The subtitle run through the host translate function.
752
+ *
753
+ * @return {string}
754
+ */
755
+ resolvedSubtitle() {
756
+ return this.subtitle ? this.effectiveTranslate(this.subtitle) : ''
757
+ },
758
+
724
759
  /**
725
760
  * The RENDERED widget title — `displayTitle` run through the host
726
761
  * translate function. Kept separate from `displayTitle` on purpose:
@@ -1163,6 +1198,12 @@ export default {
1163
1198
  min-width: 0;
1164
1199
  }
1165
1200
 
1201
+ .cn-widget-wrapper__subtitle {
1202
+ font-size: 13px;
1203
+ line-height: 1.3;
1204
+ color: var(--color-text-maxcontrast);
1205
+ }
1206
+
1166
1207
  .cn-widget-wrapper__eyebrow {
1167
1208
  font-size: 13px;
1168
1209
  line-height: 1.3;
@@ -267,6 +267,15 @@ function tasksSource() {
267
267
  */
268
268
  loading: () => !!store.loading,
269
269
 
270
+ /**
271
+ * Count the inbox for a config without touching the rows on screen,
272
+ * for a quick-filter tab with `showCount`.
273
+ *
274
+ * @param {object} config The merged config, as for `load`.
275
+ * @return {Promise<number>} The datastore total.
276
+ */
277
+ count: async (config) => (await store.fetchFor({ ...(config || {}), limit: 1 })).total,
278
+
270
279
  columns: [
271
280
  { key: 'title', label: t('nextcloud-vue', 'Task') },
272
281
  { key: 'subjectLabel', label: t('nextcloud-vue', 'Subject') },
@@ -203,3 +203,64 @@
203
203
  .cn-dialog__confirm-pending {
204
204
  opacity: 0.45;
205
205
  }
206
+
207
+ /* On a phone the board dialog is a bottom sheet (board PqMobiel): full width,
208
+ anchored to the bottom, as tall as its content up to the screen less the
209
+ header, a 16px top radius and a grab bar. The footer buttons share the row. */
210
+ @media (max-width: 600px) {
211
+ .modal-mask.cn-dialog-board .modal-wrapper .modal-container,
212
+ .modal-mask.cn-dialog-board .modal-wrapper--normal > .modal-container {
213
+ position: absolute;
214
+ inset: auto 0 0;
215
+ width: 100%;
216
+ max-width: 100%;
217
+ height: auto;
218
+ max-height: calc(100% - var(--header-height, 50px));
219
+ border-radius: 16px 16px 0 0;
220
+ }
221
+
222
+ .modal-mask.cn-dialog-board .modal-container::before {
223
+ content: '';
224
+ display: block;
225
+ flex: none;
226
+ width: 40px;
227
+ height: 4px;
228
+ margin: 8px auto 0;
229
+ border-radius: 2px;
230
+ background: var(--color-border-dark);
231
+ }
232
+
233
+ .modal-mask.cn-dialog-board .modal-container {
234
+ flex-direction: column;
235
+ }
236
+
237
+ .cn-dialog-board .dialog {
238
+ height: auto;
239
+ min-height: 0;
240
+ }
241
+
242
+ .cn-dialog-board .dialog__content {
243
+ flex: 0 1 auto;
244
+ overflow-y: auto;
245
+ }
246
+
247
+ .cn-dialog-board .dialog__actions > .button-vue,
248
+ .cn-dialog-board .dialog__actions > * {
249
+ flex: 1 1 0;
250
+ min-height: 44px;
251
+ }
252
+ }
253
+
254
+ /* Above a phone the boards top-align a dialog (DqNieuweZaak, DqUwWachtrij,
255
+ DqVolgendeStap: the top edge sits about 90px down at 1440 wide) instead of
256
+ centring it. `--cn-dialog-top` moves it. */
257
+ @media (min-width: 601px) {
258
+ .modal-mask.cn-dialog-board .modal-wrapper {
259
+ align-items: flex-start;
260
+ }
261
+
262
+ .modal-mask.cn-dialog-board .modal-wrapper .modal-container {
263
+ margin-top: var(--cn-dialog-top, 88px);
264
+ max-height: calc(100% - var(--cn-dialog-top, 88px) - 24px);
265
+ }
266
+ }
@@ -660,3 +660,11 @@
660
660
  .cn-look-board:has(.cn-detail-page) > .app-sidebar__toggle {
661
661
  display: none;
662
662
  }
663
+
664
+ /* Below 768px the header is a column (detail-page.css), where the 320px basis
665
+ above would be a 320px HEIGHT: the title sat in a band of white on a phone. */
666
+ @media (max-width: 768px) {
667
+ .cn-look-board .cn-detail-page__header--board > :first-child {
668
+ flex: none;
669
+ }
670
+ }
@@ -481,10 +481,11 @@
481
481
  border-radius: 0;
482
482
  }
483
483
 
484
+ /* The header row is near-white on the boards (#fafbfc), not the hover grey. */
484
485
  .cn-look-board .cn-table-container--board .cn-data-table th {
485
486
  padding: 12px 16px;
486
487
  border-bottom: 0;
487
- background: var(--color-background-hover);
488
+ background: var(--cn-board-table-head-bg, color-mix(in srgb, var(--color-background-hover) 40%, var(--color-main-background)));
488
489
  color: var(--color-text-maxcontrast);
489
490
  font-weight: 600;
490
491
  }
@@ -542,6 +543,15 @@
542
543
  max-width: calc(100% - 26px);
543
544
  }
544
545
 
546
+ /* The board's funnel: a 22px button 4px after the sort chevron. */
547
+ .cn-look-board .cn-table-container--board .cn-data-table th .cn-table-header__filter {
548
+ width: 22px;
549
+ min-width: 22px;
550
+ height: 22px;
551
+ min-height: 22px;
552
+ margin-inline-start: 4px;
553
+ }
554
+
545
555
  /* The selection column: 18px checkboxes, 28px of content after 16px of padding.
546
556
  The box model is set with `!important` and the widths include the padding:
547
557
  Nextcloud's server.css sets `box-sizing: border-box` on everything under
@@ -747,6 +757,14 @@
747
757
  padding: 0 12px;
748
758
  }
749
759
 
760
+ /* The inert Next of a single page: a faint outline, muted text. */
761
+ .cn-look-board .cn-pagination--board .cn-pagination__board-link--disabled {
762
+ display: inline-flex;
763
+ align-items: center;
764
+ color: var(--color-text-maxcontrast);
765
+ cursor: default;
766
+ }
767
+
750
768
  .cn-look-board .cn-pagination--board .cn-pagination__board-link--current {
751
769
  border-color: var(--color-primary-element);
752
770
  background: var(--color-primary-element);
@@ -1028,3 +1046,11 @@
1028
1046
  padding: 0 12px;
1029
1047
  border-radius: 8px;
1030
1048
  }
1049
+
1050
+ /* The sidebar's own open toggle (NcAppSidebar's `.app-sidebar__toggle`, drawn
1051
+ when the sidebar is closed) sits over the content's top-right corner, which
1052
+ on an index page is the primary header button: a stray dark square on
1053
+ "New contact". The board draws none; the Filter button opens the sidebar. */
1054
+ .cn-look-board:has(.cn-index-page) > .app-sidebar__toggle {
1055
+ display: none;
1056
+ }
@@ -591,6 +591,66 @@
591
591
  max-width: none;
592
592
  }
593
593
 
594
+ /* A Nextcloud text field or text area in a board settings card has its label
595
+ above the control (14px/600), as the boards draw it (PqBeheer Leadbeheer,
596
+ PqBeheerKassa Btw), instead of floating inside the border. The label stays
597
+ the field's own `<label for>`; it moves out of the control and the field
598
+ makes room for it. With the label outside, the placeholder shows at rest.
599
+ @spec openspec/changes/settings-section-board-fields/specs/settings-board-look/spec.md#requirement-a-settings-card-labels-its-text-fields-above-the-control */
600
+ .cn-look-board .settings-section:not(.cn-look-nextcloud *) .input-field:has(.input-field__label),
601
+ .cn-look-board .settings-section:not(.cn-look-nextcloud *) .textarea:has(.textarea__label) {
602
+ margin-block-start: 26px;
603
+ }
604
+
605
+ .cn-look-board .settings-section:not(.cn-look-nextcloud *) .input-field__input + .input-field__label,
606
+ .cn-look-board .settings-section:not(.cn-look-nextcloud *) .textarea__input + .textarea__label {
607
+ inset-block-start: auto;
608
+ inset-block-end: calc(100% + 4px);
609
+ inset-inline: 0 auto;
610
+ max-width: 100%;
611
+ margin: 0;
612
+ padding: 0;
613
+ background: none;
614
+ color: var(--color-main-text);
615
+ font-size: 14px;
616
+ font-weight: 600;
617
+ line-height: 20px;
618
+ pointer-events: auto;
619
+ transition: none;
620
+ }
621
+
622
+ .cn-look-board .settings-section:not(.cn-look-nextcloud *) .input-field:has(.input-field__label) .input-field__input:not(:focus)::placeholder,
623
+ .cn-look-board .settings-section:not(.cn-look-nextcloud *) .textarea__input:not(:focus):not(.textarea__input--label-outside)::placeholder {
624
+ opacity: 1 !important;
625
+ }
626
+
627
+ /* The support card at the end of an admin page (`supportPosition: "end"`,
628
+ board PqBeheer): one row, the title then the lines, wrapping in a narrow
629
+ window, with less padding than a settings card.
630
+ @spec openspec/changes/admin-shell-support-end/specs/settings-board-look/spec.md#requirement-support-can-be-its-own-card-at-the-end */
631
+ .cn-look-board .settings-section.cn-admin-settings-shell__support:not(.cn-look-nextcloud *) {
632
+ flex-direction: row !important;
633
+ flex-wrap: wrap;
634
+ align-items: center;
635
+ gap: 10px 24px;
636
+ padding: 16px 24px;
637
+ }
638
+
639
+ .cn-look-board .cn-admin-settings-shell__support:not(.cn-look-nextcloud *) .settings-section__name {
640
+ font-size: 16px;
641
+ }
642
+
643
+ .cn-look-board .cn-admin-settings-shell__support:not(.cn-look-nextcloud *) .cn-settings-section__content {
644
+ display: flex;
645
+ flex-wrap: wrap;
646
+ gap: 10px 24px;
647
+ }
648
+
649
+ .cn-look-board .cn-admin-settings-shell__support:not(.cn-look-nextcloud *) .cn-admin-settings-shell__support-line {
650
+ margin: 0;
651
+ font-size: 14px;
652
+ }
653
+
594
654
  /* The section footer is the save row: the hairline replaces its own rule. */
595
655
  .cn-look-board .settings-section .cn-settings-section__footer {
596
656
  margin: 0;
@@ -61,3 +61,19 @@
61
61
  white-space: nowrap;
62
62
  border: 0;
63
63
  }
64
+
65
+ /* The breadcrumb and meta line under the title (CnHeaderTrail). In the board
66
+ look it takes the detail header row's styles; here it only lays the crumbs,
67
+ the dot and the note out on one line. */
68
+ .cn-header-trail {
69
+ align-items: center;
70
+ color: var(--color-text-maxcontrast);
71
+ display: flex;
72
+ flex-wrap: wrap;
73
+ gap: var(--default-grid-baseline);
74
+ margin-bottom: var(--default-grid-baseline);
75
+ }
76
+
77
+ .cn-header-trail .cn-detail-page__breadcrumbs {
78
+ margin-bottom: 0;
79
+ }
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.108.0",
6
+ "version": "2.115.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -533,6 +533,21 @@
533
533
  }
534
534
  },
535
535
  "$defs": {
536
+ "breadcrumbCrumb": {
537
+ "type": "object",
538
+ "additionalProperties": false,
539
+ "required": ["label"],
540
+ "description": "One breadcrumb crumb (config.breadcrumb).",
541
+ "properties": {
542
+ "label": { "type": "string", "minLength": 1 },
543
+ "route": { "type": "string" },
544
+ "params": { "type": "object", "additionalProperties": true },
545
+ "href": { "type": "string" },
546
+ "icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name (e.g. `Home`) drawn as the first crumb instead of the label, which then becomes its accessible name (zuiddrecht-pixel-gaps-2). Without it the label shows as text." },
547
+ "currentField": { "type": "string", "minLength": 1, "description": "A field of the record (dotted path, e.g. `identifier`) whose value is the current crumb instead of the display name; an empty value falls back to the display name (zuiddrecht-pixel-gaps-3)." },
548
+ "separator": { "type": "string", "minLength": 1, "maxLength": 3, "description": "Text drawn between the crumbs (e.g. `/`) instead of the chevron (zuiddrecht-pixel-gaps-3)." }
549
+ }
550
+ },
536
551
  "rowIndicator": {
537
552
  "type": "object",
538
553
  "additionalProperties": false,
@@ -4259,7 +4274,7 @@
4259
4274
  "headerMeta": {
4260
4275
  "type": "string",
4261
4276
  "minLength": 1,
4262
- "description": "type='detail', board look: the meta line on row 2 of the header, after the breadcrumb and a middle dot (screens-detail-page-parity). A field template such as `\"via {channel}\"`: every `{path}` is read from the record, and a line with an empty value is dropped. Maps to CnDetailPage `headerMeta`."
4277
+ "description": "type='detail', board look: the meta line on row 2 of the header, after the breadcrumb and a middle dot (screens-detail-page-parity). A field template such as `\"via {channel}\"`: every `{path}` is read from the record, and a line with an empty value is dropped. Maps to CnDetailPage `headerMeta`. type='index' | 'dashboard' | 'settings' (page-header-breadcrumb): the note after the breadcrumb and a middle dot under the title; on an index page `{total}` and `{shown}` are the list's counts."
4263
4278
  },
4264
4279
  "tabsLabel": {
4265
4280
  "type": "string",
@@ -4285,10 +4300,16 @@
4285
4300
  "greeting": {
4286
4301
  "oneOf": [
4287
4302
  { "type": "boolean" },
4288
- { "type": "string", "enum": ["first", "full"] }
4303
+ { "type": "string", "enum": ["first", "full", "date"] },
4304
+ {
4305
+ "type": "object",
4306
+ "required": ["label"],
4307
+ "additionalProperties": false,
4308
+ "properties": { "label": { "type": "string", "minLength": 1, "description": "i18n key drawn where the greeting would be." } }
4309
+ }
4289
4310
  ],
4290
4311
  "default": false,
4291
- "description": "type='dashboard': greet the reader in the header subtitle. `true` or \"first\" writes \"Good afternoon, Pieter · Monday 5 October 2026\", \"full\" uses the whole display name; the page `description` follows after another middle dot (screens-dashboard-greeting-header). Maps to CnDashboardPage `greeting`."
4312
+ "description": "type='dashboard': greet the reader in the header subtitle. `true` or \"first\" writes \"Good afternoon, Pieter · Monday 5 October 2026\", \"full\" uses the whole display name; the page `description` follows after another middle dot (screens-dashboard-greeting-header). Since 2.111.0 \"date\" leads with the date alone (\"Tuesday 7 October 2026 · ...\") and `{ \"label\": \"Customer contact\" }` puts the translated label where the greeting would be (dashboard-greeting-label). Maps to CnDashboardPage `greeting`."
4292
4313
  },
4293
4314
  "viewLinks": {
4294
4315
  "type": "array",
@@ -4493,19 +4514,11 @@
4493
4514
  "description": "type='detail': `false` drops the type eyebrow (the type label above the record name) once the record resolves, for a header that says the type in a pill instead (zuiddrecht-pixel-gaps). Maps to CnDetailPage `showTypeEyebrow`."
4494
4515
  },
4495
4516
  "breadcrumb": {
4496
- "type": "object",
4497
- "additionalProperties": false,
4498
- "required": ["label"],
4499
- "description": "type='detail': a breadcrumb line above the header (zuiddrecht-pixel-gaps): this crumb names the list the record belongs to (`route` + optional `params`, or `href`), the record's display name follows as the current crumb. Maps to CnDetailPage `breadcrumb`.",
4500
- "properties": {
4501
- "label": { "type": "string", "minLength": 1 },
4502
- "route": { "type": "string" },
4503
- "params": { "type": "object", "additionalProperties": true },
4504
- "href": { "type": "string" },
4505
- "icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name (e.g. `Home`) drawn as the first crumb instead of the label, which then becomes its accessible name (zuiddrecht-pixel-gaps-2). Without it the label shows as text." },
4506
- "currentField": { "type": "string", "minLength": 1, "description": "A field of the record (dotted path, e.g. `identifier`) whose value is the current crumb instead of the display name; an empty value falls back to the display name (zuiddrecht-pixel-gaps-3)." },
4507
- "separator": { "type": "string", "minLength": 1, "maxLength": 3, "description": "Text drawn between the crumbs (e.g. `/`) instead of the chevron (zuiddrecht-pixel-gaps-3)." }
4508
- }
4517
+ "description": "A breadcrumb line. type='detail' (zuiddrecht-pixel-gaps): one crumb naming the list the record belongs to (`route` + optional `params`, or `href`); the record's display name follows as the current crumb. Maps to CnDetailPage `breadcrumb`. type='index' | 'dashboard' | 'settings' (page-header-breadcrumb): one crumb or a list, drawn under the title in the same row style; the page title follows as the current crumb. Maps to the page's `breadcrumb`.",
4518
+ "anyOf": [
4519
+ { "$ref": "#/$defs/breadcrumbCrumb" },
4520
+ { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/breadcrumbCrumb" } }
4521
+ ]
4509
4522
  },
4510
4523
  "headerCard": {
4511
4524
  "type": "boolean",
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * "3 dagen te laat" in the danger colour, "Vandaag", the short date for a
8
8
  * day still to come ("morgen", "20 okt"), and a configured text when the
9
- * field is empty ("Geen deadline"). One formatter for record-list rows and
9
+ * field is empty ("Geen deadline"). A day to come can read as a count instead
10
+ * ("nog 4 dagen", "nog 8 weken"). One formatter for record-list rows and
10
11
  * table cells, built on the relative date (`formatRelativeDate`).
11
12
  *
12
13
  * @module utils/dueText
@@ -23,8 +24,12 @@ import { parseDateValue } from './dateVariant.js'
23
24
  * Config (all optional): `todayLabel` (default "Due today"), `overdueLabel`
24
25
  * `{ one, other }` with `{count}` (default "{count} day overdue" / "{count} days
25
26
  * overdue"), `emptyText` (shown when the field is empty; without it an empty
26
- * field reads as nothing). Texts go through `translate` before `{count}` is
27
- * filled. Tones: `danger` (overdue), `today`, `upcoming`, `empty`.
27
+ * field reads as nothing), `upcomingLabel` `{ one, other }` with `{count}`
28
+ * (a day to come reads as the days left instead of the date), and
29
+ * `upcomingWeeksLabel` `{ one, other }` (from `upcomingWeeksFrom` days left,
30
+ * default 35, it reads the weeks left, rounded). Texts go through `translate`
31
+ * before `{count}` is filled. Tones: `danger` (overdue), `today`, `upcoming`,
32
+ * `empty`.
28
33
  *
29
34
  * @param {unknown} value A Date, a timestamp or an ISO string.
30
35
  * @param {object} [config] The `due` config.
@@ -41,10 +46,9 @@ export function resolveDue(value, config, translate = (text) => text, now = new
41
46
  }
42
47
  const days = ageInDays(date, now)
43
48
  if (days > 0) {
44
- const labels = cfg.overdueLabel && typeof cfg.overdueLabel === 'object' ? cfg.overdueLabel : null
45
- if (labels && (labels.one || labels.other)) {
46
- const template = translate((days === 1 ? labels.one : labels.other) || labels.other || labels.one)
47
- return { text: template.replace(/\{count\}/g, String(days)), tone: 'danger' }
49
+ const overdue = pluralLabel(cfg.overdueLabel, days, translate)
50
+ if (overdue !== null) {
51
+ return { text: overdue, tone: 'danger' }
48
52
  }
49
53
  return { text: n('nextcloud-vue', '{count} day overdue', '{count} days overdue', days, { count: days }), tone: 'danger' }
50
54
  }
@@ -52,5 +56,42 @@ export function resolveDue(value, config, translate = (text) => text, now = new
52
56
  const label = typeof cfg.todayLabel === 'string' && cfg.todayLabel !== '' ? translate(cfg.todayLabel) : t('nextcloud-vue', 'Due today')
53
57
  return { text: label, tone: 'today' }
54
58
  }
55
- return { text: formatRelativeDate(date, { now }), tone: 'upcoming' }
59
+ return { text: upcomingText(-days, cfg, translate) ?? formatRelativeDate(date, { now }), tone: 'upcoming' }
60
+ }
61
+
62
+ /**
63
+ * The days or weeks left before a date to come, or null without `upcomingLabel`.
64
+ *
65
+ * @param {number} left Whole days left, at least 1.
66
+ * @param {object} cfg The `due` config.
67
+ * @param {(text: string) => string} translate The host's translate.
68
+ * @return {string|null} The text, or null.
69
+ * @spec openspec/changes/due-text-upcoming-count/specs/due-text-upcoming-count/spec.md#requirement-a-due-date-to-come-can-read-as-the-time-left
70
+ */
71
+ function upcomingText(left, cfg, translate) {
72
+ const from = Number(cfg.upcomingWeeksFrom)
73
+ const weeksFrom = Number.isFinite(from) && from > 0 ? from : 35
74
+ if (left >= weeksFrom) {
75
+ const weeks = pluralLabel(cfg.upcomingWeeksLabel, Math.round(left / 7), translate)
76
+ if (weeks !== null) {
77
+ return weeks
78
+ }
79
+ }
80
+ return pluralLabel(cfg.upcomingLabel, left, translate)
81
+ }
82
+
83
+ /**
84
+ * A `{ one, other }` label with `{count}` filled, or null when none is configured.
85
+ *
86
+ * @param {unknown} labels The configured label pair.
87
+ * @param {number} count The count.
88
+ * @param {(text: string) => string} translate The host's translate.
89
+ * @return {string|null} The text, or null.
90
+ */
91
+ function pluralLabel(labels, count, translate) {
92
+ if (!labels || typeof labels !== 'object' || !(labels.one || labels.other)) {
93
+ return null
94
+ }
95
+ const template = translate((count === 1 ? labels.one : labels.other) || labels.other || labels.one)
96
+ return template.replace(/\{count\}/g, String(count))
56
97
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * featureCatalog: the feature list of the Features and roadmap page, read the
6
+ * way the board look draws it. A feature carries a status (available, beta,
7
+ * in progress or planned), an optional category and optional per-language
8
+ * titles (`title_nl`, `summary_nl`) next to the English ones.
9
+ *
10
+ * @module utils/featureCatalog
11
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md
12
+ */
13
+
14
+ /** The statuses, in the order the filter and the captions list them. */
15
+ export const FEATURE_STATUSES = Object.freeze(['available', 'beta', 'in_progress', 'planned'])
16
+
17
+ /** The badge variant of each status. */
18
+ export const FEATURE_STATUS_VARIANTS = Object.freeze({
19
+ available: 'success',
20
+ beta: 'warning',
21
+ in_progress: 'primary',
22
+ planned: 'purple',
23
+ })
24
+
25
+ const STATUS_ALIASES = Object.freeze({
26
+ available: 'available',
27
+ stable: 'available',
28
+ done: 'available',
29
+ released: 'available',
30
+ shipped: 'available',
31
+ beta: 'beta',
32
+ preview: 'beta',
33
+ in_progress: 'in_progress',
34
+ 'in-progress': 'in_progress',
35
+ inprogress: 'in_progress',
36
+ building: 'in_progress',
37
+ development: 'in_progress',
38
+ planned: 'planned',
39
+ roadmap: 'planned',
40
+ proposed: 'planned',
41
+ soon: 'planned',
42
+ })
43
+
44
+ /**
45
+ * The status key of a feature. A feature without a status is one the app
46
+ * ships, as every entry of the list was before statuses existed.
47
+ *
48
+ * @param {unknown} raw The feature's `status`.
49
+ * @return {string} One of FEATURE_STATUSES.
50
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-a-feature-has-one-of-four-statuses
51
+ */
52
+ export function featureStatus(raw) {
53
+ if (typeof raw !== 'string' || raw.trim() === '') {
54
+ return 'available'
55
+ }
56
+ return STATUS_ALIASES[raw.trim().toLowerCase()] || 'available'
57
+ }
58
+
59
+ /**
60
+ * A text field of a feature in the reader's language: `title_nl` for a Dutch
61
+ * reader when the feature has one, else `title`.
62
+ *
63
+ * @param {object} feature The feature.
64
+ * @param {string} field `title` or `summary`.
65
+ * @param {string} [language] The reader's language tag (`nl`, `nl_NL`, `de-DE`).
66
+ * @return {string} The text, or ''.
67
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-a-feature-reads-in-the-readers-language
68
+ */
69
+ export function featureText(feature, field, language = '') {
70
+ if (!feature || typeof feature !== 'object') {
71
+ return ''
72
+ }
73
+ const base = String(language || '').split(/[-_]/)[0].toLowerCase()
74
+ const own = base ? feature[`${field}_${base}`] : undefined
75
+ if (typeof own === 'string' && own.trim() !== '') {
76
+ return own
77
+ }
78
+ return typeof feature[field] === 'string' ? feature[field] : ''
79
+ }
80
+
81
+ /**
82
+ * How many features carry each status.
83
+ *
84
+ * @param {Array<{status: string}>} features Features with a normalised status.
85
+ * @return {{all: number, available: number, beta: number, in_progress: number, planned: number}} The counts.
86
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-the-status-filter-shows-a-count-per-status
87
+ */
88
+ export function countByStatus(features) {
89
+ const counts = { all: features.length, available: 0, beta: 0, in_progress: 0, planned: 0 }
90
+ for (const feature of features) {
91
+ counts[feature.status] += 1
92
+ }
93
+ return counts
94
+ }
95
+
96
+ /**
97
+ * The features in the reader's language, grouped by category. Groups follow
98
+ * the order of `categories`; features without a known category come first,
99
+ * under no caption. Inside a group the features keep their given order.
100
+ *
101
+ * @param {Array<object>} features The raw features.
102
+ * @param {object} [categories] Category key to caption.
103
+ * @param {string} [language] The reader's language tag.
104
+ * @return {Array<{key: string, label: string, features: Array<object>}>} The groups; a group without features is left out.
105
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-features-are-grouped-by-category
106
+ */
107
+ export function groupFeatures(features, categories = {}, language = '') {
108
+ const captions = categories && typeof categories === 'object' && !Array.isArray(categories) ? categories : {}
109
+ const groups = [{ key: '', label: '', features: [] }]
110
+ const byKey = { '': groups[0] }
111
+ for (const [key, label] of Object.entries(captions)) {
112
+ const group = { key, label: String(label), features: [] }
113
+ groups.push(group)
114
+ byKey[key] = group
115
+ }
116
+ for (const raw of Array.isArray(features) ? features : []) {
117
+ if (!raw || typeof raw !== 'object') {
118
+ continue
119
+ }
120
+ const feature = {
121
+ slug: String(raw.slug || raw.id || ''),
122
+ title: featureText(raw, 'title', language) || String(raw.slug || ''),
123
+ summary: featureText(raw, 'summary', language),
124
+ status: featureStatus(raw.status),
125
+ expected: typeof raw.expected === 'string' ? raw.expected : '',
126
+ docsUrl: typeof raw.docsUrl === 'string' ? raw.docsUrl : '',
127
+ }
128
+ const group = byKey[typeof raw.category === 'string' ? raw.category : ''] || byKey['']
129
+ group.features.push(feature)
130
+ }
131
+ return groups.filter((group) => group.features.length > 0)
132
+ }