@conduction/nextcloud-vue 2.94.0 → 2.95.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 (72) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +46 -28
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +17 -10
  4. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  5. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js.map +1 -1
  6. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js.map +1 -1
  7. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +56 -16
  8. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  9. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js +9 -0
  11. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue.js.map +1 -0
  12. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js +240 -0
  13. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue2.js.map +1 -0
  14. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js +65 -0
  15. package/dist/esm/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue3.js.map +1 -0
  16. package/dist/esm/components/CnDeviceFramesWidget/index.js +30 -0
  17. package/dist/esm/components/CnDeviceFramesWidget/index.js.map +1 -0
  18. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +17 -1
  20. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  21. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +30 -31
  22. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  23. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue.js +6 -0
  24. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue.js.map +1 -0
  25. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue2.js +90 -0
  26. package/dist/esm/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue2.js.map +1 -0
  27. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  28. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +48 -1
  29. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  30. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +20 -2
  31. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  32. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
  33. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +230 -25
  34. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
  35. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js +447 -221
  36. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
  37. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  38. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  39. package/dist/esm/index.js +1 -0
  40. package/dist/esm/index.js.map +1 -1
  41. package/dist/esm/l10n/en.json.js +18 -1
  42. package/dist/esm/l10n/en.json.js.map +1 -1
  43. package/dist/esm/l10n/nl.json.js +18 -1
  44. package/dist/esm/l10n/nl.json.js.map +1 -1
  45. package/dist/esm/library-style.js +1 -1
  46. package/dist/esm/nextcloud-vue.css +356 -114
  47. package/dist/esm/utils/libraryWidgetKeys.js +1 -0
  48. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  49. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  50. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  51. package/dist/nextcloud-vue.cjs.js +6964 -5943
  52. package/dist/nextcloud-vue.cjs.js.map +2 -2
  53. package/dist/nextcloud-vue.css +356 -114
  54. package/l10n/en.json +18 -1
  55. package/l10n/nl.json +18 -1
  56. package/package.json +1 -1
  57. package/src/components/CnAppRoot/CnAppRoot.vue +20 -0
  58. package/src/components/CnBrandBar/CnBrandBar.vue +5 -4
  59. package/src/components/CnDetailPage/CnDetailPage.vue +55 -15
  60. package/src/components/CnDeviceFramesWidget/CnDeviceFramesWidget.vue +360 -0
  61. package/src/components/CnDeviceFramesWidget/index.js +29 -0
  62. package/src/components/CnFactsWidget/CnFactsWidget.vue +46 -2
  63. package/src/components/CnHeaderSearchLabel/CnHeaderSearchLabel.vue +107 -0
  64. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +81 -1
  65. package/src/components/CnRegisterMapping/CnRegisterMapping.md +10 -0
  66. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +511 -28
  67. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  68. package/src/components/index.js +1 -0
  69. package/src/index.js +1 -0
  70. package/src/schemas/app-manifest-v2.schema.json +6 -1
  71. package/src/utils/libraryWidgetKeys.js +1 -0
  72. package/src/utils/validateManifestV2.compiled.js +2 -2
package/l10n/en.json CHANGED
@@ -1517,7 +1517,12 @@
1517
1517
  "Your account": "Your account",
1518
1518
  "Only for you. Colleagues keep their own settings.": "Only for you. Colleagues keep their own settings.",
1519
1519
  "Sections of the settings": "Sections of the settings",
1520
- "Changes are saved straight away": "Changes are saved straight away"
1520
+ "Changes are saved straight away": "Changes are saved straight away",
1521
+ "Change the schema for {type}": "Change the schema for {type}",
1522
+ "Show all types": "Show all types",
1523
+ "All types set up": "All types set up",
1524
+ "Set up": "Set up",
1525
+ "{configured} of {total} set up": "{configured} of {total} set up"
1521
1526
  },
1522
1527
  "plurals": {
1523
1528
  "{count} selected": [
@@ -1599,6 +1604,18 @@
1599
1604
  "{count} more filter": [
1600
1605
  "{count} more filter",
1601
1606
  "{count} more filters"
1607
+ ],
1608
+ "%n type": [
1609
+ "%n type",
1610
+ "%n types"
1611
+ ],
1612
+ "And %n more type in this group, set up.": [
1613
+ "And %n more type in this group, set up.",
1614
+ "And %n more types in this group, all set up."
1615
+ ],
1616
+ "And %n more type in this group.": [
1617
+ "And %n more type in this group.",
1618
+ "And %n more types in this group."
1602
1619
  ]
1603
1620
  }
1604
1621
  }
package/l10n/nl.json CHANGED
@@ -1703,7 +1703,12 @@
1703
1703
  "Your account": "Uw account",
1704
1704
  "Only for you. Colleagues keep their own settings.": "Alleen voor jou. Collega's houden hun eigen instellingen.",
1705
1705
  "Sections of the settings": "Onderdelen van de instellingen",
1706
- "Changes are saved straight away": "Wijzigingen worden meteen bewaard"
1706
+ "Changes are saved straight away": "Wijzigingen worden meteen bewaard",
1707
+ "Change the schema for {type}": "Schema wijzigen voor {type}",
1708
+ "Show all types": "Alle soorten tonen",
1709
+ "All types set up": "Alle soorten ingesteld",
1710
+ "Set up": "Ingesteld",
1711
+ "{configured} of {total} set up": "{configured} van {total} ingesteld"
1707
1712
  },
1708
1713
  "plurals": {
1709
1714
  "{count} selected": [
@@ -1785,6 +1790,18 @@
1785
1790
  "{count} more filter": [
1786
1791
  "Nog {count} filter",
1787
1792
  "Nog {count} filters"
1793
+ ],
1794
+ "%n type": [
1795
+ "%n soort",
1796
+ "%n soorten"
1797
+ ],
1798
+ "And %n more type in this group, set up.": [
1799
+ "En nog %n soort in deze groep, ingesteld.",
1800
+ "En nog %n soorten in deze groep, allemaal ingesteld."
1801
+ ],
1802
+ "And %n more type in this group.": [
1803
+ "En nog %n soort in deze groep.",
1804
+ "En nog %n soorten in deze groep."
1788
1805
  ]
1789
1806
  }
1790
1807
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.94.0",
3
+ "version": "2.95.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -193,6 +193,8 @@
193
193
  <template v-else>
194
194
  <!-- The brand block in Nextcloud's own header (board look); teleported, renders nothing here. -->
195
195
  <CnBrandBar v-if="brandInHeader" :brand="resolvedBrandBlock" @unavailable="headerMissing = true" />
196
+ <!-- The app's own line on the header's search field (board look); renders nothing here. -->
197
+ <CnHeaderSearchLabel v-if="headerSearchLabel" :label="headerSearchLabel" />
196
198
  <!--
197
199
  @slot menu
198
200
  @description Left-rail navigation surface. Default:
@@ -713,6 +715,7 @@ import CnCommandPalette from '../CnCommandPalette/CnCommandPalette.vue'
713
715
  import CnCredentials from '../CnCredentials/CnCredentials.vue'
714
716
  import CnDependencyMissing from '../CnDependencyMissing/CnDependencyMissing.vue'
715
717
  import CnEnvironmentBanner from '../CnEnvironmentBanner/CnEnvironmentBanner.vue'
718
+ import CnHeaderSearchLabel from '../CnHeaderSearchLabel/CnHeaderSearchLabel.vue'
716
719
  import CnNotificationPreferences from '../CnNotificationPreferences/CnNotificationPreferences.vue'
717
720
  import CnObjectSidebar from '../CnObjectSidebar/CnObjectSidebar.vue'
718
721
  import CnSetupWizard from '../CnSetupWizard/CnSetupWizard.vue'
@@ -819,6 +822,7 @@ export default {
819
822
 
820
823
  components: {
821
824
  CnBrandBar,
825
+ CnHeaderSearchLabel,
822
826
  NcAppContent,
823
827
  NcAppSettingsDialog,
824
828
  NcAppSettingsSection,
@@ -2195,6 +2199,22 @@ export default {
2195
2199
  && this.resolvedBrandBlock.placement !== 'nav'
2196
2200
  },
2197
2201
 
2202
+ /**
2203
+ * The line on the search field in Nextcloud's header: the manifest's
2204
+ * `nav.searchPlaceholder`, translated, under the board look on a page
2205
+ * with a header. Empty otherwise, and then the field keeps Nextcloud's line.
2206
+ *
2207
+ * @spec openspec/changes/screens-board-top-bar-search/specs/layout-components/spec.md#requirement-the-board-look-names-what-the-header-search-finds
2208
+ * @return {string}
2209
+ */
2210
+ headerSearchLabel() {
2211
+ const key = this.manifest?.nav?.searchPlaceholder
2212
+ if (this.resolvedLook !== 'board' || !this.headerPresent || typeof key !== 'string' || key === '') {
2213
+ return ''
2214
+ }
2215
+ return this.manifestTranslate(key)
2216
+ },
2217
+
2198
2218
  /**
2199
2219
  * The label lookup provided as `cnTranslate`: the manifest's own
2200
2220
  * translations for the language first, then the host `translate`, then the
@@ -1,8 +1,8 @@
1
1
  <!--
2
2
  CnBrandBar — the brand block in Nextcloud's own header (screens-brand-block-top-bar).
3
3
 
4
- Draws the 237px brand block (emblem, organisation, app name, and a 1px
5
- divider at its end) at the START of `#header`, before the app menu, under the
4
+ Draws the brand block (a 237px brand link with the emblem, organisation and
5
+ app name, then a 1px divider 12px on) at the START of `#header`, before the app menu, under the
6
6
  board look. CnAppRoot mounts it when a brand is declared and `#header` exists.
7
7
 
8
8
  `#header` is Nextcloud's and offers no extension point for apps, so this is
@@ -170,11 +170,12 @@ export default {
170
170
  display: flex;
171
171
  align-items: center;
172
172
  gap: 12px;
173
- width: var(--cn-board-brand-width, 237px);
174
173
  }
175
174
 
175
+ /* The brand link is 237px; the divider follows it, 12px on. */
176
176
  .cn-brand-bar__brand {
177
- flex: 1 1 0;
177
+ flex: none;
178
+ width: var(--cn-board-brand-width, 237px);
178
179
  box-sizing: border-box;
179
180
  display: flex;
180
181
  align-items: center;
@@ -2101,7 +2101,7 @@ export default {
2101
2101
  default: false,
2102
2102
  },
2103
2103
 
2104
- /** Label for the header Edit button. Defaults to a translated "Edit". */
2104
+ /** Label for the header Edit button, run through the host translate. Defaults to a translated "Edit". */
2105
2105
  editLabel: {
2106
2106
  type: String,
2107
2107
  default: '',
@@ -3416,14 +3416,40 @@ export default {
3416
3416
 
3417
3417
  /**
3418
3418
  * The side column's widget definitions. A string names a widget
3419
- * declared in `widgets`; an object is a definition of its own.
3419
+ * declared in `widgets`; an object is a definition of its own. A
3420
+ * definition whose `visibleWhen` does not hold for the record (or the
3421
+ * records `visibilityContext` names) is left out, exactly as in the
3422
+ * body grid; edit mode shows them all.
3420
3423
  *
3421
3424
  * @return {Array<object>} The definitions that resolve, each with an `id`.
3422
3425
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-side-column
3426
+ * @spec openspec/changes/detail-side-visible-when/specs/detail-page/spec.md#requirement-a-side-column-widget-shows-only-while-its-visiblewhen-holds
3423
3427
  */
3424
3428
  sideColumnWidgets() {
3429
+ const resolved = this.declaredSideWidgets.filter((widget) => this.sideWidgetVisible(widget))
3430
+ if (!this.isBoard) {
3431
+ return resolved
3432
+ }
3433
+ // The board look: the notice first, the History card last.
3434
+ const rank = (widget) => (widget.type === 'banner' ? 0 : isActivityWidget(widget) ? 2 : 1)
3435
+ return resolved
3436
+ .map((widget, index) => ({ widget, index }))
3437
+ .sort((a, b) => (rank(a.widget) - rank(b.widget)) || (a.index - b.index))
3438
+ .map(({ widget }) => widget)
3439
+ },
3440
+
3441
+ /**
3442
+ * The side column's entries resolved to definitions, before any
3443
+ * `visibleWhen` is read: a string names a widget declared in
3444
+ * `widgets`, an object is a definition of its own (given an id when it
3445
+ * has none).
3446
+ *
3447
+ * @return {Array<object>}
3448
+ * @spec openspec/changes/detail-side-visible-when/specs/detail-page/spec.md#requirement-a-side-column-widget-shows-only-while-its-visiblewhen-holds
3449
+ */
3450
+ declaredSideWidgets() {
3425
3451
  const declared = Array.isArray(this.widgets) ? this.widgets : []
3426
- const resolved = (Array.isArray(this.sideColumn) ? this.sideColumn : [])
3452
+ return (Array.isArray(this.sideColumn) ? this.sideColumn : [])
3427
3453
  .map((entry, index) => {
3428
3454
  if (typeof entry === 'string') {
3429
3455
  return declared.find((widget) => widget && widget.id === entry) || null
@@ -3434,15 +3460,6 @@ export default {
3434
3460
  return entry.id ? entry : { ...entry, id: `cn-side-${index}` }
3435
3461
  })
3436
3462
  .filter(Boolean)
3437
- if (!this.isBoard) {
3438
- return resolved
3439
- }
3440
- // The board look: the notice first, the History card last.
3441
- const rank = (widget) => (widget.type === 'banner' ? 0 : isActivityWidget(widget) ? 2 : 1)
3442
- return resolved
3443
- .map((widget, index) => ({ widget, index }))
3444
- .sort((a, b) => (rank(a.widget) - rank(b.widget)) || (a.index - b.index))
3445
- .map(({ widget }) => widget)
3446
3463
  },
3447
3464
 
3448
3465
  /**
@@ -3763,12 +3780,14 @@ export default {
3763
3780
  },
3764
3781
 
3765
3782
  /**
3766
- * Label for the header Edit button.
3783
+ * Label for the header Edit button: the manifest's `editLabel` through
3784
+ * the host translate, else the library's translated "Edit".
3767
3785
  *
3768
3786
  * @return {string}
3787
+ * @spec openspec/changes/detail-edit-label-translate/specs/detail-page/spec.md#requirement-the-edit-button-label-follows-the-reader-s-language
3769
3788
  */
3770
3789
  editActionLabel() {
3771
- return this.editLabel || t('nextcloud-vue', 'Edit')
3790
+ return this.editLabel ? this.effectiveTranslate(this.editLabel) : t('nextcloud-vue', 'Edit')
3772
3791
  },
3773
3792
 
3774
3793
  /**
@@ -4022,7 +4041,7 @@ export default {
4022
4041
  if (!this.widgetVisibilityData || this.visibilityRowsPending) {
4023
4042
  return ''
4024
4043
  }
4025
- const defs = [...(this.bodyGridWidgets || []), ...((this.activeView && this.activeView.widgets) || [])]
4044
+ const defs = [...(this.bodyGridWidgets || []), ...((this.activeView && this.activeView.widgets) || []), ...this.declaredSideWidgets]
4026
4045
  const remote = defs.filter((def) => def && def.visibleWhen && typeof def.visibleWhen === 'object'
4027
4046
  && !isLocallyDecidableVisibleWhen(def.visibleWhen))
4028
4047
  if (remote.length === 0) {
@@ -5897,6 +5916,27 @@ export default {
5897
5916
  return compactLayoutRows(drawable)
5898
5917
  },
5899
5918
 
5919
+ /**
5920
+ * Whether a side column widget shows: always in edit mode or without a
5921
+ * `visibleWhen`, else while it holds for the record and the records
5922
+ * `visibilityContext` names, read by the same `detailWidgetVisible` the
5923
+ * body grid uses (local field conditions, `@context.<name>.*`, and the
5924
+ * answers of endpoint and source conditions).
5925
+ *
5926
+ * @param {object} widget A side column widget definition.
5927
+ * @return {boolean}
5928
+ * @spec openspec/changes/detail-side-visible-when/specs/detail-page/spec.md#requirement-a-side-column-widget-shows-only-while-its-visiblewhen-holds
5929
+ */
5930
+ sideWidgetVisible(widget) {
5931
+ if (this.editingBody || widget.visibleWhen === undefined || widget.visibleWhen === null) {
5932
+ return true
5933
+ }
5934
+ return detailWidgetVisible(widget, this.widgetVisibilityData, {
5935
+ pending: this.visibilityRowsPending,
5936
+ answers: this.widgetConditionAnswers,
5937
+ })
5938
+ },
5939
+
5900
5940
  /**
5901
5941
  * Whether a layout item has anything to draw, so a grid cell is worth
5902
5942
  * spending on it.
@@ -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