@kubex/zinc 1.1.154 → 1.1.155
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 +53 -5
- package/dist/vscode.html-custom-data.json +1 -1
- package/dist/web-types.json +3 -3
- package/dist/zn.d.ts +30 -6
- package/dist/zn.min.js +86 -76
- package/package.json +1 -1
- package/src/components/page-builder/page-builder.component.ts +71 -13
- package/src/components/page-builder/page-builder.test.ts +106 -0
package/package.json
CHANGED
|
@@ -24,8 +24,10 @@ import ZincElement from '../../internal/zinc-element';
|
|
|
24
24
|
import ZnCollapsible from '../collapsible';
|
|
25
25
|
import ZnIcon from '../icon';
|
|
26
26
|
import ZnInput from '../input';
|
|
27
|
+
import ZnOption from '../option';
|
|
27
28
|
import ZnPagePaletteItem from './modules/page-palette-item';
|
|
28
29
|
import ZnPageSectionCard from './modules/page-section-card';
|
|
30
|
+
import ZnSelect from '../select';
|
|
29
31
|
|
|
30
32
|
import styles from './page-builder.scss';
|
|
31
33
|
|
|
@@ -96,8 +98,10 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
96
98
|
'zn-collapsible': ZnCollapsible,
|
|
97
99
|
'zn-icon': ZnIcon,
|
|
98
100
|
'zn-input': ZnInput,
|
|
101
|
+
'zn-option': ZnOption,
|
|
99
102
|
'zn-page-palette-item': ZnPagePaletteItem,
|
|
100
103
|
'zn-page-section-card': ZnPageSectionCard,
|
|
104
|
+
'zn-select': ZnSelect,
|
|
101
105
|
};
|
|
102
106
|
|
|
103
107
|
private readonly formControlController = new FormControlController(this);
|
|
@@ -115,10 +119,12 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
115
119
|
@property({ reflect: true }) subheading = '';
|
|
116
120
|
|
|
117
121
|
/**
|
|
118
|
-
* Section type
|
|
119
|
-
*
|
|
120
|
-
* removed, reordered or dragged into a slot, and
|
|
121
|
-
* Its content stays fully editable in the
|
|
122
|
+
* Section type keys that may lead the page, comma separated. The builder hoists an
|
|
123
|
+
* existing section of any of them to the top, or inserts an empty one of the first,
|
|
124
|
+
* and pins it there: it can't be removed, reordered or dragged into a slot, and
|
|
125
|
+
* nothing can be dropped above it. Its content stays fully editable in the
|
|
126
|
+
* inspector, which offers a swap between the allowed types when there is more
|
|
127
|
+
* than one.
|
|
122
128
|
*/
|
|
123
129
|
@property({ attribute: 'required-first', reflect: true }) requiredFirst = '';
|
|
124
130
|
|
|
@@ -520,27 +526,45 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
520
526
|
*/
|
|
521
527
|
private get _pinnedId(): string | null {
|
|
522
528
|
const first = this._state.sections[0];
|
|
523
|
-
|
|
529
|
+
const types = this._requiredFirstTypes;
|
|
530
|
+
return first && types.includes(first.type) ? first.id : null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** The types `required-first` allows to lead the page, in the order given. */
|
|
534
|
+
private get _requiredFirstTypes(): string[] {
|
|
535
|
+
return this.requiredFirst.split(',').map(t => t.trim()).filter(Boolean);
|
|
524
536
|
}
|
|
525
537
|
|
|
526
538
|
private _isPinned(id: string): boolean {
|
|
527
539
|
return this._pinnedId === id;
|
|
528
540
|
}
|
|
529
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Whether the pinned section may be removed: only while another section can take
|
|
544
|
+
* the lead in its place, so the page is never left without one. It stays undraggable
|
|
545
|
+
* either way — the replacement arrives by removing this one, not by reordering.
|
|
546
|
+
*/
|
|
547
|
+
private get _canUnpin(): boolean {
|
|
548
|
+
const types = this._requiredFirstTypes;
|
|
549
|
+
return this._state.sections.slice(1).some(s => types.includes(s.type));
|
|
550
|
+
}
|
|
551
|
+
|
|
530
552
|
/** Lowest top-level index a section may be added or moved to. */
|
|
531
553
|
private get _firstFreeIndex(): number {
|
|
532
554
|
return this._pinnedId === null ? 0 : 1;
|
|
533
555
|
}
|
|
534
556
|
|
|
535
557
|
/**
|
|
536
|
-
* Sections reordered so `required-first` leads the page: an existing section of
|
|
537
|
-
* type is hoisted to the front, otherwise an empty one
|
|
558
|
+
* Sections reordered so `required-first` leads the page: an existing section of an
|
|
559
|
+
* allowed type is hoisted to the front, otherwise an empty one of the first type is
|
|
560
|
+
* prepended. Returns the
|
|
538
561
|
* argument unchanged when there is nothing to do, so callers can compare by identity.
|
|
539
562
|
*/
|
|
540
563
|
private _requireFirst(sections: PageSection[]): PageSection[] {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
564
|
+
const types = this._requiredFirstTypes;
|
|
565
|
+
if (!types.length || types.includes(sections[0]?.type)) return sections;
|
|
566
|
+
const at = sections.findIndex(s => types.includes(s.type));
|
|
567
|
+
if (at === -1) return [{ id: generateSectionId(), type: types[0], data: {} }, ...sections];
|
|
544
568
|
const hoisted = [...sections];
|
|
545
569
|
hoisted.unshift(...hoisted.splice(at, 1));
|
|
546
570
|
return hoisted;
|
|
@@ -693,7 +717,7 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
693
717
|
}
|
|
694
718
|
|
|
695
719
|
private _removeSection(id: string) {
|
|
696
|
-
if (this._isPinned(id)) return;
|
|
720
|
+
if (this._isPinned(id) && !this._canUnpin) return;
|
|
697
721
|
const [removed, sections] = this._extract(id);
|
|
698
722
|
if (!removed) return;
|
|
699
723
|
this._pushHistory();
|
|
@@ -704,7 +728,8 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
704
728
|
if (this._selectedId === id || removed.children?.some(c => c?.id === this._selectedId)) {
|
|
705
729
|
this._select(null);
|
|
706
730
|
}
|
|
707
|
-
|
|
731
|
+
// Removing the leading section hands the lead to the next one that may hold it.
|
|
732
|
+
this._commit({ sections: this._requireFirst(sections) });
|
|
708
733
|
}
|
|
709
734
|
|
|
710
735
|
private _duplicateSection(id: string) {
|
|
@@ -1139,6 +1164,7 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1139
1164
|
) {
|
|
1140
1165
|
const type = this.registry.get(section.type);
|
|
1141
1166
|
const pinned = this._isPinned(section.id);
|
|
1167
|
+
const locked = pinned && !this._canUnpin;
|
|
1142
1168
|
return html`
|
|
1143
1169
|
<zn-page-section-card
|
|
1144
1170
|
class="${extraClass} ${this._draggingId === section.id ? 'dragging' : ''}"
|
|
@@ -1152,7 +1178,7 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1152
1178
|
color="${ifDefined(type?.color)}"
|
|
1153
1179
|
?selected="${this._selectedId === section.id}"
|
|
1154
1180
|
?unknown="${!type}"
|
|
1155
|
-
?locked="${
|
|
1181
|
+
?locked="${locked}"
|
|
1156
1182
|
@click="${(e: Event) => {
|
|
1157
1183
|
e.stopPropagation();
|
|
1158
1184
|
this._select(section.id);
|
|
@@ -1340,6 +1366,21 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1340
1366
|
this._commit({ sections: this._patchSection(id, s => ({ ...s, data: { ...s.data, ...patch } })) });
|
|
1341
1367
|
}
|
|
1342
1368
|
|
|
1369
|
+
/**
|
|
1370
|
+
* Retypes the pinned section, keeping its id, name and data so the copy shared by
|
|
1371
|
+
* the allowed types survives; keys the new type has no field for stay unread. The
|
|
1372
|
+
* inspector form is stamped from the type's template, so it is rebuilt by hand —
|
|
1373
|
+
* selection has not changed.
|
|
1374
|
+
*/
|
|
1375
|
+
private _swapPinnedType(type: string) {
|
|
1376
|
+
const id = this._pinnedId;
|
|
1377
|
+
const section = this._findSection(id);
|
|
1378
|
+
if (!id || !section || section.type === type || !this._requiredFirstTypes.includes(type)) return;
|
|
1379
|
+
this._pushHistory();
|
|
1380
|
+
this._commit({ sections: this._patchSection(id, s => ({ ...s, type })) });
|
|
1381
|
+
this._buildInspectorForm();
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1343
1384
|
private _renameSection(id: string, label: string) {
|
|
1344
1385
|
this._pushHistory();
|
|
1345
1386
|
this._commit({ sections: this._patchSection(id, s => ({ ...s, label: label || undefined })) });
|
|
@@ -1380,6 +1421,22 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1380
1421
|
this._inspectorWidth = this._clampInspector((this._inspectorWidth ?? this._inspectorRect()) + step);
|
|
1381
1422
|
};
|
|
1382
1423
|
|
|
1424
|
+
/** Offered on the pinned section alone, and only where there is another type to take. */
|
|
1425
|
+
private _renderSwapControl(section: PageSection) {
|
|
1426
|
+
const types = this._requiredFirstTypes;
|
|
1427
|
+
if (types.length < 2 || !this._isPinned(section.id)) return '';
|
|
1428
|
+
return html`
|
|
1429
|
+
<zn-select
|
|
1430
|
+
class="inspector__swap"
|
|
1431
|
+
label="Section type"
|
|
1432
|
+
help-text="Swaps the section leading the page, keeping the settings both types share."
|
|
1433
|
+
.value="${section.type}"
|
|
1434
|
+
@zn-change="${(e: Event) => this._swapPinnedType(String((e.target as ZnSelect).value ?? ''))}">
|
|
1435
|
+
${types.map(t => html`
|
|
1436
|
+
<zn-option value="${t}">${this.registry.get(t)?.label ?? t}</zn-option>`)}
|
|
1437
|
+
</zn-select>`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1383
1440
|
private _renderInspector() {
|
|
1384
1441
|
const section = this._selectedSection();
|
|
1385
1442
|
if (!section) return html``;
|
|
@@ -1430,6 +1487,7 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1430
1487
|
label="Section name"
|
|
1431
1488
|
.value="${section.label ?? type?.label ?? ''}"
|
|
1432
1489
|
@zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
|
|
1490
|
+
${this._renderSwapControl(section)}
|
|
1433
1491
|
${type?.slotsMax === undefined ? '' : html`
|
|
1434
1492
|
<zn-input
|
|
1435
1493
|
class="inspector__slots"
|
|
@@ -988,6 +988,112 @@ describe('<zn-page-builder>', () => {
|
|
|
988
988
|
expect(el.state.sections[1].children?.[0] ?? null, 'slot left empty').to.be.null;
|
|
989
989
|
});
|
|
990
990
|
|
|
991
|
+
it('should accept any of several required-first types', async () => {
|
|
992
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
993
|
+
<zn-page-builder required-first="hero,hero-split"
|
|
994
|
+
config='{"sections":[{"id":"t","type":"rich-text","data":{}},{"id":"h","type":"hero-split","data":{"title":"Help"}}]}'>
|
|
995
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
996
|
+
<template type="hero-split" slot="config" label="Split Hero"></template>
|
|
997
|
+
<template type="rich-text" slot="config" label="Rich Text"></template>
|
|
998
|
+
</zn-page-builder>`);
|
|
999
|
+
await el.updateComplete;
|
|
1000
|
+
|
|
1001
|
+
expect(el.state.sections.map(s => s.id), 'the alternate type is hoisted too').to.deep.equal(['h', 't']);
|
|
1002
|
+
expect(el.shadowRoot!.querySelector('zn-page-section-card')!.hasAttribute('locked')).to.be.true;
|
|
1003
|
+
|
|
1004
|
+
const empty = await fixture<ZnPageBuilder>(html`
|
|
1005
|
+
<zn-page-builder required-first="hero,hero-split">
|
|
1006
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1007
|
+
<template type="hero-split" slot="config" label="Split Hero"></template>
|
|
1008
|
+
</zn-page-builder>`);
|
|
1009
|
+
await empty.updateComplete;
|
|
1010
|
+
expect(empty.state.sections.map(s => s.type), 'an empty page gets the first type').to.deep.equal(['hero']);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it('should swap the pinned section for another allowed type, keeping its content', async () => {
|
|
1014
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1015
|
+
<zn-page-builder required-first="hero,hero-split"
|
|
1016
|
+
config='{"sections":[{"id":"h","type":"hero","label":"Top","data":{"title":"Help","search":true}},{"id":"t","type":"rich-text","data":{}}]}'>
|
|
1017
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1018
|
+
<template type="hero-split" slot="config" label="Split Hero">
|
|
1019
|
+
<zn-input name="title"></zn-input>
|
|
1020
|
+
</template>
|
|
1021
|
+
<template type="rich-text" slot="config" label="Rich Text"></template>
|
|
1022
|
+
</zn-page-builder>`);
|
|
1023
|
+
await el.updateComplete;
|
|
1024
|
+
|
|
1025
|
+
el.shadowRoot!.querySelector('zn-page-section-card')!.dispatchEvent(new Event('click'));
|
|
1026
|
+
await el.updateComplete;
|
|
1027
|
+
|
|
1028
|
+
const swap = el.shadowRoot!.querySelector<HTMLElement & { value: string }>('.inspector__swap')!;
|
|
1029
|
+
expect(swap, 'swap control on the pinned section').to.exist;
|
|
1030
|
+
expect([...swap.querySelectorAll('zn-option')].map(o => o.getAttribute('value')))
|
|
1031
|
+
.to.deep.equal(['hero', 'hero-split']);
|
|
1032
|
+
|
|
1033
|
+
swap.value = 'hero-split';
|
|
1034
|
+
swap.dispatchEvent(new CustomEvent('zn-change', {bubbles: true}));
|
|
1035
|
+
await el.updateComplete;
|
|
1036
|
+
|
|
1037
|
+
const [pinned, rest] = el.state.sections;
|
|
1038
|
+
expect(pinned.type, 'type swapped').to.equal('hero-split');
|
|
1039
|
+
expect(pinned.id, 'same section').to.equal('h');
|
|
1040
|
+
expect(pinned.label, 'keeps its name').to.equal('Top');
|
|
1041
|
+
expect(pinned.data, 'keeps its content').to.deep.equal({title: 'Help', search: true});
|
|
1042
|
+
expect(rest.id, 'the rest of the page is untouched').to.equal('t');
|
|
1043
|
+
expect(el.shadowRoot!.querySelector('zn-page-section-card')!.hasAttribute('locked'), 'still pinned').to.be.true;
|
|
1044
|
+
|
|
1045
|
+
// The inspector now stamps the new type's fields, filled from the kept data.
|
|
1046
|
+
const title = el.shadowRoot!.querySelector<HTMLInputElement>('.inspector__form [name="title"]');
|
|
1047
|
+
expect(title?.value, 'inspector rebuilt for the new type').to.equal('Help');
|
|
1048
|
+
|
|
1049
|
+
el.undo();
|
|
1050
|
+
await el.updateComplete;
|
|
1051
|
+
expect(el.state.sections[0].type, 'undo puts the original type back').to.equal('hero');
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it('should let the pinned section go once another may lead in its place', async () => {
|
|
1055
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1056
|
+
<zn-page-builder required-first="hero,hero-split"
|
|
1057
|
+
config='{"sections":[{"id":"h","type":"hero","data":{}},{"id":"t","type":"rich-text","data":{}},{"id":"s","type":"hero-split","data":{}}]}'>
|
|
1058
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1059
|
+
<template type="hero-split" slot="config" label="Split Hero"></template>
|
|
1060
|
+
<template type="rich-text" slot="config" label="Rich Text"></template>
|
|
1061
|
+
</zn-page-builder>`);
|
|
1062
|
+
await el.updateComplete;
|
|
1063
|
+
|
|
1064
|
+
const pinnedCard = el.shadowRoot!.querySelector('zn-page-section-card')!;
|
|
1065
|
+
expect(pinnedCard.hasAttribute('locked'), 'a replaceable hero shows no lock').to.be.false;
|
|
1066
|
+
expect(pinnedCard.getAttribute('draggable'), 'but still cannot be dragged').to.equal('false');
|
|
1067
|
+
expect(pinnedCard.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'remove offered').to.exist;
|
|
1068
|
+
|
|
1069
|
+
pinnedCard.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true}));
|
|
1070
|
+
await el.updateComplete;
|
|
1071
|
+
|
|
1072
|
+
expect(el.state.sections.map(s => s.id), 'the other hero takes the lead').to.deep.equal(['s', 't']);
|
|
1073
|
+
const nowPinned = el.shadowRoot!.querySelector('zn-page-section-card')!;
|
|
1074
|
+
expect(nowPinned.hasAttribute('locked'), 'the last hero locks again').to.be.true;
|
|
1075
|
+
|
|
1076
|
+
nowPinned.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true}));
|
|
1077
|
+
await el.updateComplete;
|
|
1078
|
+
expect(el.state.sections.map(s => s.id), 'the last hero cannot go').to.deep.equal(['s', 't']);
|
|
1079
|
+
|
|
1080
|
+
el.undo();
|
|
1081
|
+
await el.updateComplete;
|
|
1082
|
+
expect(el.state.sections.map(s => s.id), 'undo brings the removed hero back').to.deep.equal(['h', 't', 's']);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it('should offer no swap control when only one type may lead', async () => {
|
|
1086
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1087
|
+
<zn-page-builder required-first="hero" config='{"sections":[{"id":"h","type":"hero","data":{}}]}'>
|
|
1088
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1089
|
+
</zn-page-builder>`);
|
|
1090
|
+
await el.updateComplete;
|
|
1091
|
+
|
|
1092
|
+
el.shadowRoot!.querySelector('zn-page-section-card')!.dispatchEvent(new Event('click'));
|
|
1093
|
+
await el.updateComplete;
|
|
1094
|
+
expect(el.shadowRoot!.querySelector('.inspector__swap')).to.not.exist;
|
|
1095
|
+
});
|
|
1096
|
+
|
|
991
1097
|
it('should leave the page alone without required-first', async () => {
|
|
992
1098
|
const el = await fixture<ZnPageBuilder>(html`
|
|
993
1099
|
<zn-page-builder config='{"sections":[{"id":"t","type":"rich-text","data":{}}]}'>
|