@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.
- package/dist/custom-elements.json +307 -12
- package/dist/vscode.html-custom-data.json +13 -4
- package/dist/web-types.json +49 -11
- package/dist/zn.d.ts +75 -7
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +298 -245
- package/docs/pages/components/icon-picker.md +14 -0
- package/docs/pages/components/input.md +19 -1
- package/docs/pages/components/theme-editor.md +60 -18
- package/package.json +1 -1
- package/scss/_global-spacing.scss +7 -0
- package/src/components/button/button.scss +1 -1
- package/src/components/collapsible/collapsible.scss +0 -10
- package/src/components/file/file.component.ts +15 -0
- package/src/components/icon-picker/icon-picker.component.ts +150 -16
- package/src/components/icon-picker/icon-picker.scss +32 -0
- package/src/components/input/input.component.ts +44 -21
- package/src/components/input/input.scss +134 -42
- package/src/components/input/input.test.ts +45 -0
- package/src/components/theme-editor/theme-editor.component.ts +194 -35
- package/src/components/theme-editor/theme-editor.scss +47 -1
- package/src/components/theme-editor/theme-editor.test.ts +168 -0
|
@@ -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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* `
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
<div
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
691
|
-
|
|
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:
|
|
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 & Foreground" category="Colors" name="bg" value="#ffffff"></zn-input>
|
|
1041
|
+
<zn-input group="Background & Foreground" category="Colors" name="fg" value="#000000"></zn-input>
|
|
1042
|
+
<zn-input group="Background & 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`
|