@conduction/nextcloud-vue 1.0.0-beta.20 → 1.0.0-beta.21

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.
@@ -56105,7 +56105,70 @@ function validateTypeConfig(page, index, errors) {
56105
56105
  break
56106
56106
  }
56107
56107
  case 'settings': {
56108
- if (!cfg || !Array.isArray(cfg.sections)) {
56108
+ // `manifest-settings-orchestration` REQ-MSO-1: a settings page
56109
+ // MUST declare EXACTLY ONE of `sections` | `tabs`. When both
56110
+ // are set, emit the orchestration mutex error. When neither is
56111
+ // set, fall through to the legacy `sections required` error
56112
+ // (back-compat — REQ-MSO-7 / REQ-MSO-1 last scenario).
56113
+ const hasSections = cfg && Array.isArray(cfg.sections);
56114
+ const hasTabs = cfg && Array.isArray(cfg.tabs);
56115
+
56116
+ if (hasSections && hasTabs) {
56117
+ errors.push(`${pathSlash}: ${pathBracket}: must declare exactly one of sections | tabs`);
56118
+ break
56119
+ }
56120
+
56121
+ if (hasTabs) {
56122
+ // `manifest-settings-orchestration` REQ-MSO-2..4: validate
56123
+ // the `tabs[]` orchestration shape.
56124
+ if (cfg.tabs.length === 0) {
56125
+ errors.push(`${pathSlash}/tabs: ${pathBracket}.tabs: must contain at least 1 tab`);
56126
+ break
56127
+ }
56128
+ const seenTabIds = Object.create(null);
56129
+ cfg.tabs.forEach((tab, tIndex) => {
56130
+ if (!isPlainObject$1(tab)) {
56131
+ errors.push(`${pathSlash}/tabs/${tIndex}: must be an object`);
56132
+ return
56133
+ }
56134
+ if (typeof tab.id !== 'string' || tab.id.length === 0) {
56135
+ errors.push(`${pathSlash}/tabs/${tIndex}/id: required, must be a non-empty string`);
56136
+ }
56137
+ if (typeof tab.label !== 'string' || tab.label.length === 0) {
56138
+ errors.push(`${pathSlash}/tabs/${tIndex}/label: required, must be a non-empty string`);
56139
+ }
56140
+ // REQ-MSO-3: tab IDs must be unique within a page.
56141
+ if (typeof tab.id === 'string' && tab.id.length > 0) {
56142
+ if (seenTabIds[tab.id]) {
56143
+ errors.push(`${pathSlash}/tabs/${tIndex}/id: ${pathBracket}.tabs[${tIndex}].id: duplicate id "${tab.id}" — tab IDs must be unique within a page`);
56144
+ }
56145
+ seenTabIds[tab.id] = true;
56146
+ }
56147
+ // `tab.sections` MUST be a non-empty array.
56148
+ if (!Array.isArray(tab.sections)) {
56149
+ errors.push(`${pathSlash}/tabs/${tIndex}/sections: ${pathBracket}.tabs[${tIndex}].sections: required, must be an array`);
56150
+ return
56151
+ }
56152
+ if (tab.sections.length === 0) {
56153
+ errors.push(`${pathSlash}/tabs/${tIndex}/sections: ${pathBracket}.tabs[${tIndex}].sections: must contain at least 1 section`);
56154
+ return
56155
+ }
56156
+ // REQ-MSO-4: each tab's sections follow the same rules
56157
+ // as the flat case — share the per-section validator.
56158
+ tab.sections.forEach((section, sIndex) => {
56159
+ validateSettingsSection(
56160
+ section,
56161
+ `${pathSlash}/tabs/${tIndex}/sections/${sIndex}`,
56162
+ `${pathBracket}.tabs[${tIndex}].sections[${sIndex}]`,
56163
+ errors,
56164
+ );
56165
+ });
56166
+ });
56167
+ break
56168
+ }
56169
+
56170
+ // Flat `sections[]` (existing path — REQ-MSRS-* + back-compat).
56171
+ if (!hasSections) {
56109
56172
  errors.push(`${pathSlash}/sections: ${pathBracket}.sections: required, must be an array`);
56110
56173
  break
56111
56174
  }
@@ -56114,56 +56177,12 @@ function validateTypeConfig(page, index, errors) {
56114
56177
  break
56115
56178
  }
56116
56179
  cfg.sections.forEach((section, sIndex) => {
56117
- if (!isPlainObject$1(section)) {
56118
- errors.push(`${pathSlash}/sections/${sIndex}: must be an object`);
56119
- return
56120
- }
56121
- if (typeof section.title !== 'string') {
56122
- errors.push(`${pathSlash}/sections/${sIndex}/title: required, must be a string`);
56123
- }
56124
-
56125
- // `manifest-settings-rich-sections` REQ-MSRS-1: each
56126
- // section MUST declare exactly one of fields | component
56127
- // | widgets. Mixed bodies confuse the renderer + duplicate
56128
- // the section chrome; empty bodies render nothing so they
56129
- // are a manifest-author bug.
56130
- const hasFields = Array.isArray(section.fields);
56131
- const hasComponent = typeof section.component === 'string' && section.component.length > 0;
56132
- const hasWidgets = Array.isArray(section.widgets) && section.widgets.length > 0;
56133
- const bodyCount = (hasFields ? 1 : 0) + (hasComponent ? 1 : 0) + (hasWidgets ? 1 : 0);
56134
-
56135
- if (bodyCount !== 1) {
56136
- errors.push(`${pathSlash}/sections/${sIndex}: ${pathBracket}.sections[${sIndex}]: must declare exactly one of fields | component | widgets`);
56137
- }
56138
-
56139
- // `widgets` set but not an array (string / object / etc.)
56140
- if (section.widgets !== undefined && !Array.isArray(section.widgets)) {
56141
- errors.push(`${pathSlash}/sections/${sIndex}/widgets: must be an array when set`);
56142
- }
56143
-
56144
- // `component` set but not a string.
56145
- if (section.component !== undefined && typeof section.component !== 'string') {
56146
- errors.push(`${pathSlash}/sections/${sIndex}/component: must be a string when set`);
56147
- }
56148
-
56149
- // Per-widget shape rules.
56150
- if (hasWidgets) {
56151
- section.widgets.forEach((widget, wIndex) => {
56152
- if (!isPlainObject$1(widget)) {
56153
- errors.push(`${pathSlash}/sections/${sIndex}/widgets/${wIndex}: must be an object`);
56154
- return
56155
- }
56156
- if (typeof widget.type !== 'string' || widget.type.length === 0) {
56157
- errors.push(`${pathSlash}/sections/${sIndex}/widgets/${wIndex}/type: must be a non-empty string`);
56158
- }
56159
- });
56160
- }
56161
-
56162
- // `manifest-config-refs` REQ-MCR — when fields[] body is
56163
- // used, each entry must match the formField $def shape.
56164
- if (hasFields) {
56165
- validateFieldsArray(section.fields, `${pathSlash}/sections/${sIndex}/fields`, errors);
56166
- }
56180
+ validateSettingsSection(
56181
+ section,
56182
+ `${pathSlash}/sections/${sIndex}`,
56183
+ `${pathBracket}.sections[${sIndex}]`,
56184
+ errors,
56185
+ );
56167
56186
  });
56168
56187
  break
56169
56188
  }
@@ -56591,6 +56610,82 @@ function validateLayoutArray(cfg, pathSlash, pathBracket, errors) {
56591
56610
  });
56592
56611
  }
56593
56612
 
56613
+ /**
56614
+ * Validate a single `sections[]` entry for `type:"settings"` pages.
56615
+ * Shared between the flat `pages[].config.sections[]` path AND the
56616
+ * tab-nested `pages[].config.tabs[].sections[]` path
56617
+ * (`manifest-settings-orchestration` REQ-MSO-4).
56618
+ *
56619
+ * Enforces the rich-sections REQ-MSRS-1 mutex (`fields | component |
56620
+ * widgets` exactly-one-of) plus per-widget shape rules. The new
56621
+ * `widget.type === "component"` discriminator (REQ-MSO-6) requires
56622
+ * `componentName: <non-empty string>`.
56623
+ *
56624
+ * @param {*} section The section under validation
56625
+ * @param {string} pathSlash JSON-pointer-style path prefix for errors
56626
+ * @param {string} pathBracket Human-readable bracket-path for errors
56627
+ * @param {string[]} errors Accumulator
56628
+ */
56629
+ function validateSettingsSection(section, pathSlash, pathBracket, errors) {
56630
+ if (!isPlainObject$1(section)) {
56631
+ errors.push(`${pathSlash}: must be an object`);
56632
+ return
56633
+ }
56634
+ if (typeof section.title !== 'string') {
56635
+ errors.push(`${pathSlash}/title: required, must be a string`);
56636
+ }
56637
+
56638
+ // `manifest-settings-rich-sections` REQ-MSRS-1: exactly one of
56639
+ // fields | component | widgets.
56640
+ const hasFields = Array.isArray(section.fields);
56641
+ const hasComponent = typeof section.component === 'string' && section.component.length > 0;
56642
+ const hasWidgets = Array.isArray(section.widgets) && section.widgets.length > 0;
56643
+ const bodyCount = (hasFields ? 1 : 0) + (hasComponent ? 1 : 0) + (hasWidgets ? 1 : 0);
56644
+
56645
+ if (bodyCount !== 1) {
56646
+ errors.push(`${pathSlash}: ${pathBracket}: must declare exactly one of fields | component | widgets`);
56647
+ }
56648
+
56649
+ // `widgets` set but not an array (string / object / etc.)
56650
+ if (section.widgets !== undefined && !Array.isArray(section.widgets)) {
56651
+ errors.push(`${pathSlash}/widgets: must be an array when set`);
56652
+ }
56653
+
56654
+ // `component` set but not a string.
56655
+ if (section.component !== undefined && typeof section.component !== 'string') {
56656
+ errors.push(`${pathSlash}/component: must be a string when set`);
56657
+ }
56658
+
56659
+ // Per-widget shape rules.
56660
+ if (hasWidgets) {
56661
+ section.widgets.forEach((widget, wIndex) => {
56662
+ if (!isPlainObject$1(widget)) {
56663
+ errors.push(`${pathSlash}/widgets/${wIndex}: must be an object`);
56664
+ return
56665
+ }
56666
+ if (typeof widget.type !== 'string' || widget.type.length === 0) {
56667
+ errors.push(`${pathSlash}/widgets/${wIndex}/type: must be a non-empty string`);
56668
+ return
56669
+ }
56670
+ // `manifest-settings-orchestration` REQ-MSO-6: when the
56671
+ // discriminator is "component", `componentName` MUST be a
56672
+ // non-empty string. Other widget types ignore
56673
+ // `componentName`.
56674
+ if (widget.type === 'component') {
56675
+ if (typeof widget.componentName !== 'string' || widget.componentName.length === 0) {
56676
+ errors.push(`${pathSlash}/widgets/${wIndex}/componentName: required when type is "component", must be a non-empty string`);
56677
+ }
56678
+ }
56679
+ });
56680
+ }
56681
+
56682
+ // `manifest-config-refs` REQ-MCR — when fields[] body is used,
56683
+ // each entry must match the formField $def shape.
56684
+ if (hasFields) {
56685
+ validateFieldsArray(section.fields, `${pathSlash}/fields`, errors);
56686
+ }
56687
+ }
56688
+
56594
56689
  /**
56595
56690
  * Validate `config.sections[].fields[]` for settings page type
56596
56691
  * (`manifest-config-refs` REQ-MCR). Each field MUST be an object with
@@ -107628,8 +107723,46 @@ var CnSettingsWidgetMount = {
107628
107723
  //
107629
107724
  //
107630
107725
  //
107726
+ //
107727
+ //
107728
+ //
107729
+ //
107730
+ //
107731
+ //
107732
+ //
107733
+ //
107734
+ //
107735
+ //
107736
+ //
107737
+ //
107738
+ //
107739
+ //
107740
+ //
107741
+ //
107742
+ //
107743
+ //
107744
+ //
107745
+ //
107746
+ //
107747
+ //
107748
+ //
107749
+ //
107750
+ //
107751
+ //
107752
+ //
107753
+ //
107754
+ //
107631
107755
 
107632
107756
 
107757
+ /**
107758
+ * Sentinel value used in the built-in widget registry to mark the
107759
+ * `'component'` discriminator (manifest-settings-orchestration
107760
+ * REQ-MSO-6). The discriminator does NOT resolve to a fixed
107761
+ * component — instead, the resolver detects this sentinel and looks
107762
+ * up `widget.componentName` in the customComponents registry.
107763
+ */
107764
+ const COMPONENT_DISCRIMINATOR = Symbol('cn-settings-component-widget');
107765
+
107633
107766
  /**
107634
107767
  * Built-in widget registry. Used by `CnSettingsPage` to resolve
107635
107768
  * `widgets[].type` to a component BEFORE consulting the
@@ -107638,13 +107771,19 @@ var CnSettingsWidgetMount = {
107638
107771
  * The order matters — built-ins win on collision so consumers can't
107639
107772
  * accidentally shadow `version-info` with their own component. If a
107640
107773
  * consumer needs to truly replace one of these, they can render their
107641
- * own component via `section.component` instead of `widgets[]`.
107774
+ * own component via `section.component` or
107775
+ * `{ type: "component", componentName: <name> }` instead of `widgets[]`.
107642
107776
  *
107643
- * Spec: REQ-MSRS-2 (manifest-settings-rich-sections).
107777
+ * Spec:
107778
+ * - REQ-MSRS-2 (manifest-settings-rich-sections) — fixed-component
107779
+ * built-ins (`version-info`, `register-mapping`).
107780
+ * - REQ-MSO-6 (manifest-settings-orchestration) — `'component'`
107781
+ * discriminator (sentinel value, resolved via componentName).
107644
107782
  */
107645
107783
  const BUILTIN_SETTINGS_WIDGETS = Object.freeze({
107646
107784
  'version-info': __vue_component__$17,
107647
107785
  'register-mapping': __vue_component__$I,
107786
+ component: COMPONENT_DISCRIMINATOR,
107648
107787
  });
107649
107788
 
107650
107789
  /**
@@ -107752,19 +107891,50 @@ var script$8 = {
107752
107891
  default: '',
107753
107892
  },
107754
107893
  /**
107755
- * Section definitions. Each section MUST declare EXACTLY ONE of:
107894
+ * Section definitions (flat shape back-compat). Each section
107895
+ * MUST declare EXACTLY ONE of:
107756
107896
  * - `fields: Array<Field>` (back-compat flat-field body)
107757
107897
  * - `component: <registry-name>` + optional `props`
107758
- * - `widgets: Array<{ type, props? }>`
107898
+ * - `widgets: Array<{ type, props?, componentName? }>`
107759
107899
  *
107760
107900
  * Common keys: `{ title, description?, icon?, collapsible?, docUrl? }`.
107761
107901
  *
107902
+ * Mutually exclusive with `tabs[]` (XOR — see
107903
+ * manifest-settings-orchestration REQ-MSO-1).
107904
+ *
107762
107905
  * @type {Array<object>}
107763
107906
  */
107764
107907
  sections: {
107765
107908
  type: Array,
107766
107909
  default: () => [],
107767
107910
  },
107911
+ /**
107912
+ * Tab definitions (orchestration shape — manifest-settings-
107913
+ * orchestration REQ-MSO-2). When set, CnSettingsPage renders
107914
+ * a tab strip above the section area; the active tab's
107915
+ * `sections[]` flow into the same renderer used by the flat
107916
+ * shape. Mutually exclusive with `sections[]`.
107917
+ *
107918
+ * Each tab MUST be `{ id: string, label: string,
107919
+ * icon?: string, sections: array<Section> }`.
107920
+ *
107921
+ * @type {Array<object>}
107922
+ */
107923
+ tabs: {
107924
+ type: Array,
107925
+ default: () => [],
107926
+ },
107927
+ /**
107928
+ * Optional ID of the tab to activate on mount. When empty AND
107929
+ * `tabs[]` is non-empty, the first tab is active by default.
107930
+ * Unknown IDs fall back to the first tab.
107931
+ *
107932
+ * @type {string}
107933
+ */
107934
+ initialTab: {
107935
+ type: String,
107936
+ default: '',
107937
+ },
107768
107938
  /**
107769
107939
  * Initial values keyed by `field.key`. Defaults to an empty
107770
107940
  * object; in practice the consumer passes the current
@@ -107826,14 +107996,30 @@ var script$8 = {
107826
107996
  },
107827
107997
  },
107828
107998
 
107829
- emits: ['save', 'error', 'input', 'widget-event'],
107999
+ emits: ['save', 'error', 'input', 'widget-event', 'tab-change'],
107830
108000
 
107831
108001
  data() {
108002
+ // Resolve the initial active-tab id synchronously so the very
108003
+ // first render has the correct tab active (otherwise tests
108004
+ // that mount + assert without an `await tick` see the empty
108005
+ // default). Mirrors the logic in `resolveInitialTabId` (the
108006
+ // watcher path); keep them aligned.
108007
+ let activeTabId = '';
108008
+ const tabs = Array.isArray(this.tabs) ? this.tabs : [];
108009
+ if (tabs.length > 0) {
108010
+ if (typeof this.initialTab === 'string' && this.initialTab.length > 0
108011
+ && tabs.some(t => t && t.id === this.initialTab)) {
108012
+ activeTabId = this.initialTab;
108013
+ } else if (tabs[0] && typeof tabs[0].id === 'string') {
108014
+ activeTabId = tabs[0].id;
108015
+ }
108016
+ }
107832
108017
  return {
107833
108018
  formData: this.cloneInitial(),
107834
108019
  originalData: this.cloneInitial(),
107835
108020
  saving: false,
107836
108021
  lastError: null,
108022
+ activeTabId,
107837
108023
  }
107838
108024
  },
107839
108025
 
@@ -107852,6 +108038,35 @@ var script$8 = {
107852
108038
  effectiveCustomComponents() {
107853
108039
  return this.customComponents ?? this.cnCustomComponents ?? {}
107854
108040
  },
108041
+ /**
108042
+ * Whether the page is in tabs orchestration mode. True when
108043
+ * `tabs[]` is non-empty — drives the tab-strip render gate
108044
+ * (manifest-settings-orchestration REQ-MSO-5).
108045
+ *
108046
+ * @return {boolean}
108047
+ */
108048
+ hasTabs() {
108049
+ return Array.isArray(this.tabs) && this.tabs.length > 0
108050
+ },
108051
+ /**
108052
+ * The sections to render right now. In flat mode, this is the
108053
+ * `sections` prop directly. In tabs mode, this is the
108054
+ * `sections[]` array of the currently active tab. Centralising
108055
+ * this in one computed keeps the template's `v-for` simple
108056
+ * and decouples it from the body kind dispatcher (which
108057
+ * applies per-section, not per-mode).
108058
+ *
108059
+ * @return {Array<object>}
108060
+ */
108061
+ activeSections() {
108062
+ if (!this.hasTabs) return this.sections || []
108063
+ const active = this.tabs.find(t => t && t.id === this.activeTabId);
108064
+ if (active && Array.isArray(active.sections)) return active.sections
108065
+ // Defensive fallback — should not happen because
108066
+ // `resolveInitialTabId` always lands on a known tab.
108067
+ const first = this.tabs[0];
108068
+ return first && Array.isArray(first.sections) ? first.sections : []
108069
+ },
107855
108070
  },
107856
108071
 
107857
108072
  watch: {
@@ -107862,6 +108077,22 @@ var script$8 = {
107862
108077
  this.originalData = this.cloneInitial();
107863
108078
  },
107864
108079
  },
108080
+ // When `tabs[]` changes (e.g. consumer swaps manifests at
108081
+ // runtime), re-resolve the active tab so the page doesn't get
108082
+ // stuck on a removed id.
108083
+ tabs: {
108084
+ handler() {
108085
+ this.activeTabId = this.resolveInitialTabId();
108086
+ },
108087
+ },
108088
+ // When `initialTab` changes (consumer-controlled tab
108089
+ // activation), follow it.
108090
+ initialTab(next) {
108091
+ if (typeof next === 'string' && next.length > 0) {
108092
+ const exists = this.tabs.some(t => t && t.id === next);
108093
+ if (exists) this.activeTabId = next;
108094
+ }
108095
+ },
107865
108096
  },
107866
108097
 
107867
108098
  methods: {
@@ -107907,11 +108138,20 @@ var script$8 = {
107907
108138
  },
107908
108139
  cloneInitial() {
107909
108140
  const merged = { ...(this.initialValues || {}) };
107910
- // Pre-populate any field with a `default` if no value is set yet.
107911
- // Only flat-field sections contribute defaults; component and
107912
- // widgets sections own their own state.
107913
- for (const section of this.sections || []) {
107914
- if (!Array.isArray(section.fields)) continue
108141
+ // Collect every section across both modes (flat
108142
+ // `sections[]` AND `tabs[].sections[]`) so default values
108143
+ // are applied regardless of orchestration shape.
108144
+ // Only flat-field sections contribute defaults; component
108145
+ // and widgets sections own their own state.
108146
+ const allSections = [];
108147
+ for (const section of this.sections || []) allSections.push(section);
108148
+ for (const tab of this.tabs || []) {
108149
+ if (tab && Array.isArray(tab.sections)) {
108150
+ for (const section of tab.sections) allSections.push(section);
108151
+ }
108152
+ }
108153
+ for (const section of allSections) {
108154
+ if (!section || !Array.isArray(section.fields)) continue
107915
108155
  for (const field of section.fields) {
107916
108156
  if (field.default !== undefined && merged[field.key] === undefined) {
107917
108157
  merged[field.key] = field.default;
@@ -107977,23 +108217,55 @@ var script$8 = {
107977
108217
  },
107978
108218
 
107979
108219
  /**
107980
- * Resolve a `widgets[].type` string to a Vue component. Lookup
107981
- * order:
108220
+ * Resolve a single `widgets[]` entry to a concrete Vue
108221
+ * component. Lookup order:
107982
108222
  *
107983
108223
  * 1. Built-in widget map (`version-info`, `register-mapping`).
107984
- * 2. `effectiveCustomComponents` registry.
108224
+ * 2. `'component'` discriminator (REQ-MSO-6) — resolves
108225
+ * `widget.componentName` against `effectiveCustomComponents`.
108226
+ * 3. Legacy fallback — looks up `widget.type` against
108227
+ * `effectiveCustomComponents`. Kept for back-compat with
108228
+ * manifest-settings-rich-sections consumers; flagged as
108229
+ * deprecated in JSDoc — manifest authors should migrate to
108230
+ * the explicit `{ type: "component", componentName }` shape.
107985
108231
  *
107986
- * Returns `null` (and warns) when neither resolves. Built-ins
107987
- * win on collision so consumers can't accidentally shadow them
107988
- * (REQ-MSRS-2).
108232
+ * Returns `null` (and warns) when nothing resolves. Built-ins
108233
+ * win on collision so consumers can't accidentally shadow them.
107989
108234
  *
107990
- * @param {string} type The widget type string.
108235
+ * @param {object} widget A `widgets[]` entry, e.g. `{ type, props?, componentName? }`.
107991
108236
  * @return {object|null} Vue component or null.
107992
108237
  */
107993
- resolveWidgetComponent(type) {
108238
+ resolveWidgetComponent(widget) {
108239
+ const type = widget && typeof widget.type === 'string' ? widget.type : '';
108240
+ if (!type) return null
107994
108241
  if (Object.prototype.hasOwnProperty.call(BUILTIN_SETTINGS_WIDGETS, type)) {
107995
- return BUILTIN_SETTINGS_WIDGETS[type]
108242
+ const builtin = BUILTIN_SETTINGS_WIDGETS[type];
108243
+ if (builtin === COMPONENT_DISCRIMINATOR) {
108244
+ // REQ-MSO-6: discriminator — look up `componentName`.
108245
+ const name = widget.componentName;
108246
+ if (typeof name !== 'string' || name.length === 0) {
108247
+ // eslint-disable-next-line no-console
108248
+ console.warn(
108249
+ '[CnSettingsPage] Widget {type:"component"} requires a non-empty `componentName`. Widget will be skipped.',
108250
+ );
108251
+ return null
108252
+ }
108253
+ const resolved = this.effectiveCustomComponents[name];
108254
+ if (!resolved) {
108255
+ // eslint-disable-next-line no-console
108256
+ console.warn(
108257
+ `[CnSettingsPage] Widget component "${name}" not found in customComponents registry. Widget will be skipped.`,
108258
+ );
108259
+ return null
108260
+ }
108261
+ return resolved
108262
+ }
108263
+ return builtin
107996
108264
  }
108265
+ // Legacy fallback (manifest-settings-rich-sections REQ-MSRS-2).
108266
+ // Deprecated — manifest authors should migrate to
108267
+ // `{ type: "component", componentName: <X> }`. Kept here so
108268
+ // existing consumers continue working unchanged.
107997
108269
  const resolved = this.effectiveCustomComponents[type];
107998
108270
  if (!resolved) {
107999
108271
  // eslint-disable-next-line no-console
@@ -108012,6 +108284,11 @@ var script$8 = {
108012
108284
  * has already logged a warn. The filter happens here so the
108013
108285
  * template can use a clean `v-for` without nested `v-if`.
108014
108286
  *
108287
+ * The `widgetType` carried on the bubbled `@widget-event`
108288
+ * payload is the widget's `componentName` (when the
108289
+ * discriminator is `'component'`) or `widget.type` otherwise —
108290
+ * giving consumers a stable identifier for the dispatch.
108291
+ *
108015
108292
  * @param {object} section A section entry with `widgets[]`.
108016
108293
  * @param {number} sectionIndex Index in `sections[]`.
108017
108294
  * @return {Array<{key: string, component: object, props: object, widgetType: string, widgetIndex: number}>}
@@ -108021,19 +108298,57 @@ var script$8 = {
108021
108298
  const widgets = Array.isArray(section.widgets) ? section.widgets : [];
108022
108299
  for (let widgetIndex = 0; widgetIndex < widgets.length; widgetIndex++) {
108023
108300
  const widget = widgets[widgetIndex] || {};
108024
- const component = this.resolveWidgetComponent(widget.type);
108301
+ const component = this.resolveWidgetComponent(widget);
108025
108302
  if (!component) continue
108303
+ const widgetType = widget.type === 'component' && typeof widget.componentName === 'string'
108304
+ ? widget.componentName
108305
+ : widget.type;
108026
108306
  entries.push({
108027
108307
  key: `widget-${sectionIndex}-${widgetIndex}`,
108028
108308
  component,
108029
108309
  props: widget.props || {},
108030
- widgetType: widget.type,
108310
+ widgetType,
108031
108311
  widgetIndex,
108032
108312
  });
108033
108313
  }
108034
108314
  return entries
108035
108315
  },
108036
108316
 
108317
+ /**
108318
+ * Resolve the active-tab id on mount / when `tabs[]` changes.
108319
+ * Lookup order: explicit `initialTab` prop → first tab in
108320
+ * `tabs[]` → empty string. Unknown `initialTab` values fall
108321
+ * back to the first tab so the page never gets stuck.
108322
+ * (manifest-settings-orchestration REQ-MSO-5.)
108323
+ *
108324
+ * @return {string} The resolved tab id (empty in flat mode).
108325
+ */
108326
+ resolveInitialTabId() {
108327
+ if (!this.hasTabs) return ''
108328
+ if (typeof this.initialTab === 'string' && this.initialTab.length > 0) {
108329
+ const exists = this.tabs.some(t => t && t.id === this.initialTab);
108330
+ if (exists) return this.initialTab
108331
+ }
108332
+ const first = this.tabs[0];
108333
+ return first && typeof first.id === 'string' ? first.id : ''
108334
+ },
108335
+
108336
+ /**
108337
+ * Handle a tab button click. Switches the active tab and
108338
+ * emits `@tab-change` so consumers can react (e.g. persist the
108339
+ * active tab in their preference store, update the URL hash).
108340
+ * (manifest-settings-orchestration REQ-MSO-5.)
108341
+ *
108342
+ * @param {object} tab The clicked tab definition.
108343
+ * @param {number} tabIndex The tab's index in `tabs[]`.
108344
+ */
108345
+ onTabClick(tab, tabIndex) {
108346
+ if (!tab || typeof tab.id !== 'string') return
108347
+ if (this.activeTabId === tab.id) return
108348
+ this.activeTabId = tab.id;
108349
+ this.$emit('tab-change', { tabId: tab.id, tabIndex });
108350
+ },
108351
+
108037
108352
  /**
108038
108353
  * Re-emit a widget's `widget-event` (caught by the local
108039
108354
  * CnSettingsWidgetMount helper) as a top-level `widget-event`
@@ -108109,11 +108424,51 @@ var __vue_render__$8 = function () {
108109
108424
  )
108110
108425
  : _vm._e(),
108111
108426
  _vm._v(" "),
108112
- _vm._l(_vm.sections, function (section, sectionIndex) {
108427
+ _vm.hasTabs
108428
+ ? _c(
108429
+ "div",
108430
+ {
108431
+ staticClass: "cn-settings-page__tabs",
108432
+ attrs: { role: "tablist" },
108433
+ },
108434
+ _vm._l(_vm.tabs, function (tab, tabIndex) {
108435
+ return _c(
108436
+ "button",
108437
+ {
108438
+ key: "tab-" + tab.id,
108439
+ staticClass: "cn-settings-page__tab",
108440
+ class: {
108441
+ "cn-settings-page__tab--active": _vm.activeTabId === tab.id,
108442
+ },
108443
+ attrs: {
108444
+ role: "tab",
108445
+ type: "button",
108446
+ "aria-selected":
108447
+ _vm.activeTabId === tab.id ? "true" : "false",
108448
+ "aria-controls": "cn-settings-tab-panel-" + tab.id,
108449
+ },
108450
+ on: {
108451
+ click: function ($event) {
108452
+ return _vm.onTabClick(tab, tabIndex)
108453
+ },
108454
+ },
108455
+ },
108456
+ [
108457
+ _vm._v(
108458
+ "\n\t\t\t" + _vm._s(_vm.resolveLabel(tab.label)) + "\n\t\t"
108459
+ ),
108460
+ ]
108461
+ )
108462
+ }),
108463
+ 0
108464
+ )
108465
+ : _vm._e(),
108466
+ _vm._v(" "),
108467
+ _vm._l(_vm.activeSections, function (section, sectionIndex) {
108113
108468
  return _c(
108114
108469
  "CnSettingsCard",
108115
108470
  {
108116
- key: "section-" + sectionIndex,
108471
+ key: "section-" + (_vm.activeTabId || "flat") + "-" + sectionIndex,
108117
108472
  attrs: {
108118
108473
  title: _vm.resolveLabel(section.title),
108119
108474
  icon: section.icon || "",
@@ -108378,7 +108733,7 @@ __vue_render__$8._withStripped = true;
108378
108733
  /* style */
108379
108734
  const __vue_inject_styles__$8 = undefined;
108380
108735
  /* scoped */
108381
- const __vue_scope_id__$8 = "data-v-f43cb932";
108736
+ const __vue_scope_id__$8 = "data-v-4286dbd4";
108382
108737
  /* module identifier */
108383
108738
  const __vue_module_identifier__$8 = undefined;
108384
108739
  /* functional template */