@kubex/zinc 1.1.28 → 1.1.31
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/custom-elements-manifest.config.js +2 -2
- package/dist/custom-elements.json +1910 -362
- package/dist/vscode.html-custom-data.json +55 -14
- package/dist/web-types.json +185 -32
- package/dist/zn.d.ts +684 -16
- package/dist/zn.min.js +1325 -929
- package/docs/pages/components/flow-builder-troubleshooter-demo.njk +106 -78
- package/docs/pages/components/flow-builder.md +422 -66
- package/docs/pages/components/page-builder.md +167 -0
- package/docs/pages/components/settings-container.md +37 -2
- package/package.json +1 -1
- package/src/components/datepicker/datepicker.scss +0 -10
- package/src/components/flow-builder/flow-builder.component.ts +377 -16
- package/src/components/flow-builder/flow-builder.scss +398 -250
- package/src/components/flow-builder/flow-builder.test.ts +241 -4
- package/src/components/flow-builder/flow-layout.ts +42 -17
- package/src/components/flow-builder/flow.types.ts +168 -43
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.component.ts +300 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.scss +222 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.test.ts +125 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +184 -26
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +170 -120
- package/src/components/flow-builder/modules/flow-node/flow-node.scss +42 -42
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -1
- package/src/components/flow-builder/modules/flow-step/flow-step.scss +25 -17
- package/src/components/header/header.scss +3 -1
- package/src/components/icon-picker/icon-picker.component.ts +20 -37
- package/src/components/icon-picker/icon-picker.scss +5 -45
- package/src/components/page-builder/index.ts +14 -0
- package/src/components/page-builder/modules/page-palette-item/index.ts +12 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.component.ts +71 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +70 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.test.ts +20 -0
- package/src/components/page-builder/modules/page-section-card/index.ts +12 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +93 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +92 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +50 -0
- package/src/components/page-builder/page-builder.component.ts +1053 -0
- package/src/components/page-builder/page-builder.scss +494 -0
- package/src/components/page-builder/page-builder.test.ts +464 -0
- package/src/components/page-builder/page-registry.ts +48 -0
- package/src/components/page-builder/page.types.ts +75 -0
- package/src/components/panel/panel.scss +1 -0
- package/src/components/settings-container/settings-container.component.ts +74 -9
- package/src/components/settings-container/settings-container.scss +75 -0
- package/src/components/settings-container/settings-container.test.ts +35 -0
- package/src/events/events.ts +2 -0
- package/src/events/zn-page-change.ts +9 -0
- package/src/events/zn-page-selection-change.ts +7 -0
- package/src/zinc.ts +4 -0
- package/web-test-runner.config.js +6 -1
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import '../../../dist/zn.min.js';
|
|
2
|
+
import {expect, fixture, html} from '@open-wc/testing';
|
|
3
|
+
import type ZnPageBuilder from './page-builder.component';
|
|
4
|
+
|
|
5
|
+
describe('<zn-page-builder>', () => {
|
|
6
|
+
it('should render the shell', async () => {
|
|
7
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
8
|
+
<zn-page-builder></zn-page-builder>`);
|
|
9
|
+
expect(el.shadowRoot?.querySelector('[part="base"]')).to.exist;
|
|
10
|
+
expect(el.shadowRoot?.querySelector('[part="palette"]')).to.exist;
|
|
11
|
+
expect(el.shadowRoot?.querySelector('[part="canvas"]')).to.exist;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should build the palette from slotted config templates', async () => {
|
|
15
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
16
|
+
<zn-page-builder>
|
|
17
|
+
<template type="hero" slot="config" label="Hero" icon="star" category="Headers"></template>
|
|
18
|
+
<template type="article-list" slot="config" label="Article List" category="Content"></template>
|
|
19
|
+
</zn-page-builder>`);
|
|
20
|
+
await el.updateComplete;
|
|
21
|
+
|
|
22
|
+
const items = el.shadowRoot?.querySelectorAll('zn-page-palette-item');
|
|
23
|
+
expect(items?.length).to.equal(2);
|
|
24
|
+
expect(items?.[0].getAttribute('label')).to.equal('Hero');
|
|
25
|
+
const categories = [...(el.shadowRoot?.querySelectorAll('zn-collapsible.palette__category') ?? [])]
|
|
26
|
+
.map(c => c.getAttribute('caption'));
|
|
27
|
+
expect(categories).to.deep.equal(['Headers', 'Content']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should tuck the palette away via the canvas-edge chevron', async () => {
|
|
31
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
32
|
+
<zn-page-builder>
|
|
33
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
34
|
+
</zn-page-builder>`);
|
|
35
|
+
await el.updateComplete;
|
|
36
|
+
|
|
37
|
+
const toggle = el.shadowRoot?.querySelector<HTMLButtonElement>('.canvas-cell .panel-toggle--left');
|
|
38
|
+
expect(toggle, 'edge chevron').to.exist;
|
|
39
|
+
|
|
40
|
+
toggle!.click();
|
|
41
|
+
await el.updateComplete;
|
|
42
|
+
expect(el.paletteCollapsed).to.be.true;
|
|
43
|
+
expect(el.shadowRoot?.querySelector('.builder--palette-collapsed')).to.exist;
|
|
44
|
+
expect(toggle!.classList.contains('panel-toggle--tucked'), 'chevron tucks into the canvas').to.be.true;
|
|
45
|
+
|
|
46
|
+
toggle!.click();
|
|
47
|
+
await el.updateComplete;
|
|
48
|
+
expect(el.paletteCollapsed).to.be.false;
|
|
49
|
+
expect(el.shadowRoot?.querySelector('.builder--palette-collapsed')).to.not.exist;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should offer and restore an auto-saved draft', async () => {
|
|
53
|
+
const draft = {savedAt: Date.now(), state: {sections: [{id: 'd1', type: 'hero', data: {title: 'Draft'}}]}};
|
|
54
|
+
localStorage.setItem('zn-page-builder:pb-autosave-test', JSON.stringify(draft));
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
58
|
+
<zn-page-builder id="pb-autosave-test" auto-save config='{"sections":[]}'>
|
|
59
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
60
|
+
</zn-page-builder>`);
|
|
61
|
+
await el.updateComplete;
|
|
62
|
+
|
|
63
|
+
// The loaded (empty) page differs from the fresh draft — banner offered.
|
|
64
|
+
expect(el.shadowRoot?.querySelector('.restore-banner'), 'restore banner').to.exist;
|
|
65
|
+
|
|
66
|
+
expect(el.restoreAutoSave()).to.be.true;
|
|
67
|
+
await el.updateComplete;
|
|
68
|
+
expect(el.state.sections[0]?.id).to.equal('d1');
|
|
69
|
+
expect(el.shadowRoot?.querySelector('.restore-banner')).to.not.exist;
|
|
70
|
+
} finally {
|
|
71
|
+
localStorage.removeItem('zn-page-builder:pb-autosave-test');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should round-trip state through the config attribute and state property', async () => {
|
|
76
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
77
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Hi"}}]}'>
|
|
78
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
79
|
+
</zn-page-builder>`);
|
|
80
|
+
await el.updateComplete;
|
|
81
|
+
|
|
82
|
+
expect(el.state.sections).to.have.length(1);
|
|
83
|
+
expect(el.state.sections[0].id).to.equal('s1');
|
|
84
|
+
expect(el.shadowRoot?.querySelectorAll('zn-page-section-card')).to.have.length(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should add a section via addSection and emit zn-page-change', async () => {
|
|
88
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
89
|
+
<zn-page-builder>
|
|
90
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
91
|
+
</zn-page-builder>`);
|
|
92
|
+
await el.updateComplete;
|
|
93
|
+
|
|
94
|
+
let detail: {state: {sections: unknown[]}} | undefined;
|
|
95
|
+
el.addEventListener('zn-page-change', e => (detail = (e as CustomEvent<{state: {sections: unknown[]}}>).detail));
|
|
96
|
+
const section = el.addSection('hero');
|
|
97
|
+
await el.updateComplete;
|
|
98
|
+
|
|
99
|
+
expect(section?.type).to.equal('hero');
|
|
100
|
+
expect(detail?.state.sections).to.have.length(1);
|
|
101
|
+
expect(el.shadowRoot?.querySelectorAll('zn-page-section-card')).to.have.length(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should render unknown section types as unknown cards without crashing', async () => {
|
|
105
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
106
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"gone","data":{}}]}'></zn-page-builder>`);
|
|
107
|
+
await el.updateComplete;
|
|
108
|
+
|
|
109
|
+
const card = el.shadowRoot?.querySelector('zn-page-section-card');
|
|
110
|
+
expect(card).to.exist;
|
|
111
|
+
expect(card?.hasAttribute('unknown')).to.be.true;
|
|
112
|
+
expect(el.state.sections[0].type).to.equal('gone'); // preserved through save
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should round-trip section data through the inspector name binding', async () => {
|
|
116
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
117
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Before"}}]}'>
|
|
118
|
+
<template type="hero" slot="config" label="Hero">
|
|
119
|
+
<input name="title">
|
|
120
|
+
</template>
|
|
121
|
+
</zn-page-builder>`);
|
|
122
|
+
await el.updateComplete;
|
|
123
|
+
|
|
124
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
125
|
+
await el.updateComplete;
|
|
126
|
+
|
|
127
|
+
const input = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="title"]');
|
|
128
|
+
expect(input, 'stamped input').to.exist;
|
|
129
|
+
expect(input!.value).to.equal('Before'); // prefilled from data
|
|
130
|
+
|
|
131
|
+
input!.value = 'After';
|
|
132
|
+
input!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
133
|
+
await el.updateComplete;
|
|
134
|
+
|
|
135
|
+
expect(el.state.sections[0].data.title).to.equal('After');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should write back a boolean control value from the inspector', async () => {
|
|
139
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
140
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"showSearch":false}}]}'>
|
|
141
|
+
<template type="hero" slot="config" label="Hero">
|
|
142
|
+
<input type="checkbox" name="showSearch">
|
|
143
|
+
</template>
|
|
144
|
+
</zn-page-builder>`);
|
|
145
|
+
await el.updateComplete;
|
|
146
|
+
|
|
147
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
148
|
+
await el.updateComplete;
|
|
149
|
+
|
|
150
|
+
const checkbox = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="showSearch"]');
|
|
151
|
+
expect(checkbox, 'stamped checkbox').to.exist;
|
|
152
|
+
expect(checkbox!.checked).to.be.false;
|
|
153
|
+
|
|
154
|
+
checkbox!.checked = true;
|
|
155
|
+
checkbox!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
156
|
+
await el.updateComplete;
|
|
157
|
+
|
|
158
|
+
expect(el.state.sections[0].data.showSearch).to.equal(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should coerce a number control value from the inspector', async () => {
|
|
162
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
163
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
|
|
164
|
+
<template type="hero" slot="config" label="Hero">
|
|
165
|
+
<input type="number" name="limit">
|
|
166
|
+
</template>
|
|
167
|
+
</zn-page-builder>`);
|
|
168
|
+
await el.updateComplete;
|
|
169
|
+
|
|
170
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
171
|
+
await el.updateComplete;
|
|
172
|
+
|
|
173
|
+
const input = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="limit"]');
|
|
174
|
+
expect(input, 'stamped input').to.exist;
|
|
175
|
+
|
|
176
|
+
input!.value = '5';
|
|
177
|
+
input!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
178
|
+
await el.updateComplete;
|
|
179
|
+
|
|
180
|
+
expect(el.state.sections[0].data.limit).to.equal(5);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should round-trip a multi-select array value through the inspector', async () => {
|
|
184
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
185
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"categories","data":{"categories":["billing"]}}]}'>
|
|
186
|
+
<template type="categories" slot="config" label="Categories">
|
|
187
|
+
<zn-select multiple name="categories">
|
|
188
|
+
<zn-option value="billing">Billing</zn-option>
|
|
189
|
+
<zn-option value="setup">Setup</zn-option>
|
|
190
|
+
</zn-select>
|
|
191
|
+
</template>
|
|
192
|
+
</zn-page-builder>`);
|
|
193
|
+
await el.updateComplete;
|
|
194
|
+
|
|
195
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
196
|
+
await el.updateComplete;
|
|
197
|
+
|
|
198
|
+
const select = el.shadowRoot?.querySelector('.inspector__form zn-select[name="categories"]') as HTMLElement & { value: string | string[] };
|
|
199
|
+
expect(select, 'stamped select').to.exist;
|
|
200
|
+
expect(select.value).to.deep.equal(['billing']); // array prefilled, not stringified
|
|
201
|
+
|
|
202
|
+
select.value = ['billing', 'setup'];
|
|
203
|
+
select.dispatchEvent(new CustomEvent('zn-change', {bubbles: true}));
|
|
204
|
+
await el.updateComplete;
|
|
205
|
+
|
|
206
|
+
expect(el.state.sections[0].data.categories).to.deep.equal(['billing', 'setup']);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should rename a section from the inspector', async () => {
|
|
210
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
211
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
|
|
212
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
213
|
+
</zn-page-builder>`);
|
|
214
|
+
await el.updateComplete;
|
|
215
|
+
|
|
216
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
217
|
+
await el.updateComplete;
|
|
218
|
+
|
|
219
|
+
const rename = el.shadowRoot?.querySelector('.inspector__rename') as HTMLElement & { value: string };
|
|
220
|
+
rename.value = 'My Hero';
|
|
221
|
+
rename.dispatchEvent(new CustomEvent('zn-change', {bubbles: true}));
|
|
222
|
+
await el.updateComplete;
|
|
223
|
+
|
|
224
|
+
expect(el.state.sections[0].label).to.equal('My Hero');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should undo and redo a section add', async () => {
|
|
228
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
229
|
+
<zn-page-builder>
|
|
230
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
231
|
+
</zn-page-builder>`);
|
|
232
|
+
await el.updateComplete;
|
|
233
|
+
|
|
234
|
+
el.addSection('hero');
|
|
235
|
+
expect(el.state.sections).to.have.length(1);
|
|
236
|
+
|
|
237
|
+
el.undo();
|
|
238
|
+
expect(el.state.sections).to.have.length(0);
|
|
239
|
+
|
|
240
|
+
el.redo();
|
|
241
|
+
expect(el.state.sections).to.have.length(1);
|
|
242
|
+
expect(el.state.sections[0].type).to.equal('hero');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should treat undo as a no-op with empty history', async () => {
|
|
246
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
247
|
+
<zn-page-builder></zn-page-builder>`);
|
|
248
|
+
expect(() => el.undo()).to.not.throw();
|
|
249
|
+
expect(el.state.sections).to.have.length(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should be accessible', async () => {
|
|
253
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
254
|
+
<zn-page-builder>
|
|
255
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
256
|
+
</zn-page-builder>`);
|
|
257
|
+
await expect(el).to.be.accessible();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should render a slot grid for container sections and fill slots via addSectionToSlot', async () => {
|
|
261
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
262
|
+
<zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
|
|
263
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="6" accepts="article-tile"></template>
|
|
264
|
+
<template type="article-tile" slot="config" label="Article"></template>
|
|
265
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
266
|
+
</zn-page-builder>`);
|
|
267
|
+
await el.updateComplete;
|
|
268
|
+
|
|
269
|
+
expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(6);
|
|
270
|
+
|
|
271
|
+
expect(el.addSectionToSlot('hero', 'grid', 0), 'not in accepts').to.be.null;
|
|
272
|
+
expect(el.addSectionToSlot('article-grid', 'grid', 0), 'no nested containers').to.be.null;
|
|
273
|
+
|
|
274
|
+
const child = el.addSectionToSlot('article-tile', 'grid', 1);
|
|
275
|
+
await el.updateComplete;
|
|
276
|
+
expect(child?.type).to.equal('article-tile');
|
|
277
|
+
expect(el.state.sections[0].children?.[1]?.id).to.equal(child?.id);
|
|
278
|
+
expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(5);
|
|
279
|
+
expect(el.shadowRoot?.querySelectorAll('.slots zn-page-section-card')).to.have.length(1);
|
|
280
|
+
|
|
281
|
+
expect(el.addSectionToSlot('article-tile', 'grid', 1), 'occupied slot').to.be.null;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should swap children when dropping one filled slot onto another', async () => {
|
|
285
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
286
|
+
<zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
|
|
287
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="4"></template>
|
|
288
|
+
<template type="article-tile" slot="config" label="Article"></template>
|
|
289
|
+
</zn-page-builder>`);
|
|
290
|
+
await el.updateComplete;
|
|
291
|
+
|
|
292
|
+
const a = el.addSectionToSlot('article-tile', 'grid', 0)!;
|
|
293
|
+
const b = el.addSectionToSlot('article-tile', 'grid', 1)!;
|
|
294
|
+
await el.updateComplete;
|
|
295
|
+
|
|
296
|
+
// Drag child A onto child B's card (slot 1) — they swap places.
|
|
297
|
+
const cards = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card');
|
|
298
|
+
const dataTransfer = new DataTransfer();
|
|
299
|
+
dataTransfer.setData('application/x-zn-page-section', a.id);
|
|
300
|
+
cards[1].dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
|
|
301
|
+
await el.updateComplete;
|
|
302
|
+
|
|
303
|
+
expect(el.state.sections[0].children?.[0]?.id).to.equal(b.id);
|
|
304
|
+
expect(el.state.sections[0].children?.[1]?.id).to.equal(a.id);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should edit a slotted child through the inspector', async () => {
|
|
308
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
309
|
+
<zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{"title":"Old"}}]}]}'>
|
|
310
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
|
|
311
|
+
<template type="article-tile" slot="config" label="Article">
|
|
312
|
+
<input name="title">
|
|
313
|
+
</template>
|
|
314
|
+
</zn-page-builder>`);
|
|
315
|
+
await el.updateComplete;
|
|
316
|
+
|
|
317
|
+
el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
318
|
+
await el.updateComplete;
|
|
319
|
+
|
|
320
|
+
const input = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="title"]');
|
|
321
|
+
expect(input, 'stamped input for child').to.exist;
|
|
322
|
+
expect(input!.value).to.equal('Old');
|
|
323
|
+
|
|
324
|
+
input!.value = 'New';
|
|
325
|
+
input!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
326
|
+
await el.updateComplete;
|
|
327
|
+
|
|
328
|
+
expect(el.state.sections[0].children?.[0]?.data.title).to.equal('New');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should clear selection when removing a container holding the selected child', async () => {
|
|
332
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
333
|
+
<zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
|
|
334
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
|
|
335
|
+
<template type="article-tile" slot="config" label="Article"></template>
|
|
336
|
+
</zn-page-builder>`);
|
|
337
|
+
await el.updateComplete;
|
|
338
|
+
|
|
339
|
+
let lastSelection: string | null | undefined;
|
|
340
|
+
el.addEventListener('zn-page-selection-change', e =>
|
|
341
|
+
(lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
|
|
342
|
+
|
|
343
|
+
// Select the child, then remove its parent container.
|
|
344
|
+
el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
345
|
+
await el.updateComplete;
|
|
346
|
+
expect(lastSelection).to.equal('c1');
|
|
347
|
+
|
|
348
|
+
el.shadowRoot?.querySelector('.container > zn-page-section-card')
|
|
349
|
+
?.dispatchEvent(new CustomEvent('page-card-remove'));
|
|
350
|
+
await el.updateComplete;
|
|
351
|
+
|
|
352
|
+
expect(lastSelection, 'selection cleared').to.be.null;
|
|
353
|
+
expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'no ghost inspector').to.not.exist;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should move a top-level section into an empty slot but not an occupied one', async () => {
|
|
357
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
358
|
+
<zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}},{"id":"t1","type":"article-tile","data":{}},{"id":"t2","type":"article-tile","data":{}}]}'>
|
|
359
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
|
|
360
|
+
<template type="article-tile" slot="config" label="Article"></template>
|
|
361
|
+
</zn-page-builder>`);
|
|
362
|
+
await el.updateComplete;
|
|
363
|
+
|
|
364
|
+
const dropOnSlot = (sectionId: string, slotSelector: string) => {
|
|
365
|
+
const dataTransfer = new DataTransfer();
|
|
366
|
+
dataTransfer.setData('application/x-zn-page-section', sectionId);
|
|
367
|
+
el.shadowRoot!.querySelector(slotSelector)!
|
|
368
|
+
.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
dropOnSlot('t1', '.slot--empty'); // into first empty slot
|
|
372
|
+
await el.updateComplete;
|
|
373
|
+
expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
|
|
374
|
+
expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
|
|
375
|
+
|
|
376
|
+
dropOnSlot('t2', '.slots zn-page-section-card'); // onto the occupied slot — rejected
|
|
377
|
+
await el.updateComplete;
|
|
378
|
+
expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
|
|
379
|
+
expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should swap children across different containers', async () => {
|
|
383
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
384
|
+
<zn-page-builder
|
|
385
|
+
config='{"sections":[{"id":"g1","type":"article-grid","data":{},"children":[{"id":"a1","type":"article-tile","data":{}}]},{"id":"g2","type":"article-grid","data":{},"children":[{"id":"b1","type":"article-tile","data":{}}]}]}'>
|
|
386
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
|
|
387
|
+
<template type="article-tile" slot="config" label="Article"></template>
|
|
388
|
+
</zn-page-builder>`);
|
|
389
|
+
await el.updateComplete;
|
|
390
|
+
|
|
391
|
+
// Drag a1 (in g1) onto b1's card (slot 0 of g2) — they trade containers.
|
|
392
|
+
const dataTransfer = new DataTransfer();
|
|
393
|
+
dataTransfer.setData('application/x-zn-page-section', 'a1');
|
|
394
|
+
const g2Card = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card')[1];
|
|
395
|
+
g2Card.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
|
|
396
|
+
await el.updateComplete;
|
|
397
|
+
|
|
398
|
+
expect(el.state.sections[0].children?.[0]?.id).to.equal('b1');
|
|
399
|
+
expect(el.state.sections[1].children?.[0]?.id).to.equal('a1');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should undo a slot mutation and duplicate a child into the next empty slot', async () => {
|
|
403
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
404
|
+
<zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
|
|
405
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="3"></template>
|
|
406
|
+
<template type="article-tile" slot="config" label="Article"></template>
|
|
407
|
+
</zn-page-builder>`);
|
|
408
|
+
await el.updateComplete;
|
|
409
|
+
|
|
410
|
+
const child = el.addSectionToSlot('article-tile', 'grid', 0)!;
|
|
411
|
+
el.undo();
|
|
412
|
+
expect(el.state.sections[0].children?.[0] ?? null, 'undo empties the slot').to.be.null;
|
|
413
|
+
el.redo();
|
|
414
|
+
expect(el.state.sections[0].children?.[0]?.id).to.equal(child.id);
|
|
415
|
+
|
|
416
|
+
// Duplicate the child: the copy takes the next empty slot with a fresh id.
|
|
417
|
+
await el.updateComplete;
|
|
418
|
+
el.shadowRoot?.querySelector('.slots zn-page-section-card')
|
|
419
|
+
?.dispatchEvent(new CustomEvent('page-card-duplicate'));
|
|
420
|
+
await el.updateComplete;
|
|
421
|
+
const children = el.state.sections[0].children ?? [];
|
|
422
|
+
expect(children[1]?.type).to.equal('article-tile');
|
|
423
|
+
expect(children[1]?.id).to.not.equal(children[0]?.id);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should select a card with Enter and delete a child with Delete via keyboard', async () => {
|
|
427
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
428
|
+
<zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
|
|
429
|
+
<template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
|
|
430
|
+
<template type="article-tile" slot="config" label="Article"></template>
|
|
431
|
+
</zn-page-builder>`);
|
|
432
|
+
await el.updateComplete;
|
|
433
|
+
|
|
434
|
+
const childCard = el.shadowRoot!.querySelector('.slots zn-page-section-card')!;
|
|
435
|
+
childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
|
|
436
|
+
await el.updateComplete;
|
|
437
|
+
expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'Enter selects').to.exist;
|
|
438
|
+
|
|
439
|
+
childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true}));
|
|
440
|
+
await el.updateComplete;
|
|
441
|
+
expect(el.state.sections[0].children?.[0] ?? null, 'Delete removes the child').to.be.null;
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should accept a palette drop anywhere on the empty canvas', async () => {
|
|
445
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
446
|
+
<zn-page-builder>
|
|
447
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
448
|
+
</zn-page-builder>`);
|
|
449
|
+
await el.updateComplete;
|
|
450
|
+
|
|
451
|
+
const canvas = el.shadowRoot!.querySelector('.canvas')!;
|
|
452
|
+
const dataTransfer = new DataTransfer();
|
|
453
|
+
dataTransfer.setData('application/x-zn-page-type', 'hero');
|
|
454
|
+
|
|
455
|
+
const over = new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true});
|
|
456
|
+
canvas.dispatchEvent(over);
|
|
457
|
+
expect(over.defaultPrevented, 'canvas accepts the drag').to.be.true;
|
|
458
|
+
|
|
459
|
+
canvas.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
|
|
460
|
+
await el.updateComplete;
|
|
461
|
+
expect(el.state.sections).to.have.length(1);
|
|
462
|
+
expect(el.state.sections[0].type).to.equal('hero');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {PageSectionType} from './page.types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Holds the set of {@link PageSectionType}s available to a page builder.
|
|
5
|
+
* Register custom section types here (or via slotted templates) and the
|
|
6
|
+
* palette and inspector pick them up automatically.
|
|
7
|
+
*/
|
|
8
|
+
export class PageSectionRegistry {
|
|
9
|
+
private types = new Map<string, PageSectionType>();
|
|
10
|
+
|
|
11
|
+
register(type: PageSectionType): this {
|
|
12
|
+
this.types.set(type.type, type);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerAll(types: PageSectionType[]): this {
|
|
17
|
+
types.forEach(t => this.register(t));
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(type: string): PageSectionType | undefined {
|
|
22
|
+
return this.types.get(type);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
has(type: string): boolean {
|
|
26
|
+
return this.types.has(type);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
all(): PageSectionType[] {
|
|
30
|
+
return Array.from(this.types.values());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Map of category name -> types, preserving insertion order. Uncategorised under ''. */
|
|
34
|
+
categories(): Map<string, PageSectionType[]> {
|
|
35
|
+
const out = new Map<string, PageSectionType[]>();
|
|
36
|
+
for (const type of this.all()) {
|
|
37
|
+
const key = type.category ?? '';
|
|
38
|
+
const bucket = out.get(key) ?? [];
|
|
39
|
+
bucket.push(type);
|
|
40
|
+
out.set(key, bucket);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
clear(): void {
|
|
46
|
+
this.types.clear();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {TemplateResult} from 'lit';
|
|
2
|
+
|
|
3
|
+
/** A section placed on a page. */
|
|
4
|
+
export interface PageSection {
|
|
5
|
+
id: string;
|
|
6
|
+
type: string;
|
|
7
|
+
/** Overrides the type label when set (per-instance rename). */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Section content, keyed by field name (the inspector's `name` attributes). */
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
/** Slot contents for container sections, sized to the type's `slots`. Empty slots are null. */
|
|
12
|
+
children?: (PageSection | null)[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The complete serialisable state of a page. Order = render order. */
|
|
16
|
+
export interface PageState {
|
|
17
|
+
sections: PageSection[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Describes a kind of section that can be placed on a page. Declared via
|
|
22
|
+
* `<template type slot="config">` children or registered programmatically —
|
|
23
|
+
* the palette and inspector are driven entirely by registered types.
|
|
24
|
+
*/
|
|
25
|
+
export interface PageSectionType {
|
|
26
|
+
/** Unique key, persisted on every placed section. */
|
|
27
|
+
type: string;
|
|
28
|
+
label: string;
|
|
29
|
+
/** zn-icon `src`. */
|
|
30
|
+
icon?: string;
|
|
31
|
+
iconLibrary?: string;
|
|
32
|
+
/** Accent colour for the icon tile — any CSS colour. */
|
|
33
|
+
color?: string;
|
|
34
|
+
/** Collapsible palette category. */
|
|
35
|
+
category?: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
/** Inspector form markup (from a slotted `<template slot="config">`). */
|
|
38
|
+
configTemplate?: HTMLTemplateElement;
|
|
39
|
+
/** Programmatic inspector body — takes precedence over `configTemplate`. */
|
|
40
|
+
renderConfig?: (section: PageSection, update: (data: Record<string, unknown>) => void) => TemplateResult;
|
|
41
|
+
/**
|
|
42
|
+
* Number of child slots this section offers on the canvas (a container tile);
|
|
43
|
+
* rendered as a 3-column grid. Containers cannot be placed inside other containers.
|
|
44
|
+
*/
|
|
45
|
+
slots?: number;
|
|
46
|
+
/** Section type keys allowed in this container's slots. Omit to allow any non-container type. */
|
|
47
|
+
accepts?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A container section's slot contents, padded/truncated to the type's slot count. */
|
|
51
|
+
export function sectionChildren(section: PageSection, type: PageSectionType): (PageSection | null)[] {
|
|
52
|
+
return Array.from({length: type.slots ?? 0}, (_, i) => section.children?.[i] ?? null);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drag-and-drop MIME carrying a section type id from the palette to the canvas. */
|
|
56
|
+
export const PAGE_TYPE_MIME = 'application/x-zn-page-type';
|
|
57
|
+
/** Drag-and-drop MIME carrying a placed section's id when reordering. */
|
|
58
|
+
export const PAGE_SECTION_MIME = 'application/x-zn-page-section';
|
|
59
|
+
|
|
60
|
+
export function emptyPageState(): PageState {
|
|
61
|
+
return {sections: []};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let sectionCounter = 0;
|
|
65
|
+
|
|
66
|
+
/** Unique-enough id for a new section, stable across edits once assigned. */
|
|
67
|
+
export function generateSectionId(): string {
|
|
68
|
+
return `s-${Date.now().toString(36)}-${(sectionCounter++).toString(36)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Card summary: the section's first non-empty string value, else the type description. */
|
|
72
|
+
export function sectionSummary(section: PageSection, type?: PageSectionType): string {
|
|
73
|
+
const first = Object.values(section.data).find(v => typeof v === 'string' && v.trim() !== '');
|
|
74
|
+
return (first as string) ?? type?.description ?? '';
|
|
75
|
+
}
|