@conduction/nextcloud-vue 2.79.0 → 2.80.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 (197) hide show
  1. package/dist/esm/components/CnActionButtons/CnActionButtons.vue2.js.map +1 -1
  2. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js +3 -1
  3. package/dist/esm/components/CnActionButtons/CnActionButtons.vue3.js.map +1 -1
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +32 -3
  5. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  6. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -8
  7. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +39 -3
  9. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  11. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +79 -1
  12. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  13. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +133 -96
  14. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +1 -0
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +62 -4
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +24 -0
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +43 -4
  24. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +11 -0
  26. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js.map +1 -0
  27. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +736 -0
  28. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -0
  29. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +238 -0
  30. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -0
  31. package/dist/esm/components/CnFactsWidget/index.js +30 -0
  32. package/dist/esm/components/CnFactsWidget/index.js.map +1 -0
  33. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +10 -0
  34. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js.map +1 -0
  35. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +551 -0
  36. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -0
  37. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +183 -0
  38. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -0
  39. package/dist/esm/components/CnFieldListWidget/index.js +29 -0
  40. package/dist/esm/components/CnFieldListWidget/index.js.map +1 -0
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +65 -17
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +4 -4
  44. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  45. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +18 -3
  46. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  47. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  48. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +305 -72
  49. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +31 -13
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +10 -0
  53. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js.map +1 -0
  54. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +99 -0
  55. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -0
  56. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +48 -0
  57. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -0
  58. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js +29 -1
  59. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js.map +1 -1
  60. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js.map +1 -1
  61. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +10 -0
  62. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js.map +1 -0
  63. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +387 -0
  64. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -0
  65. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +169 -0
  66. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -0
  67. package/dist/esm/components/CnRecordListWidget/index.js +31 -0
  68. package/dist/esm/components/CnRecordListWidget/index.js.map +1 -0
  69. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue.js +10 -0
  70. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue.js.map +1 -0
  71. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue2.js +351 -0
  72. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue2.js.map +1 -0
  73. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue3.js +71 -0
  74. package/dist/esm/components/CnSectionsWidget/CnSectionsWidget.vue3.js.map +1 -0
  75. package/dist/esm/components/CnSectionsWidget/index.js +35 -0
  76. package/dist/esm/components/CnSectionsWidget/index.js.map +1 -0
  77. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +81 -2
  78. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  79. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  80. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  81. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +1 -0
  82. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  83. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +122 -7
  84. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  85. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +84 -23
  86. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  87. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +4 -0
  88. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  89. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js +106 -2
  90. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js.map +1 -1
  91. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js +105 -53
  92. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js.map +1 -1
  93. package/dist/esm/composables/useFormDraft.js +140 -9
  94. package/dist/esm/composables/useFormDraft.js.map +1 -1
  95. package/dist/esm/index.js +4 -0
  96. package/dist/esm/index.js.map +1 -1
  97. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue2.js +28 -20
  98. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue2.js.map +1 -1
  99. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue3.js +9 -0
  100. package/dist/esm/integrations/builtin/field-inspection/CnFieldInspectionCard.vue3.js.map +1 -1
  101. package/dist/esm/integrations/builtin/field-inspection.js +21 -0
  102. package/dist/esm/integrations/builtin/field-inspection.js.map +1 -1
  103. package/dist/esm/integrations/offline/checklistSubmission.js +141 -0
  104. package/dist/esm/integrations/offline/checklistSubmission.js.map +1 -0
  105. package/dist/esm/integrations/offline/fieldCollectionHelpers.js +70 -5
  106. package/dist/esm/integrations/offline/fieldCollectionHelpers.js.map +1 -1
  107. package/dist/esm/integrations/offline/index.js +2 -1
  108. package/dist/esm/integrations/offline/index.js.map +1 -1
  109. package/dist/esm/integrations/offline/offlineDb.js +6 -1
  110. package/dist/esm/integrations/offline/offlineDb.js.map +1 -1
  111. package/dist/esm/integrations/offline/syncReplayService.js +8 -2
  112. package/dist/esm/integrations/offline/syncReplayService.js.map +1 -1
  113. package/dist/esm/l10n/en.json.js +12 -1
  114. package/dist/esm/l10n/en.json.js.map +1 -1
  115. package/dist/esm/l10n/nl.json.js +16 -2
  116. package/dist/esm/l10n/nl.json.js.map +1 -1
  117. package/dist/esm/nextcloud-vue.css +1000 -59
  118. package/dist/esm/utils/detailActionModel.js +60 -3
  119. package/dist/esm/utils/detailActionModel.js.map +1 -1
  120. package/dist/esm/utils/fillTemplate.js +114 -0
  121. package/dist/esm/utils/fillTemplate.js.map +1 -0
  122. package/dist/esm/utils/libraryWidgetKeys.js +4 -0
  123. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  124. package/dist/esm/utils/queryPresets.js +96 -0
  125. package/dist/esm/utils/queryPresets.js.map +1 -0
  126. package/dist/esm/utils/resolveFilterTokens.js +11 -0
  127. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  128. package/dist/esm/utils/schema.js +21 -0
  129. package/dist/esm/utils/schema.js.map +1 -1
  130. package/dist/esm/utils/sentinelTokens.js +3 -3
  131. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  132. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  133. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  134. package/dist/esm/utils/widgetDispatch.js +7 -1
  135. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  136. package/dist/nextcloud-vue.cjs.js +12368 -7717
  137. package/dist/nextcloud-vue.cjs.js.map +2 -2
  138. package/dist/nextcloud-vue.css +1000 -59
  139. package/l10n/en.json +12 -1
  140. package/l10n/nl.json +16 -2
  141. package/package.json +1 -1
  142. package/src/components/CnActionButtons/CnActionButtons.vue +2 -0
  143. package/src/components/CnActionsBar/CnActionsBar.md +4 -0
  144. package/src/components/CnActionsBar/CnActionsBar.vue +42 -10
  145. package/src/components/CnBannerWidget/CnBannerWidget.vue +39 -3
  146. package/src/components/CnCellRenderer/CnCellRenderer.vue +188 -1
  147. package/src/components/CnDataTable/CnDataTable.vue +1 -0
  148. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  149. package/src/components/CnDetailPage/CnDetailPage.vue +63 -3
  150. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +66 -0
  151. package/src/components/CnFactsWidget/CnFactsWidget.vue +1050 -0
  152. package/src/components/CnFactsWidget/index.js +29 -0
  153. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +746 -0
  154. package/src/components/CnFieldListWidget/index.js +28 -0
  155. package/src/components/CnFormDialog/CnFormDialog.md +9 -3
  156. package/src/components/CnFormDialog/CnFormDialog.vue +69 -21
  157. package/src/components/CnFormPage/CnFormPage.vue +18 -3
  158. package/src/components/CnIndexPage/CnIndexPage.vue +254 -6
  159. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +169 -0
  160. package/src/components/CnPageRenderer/CnPageRenderer.vue +29 -1
  161. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +603 -0
  162. package/src/components/CnRecordListWidget/index.js +30 -0
  163. package/src/components/CnSectionsWidget/CnSectionsWidget.vue +489 -0
  164. package/src/components/CnSectionsWidget/index.js +34 -0
  165. package/src/components/CnStagesWidget/CnStagesWidget.vue +81 -2
  166. package/src/components/CnStatWidget/CnStatWidget.vue +1 -1
  167. package/src/components/CnTabsWidget/CnTabsWidget.vue +184 -8
  168. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +4 -0
  169. package/src/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue +188 -4
  170. package/src/components/index.js +4 -0
  171. package/src/composables/__tests__/useFormDraft.spec.js +69 -0
  172. package/src/composables/useFormDraft.js +139 -8
  173. package/src/css/form-field.css +14 -0
  174. package/src/css/index-page.css +71 -0
  175. package/src/css/kpi-card.css +32 -0
  176. package/src/css/look-board-detail.css +77 -0
  177. package/src/css/look-board-index.css +51 -0
  178. package/src/css/look-board.css +37 -2
  179. package/src/css/table.css +19 -0
  180. package/src/index.js +4 -0
  181. package/src/integrations/builtin/field-inspection/CnFieldInspectionCard.vue +35 -20
  182. package/src/integrations/builtin/field-inspection.js +21 -0
  183. package/src/integrations/offline/checklistSubmission.js +137 -0
  184. package/src/integrations/offline/fieldCollectionHelpers.js +69 -4
  185. package/src/integrations/offline/index.js +3 -0
  186. package/src/integrations/offline/offlineDb.js +6 -1
  187. package/src/integrations/offline/syncReplayService.js +8 -2
  188. package/src/schemas/app-manifest-v2.schema.json +104 -5
  189. package/src/utils/detailActionModel.js +59 -2
  190. package/src/utils/fillTemplate.js +110 -0
  191. package/src/utils/libraryWidgetKeys.js +4 -0
  192. package/src/utils/queryPresets.js +93 -0
  193. package/src/utils/resolveFilterTokens.js +11 -0
  194. package/src/utils/schema.js +21 -0
  195. package/src/utils/sentinelTokens.js +3 -3
  196. package/src/utils/validateManifestV2.compiled.js +3 -3
  197. package/src/utils/widgetDispatch.js +7 -1
@@ -23,6 +23,7 @@
23
23
  * value, and the audit trail is read by people answering "who changed this".
24
24
  */
25
25
 
26
+ import { getCurrentUser } from '@nextcloud/auth'
26
27
  import { translate as t } from '@nextcloud/l10n'
27
28
 
28
29
  /** How long a draft stays interesting, in milliseconds (7 days). */
@@ -34,6 +35,9 @@ export const DRAFT_DEBOUNCE_MS = 500
34
35
  /** The prefix every draft key carries, so a sweep can find them. */
35
36
  export const DRAFT_KEY_PREFIX = 'cn-form-draft'
36
37
 
38
+ /** The user part of a draft key when nobody is signed in. */
39
+ export const DRAFT_ANONYMOUS_USER = 'anonymous'
40
+
37
41
  /**
38
42
  * The storage key one form's draft lives under.
39
43
  *
@@ -54,10 +58,124 @@ export function draftKey({ appId = '', schema = '', objectId = '', userId = '' }
54
58
  String(appId || 'app'),
55
59
  String(schema || 'schema'),
56
60
  String(objectId || 'new'),
57
- String(userId || 'anonymous'),
61
+ String(userId || DRAFT_ANONYMOUS_USER),
58
62
  ].join(':')
59
63
  }
60
64
 
65
+ /**
66
+ * Who a draft belongs to: the id the host passed, else the signed-in
67
+ * Nextcloud user, else '' (which {@link draftKey} stores as `anonymous`).
68
+ *
69
+ * 🔴 A HOST THAT PASSES NOTHING STILL GETS PER-USER DRAFTS. Leaving the user
70
+ * to every host meant no host passed it, and every draft on a shared device
71
+ * was keyed `anonymous`: the next person at the counter was offered what the
72
+ * last one typed.
73
+ *
74
+ * @param {string} [explicit] The id the host passed (`draftUserId`).
75
+ * @return {string} The user id, or '' when nobody is signed in.
76
+ * @spec openspec/changes/form-draft-on-change-and-help-text-spacing/specs/form-draft-on-change-and-help-text-spacing/spec.md#requirement-a-draft-belongs-to-the-signed-in-user
77
+ */
78
+ export function resolveDraftUserId(explicit = '') {
79
+ if (explicit) {
80
+ return String(explicit)
81
+ }
82
+ try {
83
+ const uid = getCurrentUser()?.uid
84
+ return uid ? String(uid) : ''
85
+ } catch {
86
+ // No Nextcloud page around us (a test, an embed): nobody is signed in.
87
+ return ''
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Remove every draft stored under the anonymous user.
93
+ *
94
+ * Called once a signed-in user is known. Such a draft was written by a page
95
+ * that did not know who was typing, so it may be somebody else's, and it is
96
+ * deleted rather than moved: moving it would hand it to whoever opened the
97
+ * form next.
98
+ *
99
+ * @return {number} How many drafts were removed.
100
+ * @spec openspec/changes/form-draft-on-change-and-help-text-spacing/specs/form-draft-on-change-and-help-text-spacing/spec.md#requirement-a-draft-belongs-to-the-signed-in-user
101
+ */
102
+ export function clearAnonymousDrafts() {
103
+ let removed = 0
104
+ try {
105
+ const storage = window.localStorage
106
+ const suffix = ':' + DRAFT_ANONYMOUS_USER
107
+ const doomed = []
108
+ for (let i = 0; i < storage.length; i++) {
109
+ const key = storage.key(i)
110
+ if (key && key.startsWith(DRAFT_KEY_PREFIX + ':') && key.endsWith(suffix)) {
111
+ doomed.push(key)
112
+ }
113
+ }
114
+ for (const key of doomed) {
115
+ storage.removeItem(key)
116
+ removed++
117
+ }
118
+ } catch {
119
+ // Blocked storage holds no drafts to leak.
120
+ }
121
+ return removed
122
+ }
123
+
124
+ /**
125
+ * Whether a value counts as empty for a draft: null, undefined, '' and [].
126
+ *
127
+ * @param {unknown} value The value.
128
+ * @return {boolean} True when empty.
129
+ */
130
+ function isEmptyDraftValue(value) {
131
+ return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)
132
+ }
133
+
134
+ /**
135
+ * Whether two field values are the same for the purpose of a draft.
136
+ *
137
+ * Empty values are equal to each other: an input reports a missing value back
138
+ * as '', and that is the control normalising, not a person typing.
139
+ *
140
+ * @param {unknown} a One value.
141
+ * @param {unknown} b The other.
142
+ * @return {boolean} True when they are the same.
143
+ */
144
+ export function sameDraftValue(a, b) {
145
+ if (isEmptyDraftValue(a) && isEmptyDraftValue(b)) {
146
+ return true
147
+ }
148
+ if (a === b) {
149
+ return true
150
+ }
151
+ try {
152
+ return JSON.stringify(a) === JSON.stringify(b)
153
+ } catch {
154
+ return false
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Whether a person changed the form: one of the fields they edited holds a
160
+ * value different from the one the form opened with.
161
+ *
162
+ * 🔴 ONLY EDITED FIELDS COUNT. A form fills in values by itself after it
163
+ * opens (defaults, normalised dates, the active organisation, options that
164
+ * arrive later). Counting those wrote a draft and said "Saved just now" on a
165
+ * dialog nobody had touched.
166
+ *
167
+ * @param {object} values The current values.
168
+ * @param {object} baseline The values the form opened with.
169
+ * @param {string[]} editedKeys The fields a person edited.
170
+ * @return {boolean} True when there is something worth keeping.
171
+ * @spec openspec/changes/form-draft-on-change-and-help-text-spacing/specs/form-draft-on-change-and-help-text-spacing/spec.md#requirement-a-draft-is-written-only-after-a-person-changes-a-field
172
+ */
173
+ export function hasDraftChanges(values, baseline, editedKeys) {
174
+ const now = values || {}
175
+ const base = baseline || {}
176
+ return (editedKeys || []).some((key) => !sameDraftValue(now[key], base[key]))
177
+ }
178
+
61
179
  /**
62
180
  * Read one draft back, or null when there is nothing usable.
63
181
  *
@@ -218,13 +336,6 @@ export function formDraftMixin() {
218
336
  draftSavedAt: 0,
219
337
  /** Non-reactive handle for the debounce. */
220
338
  draftTimeout: null,
221
- /**
222
- * Whether the form's own initial population has happened.
223
- *
224
- * The first change to the values is the component building
225
- * them, not a person typing. See the watcher that reads this.
226
- */
227
- draftSeeded: false,
228
339
  }
229
340
  },
230
341
 
@@ -265,6 +376,26 @@ export function formDraftMixin() {
265
376
  }, DRAFT_DEBOUNCE_MS)
266
377
  },
267
378
 
379
+ /**
380
+ * The form is back to what it opened with: cancel a pending write,
381
+ * remove a draft written earlier and stop announcing anything.
382
+ *
383
+ * @param {string} key The storage key.
384
+ * @return {void}
385
+ * @spec openspec/changes/form-draft-on-change-and-help-text-spacing/specs/form-draft-on-change-and-help-text-spacing/spec.md#requirement-a-draft-is-written-only-after-a-person-changes-a-field
386
+ */
387
+ settleUnchangedDraft(key) {
388
+ if (this.draftTimeout) {
389
+ clearTimeout(this.draftTimeout)
390
+ this.draftTimeout = null
391
+ }
392
+ if (this.draftState !== 'idle') {
393
+ clearDraft(key)
394
+ this.draftState = 'idle'
395
+ this.draftSavedAt = 0
396
+ }
397
+ },
398
+
268
399
  /**
269
400
  * Drop the draft and stop announcing anything about it.
270
401
  *
@@ -82,6 +82,20 @@
82
82
  min-height: var(--cn-field-height);
83
83
  }
84
84
 
85
+ /* NcTextField draws its input inside `.input-field__main-wrapper`, which has a
86
+ FIXED height (`--default-clickable-area`, 34px on Nextcloud 34) and a 2px
87
+ padding ring for the focus outline. The min-height above makes the input
88
+ 40px, so without this the input overflowed its wrapper by 6px and the help
89
+ line under it, which starts at the wrapper's bottom edge, sat behind the
90
+ input's lower border. NcSelect grows with its content, which is why only
91
+ plain inputs showed it. The wrapper now takes the field height plus its
92
+ padding ring, so the input fits and the help line starts below it.
93
+ @spec openspec/changes/form-draft-on-change-and-help-text-spacing/specs/form-draft-on-change-and-help-text-spacing/spec.md#requirement-help-text-starts-below-every-input */
94
+ .cn-look-board .cn-form-dialog__field .input-field__main-wrapper,
95
+ .cn-look-board .cn-form-page__field .input-field__main-wrapper {
96
+ height: calc(var(--cn-field-height) + 2 * var(--border-width-input-focused, 2px));
97
+ }
98
+
85
99
  .cn-look-board .cn-form-dialog__field textarea,
86
100
  .cn-look-board .cn-form-page__field textarea {
87
101
  padding: 10px 12px;
@@ -240,3 +240,74 @@
240
240
  color: var(--color-text-maxcontrast);
241
241
  font-size: 0.9em;
242
242
  }
243
+
244
+ /* An index page with cards beside its list (`sidePanel`,
245
+ screens-index-stat-row): the header rows span the page, the toolbar and the
246
+ list take the first column and the side panel the second (300px, 20px
247
+ apart), spanning both. Every other child (dialogs, the inline sidebar) sits
248
+ in a zero-height area after them. The list row is the flexible one, so a
249
+ side panel taller than the toolbar never opens a gap under the toolbar.
250
+ Below 1024px the panel moves above the toolbar. */
251
+ .cn-index-page--with-side-panel {
252
+ display: grid;
253
+ grid-template-columns: minmax(0, 1fr) var(--cn-index-side-panel-width, 300px);
254
+ grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
255
+ grid-template-areas:
256
+ "head head"
257
+ "below below"
258
+ "stats stats"
259
+ "bar side"
260
+ "body side"
261
+ "extra extra";
262
+ column-gap: 20px;
263
+ align-content: start;
264
+ }
265
+
266
+ .cn-index-page--with-side-panel > * {
267
+ grid-area: extra;
268
+ min-width: 0;
269
+ }
270
+
271
+ .cn-index-page--with-side-panel > :first-child {
272
+ grid-area: head;
273
+ }
274
+
275
+ .cn-index-page--with-side-panel > .cn-index-page__below-header {
276
+ grid-area: below;
277
+ }
278
+
279
+ .cn-index-page--with-side-panel > .cn-index-page__stat-row {
280
+ grid-area: stats;
281
+ }
282
+
283
+ .cn-index-page--with-side-panel > .cn-actions-bar {
284
+ grid-area: bar;
285
+ }
286
+
287
+ .cn-index-page--with-side-panel > .cn-index-page__body {
288
+ grid-area: body;
289
+ }
290
+
291
+ .cn-index-page--with-side-panel > .cn-index-page__side-panel {
292
+ grid-area: side;
293
+ align-self: start;
294
+ }
295
+
296
+ @media (max-width: 1024px) {
297
+ .cn-index-page--with-side-panel {
298
+ grid-template-columns: minmax(0, 1fr);
299
+ grid-template-areas:
300
+ "head"
301
+ "below"
302
+ "stats"
303
+ "side"
304
+ "bar"
305
+ "body"
306
+ "extra";
307
+ grid-template-rows: auto auto auto auto auto minmax(0, 1fr) auto;
308
+ }
309
+
310
+ .cn-index-page--with-side-panel > .cn-index-page__side-panel {
311
+ margin-bottom: 20px;
312
+ }
313
+ }
@@ -450,6 +450,38 @@ a.cn-kpi-card--stacked:focus-visible {
450
450
  font-weight: 400;
451
451
  }
452
452
 
453
+ /* A caption on a line of its own wraps, up to two lines (DqDashboard: "haalden
454
+ hun termijn, naar doorlooptijd" runs over two lines in a narrow tile). The
455
+ one-line rule of `.cn-kpi-card__label` is for a caption BESIDE the number,
456
+ where a second line would push the tile taller; this line has the width of
457
+ the tile to itself. The clamp is safe here because the element is a plain
458
+ span, not a flex row (compare the title note above).
459
+ @spec openspec/changes/dashboard-tile-text-fits/specs/dashboard-tile-text-fits/spec.md#requirement-a-kpi-tiles-caption-and-board-label-wrap-to-two-lines */
460
+ .cn-kpi-card--stacked .cn-kpi-card__caption-line {
461
+ display: -webkit-box;
462
+ -webkit-box-orient: vertical;
463
+ -webkit-line-clamp: 2;
464
+ line-clamp: 2;
465
+ white-space: normal;
466
+ overflow-wrap: break-word;
467
+ }
468
+
469
+ /* Board look: the label wraps too, up to two lines, beside its icon badge
470
+ ("Afgehandeld deze maand"). The clamp sits on the label's own span
471
+ (`cn-kpi-card__title-text`), not on the title row, which stays the flex row
472
+ that carries the badge or the range select. */
473
+ .cn-kpi-card--board .cn-kpi-card__title-text {
474
+ flex: 1 1 auto;
475
+ min-width: 0;
476
+ display: -webkit-box;
477
+ -webkit-box-orient: vertical;
478
+ -webkit-line-clamp: 2;
479
+ line-clamp: 2;
480
+ overflow: hidden;
481
+ white-space: normal;
482
+ overflow-wrap: break-word;
483
+ }
484
+
453
485
  /* The 18px glyph before the label, drawn only on a link tile. */
454
486
  .cn-kpi-card--board .cn-kpi-card__title {
455
487
  display: flex;
@@ -87,6 +87,61 @@
87
87
  margin-bottom: 0;
88
88
  }
89
89
 
90
+ /* The breadcrumb is a line of 14px text between the pills and the meta, as
91
+ the boards draw it (screens-detail-header-row-parity). NcBreadcrumbs grows to
92
+ the full row and draws each crumb as a 34px tertiary button, which pushed the
93
+ pills above it and the meta under it. The doubled class outranks its scoped
94
+ rules. */
95
+ .cn-look-board .cn-detail-page__header-row2 .cn-detail-page__breadcrumbs.cn-detail-page__breadcrumbs {
96
+ flex: 0 1 auto;
97
+ height: auto;
98
+ min-height: 0;
99
+ min-width: 0;
100
+ width: auto;
101
+ }
102
+
103
+ .cn-look-board .cn-detail-page__header-row2 .cn-detail-page__breadcrumbs .breadcrumb__crumbs {
104
+ gap: 8px;
105
+ margin: 0;
106
+ min-height: 0;
107
+ padding: 0;
108
+ vertical-align: middle;
109
+ }
110
+
111
+ .cn-look-board .cn-detail-page__header-row2 .cn-detail-page__breadcrumbs .vue-crumb.vue-crumb {
112
+ gap: 8px;
113
+ height: auto;
114
+ min-height: 0;
115
+ min-width: 0;
116
+ }
117
+
118
+ .cn-look-board .cn-detail-page__header-row2 .cn-detail-page__breadcrumbs .vue-crumb .button-vue.button-vue {
119
+ background: transparent;
120
+ border: 0;
121
+ font-size: 14px;
122
+ font-weight: 400;
123
+ height: auto;
124
+ min-height: 0;
125
+ min-width: 0;
126
+ padding: 0;
127
+ }
128
+
129
+ .cn-look-board .cn-detail-page__header-row2 .cn-detail-page__breadcrumbs .vue-crumb .button-vue__text {
130
+ font-weight: inherit;
131
+ line-height: 20px;
132
+ padding: 0;
133
+ }
134
+
135
+ /* The list is the link; the record's own crumb is plain muted text. */
136
+ .cn-look-board .cn-detail-page__header-row2 .cn-detail-page__breadcrumbs .vue-crumb .button-vue[aria-current='page'] {
137
+ color: var(--color-text-maxcontrast);
138
+ cursor: default;
139
+ }
140
+
141
+ .cn-look-board .cn-detail-page__header-row2 .cn-detail-page__breadcrumbs .vue-crumb__separator {
142
+ margin: 0;
143
+ }
144
+
90
145
  .cn-look-board .cn-detail-page__header-dot,
91
146
  .cn-look-board .cn-detail-page__header-meta {
92
147
  color: var(--color-text-maxcontrast);
@@ -229,6 +284,22 @@
229
284
  border-radius: 0;
230
285
  }
231
286
 
287
+ /* A tab that holds a stack (`widgetIds`, screens-relation-pages): the panel
288
+ draws no frame and no padding; each widget is its own card. The first card
289
+ joins the open tab (the first-card rule above), the next ones stand 20px
290
+ apart, as the Overzicht of DqContact and PqOrganisatie draws them. */
291
+ .cn-look-board .cn-tabs--board.cn-tabs-widget__tabs .cn-tabs__content:has(> .cn-tab:not([hidden]) > .cn-tabs-widget__stack) {
292
+ background: transparent;
293
+ border: 0;
294
+ border-radius: 0;
295
+ padding: 0;
296
+ }
297
+
298
+ /* The stack's next cards are whole cards, not joined to the strip. */
299
+ .cn-look-board .cn-tabs--board .cn-tabs__content .cn-tab .cn-tabs-widget__stack-item .cn-widget-wrapper {
300
+ border-radius: var(--cn-board-card-radius);
301
+ }
302
+
232
303
  /* ---- Body cards and side cards ----------------------------------------- */
233
304
 
234
305
  .cn-look-board .cn-detail-page .cn-widget-wrapper,
@@ -308,6 +379,12 @@
308
379
  display: none;
309
380
  }
310
381
 
382
+ /* A side card whose widget reports a header action (screens-relation-pages:
383
+ the "+ Toevoegen" of Contactkanalen and Relaties) keeps that one button. */
384
+ .cn-look-board .cn-detail-page__side .cn-widget-wrapper__actions:has(.cn-detail-page__catalog-action) {
385
+ display: flex;
386
+ }
387
+
311
388
  /* The notice at the top of the side column: radius 12, padding 14px 16px. */
312
389
  .cn-look-board .cn-detail-page__side-item--notice .cn-widget-wrapper,
313
390
  .cn-look-board .cn-detail-page__side-item--notice .cn-banner-widget,
@@ -301,6 +301,15 @@
301
301
  color: var(--color-main-text);
302
302
  }
303
303
 
304
+ /* A segment the page cannot open: drawn, as the boards do, but faded and inert. */
305
+ .cn-look-board .cn-actions-bar--board .cn-actions-bar__view-toggle .cn-actions-bar__view-toggle-btn--disabled,
306
+ .cn-look-board .cn-actions-bar--board .cn-actions-bar__view-toggle .cn-actions-bar__view-toggle-btn--disabled:hover {
307
+ background: transparent !important;
308
+ color: var(--color-text-maxcontrast);
309
+ opacity: 0.5;
310
+ cursor: not-allowed;
311
+ }
312
+
304
313
  /* The search field: 40px, radius 8, not a pill. */
305
314
  .cn-look-board .cn-actions-bar--board .cn-actions-bar__search {
306
315
  flex: 1 1 260px;
@@ -448,6 +457,48 @@
448
457
  border-bottom: 0;
449
458
  }
450
459
 
460
+ /* The table fits its card (screens-index-table-fit-parity). Under the auto
461
+ layout every one-line cell asked for its whole text up to the 22rem cap, so
462
+ seven text columns made a 1500px table in a 1118px card at a 1440px
463
+ viewport: the last columns and the row menus sat past the card's edge. The
464
+ fixed layout shares the card's width: the selection and menu columns keep
465
+ their own width, the first data column takes 18%, the others split the
466
+ rest, and a value that does not fit is cut with an ellipsis (its full text
467
+ stays in the cell's tooltip). A column's own `width` still wins. */
468
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table {
469
+ table-layout: fixed;
470
+ width: 100%;
471
+ }
472
+
473
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table th.cn-table-head--title {
474
+ width: 18%;
475
+ }
476
+
477
+ /* The board's data cells sit 12px apart (PqTickets), the first one 18px from
478
+ the selection column; the selection and menu columns keep 16px. */
479
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table th:not(.cn-table-col--checkbox):not(.cn-table-col--actions):not(.cn-table-col--icon),
480
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table td:not(.cn-table-col--checkbox):not(.cn-table-col--actions):not(.cn-table-col--icon) {
481
+ padding-inline: 12px;
482
+ }
483
+
484
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table th.cn-table-head--title,
485
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table td.cn-table-col--title {
486
+ padding-inline-start: 18px;
487
+ }
488
+
489
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table td > .cn-cell-renderer {
490
+ display: block;
491
+ min-width: 0;
492
+ overflow: hidden;
493
+ text-overflow: ellipsis;
494
+ white-space: nowrap;
495
+ }
496
+
497
+ /* A narrow header cuts its label, not its filter funnel. */
498
+ .cn-look-board .cn-index-page__main--table .cn-table-container--board .cn-data-table th .cn-table-header__sort:has(~ .cn-table-header__filter) {
499
+ max-width: calc(100% - 26px);
500
+ }
501
+
451
502
  /* The selection column: 28px, 18px checkboxes. */
452
503
  .cn-look-board .cn-table-container--board .cn-data-table .cn-table-col--checkbox {
453
504
  box-sizing: content-box;
@@ -12,9 +12,14 @@
12
12
  * counterpart default to one.
13
13
  */
14
14
  .cn-look-board {
15
- /* Page content area: padding, max width, gap between blocks. */
15
+ /* Page content area: padding, max width, gap between blocks. The content
16
+ spans the whole content area (screens-one-scroller-parity): grids grow
17
+ with the window and shrink only when the sidebar takes its share. A
18
+ 1240px cap left dead space right of the content, and on a dashboard,
19
+ which scrolled itself, put the scrollbar beside the content instead of
20
+ at the window edge. */
16
21
  --cn-board-content-padding: 24px 28px;
17
- --cn-board-content-max-width: 1240px;
22
+ --cn-board-content-max-width: none;
18
23
  --cn-board-section-gap: 20px;
19
24
  /* Buttons and inputs. */
20
25
  --cn-board-control-height: 40px;
@@ -54,6 +59,15 @@
54
59
  gap: var(--cn-board-section-gap);
55
60
  }
56
61
 
62
+ /* One scroller: the content area. CnDashboardPage scrolls itself
63
+ (`height: 100%; overflow-y: auto`), which nested a second scrollbar inside
64
+ NcAppContent's; under the board look the page grows with its widgets and
65
+ NcAppContent, which already spans to the window edge, scrolls it. */
66
+ .cn-look-board .cn-dashboard-page:not(.cn-look-nextcloud *) {
67
+ height: auto;
68
+ overflow-y: visible;
69
+ }
70
+
57
71
  .cn-look-board .cn-index-page:not(.cn-look-nextcloud *) .cn-page-header,
58
72
  .cn-look-board .cn-settings-page:not(.cn-look-nextcloud *) .cn-page-header,
59
73
  .cn-look-board .cn-index-page:not(.cn-look-nextcloud *) .cn-index-page__below-header,
@@ -167,6 +181,27 @@
167
181
  gap: 2px;
168
182
  }
169
183
 
184
+ /* No scrollbars inside the navigation (screens-one-scroller-parity).
185
+ NcAppNavigation gives the region above the list `overflow-y: scroll`, an
186
+ always-on track (with arrow buttons on classic scrollbars) beside the
187
+ primary action that has nothing to scroll. It takes its content's height
188
+ instead. A menu taller than the window still scrolls (wheel, touch, focus),
189
+ without drawing a bar of its own; the one visible scrollbar is the page's. */
190
+ .cn-look-board .app-navigation[data-testid="cn-nav"] .app-navigation__body:not(.app-navigation__body--no-list) {
191
+ flex-shrink: 0;
192
+ overflow: visible;
193
+ }
194
+
195
+ .cn-look-board .app-navigation[data-testid="cn-nav"] .app-navigation__list,
196
+ .cn-look-board .app-navigation[data-testid="cn-nav"] .app-navigation__body--no-list {
197
+ scrollbar-width: none;
198
+ }
199
+
200
+ .cn-look-board .app-navigation[data-testid="cn-nav"] .app-navigation__list::-webkit-scrollbar,
201
+ .cn-look-board .app-navigation[data-testid="cn-nav"] .app-navigation__body--no-list::-webkit-scrollbar {
202
+ display: none;
203
+ }
204
+
170
205
  /* The board's vertical rhythm (AppZijbalk): the primary action 20px from the
171
206
  top, the list 20px under it, entries 2px apart. An empty search slot is a
172
207
  flex child too and pushed everything 20px down, so it goes. */
package/src/css/table.css CHANGED
@@ -258,6 +258,25 @@
258
258
  white-space: nowrap;
259
259
  }
260
260
 
261
+ /* The secondary line of the growing column ("2026-0004" under a case title)
262
+ is a line of its own with its own ellipsis. The cell does not wrap, so in
263
+ the board look, where the secondary line is an inline block (the title
264
+ underline cannot reach an atomic inline box), it stayed on the title's line
265
+ and ran out of the tile. A full-width float is a block (computed `display:
266
+ block`) that always starts under the line before it, and, being out of the
267
+ flow, the cell's underline does not reach it either. The cell is a table
268
+ cell, which contains its floats, so the row grows to hold the line.
269
+ @spec openspec/changes/dashboard-tile-text-fits/specs/dashboard-tile-text-fits/spec.md#requirement-the-secondary-line-of-a-narrow-tables-growing-column-sits-under-the-value */
270
+ .cn-data-table.cn-data-table--fit td.cn-table-col--grow .cn-table-cell__secondary {
271
+ float: inline-start;
272
+ clear: both;
273
+ box-sizing: border-box;
274
+ width: 100%;
275
+ overflow: hidden;
276
+ text-overflow: ellipsis;
277
+ white-space: nowrap;
278
+ }
279
+
261
280
  .cn-data-table th {
262
281
  background: var(--color-background-dark);
263
282
  font-weight: 500;
package/src/index.js CHANGED
@@ -112,6 +112,7 @@ export {
112
112
  CnEnvironmentBanner,
113
113
  CnExportWizard,
114
114
  CnFacetSidebar,
115
+ CnFactsWidget,
115
116
  CnFavouriteToggle,
116
117
  CnFeaturesAndRoadmapLink,
117
118
  CnFeaturesAndRoadmapPage,
@@ -120,6 +121,7 @@ export {
120
121
  CnFeaturesTab,
121
122
  CnFederationStatus,
122
123
  CnFieldHelper,
124
+ CnFieldListWidget,
123
125
  CnFileField,
124
126
  CnFileManager,
125
127
  CnFilePreview,
@@ -236,6 +238,7 @@ export {
236
238
  CnQuickFilterBar,
237
239
  CnQuicklinksWidget,
238
240
  CnQuicklinksWidgetForm,
241
+ CnRecordListWidget,
239
242
  CnReferencePreview,
240
243
  CnRegisterMapping,
241
244
  CnRegisterSchemaSelect,
@@ -258,6 +261,7 @@ export {
258
261
  CnSaveViewDialog,
259
262
  CnSchemaFormDialog,
260
263
  CnSearchPage,
264
+ CnSectionsWidget,
261
265
  CnSettingsCard,
262
266
  CnSettingsPage,
263
267
  CnSettingsSection,