@conduction/nextcloud-vue 2.56.0-beta.47 → 2.56.0-beta.49

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 (162) 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 +46 -28
  11. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +17 -10
  13. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  14. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js.map +1 -1
  15. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js.map +1 -1
  16. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
  17. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +139 -3
  18. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
  19. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js +60 -12
  20. package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  22. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +91 -36
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +115 -72
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  26. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +32 -3
  27. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +7 -7
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  30. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +56 -16
  31. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  32. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  33. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js +9 -0
  34. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js.map +1 -0
  35. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js +240 -0
  36. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js.map +1 -0
  37. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js +65 -0
  38. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js.map +1 -0
  39. package/dist/esm/components/CnDeviceFramesWidget/index.js +30 -0
  40. package/dist/esm/components/CnDeviceFramesWidget/index.js.map +1 -0
  41. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  42. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +17 -1
  43. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +30 -31
  45. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  46. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue.js +6 -0
  47. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue.js.map +1 -0
  48. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue2.js +90 -0
  49. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue2.js.map +1 -0
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +141 -7
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  52. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -2
  53. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  54. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js +8 -0
  55. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js.map +1 -0
  56. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js +114 -0
  57. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js.map +1 -0
  58. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js +45 -0
  59. package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js.map +1 -0
  60. package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js +38 -2
  61. package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js.map +1 -1
  62. package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js +12 -2
  63. package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js.map +1 -1
  64. package/dist/esm/components/CnPagination/CnPagination.vue2.js.map +1 -1
  65. package/dist/esm/components/CnPagination/CnPagination.vue3.js +56 -51
  66. package/dist/esm/components/CnPagination/CnPagination.vue3.js.map +1 -1
  67. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  68. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +51 -4
  69. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  70. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +20 -2
  71. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  72. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
  73. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +230 -25
  74. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
  75. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js +447 -221
  76. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
  77. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  78. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +24 -0
  79. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  80. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +3 -1
  81. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  82. package/dist/esm/components/CnTabs/CnTabs.vue.js +1 -1
  83. package/dist/esm/components/CnTabs/CnTabs.vue2.js +39 -2
  84. package/dist/esm/components/CnTabs/CnTabs.vue2.js.map +1 -1
  85. package/dist/esm/components/CnTabs/CnTabs.vue3.js.map +1 -1
  86. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  87. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +85 -8
  88. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  89. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +37 -9
  90. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  91. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue.js +1 -1
  92. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js.map +1 -1
  93. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js +4 -3
  94. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js.map +1 -1
  95. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  96. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  97. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  98. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +20 -0
  99. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  100. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +31 -20
  101. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  102. package/dist/esm/composables/indexSources.js +9 -0
  103. package/dist/esm/composables/indexSources.js.map +1 -1
  104. package/dist/esm/index.js +1 -0
  105. package/dist/esm/index.js.map +1 -1
  106. package/dist/esm/l10n/en.json.js +18 -1
  107. package/dist/esm/l10n/en.json.js.map +1 -1
  108. package/dist/esm/l10n/nl.json.js +18 -1
  109. package/dist/esm/l10n/nl.json.js.map +1 -1
  110. package/dist/esm/library-style.js +1 -1
  111. package/dist/esm/nextcloud-vue.css +712 -330
  112. package/dist/esm/utils/dueText.js +49 -8
  113. package/dist/esm/utils/dueText.js.map +1 -1
  114. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  115. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  116. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  117. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  118. package/dist/nextcloud-vue.cjs.js +8275 -6302
  119. package/dist/nextcloud-vue.cjs.js.map +2 -2
  120. package/dist/nextcloud-vue.css +712 -330
  121. package/l10n/en.json +18 -1
  122. package/l10n/nl.json +18 -1
  123. package/package.json +1 -1
  124. package/src/components/CnActionsMenu/CnActionsMenu.vue +3 -1
  125. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +39 -1
  126. package/src/components/CnAppRoot/CnAppRoot.vue +20 -0
  127. package/src/components/CnBrandBar/CnBrandBar.vue +5 -4
  128. package/src/components/CnCountdownWidget/CnCountdownWidget.vue +222 -6
  129. package/src/components/CnDashboardPage/CnDashboardPage.vue +96 -2
  130. package/src/components/CnDataTable/CnDataTable.md +1 -0
  131. package/src/components/CnDataTable/CnDataTable.vue +39 -10
  132. package/src/components/CnDetailPage/CnDetailPage.vue +55 -15
  133. package/src/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue +360 -0
  134. package/src/components/CnDeviceFramesWidget/index.js +29 -0
  135. package/src/components/CnFactsWidget/CnFactsWidget.vue +46 -2
  136. package/src/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue +107 -0
  137. package/src/components/CnIndexPage/CnIndexPage.vue +144 -7
  138. package/src/components/CnPageHeader/CnHeaderTrail.vue +140 -0
  139. package/src/components/CnPageHeader/CnPageHeader.md +11 -0
  140. package/src/components/CnPageHeader/CnPageHeader.vue +42 -0
  141. package/src/components/CnPagination/CnPagination.vue +10 -2
  142. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +84 -4
  143. package/src/components/CnRegisterMapping/CnRegisterMapping.md +10 -0
  144. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +511 -28
  145. package/src/components/CnSettingsPage/CnSettingsPage.vue +26 -0
  146. package/src/components/CnTabs/CnTabs.vue +39 -2
  147. package/src/components/CnTabsWidget/CnTabsWidget.vue +106 -13
  148. package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +4 -3
  149. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  150. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +8 -0
  151. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +31 -2
  152. package/src/components/index.js +1 -0
  153. package/src/composables/indexSources.js +9 -0
  154. package/src/css/dialog.css +14 -0
  155. package/src/css/look-board-index.css +27 -1
  156. package/src/css/look-board.css +27 -0
  157. package/src/css/page-header.css +16 -0
  158. package/src/index.js +1 -0
  159. package/src/schemas/app-manifest-v2.schema.json +27 -15
  160. package/src/utils/dueText.js +49 -8
  161. package/src/utils/libraryWidgetKeys.js +1 -0
  162. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -0,0 +1,360 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <div class="cn-device-frames-widget" data-testid="cn-device-frames-widget">
8
+ <section
9
+ v-for="frame in frames"
10
+ :key="frame.key"
11
+ class="cn-device-frames-widget__frame"
12
+ :style="frameStyle"
13
+ :aria-labelledby="headingId(frame.key)"
14
+ :data-testid="`cn-device-frame-${frame.key}`">
15
+ <header class="cn-device-frames-widget__head">
16
+ <span
17
+ v-if="frame.status"
18
+ class="cn-device-frames-widget__status"
19
+ :class="`cn-device-frames-widget__status--${frame.status.tone}`"
20
+ data-testid="cn-device-frame-status">
21
+ <component :is="frame.status.icon" :size="16" aria-hidden="true" />
22
+ {{ frame.status.text }}
23
+ </span>
24
+ <h3 :id="headingId(frame.key)" class="cn-device-frames-widget__title">
25
+ {{ frame.title }}
26
+ </h3>
27
+ </header>
28
+ <div class="cn-device-frames-widget__body">
29
+ <CnSectionsWidget
30
+ :content="{ sections: frame.sections, cards: false, hideEmpty: true }"
31
+ :availableWidgets="availableWidgets"
32
+ :objectId="objectId"
33
+ :objectData="objectData"
34
+ :objectType="objectType"
35
+ :schemaObject="schemaObject"
36
+ :register="register"
37
+ :schema="schema"
38
+ :store="store"
39
+ :surface="surface"
40
+ :integrationContext="integrationContext"
41
+ :cnRegistry="cnRegistry" />
42
+ </div>
43
+ </section>
44
+ </div>
45
+ </template>
46
+
47
+ <script>
48
+ import { markRaw } from 'vue'
49
+ import CloudCheckOutline from 'vue-material-design-icons/CloudCheckOutline.vue'
50
+ import CloudOffOutline from 'vue-material-design-icons/CloudOffOutline.vue'
51
+ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
52
+ import CnSectionsWidget from '../CnSectionsWidget/CnSectionsWidget.vue'
53
+
54
+ /** Counter for unique heading ids across instances. */
55
+ let instanceCounter = 0
56
+
57
+ /**
58
+ * CnDeviceFramesWidget - the `device-frames` widget: several small pages side
59
+ * by side, each in a phone frame with its own header (board DqInspectie,
60
+ * "Inspectie in het veld": the day offline, a checklist question with a
61
+ * photo, and the sync queue).
62
+ *
63
+ * Each frame stacks widgets of the surface, as the `sections` widget does,
64
+ * under a header with the frame's title and an optional status line. The
65
+ * status can follow the connection ("Offline · synced at 07:42" while the
66
+ * device is offline). The frames wrap onto the next row when the page is
67
+ * narrower, and a frame takes the full width on a phone.
68
+ *
69
+ * ```json
70
+ * {
71
+ * "type": "device-frames",
72
+ * "content": {
73
+ * "width": 350,
74
+ * "status": { "followConnection": true, "offlineText": "Offline · synced at 07:42", "onlineText": "Online" },
75
+ * "frames": [
76
+ * { "key": "today", "title": "Today", "widgetIds": ["today-runs", "offline-note"] },
77
+ * { "key": "question", "title": "Kerkplein 7", "widgetIds": ["checklist-run"] },
78
+ * { "key": "sync", "title": "Synchronise", "widgetIds": ["sync-queue"] }
79
+ * ]
80
+ * }
81
+ * }
82
+ * ```
83
+ *
84
+ * @spec openspec/changes/device-frames-widget/specs/device-frames-widget/spec.md#requirement-a-page-can-show-several-small-pages-as-phone-frames
85
+ */
86
+ export default {
87
+ name: 'CnDeviceFramesWidget',
88
+
89
+ components: { CnSectionsWidget },
90
+
91
+ inject: {
92
+ cnTranslate: { default: () => (key) => key },
93
+ },
94
+
95
+ props: {
96
+ /**
97
+ * The widget configuration.
98
+ *
99
+ * - `frames` — `[{ key?, title, widgetIds? | sections?, status? }]`, in order. `widgetIds` names
100
+ * widgets of the surface, stacked; `sections` takes the `sections` widget's entries instead.
101
+ * - `status` — the status line of every frame (a frame's own `status` wins): `{ text?, tone?,
102
+ * followConnection?, onlineText?, offlineText? }`. With `followConnection` the line reads
103
+ * `offlineText` while the device is offline and `onlineText` otherwise. `tone` is `warning`
104
+ * (default while offline), `success` or `info`.
105
+ * - `width` — the frame width in pixels (default 350).
106
+ *
107
+ * @type {{frames?: Array<object>, status?: object, width?: number}}
108
+ */
109
+ content: {
110
+ type: Object,
111
+ default: () => ({}),
112
+ },
113
+
114
+ /** Sibling widget definitions on the surface, for `widgetIds`. */
115
+ availableWidgets: {
116
+ type: Array,
117
+ default: () => [],
118
+ },
119
+
120
+ /** The bound record's id, forwarded to the children. */
121
+ objectId: {
122
+ type: [String, Number],
123
+ default: '',
124
+ },
125
+
126
+ /** The loaded record, forwarded to the children. */
127
+ objectData: {
128
+ type: Object,
129
+ default: null,
130
+ },
131
+
132
+ /** The resolved object-type slug. */
133
+ objectType: {
134
+ type: String,
135
+ default: '',
136
+ },
137
+
138
+ /** The resolved JSON Schema object. */
139
+ schemaObject: {
140
+ type: Object,
141
+ default: null,
142
+ },
143
+
144
+ /** OpenRegister register slug of the surface. */
145
+ register: {
146
+ type: [String, Object],
147
+ default: '',
148
+ },
149
+
150
+ /** OpenRegister schema slug of the surface. */
151
+ schema: {
152
+ type: [String, Object],
153
+ default: '',
154
+ },
155
+
156
+ /** The effective object store. */
157
+ store: {
158
+ type: Object,
159
+ default: null,
160
+ },
161
+
162
+ /** Rendering surface forwarded to the children. */
163
+ surface: {
164
+ type: String,
165
+ default: 'app-dashboard',
166
+ },
167
+
168
+ /** Object context forwarded to integration children. */
169
+ integrationContext: {
170
+ type: Object,
171
+ default: null,
172
+ },
173
+
174
+ /** The consumer's component registry, for custom child widget types. */
175
+ cnRegistry: {
176
+ type: Object,
177
+ default: () => ({}),
178
+ },
179
+ },
180
+
181
+ data() {
182
+ instanceCounter += 1
183
+ return {
184
+ uid: instanceCounter,
185
+ online: typeof navigator === 'undefined' || navigator.onLine !== false,
186
+ }
187
+ },
188
+
189
+ computed: {
190
+ /** @return {object} The frame width. */
191
+ frameStyle() {
192
+ const width = Number(this.content?.width)
193
+ return { '--cn-device-frame-width': `${Number.isFinite(width) && width >= 240 ? width : 350}px` }
194
+ },
195
+
196
+ /**
197
+ * The frames to draw: a key, a translated title, the sections and the
198
+ * status line.
199
+ *
200
+ * @return {Array<{key: string, title: string, sections: Array<object>, status: object|null}>}
201
+ */
202
+ frames() {
203
+ const list = Array.isArray(this.content?.frames) ? this.content.frames : []
204
+ return list
205
+ .filter((frame) => frame && typeof frame === 'object')
206
+ .map((frame, index) => {
207
+ const sections = Array.isArray(frame.sections)
208
+ ? frame.sections
209
+ : (Array.isArray(frame.widgetIds) ? frame.widgetIds : []).filter((id) => typeof id === 'string' && id !== '').map((widgetId) => ({ widgetId }))
210
+ return {
211
+ key: String(frame.key || `frame-${index}`).replace(/[^\w-]/g, '-'),
212
+ title: this.tr(frame.title || ''),
213
+ sections,
214
+ status: this.statusOf(frame.status || this.content?.status || null),
215
+ }
216
+ })
217
+ },
218
+ },
219
+
220
+ mounted() {
221
+ if (typeof window !== 'undefined') {
222
+ window.addEventListener('online', this.onConnection)
223
+ window.addEventListener('offline', this.onConnection)
224
+ }
225
+ },
226
+
227
+ beforeUnmount() {
228
+ if (typeof window !== 'undefined') {
229
+ window.removeEventListener('online', this.onConnection)
230
+ window.removeEventListener('offline', this.onConnection)
231
+ }
232
+ },
233
+
234
+ methods: {
235
+ /**
236
+ * Translate through the app's translate.
237
+ *
238
+ * @param {string} key The source string.
239
+ * @return {string}
240
+ */
241
+ tr(key) {
242
+ return key && typeof this.cnTranslate === 'function' ? this.cnTranslate(key) : (key || '')
243
+ },
244
+
245
+ /**
246
+ * The status line of a frame, or null.
247
+ *
248
+ * @param {object|null} status The configured status.
249
+ * @return {{text: string, tone: string, icon: object}|null}
250
+ */
251
+ statusOf(status) {
252
+ if (!status || typeof status !== 'object') {
253
+ return null
254
+ }
255
+ let text = status.text
256
+ let tone = status.tone
257
+ if (status.followConnection === true) {
258
+ text = this.online ? status.onlineText : status.offlineText
259
+ tone = tone || (this.online ? 'success' : 'warning')
260
+ }
261
+ if (typeof text !== 'string' || text === '') {
262
+ return null
263
+ }
264
+ const known = ['warning', 'success', 'info'].includes(tone) ? tone : 'info'
265
+ const icon = known === 'warning' ? CloudOffOutline : (known === 'success' ? CloudCheckOutline : InformationOutline)
266
+ return { text: this.tr(text), tone: known, icon: markRaw(icon) }
267
+ },
268
+
269
+ /** Follow the browser's connection state. */
270
+ onConnection() {
271
+ this.online = typeof navigator === 'undefined' || navigator.onLine !== false
272
+ },
273
+
274
+ /**
275
+ * The id of a frame's heading.
276
+ *
277
+ * @param {string} key The frame key.
278
+ * @return {string}
279
+ */
280
+ headingId(key) {
281
+ return `cn-device-frame-${this.uid}-${key}`
282
+ },
283
+ },
284
+ }
285
+ </script>
286
+
287
+ <style scoped>
288
+ .cn-device-frames-widget {
289
+ display: flex;
290
+ flex-wrap: wrap;
291
+ align-items: flex-start;
292
+ gap: 20px;
293
+ }
294
+
295
+ .cn-device-frames-widget__frame {
296
+ display: flex;
297
+ flex: none;
298
+ flex-direction: column;
299
+ width: var(--cn-device-frame-width, 350px);
300
+ max-width: 100%;
301
+ min-height: 600px;
302
+ box-sizing: border-box;
303
+ overflow: hidden;
304
+ border: 10px solid var(--color-main-text);
305
+ border-radius: 36px;
306
+ background: var(--color-background-hover);
307
+ }
308
+
309
+ .cn-device-frames-widget__head {
310
+ display: flex;
311
+ flex-direction: column;
312
+ gap: 6px;
313
+ padding: 14px 18px 10px;
314
+ border-bottom: 1px solid var(--color-border);
315
+ background: var(--color-main-background);
316
+ }
317
+
318
+ .cn-device-frames-widget__status {
319
+ display: inline-flex;
320
+ align-items: center;
321
+ gap: 6px;
322
+ font-size: 13px;
323
+ font-weight: 600;
324
+ }
325
+
326
+ .cn-device-frames-widget__status--warning {
327
+ color: var(--color-warning-text, var(--color-main-text));
328
+ }
329
+
330
+ .cn-device-frames-widget__status--success {
331
+ color: var(--color-success-text, var(--color-main-text));
332
+ }
333
+
334
+ .cn-device-frames-widget__status--info {
335
+ color: var(--color-text-maxcontrast);
336
+ }
337
+
338
+ .cn-device-frames-widget__title {
339
+ margin: 0;
340
+ font-size: 20px;
341
+ font-weight: 700;
342
+ }
343
+
344
+ .cn-device-frames-widget__body {
345
+ display: flex;
346
+ flex-direction: column;
347
+ gap: 12px;
348
+ padding: 14px;
349
+ }
350
+
351
+ /* On a phone the frame is the screen: no bezel, the full width. */
352
+ @media (max-width: 640px) {
353
+ .cn-device-frames-widget__frame {
354
+ width: 100%;
355
+ min-height: 0;
356
+ border-width: 0;
357
+ border-radius: var(--cn-board-card-radius, 12px);
358
+ }
359
+ }
360
+ </style>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnDeviceFramesWidget — self-registers the `device-frames` widget type
6
+ * (several small pages side by side, each in a phone frame) into the shared
7
+ * dashboardWidgetRegistry at module load. A container: it renders other
8
+ * widgets of the surface, so the host hands it their definitions.
9
+ */
10
+
11
+ import CnDeviceFramesWidget from './CnDeviceFramesWidget.vue'
12
+ import { registerDashboardWidget } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
13
+
14
+ registerDashboardWidget('device-frames', {
15
+ renderer: CnDeviceFramesWidget,
16
+ form: null,
17
+ defaultContent: {
18
+ frames: [],
19
+ width: 350,
20
+ },
21
+ displayName: 'Device frames',
22
+ icon: 'CellphoneText',
23
+ container: true,
24
+ ownsTitle: true,
25
+ surfaces: ['app-dashboard', 'detail-page'],
26
+ })
27
+
28
+ export { CnDeviceFramesWidget }
29
+ export default CnDeviceFramesWidget
@@ -18,7 +18,8 @@
18
18
  <template v-for="block in blocks" :key="block.key">
19
19
  <dl
20
20
  v-if="block.kind === 'rows'"
21
- class="cn-facts-widget__rows">
21
+ class="cn-facts-widget__rows"
22
+ :class="{ 'cn-facts-widget__rows--columns': twoColumns }">
22
23
  <div
23
24
  v-for="row in block.rows"
24
25
  :key="row.key"
@@ -268,6 +269,7 @@ export default {
268
269
  * - `source` — `{ register, schema, id }` or `{ url }`; omitted = the current object. An entry with `from: "object"` reads the current object even then (a client card that also shows the ticket's channel).
269
270
  * - `link` — `{ label, route? | href? }` at the foot.
270
271
  * - `hideEmpty` — drop an entry whose value is empty (default true).
272
+ * - `columns` — `2` lays the rows out in two columns with the label above the value (board DqContact); a row with `parts` joins them like a line (an address on one row).
271
273
  * - `emptyText` — shown when no entry has a value.
272
274
  *
273
275
  * @type {{fields?: Array<object>, source?: object, link?: object, hideEmpty?: boolean, emptyText?: string}}
@@ -310,6 +312,16 @@ export default {
310
312
  },
311
313
 
312
314
  computed: {
315
+ /**
316
+ * Whether the rows sit in two columns, label above value.
317
+ *
318
+ * @spec openspec/changes/facts-columns/specs/facts-widget/spec.md#requirement-facts-rows-can-sit-in-two-columns
319
+ * @return {boolean}
320
+ */
321
+ twoColumns() {
322
+ return Number(this.content?.columns) === 2
323
+ },
324
+
313
325
  /**
314
326
  * The token context: the detail object (prop, else the injected context)
315
327
  * and the page workspace.
@@ -546,7 +558,7 @@ export default {
546
558
  if (kind === 'lead') {
547
559
  return this.partsValue(record, field.title)
548
560
  }
549
- if ((kind === 'line' || kind === 'text') && Array.isArray(field.parts)) {
561
+ if ((kind === 'line' || kind === 'text' || kind === 'row') && Array.isArray(field.parts)) {
550
562
  return this.lineValue(record, field)
551
563
  }
552
564
  if ((kind === 'line' || kind === 'text') && !field.path && typeof field.text === 'string') {
@@ -924,6 +936,11 @@ export default {
924
936
  if (!spec.path && typeof spec.text === 'string') {
925
937
  return this.tr(spec.text)
926
938
  }
939
+ // A group of parts with its own separator ("Parkstraat 24" inside
940
+ // "Parkstraat 24, 3421 AB Zuiddrecht").
941
+ if (!spec.path && Array.isArray(spec.parts)) {
942
+ return this.lineValue(record, spec)
943
+ }
927
944
  const value = this.format(readPath(record, spec.path), spec, record)
928
945
  if (value === '') {
929
946
  return ''
@@ -1240,4 +1257,31 @@ export default {
1240
1257
  color: var(--color-primary-element);
1241
1258
  text-decoration: underline;
1242
1259
  }
1260
+
1261
+ /* `columns: 2` (board DqContact): two columns, the label above the value, a
1262
+ hairline under each fact; one column on a phone. */
1263
+ .cn-facts-widget__rows--columns {
1264
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1265
+ gap: 14px 24px;
1266
+ font-size: 15px;
1267
+ }
1268
+
1269
+ .cn-facts-widget__rows--columns .cn-facts-widget__row {
1270
+ display: flex;
1271
+ flex-direction: column;
1272
+ gap: 3px;
1273
+ min-width: 0;
1274
+ padding-bottom: 10px;
1275
+ border-bottom: 1px solid var(--color-border);
1276
+ }
1277
+
1278
+ .cn-facts-widget__rows--columns .cn-facts-widget__label.cn-facts-widget__label {
1279
+ font-size: 13px;
1280
+ }
1281
+
1282
+ @media (max-width: 640px) {
1283
+ .cn-facts-widget__rows--columns {
1284
+ grid-template-columns: minmax(0, 1fr);
1285
+ }
1286
+ }
1243
1287
  </style>
@@ -0,0 +1,107 @@
1
+ <!--
2
+ CnHeaderSearchLabel — the app's own words on the search field in Nextcloud's
3
+ header (screens-board-top-bar-search). Renders nothing of its own.
4
+
5
+ Under the board look the screens give the top bar's search field a line that
6
+ names what the app holds ("Zoek zaken, documenten of contacten"), where
7
+ Nextcloud shows its global "Search apps, files, tags, messages …". Nextcloud
8
+ offers no way for an app to set that line, so this component replaces the
9
+ TEXT of the field's label and nothing else:
10
+ - it changes only the text of `.unified-search-input__label` (which is also
11
+ the search button's accessible name); it never adds, moves or removes a node;
12
+ - it writes the text again when Nextcloud re-renders the field, and only
13
+ when the text differs, so its own write never loops;
14
+ - on unmount it puts Nextcloud's text back;
15
+ - with no header or no such label it does nothing.
16
+
17
+ The search Nextcloud opens is unchanged: this is the resting field's line.
18
+ -->
19
+ <script>
20
+ const LABEL_SELECTOR = '.unified-search-input__label'
21
+
22
+ /**
23
+ * Put the app's own line on the search field in Nextcloud's header.
24
+ *
25
+ * @spec openspec/changes/screens-board-top-bar-search/specs/layout-components/spec.md#requirement-the-board-look-names-what-the-header-search-finds
26
+ */
27
+ export default {
28
+ name: 'CnHeaderSearchLabel',
29
+
30
+ props: {
31
+ /**
32
+ * The line to show, already translated.
33
+ *
34
+ * @type {string}
35
+ */
36
+ label: {
37
+ type: String,
38
+ required: true,
39
+ },
40
+ },
41
+
42
+ data() {
43
+ return { observer: null, original: null, labelNode: null }
44
+ },
45
+
46
+ watch: {
47
+ label() {
48
+ this.apply()
49
+ },
50
+ },
51
+
52
+ mounted() {
53
+ const header = document.getElementById('header')
54
+ if (!header) {
55
+ return
56
+ }
57
+ this.apply()
58
+ if (typeof MutationObserver === 'function') {
59
+ this.observer = new MutationObserver(() => this.apply())
60
+ this.observer.observe(header, { childList: true, subtree: true, characterData: true })
61
+ }
62
+ },
63
+
64
+ beforeUnmount() {
65
+ this.observer?.disconnect()
66
+ this.observer = null
67
+ if (this.labelNode && this.original !== null && this.labelNode.isConnected) {
68
+ this.labelNode.textContent = this.original
69
+ }
70
+ this.labelNode = null
71
+ this.original = null
72
+ },
73
+
74
+ methods: {
75
+ /**
76
+ * Write the line into the label when it shows something else.
77
+ * Remembers the first text Nextcloud drew in each label node, to put back.
78
+ *
79
+ * @return {void}
80
+ */
81
+ apply() {
82
+ const node = document.querySelector('#header ' + LABEL_SELECTOR)
83
+ if (!node || !this.label) {
84
+ return
85
+ }
86
+ if (node !== this.labelNode) {
87
+ this.labelNode = node
88
+ this.original = node.textContent
89
+ }
90
+ if (node.textContent.trim() === this.label) {
91
+ return
92
+ }
93
+ // Keep Nextcloud's own text node where there is one, so its renderer still holds the live node.
94
+ const text = node.childNodes.length === 1 && node.firstChild.nodeType === 3 ? node.firstChild : null
95
+ if (text) {
96
+ text.data = this.label
97
+ } else {
98
+ node.textContent = this.label
99
+ }
100
+ },
101
+ },
102
+
103
+ render() {
104
+ return null
105
+ },
106
+ }
107
+ </script>