@conduction/nextcloud-vue 2.102.0 → 2.104.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 (147) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +23 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +94 -72
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +18 -1
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +3 -3
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  10. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +73 -0
  11. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  12. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +99 -64
  13. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  14. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +46 -0
  16. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +37 -2
  18. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  20. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -6
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  22. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +39 -2
  24. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +14 -0
  26. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +40 -2
  28. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  30. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  31. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +113 -20
  32. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  33. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +387 -359
  34. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +173 -3
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +107 -87
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +7 -2
  40. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue.js +1 -1
  42. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +10 -1
  43. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  44. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  45. package/dist/esm/components/CnObjectRow/CnObjectRow.vue.js +1 -1
  46. package/dist/esm/components/CnObjectRow/CnObjectRow.vue2.js +57 -5
  47. package/dist/esm/components/CnObjectRow/CnObjectRow.vue2.js.map +1 -1
  48. package/dist/esm/components/CnObjectRow/CnObjectRow.vue3.js +37 -4
  49. package/dist/esm/components/CnObjectRow/CnObjectRow.vue3.js.map +1 -1
  50. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  51. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +97 -3
  52. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  53. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +46 -2
  54. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  55. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js +9 -0
  56. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue.js.map +1 -0
  57. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js +165 -0
  58. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue2.js.map +1 -0
  59. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js +85 -0
  60. package/dist/esm/components/CnSearchSelect/CnSearchSelect.vue3.js.map +1 -0
  61. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue.js +1 -1
  62. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +54 -6
  63. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  64. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +3 -1
  65. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  66. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  67. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +8 -1
  68. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  69. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  70. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +1 -1
  71. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -1
  72. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -1
  73. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
  74. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +18 -2
  75. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  76. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  77. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue.js +1 -1
  78. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
  79. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  80. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
  81. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  82. package/dist/esm/composables/indexSources.js +7 -7
  83. package/dist/esm/composables/indexSources.js.map +1 -1
  84. package/dist/esm/index.js +1 -0
  85. package/dist/esm/index.js.map +1 -1
  86. package/dist/esm/l10n/en.json.js +4 -0
  87. package/dist/esm/l10n/en.json.js.map +1 -1
  88. package/dist/esm/l10n/nl.json.js +4 -0
  89. package/dist/esm/l10n/nl.json.js.map +1 -1
  90. package/dist/esm/library-style.js +1 -1
  91. package/dist/esm/mixins/pageViews.js +8 -1
  92. package/dist/esm/mixins/pageViews.js.map +1 -1
  93. package/dist/esm/nextcloud-vue.css +586 -416
  94. package/dist/esm/utils/nextcloudDirectory.js +18 -1
  95. package/dist/esm/utils/nextcloudDirectory.js.map +1 -1
  96. package/dist/esm/utils/sortOrder.js +61 -0
  97. package/dist/esm/utils/sortOrder.js.map +1 -0
  98. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  99. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  100. package/dist/esm/utils/visibleWhen.js +13 -2
  101. package/dist/esm/utils/visibleWhen.js.map +1 -1
  102. package/dist/nextcloud-vue.cjs.js +12270 -10936
  103. package/dist/nextcloud-vue.cjs.js.map +2 -2
  104. package/dist/nextcloud-vue.css +586 -416
  105. package/l10n/en.json +4 -0
  106. package/l10n/nl.json +4 -0
  107. package/package.json +1 -1
  108. package/src/components/CnActionsBar/CnActionsBar.md +1 -0
  109. package/src/components/CnActionsBar/CnActionsBar.vue +39 -2
  110. package/src/components/CnAppRoot/CnAppRoot.md +1 -1
  111. package/src/components/CnAppRoot/CnAppRoot.vue +21 -4
  112. package/src/components/CnConversationThread/CnConversationThread.md +17 -0
  113. package/src/components/CnConversationThread/CnConversationThread.vue +113 -8
  114. package/src/components/CnConversationThread/CnConversationWidget.vue +60 -1
  115. package/src/components/CnDashboardPage/CnDashboardPage.md +4 -1
  116. package/src/components/CnDashboardPage/CnDashboardPage.vue +29 -6
  117. package/src/components/CnDataTable/CnDataTable.md +1 -0
  118. package/src/components/CnDataTable/CnDataTable.vue +49 -0
  119. package/src/components/CnDetailPage/CnDetailPage.md +2 -1
  120. package/src/components/CnDetailPage/CnDetailPage.vue +40 -2
  121. package/src/components/CnFormDialog/CnFormDialog.md +1 -1
  122. package/src/components/CnFormDialog/CnFormDialog.vue +127 -0
  123. package/src/components/CnIndexPage/CnIndexPage.md +14 -2
  124. package/src/components/CnIndexPage/CnIndexPage.vue +192 -11
  125. package/src/components/CnIndexPage/useSelfFetchList.js +6 -1
  126. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +10 -1
  127. package/src/components/CnObjectRow/CnObjectRow.vue +95 -1
  128. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +134 -2
  129. package/src/components/CnSearchSelect/CnSearchSelect.vue +283 -0
  130. package/src/components/CnSearchSelect/index.js +12 -0
  131. package/src/components/CnSetupWizard/CnSetupWizard.vue +56 -6
  132. package/src/components/CnTabsWidget/CnTabsWidget.vue +8 -1
  133. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +7 -2
  134. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +18 -2
  135. package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
  136. package/src/components/index.js +1 -0
  137. package/src/composables/indexSources.js +7 -7
  138. package/src/css/dialog.css +5 -3
  139. package/src/css/index-page.css +19 -0
  140. package/src/css/look-board-index.css +48 -0
  141. package/src/index.js +1 -0
  142. package/src/mixins/pageViews.js +8 -1
  143. package/src/schemas/app-manifest-v2.schema.json +75 -6
  144. package/src/utils/nextcloudDirectory.js +17 -0
  145. package/src/utils/sortOrder.js +58 -0
  146. package/src/utils/validateManifestV2.compiled.js +3 -3
  147. package/src/utils/visibleWhen.js +13 -2
@@ -201,13 +201,38 @@
201
201
  </span>
202
202
  </li>
203
203
  </ul>
204
+ <!-- `footerAction`: one action under the list, also under the empty
205
+ text ("Beroep registreren"). With `disabledWhen` it greys out and
206
+ says why (`disabledReason`: "Kan zodra de beslissing is
207
+ verzonden"). -->
208
+ <div
209
+ v-if="footerAction && !error"
210
+ class="cn-record-list__footer"
211
+ data-testid="cn-record-list-footer">
212
+ <NcButton
213
+ variant="secondary"
214
+ :disabled="footerDisabled"
215
+ :aria-describedby="footerDisabled && footerAction.reason ? footerReasonId : null"
216
+ data-testid="cn-record-list-footer-action"
217
+ @click="runFooterAction">
218
+ <template v-if="footerAction.icon" #icon>
219
+ <CnIcon :name="footerAction.icon" :size="16" />
220
+ </template>
221
+ {{ footerAction.label }}
222
+ </NcButton>
223
+ <span
224
+ v-if="footerDisabled && footerAction.reason"
225
+ :id="footerReasonId"
226
+ class="cn-record-list__footer-reason"
227
+ data-testid="cn-record-list-footer-reason">{{ footerAction.reason }}</span>
228
+ </div>
204
229
  </div>
205
230
  </template>
206
231
 
207
232
  <script>
208
233
  import { subscribe, unsubscribe } from '@nextcloud/event-bus'
209
234
  import { translate as t } from '@nextcloud/l10n'
210
- import { NcLoadingIcon } from '@nextcloud/vue'
235
+ import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
211
236
  import { inject, ref } from 'vue'
212
237
  import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
213
238
  import CnIcon from '../CnIcon/CnIcon.vue'
@@ -224,8 +249,12 @@ import { lensUnavailableText, readLensReports } from '../../utils/lensAvailabili
224
249
  import { pageFilter, pageSort } from '../../utils/pageControls.js'
225
250
  import { readPath } from '../../utils/readPath.js'
226
251
  import { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
252
+ import { evaluateVisibleWhen } from '../../utils/visibleWhen.js'
227
253
 
228
254
  const PAGE_REFRESH_CHANNEL = 'cn:page:refresh'
255
+
256
+ /** Gives each list's footer reason its own element id. */
257
+ let footerSeq = 0
229
258
  const PRESENTATIONS = ['rows', 'feed', 'inline', 'cards', 'notice']
230
259
 
231
260
  /** The tones a `notice` list takes. */
@@ -279,7 +308,7 @@ function unwrap(value) {
279
308
  export default {
280
309
  name: 'CnRecordListWidget',
281
310
 
282
- components: { ChevronRight, CnIcon, CnStatusBadge, NcLoadingIcon },
311
+ components: { ChevronRight, CnIcon, CnStatusBadge, NcButton, NcLoadingIcon },
283
312
 
284
313
  inject: {
285
314
  /** Detail-page object context (`{ objectId, object, register, schema }`). */
@@ -314,6 +343,10 @@ export default {
314
343
  * `presentation: "notice"` draws the rows as sentences in one box toned by
315
344
  * `variant` (`info`, `warning` or `error`), after an optional `lead`
316
345
  * (`{total}` filled in).
346
+ * `footerAction` (`{ label, icon?, type, ..., disabledWhen?,
347
+ * disabledReason? }`) ends the card with one action, also under the
348
+ * empty text; while the visibleWhen condition `disabledWhen` holds the
349
+ * button is disabled and `disabledReason` shows under it.
317
350
  *
318
351
  * @type {object}
319
352
  */
@@ -367,10 +400,48 @@ export default {
367
400
  error: false,
368
401
  /** Personal-lens reports (`@self.lenses`) of the latest response. */
369
402
  lenses: {},
403
+ /** Whether `footerAction.disabledWhen` holds. */
404
+ footerDisabled: false,
405
+ footerReasonId: `cn-record-list-footer-reason-${++footerSeq}`,
370
406
  }
371
407
  },
372
408
 
373
409
  computed: {
410
+ /**
411
+ * The action under the list (`footerAction`: `{ label, icon?, type,
412
+ * ..., disabledWhen?, disabledReason? }`), labels through the host
413
+ * translate, or null without a label.
414
+ *
415
+ * @spec openspec/changes/record-list-footer-action/specs/record-list/spec.md#requirement-a-record-list-can-end-with-an-action-that-says-why-it-is-disabled
416
+ * @return {{label: string, icon: string, reason: string, action: object}|null}
417
+ */
418
+ footerAction() {
419
+ const action = this.content?.footerAction
420
+ if (!action || typeof action !== 'object' || typeof action.label !== 'string' || action.label === '') {
421
+ return null
422
+ }
423
+ return {
424
+ label: this.translate(action.label),
425
+ icon: typeof action.icon === 'string' ? action.icon : '',
426
+ reason: typeof action.disabledReason === 'string' && action.disabledReason !== '' ? this.translate(action.disabledReason) : '',
427
+ action,
428
+ }
429
+ },
430
+
431
+ /**
432
+ * What `footerAction.disabledWhen` depends on, so it is evaluated again
433
+ * when the condition, the record or the workspace changes.
434
+ *
435
+ * @return {string}
436
+ */
437
+ footerDisabledKey() {
438
+ const cond = this.content?.footerAction?.disabledWhen
439
+ if (!cond) {
440
+ return ''
441
+ }
442
+ return JSON.stringify({ cond, object: this.tokenCtx.object, workspace: this.tokenCtx.workspace, total: this.total })
443
+ },
444
+
374
445
  /** @return {string} `rows`, `feed`, `inline` or `cards`. */
375
446
  /**
376
447
  * The tone of a `notice` list (`content.variant`): info, warning or error.
@@ -595,6 +666,13 @@ export default {
595
666
  this.fetchRows()
596
667
  },
597
668
 
669
+ footerDisabledKey: {
670
+ immediate: true,
671
+ handler() {
672
+ this.evaluateFooterDisabled()
673
+ },
674
+ },
675
+
598
676
  endpointKey() {
599
677
  this.fetchRows()
600
678
  },
@@ -621,6 +699,47 @@ export default {
621
699
  return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
622
700
  },
623
701
 
702
+ /**
703
+ * Settle whether the footer action is disabled: `disabledWhen` is a
704
+ * visibleWhen condition read against the page's record (`field`), the
705
+ * workspace, an endpoint or a source; `total` is the list's total.
706
+ *
707
+ * @spec openspec/changes/record-list-footer-action/specs/record-list/spec.md#requirement-a-record-list-can-end-with-an-action-that-says-why-it-is-disabled
708
+ * @return {Promise<void>}
709
+ */
710
+ async evaluateFooterDisabled() {
711
+ const cond = this.content?.footerAction?.disabledWhen
712
+ if (!cond) {
713
+ this.footerDisabled = false
714
+ return
715
+ }
716
+ const ctx = { ...this.tokenCtx, object: { ...(this.tokenCtx.object || {}), total: this.total } }
717
+ const key = this.footerDisabledKey
718
+ const disabled = await evaluateVisibleWhen(cond, ctx)
719
+ if (key === this.footerDisabledKey) {
720
+ this.footerDisabled = disabled
721
+ }
722
+ },
723
+
724
+ /**
725
+ * Run the footer action through the page's dispatcher, with the page's
726
+ * record as the token context.
727
+ *
728
+ * @spec openspec/changes/record-list-footer-action/specs/record-list/spec.md#requirement-a-record-list-can-end-with-an-action-that-says-why-it-is-disabled
729
+ * @return {unknown} What the dispatcher returns.
730
+ */
731
+ runFooterAction() {
732
+ const declared = this.footerAction?.action
733
+ if (!declared || this.footerDisabled) {
734
+ return undefined
735
+ }
736
+ const { disabledWhen, disabledReason, ...action } = declared
737
+ if (typeof this.cnDispatchAction === 'function') {
738
+ return this.cnDispatchAction(action)
739
+ }
740
+ return dispatchAction(action, { router: this.$router || null, tokenCtx: this.tokenCtx, source: { register: this.content?.register, schema: this.content?.schema } })
741
+ },
742
+
624
743
  /**
625
744
  * A row's `bar`: its value, the maximum it is measured against (the
626
745
  * row's `maxField`, else the largest row), the filled share and
@@ -1196,6 +1315,19 @@ export default {
1196
1315
  padding: 0;
1197
1316
  }
1198
1317
 
1318
+ .cn-record-list__footer {
1319
+ display: flex;
1320
+ flex-direction: column;
1321
+ align-items: flex-start;
1322
+ gap: 6px;
1323
+ margin-top: 10px;
1324
+ }
1325
+
1326
+ .cn-record-list__footer-reason {
1327
+ font-size: 13px;
1328
+ color: var(--color-text-maxcontrast);
1329
+ }
1330
+
1199
1331
  .cn-record-list__empty,
1200
1332
  .cn-record-list__error {
1201
1333
  color: var(--color-text-maxcontrast);
@@ -0,0 +1,283 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+ <template>
6
+ <div class="cn-search-select" :class="{ 'cn-search-select--label-outside': labelOutside }" data-testid="cn-search-select">
7
+ <NcSelect
8
+ :inputId="inputId"
9
+ :inputLabel="inputLabel"
10
+ :labelOutside="labelOutside"
11
+ :modelValue="selectedOption"
12
+ :options="options"
13
+ :loading="loading"
14
+ :clearable="clearable"
15
+ :disabled="disabled"
16
+ :placeholder="resolvedPlaceholder"
17
+ :filterable="filterable"
18
+ label="label"
19
+ @search="onSearch"
20
+ @update:modelValue="onInput">
21
+ <template #option="option">
22
+ <span class="cn-search-select__row" data-testid="cn-search-select-row">
23
+ <span
24
+ v-if="option.avatar || option.icon"
25
+ class="cn-search-select__lead"
26
+ :class="{ 'cn-search-select__lead--avatar': option.avatar && !option.icon }"
27
+ aria-hidden="true">
28
+ <CnIcon v-if="option.icon" :name="option.icon" :size="18" />
29
+ <template v-else>{{ initialsOf(option) }}</template>
30
+ </span>
31
+ <span class="cn-search-select__text">
32
+ <span class="cn-search-select__name">{{ option.label }}</span>
33
+ <span v-if="option.secondary" class="cn-search-select__secondary">{{ option.secondary }}</span>
34
+ </span>
35
+ <Check
36
+ v-if="isSelected(option)"
37
+ class="cn-search-select__check"
38
+ :size="18"
39
+ data-testid="cn-search-select-check" />
40
+ </span>
41
+ </template>
42
+ </NcSelect>
43
+ <Magnify class="cn-search-select__magnifier" :size="18" aria-hidden="true" />
44
+ </div>
45
+ </template>
46
+
47
+ <script>
48
+ import { translate as t } from '@nextcloud/l10n'
49
+ import { NcSelect } from '@nextcloud/vue'
50
+ import Check from 'vue-material-design-icons/Check.vue'
51
+ import Magnify from 'vue-material-design-icons/Magnify.vue'
52
+ import CnIcon from '../CnIcon/CnIcon.vue'
53
+ import { initialsOf } from '../../utils/initials.js'
54
+
55
+ /**
56
+ * CnSearchSelect — a single picker drawn as a search field: a magnifier
57
+ * instead of the chevron, and results as rows with an icon or initials, a
58
+ * name, a secondary line and a check on the chosen one (boards DqOverdragen
59
+ * "Nieuw team", DqNieuweZaak "Aanvrager").
60
+ *
61
+ * Each option is `{ id, label, secondary?, icon?, avatar? }`: `icon` names a
62
+ * CnIcon, `avatar: true` draws the label's initials in a circle. The value is
63
+ * the option's `id` (or `value`); `update:modelValue` emits that id, or null
64
+ * when cleared. Searching is the host's: listen to `search` and pass new
65
+ * `options`, with `filterable: false` when the host already filtered them.
66
+ *
67
+ * ```vue
68
+ * <CnSearchSelect
69
+ * v-model="team"
70
+ * inputLabel="Nieuw team"
71
+ * :options="[{ id: 'legal', label: 'Juridische zaken', secondary: '6 leden · bezwaar, beroep en Woo-advies', avatar: true }]" />
72
+ * ```
73
+ */
74
+ export default {
75
+ name: 'CnSearchSelect',
76
+
77
+ components: { Check, CnIcon, Magnify, NcSelect },
78
+
79
+ props: {
80
+ /** The chosen option's id, or the option itself. */
81
+ modelValue: {
82
+ type: [String, Number, Object],
83
+ default: null,
84
+ },
85
+
86
+ /**
87
+ * The options: `{ id, label, secondary?, icon?, avatar? }`.
88
+ *
89
+ * @type {Array<{id: (string|number), label: string, secondary?: string, icon?: string, avatar?: boolean}>}
90
+ */
91
+ options: {
92
+ type: Array,
93
+ default: () => [],
94
+ },
95
+
96
+ /** The field's label. */
97
+ inputLabel: {
98
+ type: String,
99
+ default: '',
100
+ },
101
+
102
+ /** Draw the label above the field instead of inside it. */
103
+ labelOutside: {
104
+ type: Boolean,
105
+ default: false,
106
+ },
107
+
108
+ /** The input's id. */
109
+ inputId: {
110
+ type: String,
111
+ default: undefined,
112
+ },
113
+
114
+ /** Placeholder text; defaults to "Search…". */
115
+ placeholder: {
116
+ type: String,
117
+ default: '',
118
+ },
119
+
120
+ /** Show the loading spinner. */
121
+ loading: {
122
+ type: Boolean,
123
+ default: false,
124
+ },
125
+
126
+ /** Offer a clear button. */
127
+ clearable: {
128
+ type: Boolean,
129
+ default: true,
130
+ },
131
+
132
+ /** Disable the field. */
133
+ disabled: {
134
+ type: Boolean,
135
+ default: false,
136
+ },
137
+
138
+ /** Filter the options on the typed text; false when the host searches. */
139
+ filterable: {
140
+ type: Boolean,
141
+ default: true,
142
+ },
143
+ },
144
+
145
+ emits: ['update:modelValue', 'search'],
146
+
147
+ computed: {
148
+ /**
149
+ * The option matching `modelValue`, or a bare one carrying it.
150
+ *
151
+ * @return {object|null}
152
+ */
153
+ selectedOption() {
154
+ const value = this.modelValue
155
+ if (value === null || value === undefined || value === '') {
156
+ return null
157
+ }
158
+ if (typeof value === 'object') {
159
+ return value
160
+ }
161
+ return this.options.find((o) => this.idOf(o) === String(value)) || { id: value, label: String(value) }
162
+ },
163
+
164
+ /** @return {string} The placeholder. */
165
+ resolvedPlaceholder() {
166
+ return this.placeholder || t('nextcloud-vue', 'Search…')
167
+ },
168
+ },
169
+
170
+ methods: {
171
+ initialsOf(option) {
172
+ return initialsOf(option.label || '')
173
+ },
174
+
175
+ /**
176
+ * @param {object} option An option.
177
+ * @return {string} Its id as a string, '' without one.
178
+ */
179
+ idOf(option) {
180
+ const id = option && (option.id ?? option.value)
181
+ return id === null || id === undefined ? '' : String(id)
182
+ },
183
+
184
+ /**
185
+ * @param {object} option An option.
186
+ * @return {boolean} Whether it is the chosen one.
187
+ */
188
+ isSelected(option) {
189
+ return this.selectedOption !== null && this.idOf(option) !== '' && this.idOf(option) === this.idOf(this.selectedOption)
190
+ },
191
+
192
+ /**
193
+ * @param {object|null} option The picked option.
194
+ */
195
+ onInput(option) {
196
+ const id = option ? (option.id ?? option.value ?? null) : null
197
+ /** @event update:modelValue The picked option's id, or null when cleared. */
198
+ this.$emit('update:modelValue', id)
199
+ },
200
+
201
+ /**
202
+ * @param {string} query The typed text.
203
+ */
204
+ onSearch(query) {
205
+ /** @event search The typed text, for a host that searches itself. */
206
+ this.$emit('search', query)
207
+ },
208
+ },
209
+ }
210
+ </script>
211
+
212
+ <style scoped>
213
+ .cn-search-select {
214
+ position: relative;
215
+ }
216
+
217
+ /* The magnifier sits where the chevron was: in the field, at its end. */
218
+ .cn-search-select__magnifier {
219
+ position: absolute;
220
+ inset-inline-end: 12px;
221
+ bottom: 0;
222
+ height: var(--default-clickable-area, 34px);
223
+ display: flex;
224
+ align-items: center;
225
+ color: var(--color-text-maxcontrast);
226
+ pointer-events: none;
227
+ }
228
+
229
+ .cn-search-select :deep(.vs__open-indicator),
230
+ .cn-search-select :deep(.vs__open-indicator-button) {
231
+ display: none;
232
+ }
233
+
234
+ .cn-search-select :deep(.vs__actions) {
235
+ padding-inline-end: 36px;
236
+ }
237
+
238
+ .cn-search-select__row {
239
+ display: flex;
240
+ align-items: center;
241
+ gap: 10px;
242
+ width: 100%;
243
+ min-width: 0;
244
+ padding: 4px 0;
245
+ }
246
+
247
+ .cn-search-select__lead {
248
+ flex: 0 0 32px;
249
+ width: 32px;
250
+ height: 32px;
251
+ border-radius: 50%;
252
+ display: inline-flex;
253
+ align-items: center;
254
+ justify-content: center;
255
+ background: var(--color-primary-element-light);
256
+ color: var(--color-primary-element-light-text);
257
+ font-size: 13px;
258
+ font-weight: 600;
259
+ }
260
+
261
+ .cn-search-select__text {
262
+ display: flex;
263
+ flex-direction: column;
264
+ flex: 1 1 auto;
265
+ min-width: 0;
266
+ }
267
+
268
+ .cn-search-select__name {
269
+ font-weight: 600;
270
+ overflow-wrap: anywhere;
271
+ }
272
+
273
+ .cn-search-select__secondary {
274
+ font-size: 13px;
275
+ color: var(--color-text-maxcontrast);
276
+ overflow-wrap: anywhere;
277
+ }
278
+
279
+ .cn-search-select__check {
280
+ flex: 0 0 auto;
281
+ color: var(--color-primary-element);
282
+ }
283
+ </style>
@@ -0,0 +1,12 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnSearchSelect — a single picker drawn as a search field with rows that
6
+ * carry an icon, a name, a secondary line and a check on the chosen one.
7
+ */
8
+
9
+ import CnSearchSelect from './CnSearchSelect.vue'
10
+
11
+ export { CnSearchSelect }
12
+ export default CnSearchSelect
@@ -5,6 +5,7 @@
5
5
  ref="wizard"
6
6
  :steps="wizardSteps"
7
7
  :dialogTitle="resolvedDialogTitle"
8
+ stepHeading
8
9
  :submitLabel="submitLabel"
9
10
  :cancelLabel="cancelLabel"
10
11
  :nextLabel="nextLabel"
@@ -79,6 +80,7 @@
79
80
  </NcNoteCard>
80
81
  <CnChoiceCards
81
82
  v-if="isCardChoice(step)"
83
+ :layout="step.display === 'rows' ? 'rows' : 'cards'"
82
84
  :label="stepTitle(step)"
83
85
  :options="optionsFor(step)"
84
86
  :multiple="step.multiple === true"
@@ -464,14 +466,16 @@ export default {
464
466
  computed: {
465
467
  /**
466
468
  * The title actually rendered by CnWizardDialog: "Set up {appName}"
467
- * when a display name was passed, otherwise whatever `dialogTitle`
468
- * resolves to (a caller override, or its own generic default).
469
+ * when a display name was passed (the name through the host's
470
+ * translate), otherwise whatever `dialogTitle` resolves to (a caller
471
+ * override, or its own generic default). In the board look it is the
472
+ * eyebrow's context and the current step's title is the heading.
469
473
  *
470
474
  * @return {string}
471
475
  */
472
476
  resolvedDialogTitle() {
473
477
  return this.appName
474
- ? t('nextcloud-vue', 'Set up {app}', { app: this.appName })
478
+ ? t('nextcloud-vue', 'Set up {app}', { app: this.cnTranslate(this.appName) })
475
479
  : this.dialogTitle
476
480
  },
477
481
 
@@ -727,6 +731,12 @@ export default {
727
731
  },
728
732
  },
729
733
 
734
+ created() {
735
+ for (const step of this.setupSteps) {
736
+ this.applyDefault(step)
737
+ }
738
+ },
739
+
730
740
  mounted() {
731
741
  // Covers a RESUMED wizard opening straight onto its run-action step
732
742
  // (`initialStepId`) — `onStepChange` only fires on later navigation,
@@ -944,13 +954,14 @@ export default {
944
954
  },
945
955
 
946
956
  /**
947
- * Whether a choice step renders as a card grid rather than a dropdown.
957
+ * Whether a choice step renders as CnChoiceCards (a card grid, or radio
958
+ * rows with `display: "rows"`) rather than a dropdown.
948
959
  *
949
960
  * @param {object} step The choice step.
950
- * @return {boolean} True for `display: "cards"`.
961
+ * @return {boolean} True for `display: "cards"` and `display: "rows"`.
951
962
  */
952
963
  isCardChoice(step) {
953
- return step.display === 'cards'
964
+ return step.display === 'cards' || step.display === 'rows'
954
965
  },
955
966
 
956
967
  /**
@@ -1054,6 +1065,36 @@ export default {
1054
1065
  }
1055
1066
  },
1056
1067
 
1068
+ /**
1069
+ * Preselect a `choice` step's `default` option. Skipped once the user
1070
+ * picked here, when a value is already chosen, and for a step the
1071
+ * server already completed (its stored value must not be overwritten
1072
+ * by a Next). A card step holds the plain value; a dropdown holds the
1073
+ * option object, or the value while the options are still loading.
1074
+ *
1075
+ * @param {object} step The choice step.
1076
+ * @return {void}
1077
+ */
1078
+ applyDefault(step) {
1079
+ if (!step || step.type !== 'choice' || step.default === undefined || step.default === null) {
1080
+ return
1081
+ }
1082
+ if (this.userTouched[step.id] === true || this.hasChoice(step) || this.isServerDone(step.id)) {
1083
+ return
1084
+ }
1085
+ const wanted = step.multiple === true ? [].concat(step.default) : step.default
1086
+ if (this.isCardChoice(step)) {
1087
+ this.choiceModel[step.id] = wanted
1088
+ } else {
1089
+ const options = this.optionsFor(step)
1090
+ const pick = (value) => options.find((o) => o.value === value) || value
1091
+ this.choiceModel[step.id] = Array.isArray(wanted) ? wanted.map(pick) : pick(wanted)
1092
+ }
1093
+ if (step.configKey) {
1094
+ this.choiceValues[step.configKey] = this.scalarChoice(step)
1095
+ }
1096
+ },
1097
+
1057
1098
  /**
1058
1099
  * Auto-fill a `choice` step's suggested default from an earlier choice
1059
1100
  * (its `suggestFrom` configKey → `suggestMap[parentValue]`). No-op once
@@ -1360,6 +1401,15 @@ export default {
1360
1401
  if (step && step.suggestFrom) {
1361
1402
  this.applySuggestion(step)
1362
1403
  }
1404
+ // A default seeded before a dropdown's options arrived is a bare
1405
+ // value; swap in its option object now the step opens.
1406
+ if (step && step.default !== undefined && this.userTouched[step.id] !== true && !this.isCardChoice(step)) {
1407
+ const held = this.choiceModel[step.id]
1408
+ const option = this.optionsFor(step).find((o) => o.value === held)
1409
+ if (option && held !== null && typeof held !== 'object') {
1410
+ this.choiceModel[step.id] = option
1411
+ }
1412
+ }
1363
1413
  this.maybeAutoRunStep(payload.stepId)
1364
1414
  /**
1365
1415
  * @event step-change Emitted when the active step changes.
@@ -624,6 +624,7 @@ export default {
624
624
  // is already going under "More" does not use one up.
625
625
  let placed = pinActivity ? 1 : 0
626
626
  const resolved = []
627
+ const usedKeys = new Set()
627
628
  tabs.forEach((tab, position) => {
628
629
  const index = originalIndex[position]
629
630
  // A tab whose condition is false is absent, not empty.
@@ -654,8 +655,14 @@ export default {
654
655
  placed += 1
655
656
  }
656
657
  }
658
+ // Keyed on the tab's own id, else its place in `content.tabs`:
659
+ // never on the widget the panel starts with, which changes when
660
+ // a stack's first card turns visible and would remount the panel.
661
+ const ownId = tab && typeof tab === 'object' && typeof tab.id === 'string' ? tab.id : ''
662
+ const key = ownId !== '' && !usedKeys.has(ownId) ? ownId : `tab-${index}`
663
+ usedKeys.add(key)
657
664
  resolved.push({
658
- key: `${widgetId || 'tab'}-${index}`,
665
+ key,
659
666
  id: (tab && typeof tab === 'object' && tab.id) || widgetId || '',
660
667
  widgetId,
661
668
  label: this.tabLabel(tab, widget, widgetId),
@@ -339,7 +339,11 @@ export default {
339
339
  list-style: none;
340
340
  }
341
341
 
342
- .cn-user-settings-dialog__nav-link {
342
+ /* A navigation entry, not a link in running text: no underline, also not
343
+ on hover. `!important` because a theme's `a { text-decoration }` carries
344
+ it (thematiq), and a bare `a` with `!important` beats any class. */
345
+ .cn-user-settings-dialog__nav-link,
346
+ .cn-user-settings-dialog__nav-link:visited {
343
347
  display: flex;
344
348
  align-items: center;
345
349
  gap: 10px;
@@ -348,11 +352,12 @@ export default {
348
352
  border-radius: var(--border-radius-element, 8px);
349
353
  font-size: 15px;
350
354
  color: var(--color-main-text);
351
- text-decoration: none;
355
+ text-decoration: none !important;
352
356
  }
353
357
 
354
358
  .cn-user-settings-dialog__nav-link:hover {
355
359
  background: var(--color-background-hover);
360
+ text-decoration: none !important;
356
361
  }
357
362
 
358
363
  .cn-user-settings-dialog__nav-link:focus-visible {