@kubex/zinc 1.1.69 → 1.1.71

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.
@@ -1,4 +1,4 @@
1
- import { type CSSResultGroup, html, nothing, unsafeCSS } from 'lit';
1
+ import { type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS } from 'lit';
2
2
  import { HasSlotController } from '../../internal/slot';
3
3
  import { ifDefined } from 'lit/directives/if-defined.js';
4
4
  import { MutationController } from '@lit-labs/observers/mutation-controller.js';
@@ -62,6 +62,17 @@ interface HarvestableControl extends HTMLElement {
62
62
  type?: string;
63
63
  }
64
64
 
65
+ /** Turns a freeform `group`/`category` label into a slot-safe name. */
66
+ function slugify(label: string): string {
67
+ return label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
68
+ }
69
+
70
+ interface DerivedStructure {
71
+ sections: ThemeEditorSection[];
72
+ /** The slot name each control must be assigned to. */
73
+ assignments: Map<Element, string>;
74
+ }
75
+
65
76
  /**
66
77
  * @summary A theme editor: slotted form controls drive a live preview frame,
67
78
  * with a toolbar for the preview's light/dark mode and device width.
@@ -89,6 +100,14 @@ interface HarvestableControl extends HTMLElement {
89
100
  * `groups` entry) render inside that section/group instead. Harvesting and
90
101
  * change detection walk every slot's full assigned subtree, not just direct
91
102
  * children.
103
+ *
104
+ * With `sections` left unset, the structure is instead derived from the
105
+ * controls' own attributes: `group="<label>"` becomes a tab and
106
+ * `category="<label>"` a collapsible within it, and the control is slotted
107
+ * into that collapsible automatically. Either attribute works alone - a
108
+ * control with only `group` sits directly in its tab, and one with only
109
+ * `category` becomes its own top-level section. Setting `sections`
110
+ * explicitly disables the derivation entirely.
92
111
  * @slot toolbar - Actions in the toolbar, right-aligned beside the device
93
112
  * controls. Where a save button belongs.
94
113
  * @slot footer - Actions pinned beneath the controls. The built-in submit button
@@ -150,10 +169,11 @@ export default class ZnThemeEditor extends ZincElement {
150
169
  @property({ type: Number, attribute: 'save-debounce' }) saveDebounce = 1000;
151
170
 
152
171
  /**
153
- * Groups controls into named sections. Empty/unset renders one ungrouped
154
- * column. A section with a non-empty `groups` nests a collapsible per
155
- * group inside a `zn-tabs` tab for that section - see `groups` on
156
- * `ThemeEditorSection`.
172
+ * Groups controls into named sections. Empty/unset falls back to deriving the
173
+ * structure from the controls' own `group`/`category` attributes, and renders
174
+ * one ungrouped column when they carry neither. A section with a non-empty
175
+ * `groups` nests a collapsible per group inside a `zn-tabs` tab for that
176
+ * section - see `groups` on `ThemeEditorSection`.
157
177
  */
158
178
  @property({ type: Array }) sections: ThemeEditorSection[] = [];
159
179
 
@@ -246,6 +266,9 @@ export default class ZnThemeEditor extends ZincElement {
246
266
  // the other's guard early.
247
267
  private _suppressDepth = 0;
248
268
 
269
+ // One-shot: the initial auto-expand must not fight a user who closes it.
270
+ private _openedInitialCollapsible = false;
271
+
249
272
  /** The current per-mode value sets. Returns copies. */
250
273
  get values(): { light: Record<string, unknown>; dark: Record<string, unknown> } {
251
274
  return { light: { ...this._modeValues.light }, dark: { ...this._modeValues.dark } };
@@ -595,9 +618,93 @@ export default class ZnThemeEditor extends ZincElement {
595
618
  return Array.isArray(section?.groups) ? section.groups : [];
596
619
  }
597
620
 
621
+ /** Whether any direct child carries the `group`/`category` structure attributes. */
622
+ private _hasStructureAttributes(): boolean {
623
+ return Array.from(this.children).some(el => el.hasAttribute('group') || el.hasAttribute('category'));
624
+ }
625
+
626
+ /** Attribute-derived structure applies only when `sections` is left unset. */
627
+ private _usesDerivedSections(): boolean {
628
+ return this._sectionsSafe().length === 0 && this._hasStructureAttributes();
629
+ }
630
+
631
+ /**
632
+ * Builds the tab/collapsible tree from the controls' own `group` (tab) and
633
+ * `category` (collapsible) attributes, alongside the slot name each control
634
+ * needs assigning to. Only direct children are considered, since `slot` only
635
+ * works one level deep. Slot names are slugged from the labels and made
636
+ * unique across the whole tree, so two tabs can each hold a "Colors"
637
+ * category without their slots colliding.
638
+ */
639
+ private _derive(): DerivedStructure {
640
+ const used = new Set<string>();
641
+ const unique = (label: string) => {
642
+ const base = slugify(label) || 'group';
643
+ let name = base;
644
+ for (let i = 2; used.has(name); i++) name = `${base}-${i}`;
645
+ used.add(name);
646
+ return name;
647
+ };
648
+
649
+ const sections = new Map<string, ThemeEditorSection & { groups: ThemeEditorGroup[] }>();
650
+ const groupNames = new Map<string, string>();
651
+ const assignments = new Map<Element, string>();
652
+
653
+ for (const child of Array.from(this.children)) {
654
+ const group = child.getAttribute('group')?.trim() ?? '';
655
+ const category = child.getAttribute('category')?.trim() ?? '';
656
+ if (!group && !category) continue;
657
+
658
+ // A `category` with no `group` becomes its own top-level section.
659
+ const sectionLabel = group || category;
660
+ let section = sections.get(sectionLabel);
661
+ if (!section) {
662
+ section = { name: unique(sectionLabel), caption: sectionLabel, groups: [] };
663
+ sections.set(sectionLabel, section);
664
+ }
665
+
666
+ // Only one of the two present: the control sits directly in the section.
667
+ if (!group || !category) {
668
+ assignments.set(child, section.name);
669
+ continue;
670
+ }
671
+
672
+ const key = `${sectionLabel}${category}`;
673
+ let groupName = groupNames.get(key);
674
+ if (!groupName) {
675
+ groupName = unique(`${sectionLabel}-${category}`);
676
+ groupNames.set(key, groupName);
677
+ section.groups.push({ name: groupName, caption: category });
678
+ }
679
+ assignments.set(child, groupName);
680
+ }
681
+
682
+ return { sections: Array.from(sections.values()), assignments };
683
+ }
684
+
685
+ /** Explicit `sections` when set, otherwise the attribute-derived tree. */
686
+ private _effectiveSections(): ThemeEditorSection[] {
687
+ return this._usesDerivedSections() ? this._derive().sections : this._sectionsSafe();
688
+ }
689
+
690
+ // Assignment is idempotent: the `slot` attribute is only written when it
691
+ // actually differs, so the slotchange this triggers settles in one pass. The
692
+ // observer's childList-only config means these writes never feed back into it.
693
+ private _assignDerivedSlots() {
694
+ if (!this._usesDerivedSections()) return;
695
+ for (const [el, slotName] of this._derive().assignments) {
696
+ if (el.getAttribute('slot') !== slotName) el.setAttribute('slot', slotName);
697
+ }
698
+ }
699
+
700
+ protected willUpdate(changed: PropertyValues) {
701
+ super.willUpdate(changed);
702
+ this._assignDerivedSlots();
703
+ }
704
+
598
705
  /** Whether any section has a populated `groups` - the switch to nested tabs+collapsibles. */
599
706
  private _hasNestedGroups(): boolean {
600
- return this._sectionsSafe().some(section => this._groupsFor(section).length > 0);
707
+ return this._effectiveSections().some(section => this._groupsFor(section).length > 0);
601
708
  }
602
709
 
603
710
  private _visibleGroups(section: ThemeEditorSection): ThemeEditorGroup[] {
@@ -606,7 +713,7 @@ export default class ZnThemeEditor extends ZincElement {
606
713
 
607
714
  /** Configured sections that have an assigned control, or (nested) a populated group - shared by every presentation. */
608
715
  private _visibleSections(): ThemeEditorSection[] {
609
- const sections = this._sectionsSafe();
716
+ const sections = this._effectiveSections();
610
717
  return this._hasNestedGroups()
611
718
  ? sections.filter(section => this._visibleGroups(section).length > 0)
612
719
  : sections.filter(section => this._hasAssignedControls(section.name));
@@ -640,6 +747,52 @@ export default class ZnThemeEditor extends ZincElement {
640
747
  </zn-collapsible>`);
641
748
  }
642
749
 
750
+ /** Every collapsible inside a tab's panel, in document order. */
751
+ private _tabCollapsibles(section: ThemeEditorSection): ZnCollapsible[] {
752
+ const panel = Array.from(this.renderRoot.querySelectorAll<HTMLElement>('.editor__tab-panel'))
753
+ .find(el => el.id === section.name);
754
+ if (!panel) return [];
755
+
756
+ // Author-slotted collapsibles stay in the light DOM, so they aren't
757
+ // descendants of the panel - reach them through the slot's assignment.
758
+ // They precede any group collapsibles the editor renders itself.
759
+ const slotted = Array.from(panel.querySelectorAll('slot'))
760
+ .flatMap(slot => slot.assignedElements({ flatten: true }))
761
+ .filter((el): el is ZnCollapsible => el.tagName.toLowerCase() === 'zn-collapsible');
762
+
763
+ return [...slotted, ...Array.from(panel.querySelectorAll<ZnCollapsible>('zn-collapsible'))];
764
+ }
765
+
766
+ /** Expands the first collapsible, unless one is already open. */
767
+ private _expandFirst(collapsibles: ZnCollapsible[]) {
768
+ if (collapsibles.length === 0 || collapsibles.some(collapsible => collapsible.expanded)) return;
769
+ collapsibles[0].expanded = true;
770
+ }
771
+
772
+ // zn-tabs emits no selection event, so this hangs off the same <li> zn-tabs
773
+ // binds its own click handler to. Independent of that handler: expanding a
774
+ // collapsible in a panel that is about to be selected needs no ordering.
775
+ private _openFirstCollapsible(section: ThemeEditorSection) {
776
+ this._expandFirst(this._tabCollapsibles(section));
777
+ }
778
+
779
+ // Same rule as a tab click, applied once to whatever renders first, so the
780
+ // editor never opens with every collapsible shut. Runs from updated() rather
781
+ // than firstUpdated() because the sections themselves only appear once slot
782
+ // assignment (including the derived kind) has settled.
783
+ protected updated(changed: PropertyValues) {
784
+ super.updated(changed);
785
+ if (this._openedInitialCollapsible) return;
786
+
787
+ const sections = this._visibleSections();
788
+ if (sections.length === 0) return;
789
+ this._openedInitialCollapsible = true;
790
+
791
+ this._expandFirst(this._hasNestedGroups() || this.sectionLayout === 'tabs'
792
+ ? this._tabCollapsibles(sections[0])
793
+ : Array.from(this.renderRoot.querySelectorAll<ZnCollapsible>('.editor__section')));
794
+ }
795
+
643
796
  // zn-tabs never removes a panel - it toggles `selected` on it and hides the
644
797
  // rest via its own shadow stylesheet - so every section's slot(s) stay
645
798
  // assigned and switching tabs can never drop a control's value from the
@@ -653,7 +806,8 @@ export default class ZnThemeEditor extends ZincElement {
653
806
  return html`
654
807
  <zn-tabs class="editor__tabs" flush active="${sections[0].name}">
655
808
  <zn-navbar slot="top" border>
656
- ${sections.map(section => html`<li tab="${section.name}">${section.caption}</li>`)}
809
+ ${sections.map(section => html`
810
+ <li tab="${section.name}" @click="${() => this._openFirstCollapsible(section)}">${section.caption}</li>`)}
657
811
  </zn-navbar>
658
812
  ${sections.map(section => html`
659
813
  <div id="${section.name}" class="editor__tab-panel">${panel(section)}</div>`)}
@@ -665,35 +819,40 @@ export default class ZnThemeEditor extends ZincElement {
665
819
  <div part="base" class="editor ${this.controlsCollapsed ? 'editor--controls-collapsed' : ''}"
666
820
  style="min-height: ${this.minHeight}px">
667
821
  <div part="controls" class="editor__controls">
668
- <div part="controls-header" class="editor__controls-header">
669
- ${this.controlsCaption ? html`<span class="editor__caption">${this.controlsCaption}</span>` : nothing}
670
- <button type="button"
671
- class="editor__mode"
672
- data-mode-toggle
673
- aria-label="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
674
- @click="${this._toggleMode}">
675
- <zn-icon src="${this.mode === 'dark' ? 'sun' : 'moon'}" library="lucide" size="16"></zn-icon>
676
- </button>
677
- </div>
678
- <div class="editor__controls-body">
679
- <div
680
- class="editor__fields ${this.hasSlotController.test('[default]') ? '' : 'editor__fields--sections-only'}"
681
- @zn-change="${this._onControlChange}"
682
- @zn-input="${this._onControlChange}"
683
- @change="${this._onControlChange}"
684
- @input="${this._onControlChange}">
685
- <slot @slotchange="${this._onSlotChange}"></slot>
686
- ${this._hasNestedGroups()
687
- ? this._renderTabs(section => this._renderGroups(section))
688
- : this.sectionLayout === 'tabs'
822
+ <div class="editor__controls-inner">
823
+ <div part="controls-header" class="editor__controls-header">
824
+ ${this.controlsCaption ? html`<span class="editor__caption">${this.controlsCaption}</span>` : nothing}
825
+ <button type="button"
826
+ class="editor__mode"
827
+ data-mode-toggle
828
+ aria-label="${this.mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}"
829
+ @click="${this._toggleMode}">
830
+ <zn-icon src="${this.mode === 'dark' ? 'sun' : 'moon'}" library="lucide" size="16"></zn-icon>
831
+ </button>
832
+ </div>
833
+ <div class="editor__controls-body">
834
+ <div
835
+ class="editor__fields ${this.hasSlotController.test('[default]') ? '' : 'editor__fields--sections-only'}"
836
+ @zn-change="${this._onControlChange}"
837
+ @zn-input="${this._onControlChange}"
838
+ @change="${this._onControlChange}"
839
+ @input="${this._onControlChange}">
840
+ <slot @slotchange="${this._onSlotChange}"></slot>
841
+ ${this._hasNestedGroups()
689
842
  ? this._renderTabs(section => html`
690
- <slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>`)
691
- : this._renderSections()}
843
+ ${this._hasAssignedControls(section.name) ? html`
844
+ <slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>` : nothing}
845
+ ${this._renderGroups(section)}`)
846
+ : this.sectionLayout === 'tabs'
847
+ ? this._renderTabs(section => html`
848
+ <slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>`)
849
+ : this._renderSections()}
850
+ </div>
851
+ ${this.hasSlotController.test('footer') ? html`
852
+ <div part="footer" class="editor__footer">
853
+ <slot name="footer"></slot>
854
+ </div>` : nothing}
692
855
  </div>
693
- ${this.hasSlotController.test('footer') ? html`
694
- <div part="footer" class="editor__footer">
695
- <slot name="footer"></slot>
696
- </div>` : nothing}
697
856
  </div>
698
857
  </div>
699
858
 
@@ -4,6 +4,10 @@
4
4
  display: block;
5
5
  // Matches page-builder's --palette-col.
6
6
  --zn-theme-editor-controls-width: 343px;
7
+ // Resolves to auto when the parent height is indefinite, so the editor still
8
+ // grows to its content there; given a definite parent it stays inside it and
9
+ // the two columns scroll on their own instead.
10
+ height: 100%;
7
11
  }
8
12
 
9
13
  .editor {
@@ -11,6 +15,7 @@
11
15
 
12
16
  display: flex;
13
17
  align-items: stretch;
18
+ height: 100%;
14
19
 
15
20
  &--controls-collapsed {
16
21
  --controls-col: 0px;
@@ -38,6 +43,7 @@
38
43
  .editor__toolbar {
39
44
  display: flex;
40
45
  align-items: center;
46
+ flex: 0 0 auto;
41
47
  min-height: 44px;
42
48
  gap: var(--zn-spacing-small);
43
49
  padding: var(--zn-spacing-small) var(--zn-spacing-medium);
@@ -59,6 +65,7 @@
59
65
  }
60
66
 
61
67
  .editor__controls {
68
+ position: relative;
62
69
  display: flex;
63
70
  flex-direction: column;
64
71
  flex: 0 0 var(--controls-col);
@@ -70,9 +77,21 @@
70
77
  transition: flex-basis 0.2s ease;
71
78
  }
72
79
 
80
+ // Taken out of flow so the controls contribute no height to the row: expanding
81
+ // a section can then never stretch the editor, it just scrolls in place. The
82
+ // preview column alone sets the height (its own min-height, or the parent's if
83
+ // that is definite).
84
+ .editor__controls-inner {
85
+ position: absolute;
86
+ inset: 0;
87
+ display: flex;
88
+ flex-direction: column;
89
+ }
90
+
73
91
  .editor__controls-header {
74
92
  display: flex;
75
93
  align-items: center;
94
+ flex: 0 0 auto;
76
95
  min-height: 44px;
77
96
  padding: var(--zn-spacing-small) var(--zn-spacing-medium);
78
97
  }
@@ -82,6 +101,7 @@
82
101
  flex-direction: column;
83
102
  gap: var(--zn-spacing-medium);
84
103
  flex: 1 1 auto;
104
+ min-height: 0;
85
105
  }
86
106
 
87
107
  .editor__main {
@@ -90,12 +110,17 @@
90
110
  flex-direction: column;
91
111
  flex: 1 1 auto;
92
112
  min-width: 0;
113
+ min-height: 0;
93
114
  }
94
115
 
116
+ // The sidebar's scroll region: the header above and the footer below stay put.
95
117
  .editor__fields {
96
118
  display: flex;
97
119
  flex-direction: column;
98
120
  gap: var(--zn-spacing-medium);
121
+ flex: 1 1 auto;
122
+ min-height: 0;
123
+ overflow-y: auto;
99
124
  }
100
125
 
101
126
  // Supports an author hand-slotting a bare zn-collapsible into the default slot.
@@ -148,16 +173,19 @@
148
173
 
149
174
  .editor__footer {
150
175
  display: flex;
176
+ flex: 0 0 auto;
151
177
  gap: var(--zn-spacing-small);
152
178
  padding-top: var(--zn-spacing-small);
153
179
  border-top: 1px solid rgb(var(--zn-border-color));
154
180
  margin-top: auto;
155
181
  }
156
182
 
183
+ // The preview's scroll region: the toolbar above stays put.
157
184
  .editor__preview {
158
- overflow: visible;
185
+ overflow: auto;
159
186
  flex: 1 1 auto;
160
187
  min-width: 0;
188
+ min-height: 0;
161
189
  display: flex;
162
190
  flex-direction: column;
163
191
  }
@@ -267,8 +295,20 @@
267
295
 
268
296
  // 768 must match STACKED_QUERY in theme-editor.component.ts.
269
297
  @media (max-width: 768px) {
298
+ // Stacked, the two columns share one vertical axis, so they flow with the
299
+ // page rather than each owning a cramped scroller of its own.
300
+ :host {
301
+ height: auto;
302
+ }
303
+
270
304
  .editor {
271
305
  flex-direction: column;
306
+ height: auto;
307
+ }
308
+
309
+ .editor__fields,
310
+ .editor__preview {
311
+ overflow: visible;
272
312
  }
273
313
 
274
314
  .editor__controls {
@@ -278,6 +318,12 @@
278
318
  border-bottom: 1px solid rgb(var(--zn-border-color));
279
319
  }
280
320
 
321
+ // Stacked there is no second column to take the height from, so the controls
322
+ // go back in flow and size themselves.
323
+ .editor__controls-inner {
324
+ position: static;
325
+ }
326
+
281
327
  .editor--controls-collapsed .editor__controls {
282
328
  display: none;
283
329
  }
@@ -970,6 +970,57 @@ describe('<zn-theme-editor>', () => {
970
970
  expect(el.shadowRoot!.querySelectorAll('.editor__section').length).to.equal(0);
971
971
  });
972
972
 
973
+ it("expands the first tab's first group on load, and closing it stays closed", async () => {
974
+ const el = await fixture(html`
975
+ <zn-theme-editor
976
+ src="about:blank" frame-origin="https://site.example"
977
+ .sections="${[
978
+ {name: 'colors', caption: 'Colors', groups: [
979
+ {name: 'brand', caption: 'Brand'},
980
+ {name: 'semantic', caption: 'Semantic'},
981
+ ]},
982
+ ]}">
983
+ <zn-input slot="brand" name="brand" value="1"></zn-input>
984
+ <zn-input slot="semantic" name="semantic" value="2"></zn-input>
985
+ </zn-theme-editor>`);
986
+
987
+ const sections = el.shadowRoot!.querySelectorAll<HTMLElement & {expanded: boolean}>('.editor__section');
988
+ expect([sections[0].expanded, sections[1].expanded]).to.deep.equal([true, false]);
989
+
990
+ // The auto-expand is one-shot - a later render must not reopen it.
991
+ sections[0].expanded = false;
992
+ (el as HTMLElement & {requestUpdate: () => void}).requestUpdate();
993
+ await (el as HTMLElement & {updateComplete: Promise<boolean>}).updateComplete;
994
+ expect(sections[0].expanded).to.be.false;
995
+ });
996
+
997
+ it("clicking a tab expands its first group, and leaves an already-open group alone", async () => {
998
+ const el = await NESTED_GROUPS_FIXTURE();
999
+ const sections = el.shadowRoot!.querySelectorAll<HTMLElement & {expanded: boolean}>('.editor__section');
1000
+ const panels = el.shadowRoot!.querySelectorAll('.editor__tab-panel');
1001
+ const tabs = el.shadowRoot!.querySelectorAll<HTMLElement>('li[tab]');
1002
+ await waitUntil(() => panels[0].hasAttribute('selected'));
1003
+
1004
+ // Colors: Brand (closed) + Semantic (open: true). Shapes: Radius (closed).
1005
+ expect([sections[0].expanded, sections[1].expanded, sections[2].expanded])
1006
+ .to.deep.equal([false, true, false]);
1007
+
1008
+ tabs[1].click();
1009
+ await waitUntil(() => panels[1].hasAttribute('selected'));
1010
+ expect(sections[2].expanded).to.be.true;
1011
+
1012
+ // Semantic is already open, so Brand stays closed.
1013
+ tabs[0].click();
1014
+ await waitUntil(() => panels[0].hasAttribute('selected'));
1015
+ expect(sections[0].expanded).to.be.false;
1016
+ expect(sections[1].expanded).to.be.true;
1017
+
1018
+ // With every group closed, the first one opens.
1019
+ sections[1].expanded = false;
1020
+ tabs[0].click();
1021
+ expect(sections[0].expanded).to.be.true;
1022
+ });
1023
+
973
1024
  it('does not crash render when groups is malformed', async () => {
974
1025
  const el = await fixture(html`
975
1026
  <zn-theme-editor
@@ -983,6 +1034,123 @@ describe('<zn-theme-editor>', () => {
983
1034
  });
984
1035
  });
985
1036
 
1037
+ describe('structure derived from group/category attributes', () => {
1038
+ const DERIVED_FIXTURE = () => fixture(html`
1039
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example" debounce="10">
1040
+ <zn-input group="Background &amp; Foreground" category="Colors" name="bg" value="#ffffff"></zn-input>
1041
+ <zn-input group="Background &amp; Foreground" category="Colors" name="fg" value="#000000"></zn-input>
1042
+ <zn-input group="Background &amp; Foreground" category="Spacing" name="gap" value="8"></zn-input>
1043
+ <zn-input group="Typography" category="Colors" name="font" value="Inter"></zn-input>
1044
+ </zn-theme-editor>`);
1045
+
1046
+ it('renders and is accessible', async () => {
1047
+ const el = await DERIVED_FIXTURE();
1048
+ await expect(el).to.be.accessible();
1049
+ });
1050
+
1051
+ it('creates a tab per group and a collapsible per category within it', async () => {
1052
+ const el = await DERIVED_FIXTURE();
1053
+
1054
+ const tabs = Array.from(el.shadowRoot!.querySelectorAll('li[tab]')).map(t => t.textContent?.trim());
1055
+ expect(tabs).to.deep.equal(['Background & Foreground', 'Typography']);
1056
+
1057
+ const captions = Array.from(el.shadowRoot!.querySelectorAll('.editor__section'))
1058
+ .map(s => s.getAttribute('caption'));
1059
+ expect(captions).to.deep.equal(['Colors', 'Spacing', 'Colors']);
1060
+ });
1061
+
1062
+ it('slots each control into its derived collapsible and harvests them all', async () => {
1063
+ const el = await DERIVED_FIXTURE();
1064
+
1065
+ // Two tabs each hold a "Colors" category - their slots must not collide.
1066
+ const bgSlot = el.querySelector('zn-input[name="bg"]')!.getAttribute('slot');
1067
+ const fontSlot = el.querySelector('zn-input[name="font"]')!.getAttribute('slot');
1068
+ expect(bgSlot).to.be.a('string').and.not.equal('');
1069
+ expect(fontSlot).to.not.equal(bgSlot);
1070
+ // controls sharing a group+category land in the same slot
1071
+ expect(el.querySelector('zn-input[name="fg"]')!.getAttribute('slot')).to.equal(bgSlot);
1072
+
1073
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
1074
+ .to.deep.equal({bg: '#ffffff', fg: '#000000', gap: '8', font: 'Inter'});
1075
+ });
1076
+
1077
+ it('puts a control with only group directly in its tab', async () => {
1078
+ const el = await fixture(html`
1079
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
1080
+ <zn-input group="Colors" category="Brand" name="accent" value="#6936f5"></zn-input>
1081
+ <zn-input group="Colors" name="loose" value="x"></zn-input>
1082
+ </zn-theme-editor>`);
1083
+
1084
+ expect(el.shadowRoot!.querySelectorAll('li[tab]').length).to.equal(1);
1085
+ const captions = Array.from(el.shadowRoot!.querySelectorAll('.editor__section'))
1086
+ .map(s => s.getAttribute('caption'));
1087
+ expect(captions).to.deep.equal(['Brand']);
1088
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
1089
+ .to.deep.equal({accent: '#6936f5', loose: 'x'});
1090
+ });
1091
+
1092
+ it('treats a control with only category as its own top-level section', async () => {
1093
+ const el = await fixture(html`
1094
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
1095
+ <zn-input category="Colors" name="accent" value="#6936f5"></zn-input>
1096
+ </zn-theme-editor>`);
1097
+
1098
+ const captions = Array.from(el.shadowRoot!.querySelectorAll('.editor__section'))
1099
+ .map(s => s.getAttribute('caption'));
1100
+ expect(captions).to.deep.equal(['Colors']);
1101
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
1102
+ .to.deep.equal({accent: '#6936f5'});
1103
+ });
1104
+
1105
+ it('an explicit sections wins over the attributes', async () => {
1106
+ const el = await fixture(html`
1107
+ <zn-theme-editor
1108
+ src="about:blank" frame-origin="https://site.example"
1109
+ .sections="${[{name: 'explicit', caption: 'Explicit'}]}">
1110
+ <zn-input slot="explicit" group="Ignored" category="Ignored too" name="accent" value="#6936f5"></zn-input>
1111
+ </zn-theme-editor>`);
1112
+
1113
+ const captions = Array.from(el.shadowRoot!.querySelectorAll('.editor__section'))
1114
+ .map(s => s.getAttribute('caption'));
1115
+ expect(captions).to.deep.equal(['Explicit']);
1116
+ // the explicit slot assignment is left untouched
1117
+ expect(el.querySelector('zn-input')!.getAttribute('slot')).to.equal('explicit');
1118
+ });
1119
+
1120
+ it('derives, slots and pushes a control added after mount', async () => {
1121
+ const el = await fixture(html`
1122
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
1123
+ <zn-input group="Colors" category="Brand" name="accent" value="#6936f5"></zn-input>
1124
+ </zn-theme-editor>`);
1125
+ const calls = spyOnFrame(el);
1126
+ expect(el.shadowRoot!.querySelectorAll('li[tab]').length).to.equal(1);
1127
+
1128
+ const added = document.createElement('zn-input');
1129
+ added.setAttribute('group', 'Shapes');
1130
+ added.setAttribute('category', 'Radius');
1131
+ added.setAttribute('name', 'radius');
1132
+ added.setAttribute('value', '4');
1133
+ el.append(added);
1134
+
1135
+ await waitUntil(() => calls.length >= 1);
1136
+ expect((calls[0]['values'] as Record<string, unknown>)['radius']).to.equal('4');
1137
+ await waitUntil(() => el.shadowRoot!.querySelectorAll('li[tab]').length === 2);
1138
+ expect(added.getAttribute('slot')).to.be.a('string').and.not.equal('');
1139
+ });
1140
+
1141
+ it('leaves controls with neither attribute in the default slot', async () => {
1142
+ const el = await fixture(html`
1143
+ <zn-theme-editor src="about:blank" frame-origin="https://site.example">
1144
+ <zn-input group="Colors" category="Brand" name="accent" value="#6936f5"></zn-input>
1145
+ <zn-input name="loose" value="x"></zn-input>
1146
+ </zn-theme-editor>`);
1147
+
1148
+ expect(el.querySelector('zn-input[name="loose"]')!.hasAttribute('slot')).to.be.false;
1149
+ expect((el as HTMLElement & {values: {light: Record<string, unknown>}}).values.light)
1150
+ .to.deep.equal({accent: '#6936f5', loose: 'x'});
1151
+ });
1152
+ });
1153
+
986
1154
  describe('preview sources', () => {
987
1155
  it('with sources unset, renders no dropdown and leaves src alone', async () => {
988
1156
  const el = await fixture(html`