@conduction/nextcloud-vue 2.74.0 → 2.75.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 (68) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +19 -14
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +2 -2
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +77 -26
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +22 -14
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +23 -4
  10. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  11. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  12. package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js +9 -0
  13. package/dist/esm/components/CnBrandBar/CnBrandBar.vue.js.map +1 -0
  14. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js +107 -0
  15. package/dist/esm/components/CnBrandBar/CnBrandBar.vue2.js.map +1 -0
  16. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js +79 -0
  17. package/dist/esm/components/CnBrandBar/CnBrandBar.vue3.js.map +1 -0
  18. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js +4 -1
  19. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue2.js.map +1 -1
  20. package/dist/esm/components/CnEnvironmentBanner/CnEnvironmentBanner.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +7 -0
  22. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  23. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +14 -7
  24. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +32 -0
  26. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js +6 -3
  28. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  32. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +18 -2
  33. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  34. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js +2 -2
  35. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  36. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js +16 -0
  37. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js.map +1 -1
  38. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js +2 -2
  39. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js.map +1 -1
  40. package/dist/esm/directives/cnSelectAria.js +55 -0
  41. package/dist/esm/directives/cnSelectAria.js.map +1 -0
  42. package/dist/esm/nextcloud-vue.css +66 -1
  43. package/dist/esm/utils/cnFetch.js +17 -2
  44. package/dist/esm/utils/cnFetch.js.map +1 -1
  45. package/dist/esm/utils/resolveBrand.js +38 -0
  46. package/dist/esm/utils/resolveBrand.js.map +1 -0
  47. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  48. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  49. package/dist/nextcloud-vue.cjs.js +8775 -8327
  50. package/dist/nextcloud-vue.cjs.js.map +2 -2
  51. package/dist/nextcloud-vue.css +66 -1
  52. package/package.json +1 -1
  53. package/src/components/CnAppNav/CnAppNav.vue +21 -16
  54. package/src/components/CnAppRoot/CnAppRoot.vue +53 -0
  55. package/src/components/CnBannerWidget/CnBannerWidget.vue +23 -4
  56. package/src/components/CnBrandBar/CnBrandBar.vue +230 -0
  57. package/src/components/CnEnvironmentBanner/CnEnvironmentBanner.vue +4 -1
  58. package/src/components/CnFormDialog/CnFormDialog.vue +10 -0
  59. package/src/components/CnFormPage/CnFormPage.vue +33 -0
  60. package/src/components/CnIndexPage/CnIndexPage.vue +2 -2
  61. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +20 -4
  62. package/src/components/CnStatsBlock/CnStatsBlock.vue +18 -2
  63. package/src/css/form-field.css +1 -1
  64. package/src/directives/cnSelectAria.js +54 -0
  65. package/src/schemas/app-manifest-v2.schema.json +6 -1
  66. package/src/utils/cnFetch.js +16 -1
  67. package/src/utils/resolveBrand.js +37 -0
  68. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/dist/nextcloud-vue.cjs.js.map
3
- size: 38245574 bytes
4
- sha256: a5678854a3205a4621af802be47ea65067c29bb280f4f1765558f532f8d9e13c
3
+ size: 38285312 bytes
4
+ sha256: 91421df55f0b2ab43c1c2b9428d1808f048aa4d3a8a0909a9681e4f49b38b627
@@ -795,7 +795,7 @@
795
795
  }
796
796
 
797
797
  /* An invalid field: 2px error border on the control, 4px edge on the group. */
798
- .cn-look-board .cn-form-field--invalid input,
798
+ .cn-look-board .cn-form-field--invalid input:not([type='checkbox']):not([type='radio']),
799
799
  .cn-look-board .cn-form-field--invalid textarea,
800
800
  .cn-look-board .cn-form-field--invalid .select {
801
801
  border: var(--cn-field-error-border) solid var(--color-error);
@@ -38840,6 +38840,71 @@ a.cn-integration-card__title[data-v-f7e9bbfa]:hover {
38840
38840
  }
38841
38841
 
38842
38842
 
38843
+ /* The host is one flex item at the start of Nextcloud's header. */
38844
+ .cn-brand-bar-host {
38845
+ flex: none;
38846
+ display: flex;
38847
+ align-items: center;
38848
+ height: 100%;
38849
+ margin-inline-end: 12px;
38850
+ }
38851
+ .cn-brand-bar {
38852
+ box-sizing: border-box;
38853
+ display: flex;
38854
+ align-items: center;
38855
+ gap: 12px;
38856
+ width: var(--cn-board-brand-width, 237px);
38857
+ }
38858
+ .cn-brand-bar__brand {
38859
+ flex: 1 1 0;
38860
+ box-sizing: border-box;
38861
+ display: flex;
38862
+ align-items: center;
38863
+ gap: 10px;
38864
+ min-width: 0;
38865
+ min-height: 48px;
38866
+ max-height: 100%;
38867
+ padding: 0 6px;
38868
+ border-radius: 8px;
38869
+ color: var(--color-background-plain-text, var(--color-main-text));
38870
+ }
38871
+ .cn-brand-bar__emblem {
38872
+ flex: none;
38873
+ height: var(--cn-nav-emblem-size, 34px);
38874
+ width: auto;
38875
+ object-fit: contain;
38876
+ }
38877
+ .cn-brand-bar__emblem--theme {
38878
+ display: inline-block;
38879
+ width: var(--cn-nav-emblem-size, 34px);
38880
+ background: var(--nldesign-emblem-url) center / contain no-repeat;
38881
+ }
38882
+ .cn-brand-bar__text {
38883
+ display: flex;
38884
+ flex-direction: column;
38885
+ line-height: 1.15;
38886
+ min-width: 0;
38887
+ }
38888
+ .cn-brand-bar__caption {
38889
+ font-size: 13px;
38890
+ white-space: nowrap;
38891
+ opacity: 0.8;
38892
+ }
38893
+ .cn-brand-bar__name {
38894
+ font-size: 18px;
38895
+ font-weight: 700;
38896
+ white-space: nowrap;
38897
+ }
38898
+ .cn-brand-bar__divider {
38899
+ flex: none;
38900
+ width: 1px;
38901
+ height: 28px;
38902
+ background: currentColor;
38903
+ opacity: 0.25;
38904
+ color: var(--color-background-plain-text, var(--color-main-text));
38905
+ }
38906
+
38907
+
38843
38908
  .cn-notif-prefs__intro[data-v-72cc0056] {
38844
38909
  margin-bottom: 12px;
38845
38910
  color: var(--color-text-maxcontrast);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.74.0",
3
+ "version": "2.75.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": {
@@ -75,7 +75,7 @@
75
75
  -->
76
76
  <template>
77
77
  <NcAppNavigation :aria-label="ariaLabel" data-testid="cn-nav">
78
- <template v-if="$slots.search || $slots.brand || resolvedBrand" #search>
78
+ <template v-if="$slots.search || $slots.brand || navBrand" #search>
79
79
  <!--
80
80
  @slot brand
81
81
  @description Replace the brand block at the very top of the
@@ -86,7 +86,7 @@
86
86
  -->
87
87
  <slot name="brand" :brand="resolvedBrand">
88
88
  <div
89
- v-if="resolvedBrand"
89
+ v-if="navBrand"
90
90
  class="cn-app-nav__brand"
91
91
  data-testid="cn-nav-brand">
92
92
  <!-- Decorative beside a name: the name already says whose
@@ -481,6 +481,7 @@ import CnMenuItemIcon from '../CnMenuWidget/CnMenuItemIcon.vue'
481
481
  import { useLook } from '../../composables/useLook.js'
482
482
  import { isAppInstalled } from '../../utils/appInstalled.js'
483
483
  import { isSvgPath } from '../../utils/iconUtils.js'
484
+ import { resolveBrand } from '../../utils/resolveBrand.js'
484
485
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
485
486
  import { CnActionButtons } from '../CnActionButtons/index.js'
486
487
  // The legacy `icon-*` → MDI bridge lives beside CnIcon now, so the menu EDITOR
@@ -579,6 +580,12 @@ export default {
579
580
 
580
581
  inject: {
581
582
  cnManifest: { default: null },
583
+ /**
584
+ * Provided by CnAppRoot as true while the board look draws the brand
585
+ * block in Nextcloud's header (screens-brand-block-top-bar): the
586
+ * navigation then skips its own.
587
+ */
588
+ cnBrandInHeader: { default: false },
582
589
  cnTranslate: { default: () => (key) => key },
583
590
  /**
584
591
  * Provided by CnAppRoot — opens the host app's
@@ -808,6 +815,17 @@ export default {
808
815
  return this.translate ?? this.cnTranslate
809
816
  },
810
817
 
818
+ /**
819
+ * The brand the navigation draws itself: the resolved brand, or null
820
+ * while the app's top bar draws it.
821
+ *
822
+ * @spec openspec/changes/screens-brand-block-top-bar/specs/layout-components/spec.md#requirement-the-navigation-does-not-draw-a-brand-block-the-bar-already-draws
823
+ * @return {object|null}
824
+ */
825
+ navBrand() {
826
+ return this.cnBrandInHeader ? null : this.resolvedBrand
827
+ },
828
+
811
829
  /**
812
830
  * The brand to draw: the `brand` prop, else the manifest's
813
831
  * `nav.brand`. Name and caption go through the translate function,
@@ -819,20 +837,7 @@ export default {
819
837
  * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-navigation-brand
820
838
  */
821
839
  resolvedBrand() {
822
- const declared = this.brand ?? this.effectiveManifest?.nav?.brand
823
- if (!declared || typeof declared !== 'object') {
824
- return null
825
- }
826
- const text = (value) => (typeof value === 'string' && value !== '' ? this.effectiveTranslate(value) : '')
827
- const brand = {
828
- logo: typeof declared.logo === 'string' ? declared.logo : '',
829
- // A URL, or `true` for the theme's emblem (--nldesign-emblem-url).
830
- emblem: declared.emblem === true ? true : (typeof declared.emblem === 'string' ? declared.emblem : ''),
831
- name: text(declared.name),
832
- caption: text(declared.caption),
833
- alt: text(declared.alt),
834
- }
835
- return (brand.logo || brand.emblem || brand.name || brand.caption) ? brand : null
840
+ return resolveBrand(this.brand ?? this.effectiveManifest?.nav?.brand, (value) => this.effectiveTranslate(value))
836
841
  },
837
842
 
838
843
  /**
@@ -189,6 +189,8 @@
189
189
 
190
190
  <!-- Phase 3: shell -->
191
191
  <template v-else>
192
+ <!-- The brand block in Nextcloud's own header (board look); teleported, renders nothing here. -->
193
+ <CnBrandBar v-if="brandInHeader" :brand="resolvedBrandBlock" @unavailable="headerMissing = true" />
192
194
  <!--
193
195
  @slot menu
194
196
  @description Left-rail navigation surface. Default:
@@ -681,6 +683,7 @@ import Restart from 'vue-material-design-icons/Restart.vue'
681
683
  import CnAiCompanion from '../CnAiCompanion/CnAiCompanion.vue'
682
684
  import CnAppLoading from '../CnAppLoading/CnAppLoading.vue'
683
685
  import CnAppNav from '../CnAppNav/CnAppNav.vue'
686
+ import CnBrandBar from '../CnBrandBar/CnBrandBar.vue'
684
687
  import CnCommandPalette from '../CnCommandPalette/CnCommandPalette.vue'
685
688
  import CnCredentials from '../CnCredentials/CnCredentials.vue'
686
689
  import CnDependencyMissing from '../CnDependencyMissing/CnDependencyMissing.vue'
@@ -721,6 +724,7 @@ import { DEFAULT_FORGE, resolveForge } from '../../utils/forge.js'
721
724
  import { BUILT_IN_KB_PROVIDERS } from '../../utils/kbSearchProviders.js'
722
725
  import { createManifestTranslate } from '../../utils/manifestTranslate.js'
723
726
  import { installModalStack, uninstallModalStack } from '../../utils/modalStack.js'
727
+ import { resolveBrand } from '../../utils/resolveBrand.js'
724
728
  import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
725
729
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
726
730
 
@@ -781,6 +785,7 @@ export default {
781
785
  name: 'CnAppRoot',
782
786
 
783
787
  components: {
788
+ CnBrandBar,
784
789
  NcAppContent,
785
790
  NcAppSettingsDialog,
786
791
  NcAppSettingsSection,
@@ -831,6 +836,9 @@ export default {
831
836
  // value once, so a getter would freeze the look at creation.
832
837
  cnLook: computed(() => self.resolvedLook),
833
838
 
839
+ // True while CnBrandBar draws the block in Nextcloud's header, so CnAppNav skips its own.
840
+ cnBrandInHeader: computed(() => self.brandInHeader),
841
+
834
842
  get cnManifest() {
835
843
  return self.manifestEditor ? self.manifestEditor.source.value : self.manifest
836
844
  },
@@ -1591,6 +1599,19 @@ export default {
1591
1599
  default: '',
1592
1600
  },
1593
1601
 
1602
+ /**
1603
+ * The brand block (`{ logo, emblem, name, caption, alt, placement }`)
1604
+ * the board look draws at the start of Nextcloud's header. Empty falls back
1605
+ * to the manifest's `nav.brand`. `placement: "nav"` keeps the block
1606
+ * in the navigation.
1607
+ *
1608
+ * @type {object|null}
1609
+ */
1610
+ brand: {
1611
+ type: Object,
1612
+ default: null,
1613
+ },
1614
+
1594
1615
  /**
1595
1616
  * Title rendered at the top of the user-settings modal
1596
1617
  * (NcAppSettingsDialog `name` prop). Defaults to the
@@ -1790,6 +1811,12 @@ export default {
1790
1811
  data() {
1791
1812
  const willCheck = Array.isArray(this.requiresApps) && this.requiresApps.length > 0
1792
1813
  return {
1814
+ // Nextcloud's own header, present on a server-rendered page; the
1815
+ // brand block goes into it (CnBrandBar). Read once at creation so
1816
+ // the navigation never draws a block that then moves.
1817
+ headerPresent: typeof document !== 'undefined' && !!document.getElementById('header'),
1818
+ // Set when CnBrandBar finds the header gone: the navigation draws the block.
1819
+ headerMissing: false,
1793
1820
  capabilitiesLoading: willCheck,
1794
1821
  missingApps: [],
1795
1822
  guardError: null,
@@ -2062,6 +2089,32 @@ export default {
2062
2089
  return (this.look || this.manifest?.look) === 'board' ? 'board' : 'nextcloud'
2063
2090
  },
2064
2091
 
2092
+ /**
2093
+ * The brand CnBrandBar draws in the header: the `brand` prop, else the manifest's
2094
+ * `nav.brand`, read into one shape.
2095
+ *
2096
+ * @return {object|null} The resolved brand, or null.
2097
+ */
2098
+ resolvedBrandBlock() {
2099
+ return resolveBrand(this.brand || this.manifest?.nav?.brand, (text) => this.manifestTranslate(text))
2100
+ },
2101
+
2102
+ /**
2103
+ * Whether CnBrandBar draws the brand block in Nextcloud's header: the
2104
+ * board look, a brand that can be drawn, a placement other than `nav`,
2105
+ * and a `#header` to put it in. Without `#header` the navigation draws it.
2106
+ *
2107
+ * @spec openspec/changes/screens-brand-block-top-bar/specs/layout-components/spec.md#requirement-the-board-look-draws-the-brand-block-in-the-nextcloud-header
2108
+ * @return {boolean}
2109
+ */
2110
+ brandInHeader() {
2111
+ return this.resolvedLook === 'board'
2112
+ && !this.headerMissing
2113
+ && this.headerPresent
2114
+ && !!this.resolvedBrandBlock
2115
+ && this.resolvedBrandBlock.placement !== 'nav'
2116
+ },
2117
+
2065
2118
  /**
2066
2119
  * The label lookup provided as `cnTranslate`: the manifest's own
2067
2120
  * translations for the language first, then the host `translate`, then the
@@ -142,6 +142,14 @@ export default {
142
142
 
143
143
  components: { NcNoteCard },
144
144
 
145
+ inject: {
146
+ /**
147
+ * The host's label lookup (CnAppRoot provides it); identity without one, so a
148
+ * label that is not a key renders as written.
149
+ */
150
+ cnTranslate: { default: () => (key) => key },
151
+ },
152
+
145
153
  props: {
146
154
  /**
147
155
  * Banner severity variant: `info | warning | error` (plus `success`
@@ -362,7 +370,7 @@ export default {
362
370
  */
363
371
  resolvedKicker() {
364
372
  const kicker = this.kicker || (this.content && this.content.kicker) || ''
365
- return kicker
373
+ return kicker ? this.tl(kicker) : ''
366
374
  },
367
375
 
368
376
  /**
@@ -373,7 +381,7 @@ export default {
373
381
  */
374
382
  displayTitle() {
375
383
  const title = this.title || (this.content && this.content.title) || ''
376
- return title ? this.fillValue(title) : this.displayText
384
+ return title ? this.fillValue(this.tl(title)) : this.displayText
377
385
  },
378
386
 
379
387
  /**
@@ -386,7 +394,7 @@ export default {
386
394
  displayReason() {
387
395
  const reason = this.reason || (this.content && this.content.reason) || ''
388
396
  if (reason) {
389
- return this.fillValue(reason)
397
+ return this.fillValue(this.tl(reason))
390
398
  }
391
399
  const title = this.title || (this.content && this.content.title) || ''
392
400
  // A card that sets `text` to the same words as its `title` says
@@ -424,7 +432,7 @@ export default {
424
432
  return {
425
433
  key: `${index}-${action.id || action.label}`,
426
434
  id: action.id,
427
- label: action.label,
435
+ label: this.tl(action.label),
428
436
  href,
429
437
  target,
430
438
  primary: index === primaryIndex,
@@ -526,6 +534,17 @@ export default {
526
534
  },
527
535
 
528
536
  methods: {
537
+ /**
538
+ * A manifest string through the host's label lookup (`cnTranslate`), so a key
539
+ * such as "Mine" reads in the user's language.
540
+ *
541
+ * @param {string} text The text as written in the manifest.
542
+ * @return {string} The translated text.
543
+ */
544
+ tl(text) {
545
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
546
+ },
547
+
529
548
  /**
530
549
  * Replace `{value}` in a text by the value the predicate read, once
531
550
  * one is known.
@@ -0,0 +1,230 @@
1
+ <!--
2
+ CnBrandBar — the brand block in Nextcloud's own header (screens-brand-block-top-bar).
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
6
+ board look. CnAppRoot mounts it when a brand is declared and `#header` exists.
7
+
8
+ `#header` is Nextcloud's and offers no extension point for apps, so this is
9
+ the one place the library reaches into it, and it does so defensively:
10
+ - it adds ONE node of its own (a host element) as the first child of the
11
+ header, and teleports the block into it; it never moves, edits or removes
12
+ a node Nextcloud drew;
13
+ - it re-inserts its own host when Nextcloud re-renders the header (a
14
+ MutationObserver on the header and its parent), never a second one;
15
+ - with no `#header` it renders nothing and emits `unavailable`, so
16
+ CnAppRoot lets the navigation draw the block instead.
17
+
18
+ Sizes come from the board: `dossiq/DqKop`.
19
+ -->
20
+ <template>
21
+ <Teleport v-if="host" :to="host">
22
+ <div class="cn-brand-bar" data-testid="cn-brand-bar">
23
+ <div class="cn-brand-bar__brand" data-testid="cn-brand-bar-brand">
24
+ <!-- Decorative beside a name: the name already says whose app this is. -->
25
+ <img
26
+ v-if="typeof brand.emblem === 'string' && brand.emblem !== ''"
27
+ class="cn-brand-bar__emblem"
28
+ data-testid="cn-brand-bar-emblem"
29
+ :src="brand.emblem"
30
+ :alt="brand.name ? '' : brand.alt">
31
+ <span
32
+ v-else-if="brand.emblem === true"
33
+ class="cn-brand-bar__emblem cn-brand-bar__emblem--theme"
34
+ data-testid="cn-brand-bar-emblem"
35
+ aria-hidden="true" />
36
+ <img
37
+ v-else-if="brand.logo"
38
+ class="cn-brand-bar__emblem"
39
+ :src="brand.logo"
40
+ :alt="brand.name ? '' : brand.alt">
41
+ <span v-if="brand.name || brand.caption" class="cn-brand-bar__text">
42
+ <span v-if="brand.caption" class="cn-brand-bar__caption">{{ brand.caption }}</span>
43
+ <strong v-if="brand.name" class="cn-brand-bar__name">{{ brand.name }}</strong>
44
+ </span>
45
+ </div>
46
+ <span class="cn-brand-bar__divider" aria-hidden="true" />
47
+ </div>
48
+ </Teleport>
49
+ </template>
50
+
51
+ <script>
52
+ import { markRaw } from 'vue'
53
+
54
+ const HOST_CLASS = 'cn-brand-bar-host'
55
+
56
+ /**
57
+ * The brand block in Nextcloud's header.
58
+ *
59
+ * @event unavailable Emitted when there is no `#header` to put the block in (or it disappears), so the host lets the navigation draw it.
60
+ *
61
+ * @spec openspec/changes/screens-brand-block-top-bar/specs/layout-components/spec.md#requirement-the-board-look-draws-the-brand-block-in-the-nextcloud-header
62
+ */
63
+ export default {
64
+ name: 'CnBrandBar',
65
+
66
+ props: {
67
+ /**
68
+ * The resolved brand: `{ logo, emblem, name, caption, alt }`. `emblem`
69
+ * is a URL, or `true` for the theme's `--nldesign-emblem-url`.
70
+ *
71
+ * @type {{logo?: string, emblem?: (string|boolean), name?: string, caption?: string, alt?: string}}
72
+ */
73
+ brand: {
74
+ type: Object,
75
+ required: true,
76
+ },
77
+ },
78
+
79
+ emits: ['unavailable'],
80
+
81
+ data() {
82
+ return { host: null, observer: null }
83
+ },
84
+
85
+ mounted() {
86
+ this.attach()
87
+ },
88
+
89
+ beforeUnmount() {
90
+ this.detach()
91
+ },
92
+
93
+ methods: {
94
+ /**
95
+ * Insert the host as the first child of `#header` and watch the header.
96
+ * Emits `unavailable` when there is no header.
97
+ *
98
+ * @return {void}
99
+ */
100
+ attach() {
101
+ const header = document.getElementById('header')
102
+ if (!header) {
103
+ /**
104
+ * @event unavailable Emitted when there is no `#header` to put the block in.
105
+ */
106
+ this.$emit('unavailable')
107
+ return
108
+ }
109
+ this.host = markRaw(document.createElement('div'))
110
+ this.host.className = HOST_CLASS
111
+ header.insertBefore(this.host, header.firstChild)
112
+ if (typeof MutationObserver === 'function') {
113
+ this.observer = new MutationObserver(() => this.reattach())
114
+ this.observer.observe(header, { childList: true })
115
+ if (header.parentNode) {
116
+ this.observer.observe(header.parentNode, { childList: true })
117
+ }
118
+ }
119
+ },
120
+
121
+ /**
122
+ * Put the same host back when Nextcloud re-rendered the header and took
123
+ * it out. Never creates a second host; when the header is gone for good
124
+ * it hands the block back to the navigation.
125
+ *
126
+ * @return {void}
127
+ */
128
+ reattach() {
129
+ if (!this.host) {
130
+ return
131
+ }
132
+ const header = document.getElementById('header')
133
+ if (!header) {
134
+ this.$emit('unavailable')
135
+ return
136
+ }
137
+ if (this.host.parentNode !== header) {
138
+ header.insertBefore(this.host, header.firstChild)
139
+ this.observer?.observe(header, { childList: true })
140
+ }
141
+ },
142
+
143
+ /**
144
+ * Stop watching and remove only the host this component added.
145
+ *
146
+ * @return {void}
147
+ */
148
+ detach() {
149
+ this.observer?.disconnect()
150
+ this.observer = null
151
+ this.host?.remove()
152
+ this.host = null
153
+ },
154
+ },
155
+ }
156
+ </script>
157
+
158
+ <style>
159
+ /* The host is one flex item at the start of Nextcloud's header. */
160
+ .cn-brand-bar-host {
161
+ flex: none;
162
+ display: flex;
163
+ align-items: center;
164
+ height: 100%;
165
+ margin-inline-end: 12px;
166
+ }
167
+
168
+ .cn-brand-bar {
169
+ box-sizing: border-box;
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 12px;
173
+ width: var(--cn-board-brand-width, 237px);
174
+ }
175
+
176
+ .cn-brand-bar__brand {
177
+ flex: 1 1 0;
178
+ box-sizing: border-box;
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 10px;
182
+ min-width: 0;
183
+ min-height: 48px;
184
+ max-height: 100%;
185
+ padding: 0 6px;
186
+ border-radius: 8px;
187
+ color: var(--color-background-plain-text, var(--color-main-text));
188
+ }
189
+
190
+ .cn-brand-bar__emblem {
191
+ flex: none;
192
+ height: var(--cn-nav-emblem-size, 34px);
193
+ width: auto;
194
+ object-fit: contain;
195
+ }
196
+
197
+ .cn-brand-bar__emblem--theme {
198
+ display: inline-block;
199
+ width: var(--cn-nav-emblem-size, 34px);
200
+ background: var(--nldesign-emblem-url) center / contain no-repeat;
201
+ }
202
+
203
+ .cn-brand-bar__text {
204
+ display: flex;
205
+ flex-direction: column;
206
+ line-height: 1.15;
207
+ min-width: 0;
208
+ }
209
+
210
+ .cn-brand-bar__caption {
211
+ font-size: 13px;
212
+ white-space: nowrap;
213
+ opacity: 0.8;
214
+ }
215
+
216
+ .cn-brand-bar__name {
217
+ font-size: 18px;
218
+ font-weight: 700;
219
+ white-space: nowrap;
220
+ }
221
+
222
+ .cn-brand-bar__divider {
223
+ flex: none;
224
+ width: 1px;
225
+ height: 28px;
226
+ background: currentColor;
227
+ opacity: 0.25;
228
+ color: var(--color-background-plain-text, var(--color-main-text));
229
+ }
230
+ </style>
@@ -103,7 +103,10 @@ export default {
103
103
  return
104
104
  }
105
105
  const all = Object.values(ENVIRONMENT_TITLE_PREFIX)
106
- const bare = document.title.replace(new RegExp(`^(?:${all.map((p) => p.replace(/[[\]]/g, '\\$&')).join('|')}) `), '')
106
+ // Plain string comparison: no pattern is built from the prefixes, so
107
+ // there is nothing to escape.
108
+ const owned = all.find((p) => document.title.startsWith(`${p} `))
109
+ const bare = owned ? document.title.slice(owned.length + 1) : document.title
107
110
  const wanted = environment ? `${ENVIRONMENT_TITLE_PREFIX[environment]} ${bare}` : bare
108
111
  if (document.title !== wanted) {
109
112
  document.title = wanted
@@ -309,6 +309,7 @@
309
309
  and select machinery is unchanged either way. -->
310
310
  <component
311
311
  :is="isUserField(field) ? 'NcSelectUsers' : 'NcSelect'"
312
+ v-cn-select-aria="fieldAria(field)"
312
313
  :inputId="'cn-form-' + field.key"
313
314
  :inputLabel="fieldLabelText(field)"
314
315
  :labelOutside="isBoardLook"
@@ -358,6 +359,7 @@
358
359
  <!-- Multiselect (array enum items / $ref array / Nextcloud users, supports async function) -->
359
360
  <div v-else-if="field.widget === 'multiselect' || field.widget === 'user-multiselect' || field.widget === 'group-multiselect'" class="cn-form-dialog__select-wrapper">
360
361
  <NcSelect
362
+ v-cn-select-aria="fieldAria(field)"
361
363
  :inputId="'cn-form-' + field.key"
362
364
  :inputLabel="fieldLabelText(field)"
363
365
  :labelOutside="isBoardLook"
@@ -401,6 +403,7 @@
401
403
  <div v-else-if="field.widget === 'tags'" class="cn-form-dialog__select-wrapper">
402
404
  <!-- TODO: restore `:options` to `asyncState[field.key]?.options` once on Vue 3 (buble doesn't support optional chaining) -->
403
405
  <NcSelect
406
+ v-cn-select-aria="fieldAria(field)"
404
407
  :inputId="'cn-form-' + field.key"
405
408
  :inputLabel="fieldLabelText(field)"
406
409
  :labelOutside="isBoardLook"
@@ -789,6 +792,7 @@ import { heldFromFailures, splitHeldFiles, uploadHeldFiles } from '../../composa
789
792
  import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'
790
793
  import { TENANT_CONTEXT_KEY } from '../../composables/useTenantContext.js'
791
794
  import { useWriteFeedback } from '../../composables/useWriteFeedback.js'
795
+ import { cnSelectAria } from '../../directives/cnSelectAria.js'
792
796
  import { dialogBoardMixin } from '../../mixins/dialogBoard.js'
793
797
  import { useObjectStore } from '../../store/useObjectStore.js'
794
798
  import { resolveCreateOverrideHandler } from '../../utils/actionsDispatcher.js'
@@ -1009,6 +1013,8 @@ const SEMANTIC_RESOLVE_ENDPOINT = '/apps/openregister/api/schemas/resolve-by-imp
1009
1013
  export default {
1010
1014
  name: 'CnFormDialog',
1011
1015
 
1016
+ directives: { cnSelectAria },
1017
+
1012
1018
  components: {
1013
1019
  CnFileField,
1014
1020
  CnDurationField,
@@ -3618,6 +3624,10 @@ export default {
3618
3624
  return
3619
3625
  }
3620
3626
  const [head, ...rest] = key.split('.')
3627
+ const unsafe = ['__proto__', 'constructor', 'prototype']
3628
+ if (unsafe.includes(head) || rest.some((k) => unsafe.includes(k))) {
3629
+ return
3630
+ }
3621
3631
  const clone = JSON.parse(JSON.stringify(this.formData[head] ?? {}))
3622
3632
  let cur = clone
3623
3633
  rest.slice(0, -1).forEach((k) => {