@kubex/zinc 1.1.69 → 1.1.70

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.
@@ -33,7 +33,7 @@ import styles from './input.scss';
33
33
  *
34
34
  * @slot label - The input's label. Alternatively, you can use the `label` attribute.
35
35
  * @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute.
36
- * @slot context-note - Used to add contextual text that is displayed above the input, on the right. Alternatively, you can use the `context-note` attribute.
36
+ * @slot context-note - Used to add contextual text that is displayed above the input, on the right. Alternatively, you can use the `context-note` attribute. Range inputs display their live value here unless this is set.
37
37
  * @slot prefix - Used to prepend a presentational icon or similar element to the input.
38
38
  * @slot suffix - Used to append a presentational icon or similar element to the input.
39
39
  * @slot clear-icon - An icon to use in lieu of the default clear icon.
@@ -51,6 +51,12 @@ import styles from './input.scss';
51
51
  * @csspart clear-button - The clear button.
52
52
  * @csspart password-toggle-button - The password toggle button.
53
53
  * @csspart suffix - The container that wraps the suffix.
54
+ *
55
+ * @cssproperty --zn-range-track-height - The height of a range input's track.
56
+ * @cssproperty --zn-range-track-color - The color of a range input's track.
57
+ * @cssproperty --zn-range-thumb-size - The diameter of a range input's thumb.
58
+ * @cssproperty --zn-range-thumb-color - The color of a range input's thumb.
59
+ * @cssproperty --zn-range-thumb-ring-width - The width of the ring drawn around a range input's thumb.
54
60
  */
55
61
  export default class ZnInput extends ZincElement implements ZincFormControl {
56
62
  static styles = unsafeCSS(styles);
@@ -80,7 +86,7 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
80
86
  * to `text`
81
87
  */
82
88
  @property({reflect: true}) type: 'color' | 'currency' | 'date' | 'datetime-local' | 'email' | 'number' | 'password' |
83
- 'search' | 'tel' | 'text' | 'time' | 'url' = 'text';
89
+ 'range' | 'search' | 'tel' | 'text' | 'time' | 'url' = 'text';
84
90
 
85
91
  /** The name of the input, submitted as a name/value pair with form data. */
86
92
  @property() name: string = "";
@@ -146,6 +152,12 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
146
152
  /** The color format to display for color inputs. Only applies when type is 'color'. **/
147
153
  @property({attribute: 'color-format'}) colorFormat: 'hex' | 'rgb' | 'hsl' | 'oklch' = 'hex';
148
154
 
155
+ /**
156
+ * Appended to the value that range inputs display above the track, on the right, e.g. `rem`. Only applies when type
157
+ * is 'range' and no `context-note` is set.
158
+ */
159
+ @property({attribute: 'value-suffix'}) valueSuffix: string = '';
160
+
149
161
  /**
150
162
  * By default, form-controls are associated with the nearest containing `<form>` element. This attribute allows you
151
163
  * to place the form control outside a form and associate it with the form that has this `id`. The form must be
@@ -615,6 +627,13 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
615
627
  }
616
628
  }
617
629
 
630
+ protected firstUpdated() {
631
+ // A range input with no value resolves to the midpoint of min/max natively, so adopt it as our value
632
+ if (this.type === 'range' && this.value === '') {
633
+ this.value = this.input.value;
634
+ }
635
+ }
636
+
618
637
  @watch('disabled', {waitUntilFirstUpdate: true})
619
638
  handleDisabledChange() {
620
639
  // Disabled form controls are always valid
@@ -763,9 +782,10 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
763
782
  const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip');
764
783
  const hasContextNoteSlot = this.hasSlotController.test('context-note');
765
784
  const hasHelpTextSlot = this.hasSlotController.test('help-text');
785
+ const isRange = this.type === 'range';
766
786
  const hasLabel = this.label ? true : hasLabelSlot;
767
787
  const hasLabelTooltip = this.labelTooltip ? true : hasLabelTooltipSlot;
768
- const hasContextNote = this.contextNote ? true : hasContextNoteSlot;
788
+ const hasContextNote = this.contextNote || isRange ? true : hasContextNoteSlot;
769
789
  const hasHelpText = this.helpText ? true : hasHelpTextSlot;
770
790
  const hasClearIcon = this.clearable && !this.disabled && !this.readonly;
771
791
  const hasOptionalIcon = this.optionalIcon;
@@ -803,7 +823,8 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
803
823
 
804
824
  ${hasContextNote
805
825
  ? html`
806
- <span class="form-control__label-context-note"><slot name="context-note">${this.contextNote}</slot></span>`
826
+ <span class="form-control__label-context-note"><slot name="context-note">${this.contextNote ||
827
+ (isRange ? `${this.value ?? ''}${this.valueSuffix}` : '')}</slot></span>`
807
828
  : ''}
808
829
 
809
830
  <div part="form-control-input"
@@ -822,27 +843,12 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
822
843
  'input--filled': this.filled,
823
844
  'input--focused': this.hasFocus,
824
845
  'input--empty': !this.value,
846
+ 'input--range': isRange,
825
847
  'input--no-spin-buttons': this.noSpinButtons && this.type !== 'currency',
826
848
  })}>
827
849
 
828
850
  <span part="prefix" class="input__prefix">
829
- ${this.type === 'color'
830
- ? html`
831
- <div
832
- class="input__color-swatch"
833
- @click=${this.handleColorSwatchClick}
834
- style="--color-value: ${this.convertToHex(this.value) || '#000000'}">
835
- <div class="input__color-swatch-inner"></div>
836
- </div>
837
- <input
838
- class="input__color-picker"
839
- type="color"
840
- .value=${this.convertToHex(this.value) || '#000000'}
841
- @change=${this.handleColorPickerChange}
842
- @input=${this.handleColorPickerChange}
843
- tabindex="-1"
844
- />`
845
- : this.type === 'currency'
851
+ ${this.type === 'currency'
846
852
  ? html`
847
853
  <zn-icon class="input__prefix-default" src="payments"></zn-icon>`
848
854
  : this.type === 'email' && hasOptionalIcon
@@ -933,6 +939,23 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
933
939
 
934
940
  <span part="suffix" class="input__suffix">
935
941
  <slot name="suffix"></slot>
942
+ ${this.type === 'color'
943
+ ? html`
944
+ <div
945
+ class="input__color-swatch"
946
+ @click=${this.handleColorSwatchClick}
947
+ style="--color-value: ${this.convertToHex(this.value) || '#000000'}">
948
+ <div class="input__color-swatch-inner"></div>
949
+ </div>
950
+ <input
951
+ class="input__color-picker"
952
+ type="color"
953
+ .value=${this.convertToHex(this.value) || '#000000'}
954
+ @change=${this.handleColorPickerChange}
955
+ @input=${this.handleColorPickerChange}
956
+ tabindex="-1"
957
+ />`
958
+ : ''}
936
959
  </span>
937
960
  </div>
938
961
  </div>
@@ -384,51 +384,163 @@ input[type='date']::-webkit-calendar-picker-indicator {
384
384
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="14" width="12.25" viewBox="0 0 448 512"><path fill="%236d7176" d="M128 0c13.3 0 24 10.7 24 24V64H296V24c0-13.3 10.7-24 24-24s24 10.7 24 24V64h40c35.3 0 64 28.7 64 64v16 48V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V192 144 128C0 92.7 28.7 64 64 64h40V24c0-13.3 10.7-24 24-24zM400 192H48V448c0 8.8 7.2 16 16 16H384c8.8 0 16-7.2 16-16V192zM112 256h96c8.8 0 16 7.2 16 16v96c0 8.8-7.2 16-16 16H112c-8.8 0-16-7.2-16-16V272c0-8.8 7.2-16 16-16z"/></svg>');
385
385
  }
386
386
 
387
- /* Color input styling */
387
+ /* Color input styling — the preview fills the trailing edge of the field */
388
388
  .input__color-swatch {
389
389
  display: inline-flex;
390
- align-items: center;
391
- justify-content: center;
392
- width: 1.75rem;
393
- height: 1.75rem;
394
- border-radius: var(--zn-border-radius);
395
- border: solid 1px rgba(0, 0, 0, 0.15);
390
+ align-self: stretch;
391
+ border: none;
392
+ border-inline-start: solid var(--zn-input-border-width) var(--zn-input-border-color);
393
+ border-radius: 0;
396
394
  background-color: var(--zn-color-neutral-0);
397
395
  cursor: pointer;
398
- transition: var(--zn-transition-fast) border-color, var(--zn-transition-fast) box-shadow;
399
396
  overflow: hidden;
400
397
  position: relative;
401
398
  flex-shrink: 0;
402
- margin-inline-start: var(--zn-input-spacing-small);
399
+ margin: 0;
403
400
 
404
- &:hover {
405
- border-color: rgba(0, 0, 0, 0.25);
406
- box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
401
+ &:hover .input__color-swatch-inner {
402
+ filter: brightness(0.95);
407
403
  }
408
404
  }
409
405
 
410
- /* Size-specific spacing adjustments for color swatch */
406
+ .input__color-swatch-inner {
407
+ width: 100%;
408
+ height: 100%;
409
+ background-color: var(--color-value, #000000);
410
+ transition: var(--zn-transition-fast) filter;
411
+ }
412
+
413
+ /* Size adjustments for color inputs — .input clips the trailing corners */
411
414
  .input--x-small .input__color-swatch {
412
- margin-inline-start: var(--zn-input-spacing-x-small);
415
+ width: calc(var(--zn-input-height-x-small) * 1.2);
413
416
  }
414
417
 
415
418
  .input--small .input__color-swatch {
416
- margin-inline-start: var(--zn-spacing-x2-small);
419
+ width: calc(var(--zn-input-height-small) * 1.2);
417
420
  }
418
421
 
419
422
  .input--medium .input__color-swatch {
420
- margin-inline-start: var(--zn-spacing-x-small);
423
+ width: calc(var(--zn-input-height-medium) * 1.2);
421
424
  }
422
425
 
423
426
  .input--large .input__color-swatch {
424
- margin-inline-start: var(--zn-spacing-small);
427
+ width: calc(var(--zn-input-height-large) * 1.2);
425
428
  }
426
429
 
427
- .input__color-swatch-inner {
428
- width: 100%;
429
- height: 100%;
430
- border-radius: calc(var(--zn-border-radius) - 2px);
431
- background-color: var(--color-value, #000000);
430
+ /* Range inputs render as a bare track, with the value shown in the context note above */
431
+ /* The thumb already leaves clear space above the track, so drop the label/field gap */
432
+ :host([type='range']) .form-control--has-label .form-control__label,
433
+ :host([type='range']) .form-control__label-context-note {
434
+ margin-bottom: 0;
435
+ }
436
+
437
+ :host([type='range']) .form-control-input {
438
+ margin-top: 0;
439
+ }
440
+
441
+ .input--standard.input--range {
442
+ --zn-range-track-height: 6px;
443
+ --zn-range-track-color: var(--zn-color-neutral-200);
444
+ --zn-range-thumb-size: 18px;
445
+ --zn-range-thumb-color: var(--zn-color-primary-600);
446
+ --zn-range-thumb-ring-width: 4px;
447
+
448
+ align-items: center;
449
+ height: var(--zn-range-thumb-size);
450
+ padding: 0;
451
+ overflow: visible;
452
+ cursor: pointer;
453
+ }
454
+
455
+ /* Matches the specificity of the hover/focus/disabled chrome rules above so the track stays bare */
456
+ .input--standard.input--range,
457
+ .input--standard.input--range:hover,
458
+ .input--standard.input--range.input--focused,
459
+ .input--standard.input--range.input--disabled {
460
+ border: none;
461
+ background: none;
462
+ box-shadow: none;
463
+ }
464
+
465
+ .input--standard.input--range.input--x-small {
466
+ --zn-range-track-height: 4px;
467
+ --zn-range-thumb-size: 14px;
468
+ --zn-range-thumb-ring-width: 3px;
469
+ }
470
+
471
+ .input--standard.input--range.input--small {
472
+ --zn-range-track-height: 5px;
473
+ --zn-range-thumb-size: 16px;
474
+ --zn-range-thumb-ring-width: 3px;
475
+ }
476
+
477
+ .input--standard.input--range.input--large {
478
+ --zn-range-track-height: 7px;
479
+ --zn-range-thumb-size: 20px;
480
+ }
481
+
482
+ .input--range .input__control {
483
+ -webkit-appearance: none;
484
+ appearance: none;
485
+ height: var(--zn-range-thumb-size);
486
+ padding: 0;
487
+ margin: 0;
488
+ background: none;
489
+ cursor: inherit;
490
+ }
491
+
492
+ .input--range .input__control::-webkit-slider-runnable-track {
493
+ height: var(--zn-range-track-height);
494
+ border-radius: calc(var(--zn-range-track-height) / 2);
495
+ background-color: var(--zn-range-track-color);
496
+ }
497
+
498
+ .input--range .input__control::-moz-range-track {
499
+ height: var(--zn-range-track-height);
500
+ border-radius: calc(var(--zn-range-track-height) / 2);
501
+ background-color: var(--zn-range-track-color);
502
+ }
503
+
504
+ .input--range .input__control::-webkit-slider-thumb {
505
+ -webkit-appearance: none;
506
+ appearance: none;
507
+ width: var(--zn-range-thumb-size);
508
+ height: var(--zn-range-thumb-size);
509
+ margin-top: calc((var(--zn-range-track-height) - var(--zn-range-thumb-size)) / 2);
510
+ border: var(--zn-range-thumb-ring-width) solid var(--zn-color-neutral-0);
511
+ border-radius: 50%;
512
+ background-color: var(--zn-range-thumb-color);
513
+ box-shadow: 0 0 0 1px var(--zn-input-border-color);
514
+ transition: var(--zn-transition-fast) box-shadow, var(--zn-transition-fast) background-color;
515
+ }
516
+
517
+ .input--range .input__control::-moz-range-thumb {
518
+ width: var(--zn-range-thumb-size);
519
+ height: var(--zn-range-thumb-size);
520
+ box-sizing: border-box;
521
+ border: var(--zn-range-thumb-ring-width) solid var(--zn-color-neutral-0);
522
+ border-radius: 50%;
523
+ background-color: var(--zn-range-thumb-color);
524
+ box-shadow: 0 0 0 1px var(--zn-input-border-color);
525
+ transition: var(--zn-transition-fast) box-shadow, var(--zn-transition-fast) background-color;
526
+ }
527
+
528
+ .input--range:hover:not(.input--disabled) .input__control::-webkit-slider-thumb {
529
+ background-color: var(--zn-color-primary-500);
530
+ box-shadow: 0 0 0 1px var(--zn-input-border-color-hover);
531
+ }
532
+
533
+ .input--range:hover:not(.input--disabled) .input__control::-moz-range-thumb {
534
+ background-color: var(--zn-color-primary-500);
535
+ box-shadow: 0 0 0 1px var(--zn-input-border-color-hover);
536
+ }
537
+
538
+ .input--range .input__control:focus-visible::-webkit-slider-thumb {
539
+ box-shadow: 0 0 0 var(--zn-focus-ring-width) var(--zn-input-focus-ring-color);
540
+ }
541
+
542
+ .input--range .input__control:focus-visible::-moz-range-thumb {
543
+ box-shadow: 0 0 0 var(--zn-focus-ring-width) var(--zn-input-focus-ring-color);
432
544
  }
433
545
 
434
546
  /* Hide the native color input but keep it functional */
@@ -444,23 +556,3 @@ input[type='date']::-webkit-calendar-picker-indicator {
444
556
  visibility: hidden;
445
557
  }
446
558
 
447
- /* Size adjustments for color inputs */
448
- .input--x-small .input__color-swatch {
449
- width: 1.25rem;
450
- height: 1.25rem;
451
- }
452
-
453
- .input--small .input__color-swatch {
454
- width: 1.5rem;
455
- height: 1.5rem;
456
- }
457
-
458
- .input--medium .input__color-swatch {
459
- width: 1.75rem;
460
- height: 1.75rem;
461
- }
462
-
463
- .input--large .input__color-swatch {
464
- width: 2rem;
465
- height: 2rem;
466
- }
@@ -172,4 +172,49 @@ describe('<zn-input>', () => {
172
172
  expect(secondInput.value).to.equal('');
173
173
  });
174
174
  });
175
+
176
+ describe('type="range"', () => {
177
+ const contextNote = (el: ZnInput) =>
178
+ el.shadowRoot!.querySelector('.form-control__label-context-note')!.textContent!.trim();
179
+
180
+ it('adopts the native midpoint when no value is set', async () => {
181
+ const el = await fixture<ZnInput>(html`<zn-input type="range" min="0" max="10"></zn-input>`);
182
+ await el.updateComplete;
183
+
184
+ expect(el.value).to.equal('5');
185
+ });
186
+
187
+ it('displays the value with its suffix in the context note', async () => {
188
+ const el = await fixture<ZnInput>(
189
+ html`<zn-input type="range" label="Radius" min="0" max="2" step="0.5" value="0.5" value-suffix="rem"></zn-input>`
190
+ );
191
+ await el.updateComplete;
192
+
193
+ expect(contextNote(el)).to.equal('0.5rem');
194
+ });
195
+
196
+ it('updates the context note as the value changes', async () => {
197
+ const el = await fixture<ZnInput>(
198
+ html`<zn-input type="range" label="Radius" min="0" max="10" value="4" value-suffix="px"></zn-input>`
199
+ );
200
+ await el.updateComplete;
201
+
202
+ const input = el.shadowRoot!.querySelector('.input__control') as HTMLInputElement;
203
+ input.value = '7';
204
+ input.dispatchEvent(new Event('input'));
205
+ await el.updateComplete;
206
+
207
+ expect(el.value).to.equal('7');
208
+ expect(contextNote(el)).to.equal('7px');
209
+ });
210
+
211
+ it('prefers an explicit context-note over the value', async () => {
212
+ const el = await fixture<ZnInput>(
213
+ html`<zn-input type="range" label="Radius" context-note="Custom" value="4"></zn-input>`
214
+ );
215
+ await el.updateComplete;
216
+
217
+ expect(contextNote(el)).to.equal('Custom');
218
+ });
219
+ });
175
220
  });
@@ -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>`)}
@@ -684,7 +838,10 @@ export default class ZnThemeEditor extends ZincElement {
684
838
  @input="${this._onControlChange}">
685
839
  <slot @slotchange="${this._onSlotChange}"></slot>
686
840
  ${this._hasNestedGroups()
687
- ? this._renderTabs(section => this._renderGroups(section))
841
+ ? this._renderTabs(section => html`
842
+ ${this._hasAssignedControls(section.name) ? html`
843
+ <slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>` : nothing}
844
+ ${this._renderGroups(section)}`)
688
845
  : this.sectionLayout === 'tabs'
689
846
  ? this._renderTabs(section => html`
690
847
  <slot name="${section.name}" class="editor__section-slot" @slotchange="${this._onSlotChange}"></slot>`)