@kubex/zinc 1.1.92 → 1.1.95
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 +2880 -453
- package/dist/vscode.html-custom-data.json +209 -22
- package/dist/web-types.json +452 -39
- package/dist/zn.d.ts +633 -61
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +865 -560
- package/docs/pages/components/page-builder.md +143 -19
- package/docs/pages/components/schedule-builder.md +345 -0
- package/docs/pages/components/slash-menu.md +132 -6
- package/docs/pages/components/textarea.md +16 -0
- package/package.json +1 -1
- package/scss/_root.scss +7 -1
- package/src/components/alert/alert.scss +9 -13
- package/src/components/button/button.scss +5 -2
- package/src/components/chip/chip.scss +1 -1
- package/src/components/icon-picker/icon-picker.component.ts +1 -1
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/linked-select/linked-select.component.ts +22 -5
- package/src/components/page/page.scss +20 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
- package/src/components/page-builder/page-builder.component.ts +535 -232
- package/src/components/page-builder/page-builder.scss +230 -19
- package/src/components/page-builder/page-builder.test.ts +790 -110
- package/src/components/page-builder/page-tree.test.ts +483 -0
- package/src/components/page-builder/page-tree.ts +329 -0
- package/src/components/page-builder/page.types.ts +98 -10
- package/src/components/page-nav/page-nav.scss +9 -1
- package/src/components/panel/panel.component.ts +5 -1
- package/src/components/priority-list/priority-list.component.ts +1 -0
- package/src/components/priority-list/priority-list.scss +2 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
- package/src/components/schedule-builder/index.ts +12 -0
- package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
- package/src/components/schedule-builder/schedule-builder.scss +448 -0
- package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
- package/src/components/settings-container/settings-container.scss +2 -1
- package/src/components/slash-item/slash-item.component.ts +1 -1
- package/src/components/slash-menu/slash-menu-items.ts +48 -0
- package/src/components/slash-menu/slash-menu.component.ts +134 -27
- package/src/components/slash-menu/slash-menu.scss +90 -12
- package/src/components/slash-menu/slash-menu.test.ts +107 -0
- package/src/components/textarea/textarea.component.ts +12 -2
- package/src/components/textarea/textarea.test.ts +2 -2
- package/src/components/toggle/toggle.component.ts +2 -1
- package/src/components/translations/translations.component.ts +5 -1
- package/src/zinc.ts +1 -0
- package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
- package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import '../../../dist/zn.min.js';
|
|
2
2
|
import {expect, fixture, html} from '@open-wc/testing';
|
|
3
|
+
import {PAGE_SECTION_MIME, PAGE_TYPE_MIME} from './page.types';
|
|
4
|
+
import type {PageSectionType, PageState} from './page.types';
|
|
3
5
|
import type ZnPageBuilder from './page-builder.component';
|
|
4
6
|
|
|
7
|
+
/** Reaches the private registry to inspect a parsed template's PageSectionType. */
|
|
8
|
+
const registryOf = (el: ZnPageBuilder) =>
|
|
9
|
+
(el as unknown as { registry: { get: (type: string) => PageSectionType | undefined } }).registry;
|
|
10
|
+
|
|
11
|
+
const containerConfig = (over = {}) => JSON.stringify({
|
|
12
|
+
sections: [{
|
|
13
|
+
id: 'outer', type: 'row', data: {},
|
|
14
|
+
layout: {widths: [1, 2, 1], grow: false},
|
|
15
|
+
cells: [[{id: 'a', type: 'hero', data: {}}], [], []],
|
|
16
|
+
...over,
|
|
17
|
+
}],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const selectFirst = async (el: ZnPageBuilder) => {
|
|
21
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
22
|
+
await el.updateComplete;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const dragTo = (el: Element, mime: string, payload: string, kind: 'dragover' | 'drop') => {
|
|
26
|
+
const data = new Map([[mime, payload]]);
|
|
27
|
+
const event = new DragEvent(kind, {bubbles: true, cancelable: true, composed: true});
|
|
28
|
+
Object.defineProperty(event, 'dataTransfer', {
|
|
29
|
+
value: {types: [...data.keys()], getData: (k: string) => data.get(k) ?? '', setData: () => {}},
|
|
30
|
+
});
|
|
31
|
+
el.dispatchEvent(event);
|
|
32
|
+
return event;
|
|
33
|
+
};
|
|
34
|
+
|
|
5
35
|
describe('<zn-page-builder>', () => {
|
|
6
36
|
it('should render the shell', async () => {
|
|
7
37
|
const el = await fixture<ZnPageBuilder>(html`
|
|
@@ -27,6 +57,58 @@ describe('<zn-page-builder>', () => {
|
|
|
27
57
|
expect(categories).to.deep.equal(['Headers', 'Content']);
|
|
28
58
|
});
|
|
29
59
|
|
|
60
|
+
it('should parse container/columns/widths/grow template attributes', async () => {
|
|
61
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
62
|
+
<zn-page-builder>
|
|
63
|
+
<template type="row" slot="config" label="Row" container widths="1, 2 1" grow></template>
|
|
64
|
+
<template type="cols" slot="config" label="Cols" container columns="4"></template>
|
|
65
|
+
<template type="bare" slot="config" label="Bare" container></template>
|
|
66
|
+
<template type="legacy" slot="config" label="Legacy" slots="6"></template>
|
|
67
|
+
</zn-page-builder>`);
|
|
68
|
+
await el.updateComplete;
|
|
69
|
+
const registry = registryOf(el);
|
|
70
|
+
|
|
71
|
+
// widths beats columns and tolerates mixed comma/whitespace separators.
|
|
72
|
+
const row = registry.get('row');
|
|
73
|
+
expect(row?.container).to.be.true;
|
|
74
|
+
expect(row?.defaultWidths).to.deep.equal([1, 2, 1]);
|
|
75
|
+
expect(row?.defaultGrow).to.be.true;
|
|
76
|
+
|
|
77
|
+
expect(registry.get('cols')?.defaultWidths).to.deep.equal([1, 1, 1, 1]);
|
|
78
|
+
expect(registry.get('cols')?.defaultGrow).to.be.false;
|
|
79
|
+
|
|
80
|
+
// bare `container` seeds DEFAULT_WIDTHS.
|
|
81
|
+
expect(registry.get('bare')?.defaultWidths).to.deep.equal([1, 1, 1]);
|
|
82
|
+
expect(registry.get('bare')?.defaultGrow).to.be.false;
|
|
83
|
+
|
|
84
|
+
// legacy `slots` keeps working, untouched by the new fields.
|
|
85
|
+
const legacy = registry.get('legacy');
|
|
86
|
+
expect(legacy?.slots).to.equal(6);
|
|
87
|
+
expect(legacy?.container).to.be.undefined;
|
|
88
|
+
expect(legacy?.defaultWidths).to.be.undefined;
|
|
89
|
+
expect(legacy?.defaultGrow).to.be.undefined;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should filter non-numeric widths tokens before falling back to columns/DEFAULT_WIDTHS', async () => {
|
|
93
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
94
|
+
<zn-page-builder>
|
|
95
|
+
<template type="typo" slot="config" label="Typo" container widths="1 x 2"></template>
|
|
96
|
+
<template type="rescue" slot="config" label="Rescue" container widths="abc" columns="4"></template>
|
|
97
|
+
<template type="garbage" slot="config" label="Garbage" container widths="abc"></template>
|
|
98
|
+
</zn-page-builder>`);
|
|
99
|
+
await el.updateComplete;
|
|
100
|
+
const registry = registryOf(el);
|
|
101
|
+
|
|
102
|
+
// A stray non-numeric token is dropped, not coerced to a column via sanitiseWidths.
|
|
103
|
+
expect(registry.get('typo')?.defaultWidths).to.deep.equal([1, 2]);
|
|
104
|
+
|
|
105
|
+
// A wholly non-numeric widths falls through to columns, not to a single column.
|
|
106
|
+
expect(registry.get('rescue')?.defaultWidths).to.deep.equal([1, 1, 1, 1]);
|
|
107
|
+
|
|
108
|
+
// A wholly non-numeric widths with no columns falls through to DEFAULT_WIDTHS.
|
|
109
|
+
expect(registry.get('garbage')?.defaultWidths).to.deep.equal([1, 1, 1]);
|
|
110
|
+
});
|
|
111
|
+
|
|
30
112
|
it('should tuck the palette away via the canvas-edge chevron', async () => {
|
|
31
113
|
const el = await fixture<ZnPageBuilder>(html`
|
|
32
114
|
<zn-page-builder>
|
|
@@ -112,6 +194,35 @@ describe('<zn-page-builder>', () => {
|
|
|
112
194
|
expect(el.state.sections[0].type).to.equal('gone'); // preserved through save
|
|
113
195
|
});
|
|
114
196
|
|
|
197
|
+
it('should summarise a card with the chosen option label, not the stored value', async () => {
|
|
198
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
199
|
+
<zn-page-builder
|
|
200
|
+
config='{"sections":[{"id":"s1","type":"tile","data":{"icon":"folder","article":"1a2b3c"}}]}'>
|
|
201
|
+
<template type="tile" slot="config" label="Article">
|
|
202
|
+
<zn-icon-picker name="icon"></zn-icon-picker>
|
|
203
|
+
<zn-select name="article">
|
|
204
|
+
<zn-option value="1a2b3c">Getting Started</zn-option>
|
|
205
|
+
</zn-select>
|
|
206
|
+
</template>
|
|
207
|
+
</zn-page-builder>`);
|
|
208
|
+
await el.updateComplete;
|
|
209
|
+
|
|
210
|
+
const card = el.shadowRoot?.querySelector('zn-page-section-card');
|
|
211
|
+
expect(card?.getAttribute('summary')).to.equal('Getting Started');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should fall back to the first string value when nothing matches an option', async () => {
|
|
215
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
216
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Welcome"}}]}'>
|
|
217
|
+
<template type="hero" slot="config" label="Hero">
|
|
218
|
+
<input name="title">
|
|
219
|
+
</template>
|
|
220
|
+
</zn-page-builder>`);
|
|
221
|
+
await el.updateComplete;
|
|
222
|
+
|
|
223
|
+
expect(el.shadowRoot?.querySelector('zn-page-section-card')?.getAttribute('summary')).to.equal('Welcome');
|
|
224
|
+
});
|
|
225
|
+
|
|
115
226
|
it('should round-trip section data through the inspector name binding', async () => {
|
|
116
227
|
const el = await fixture<ZnPageBuilder>(html`
|
|
117
228
|
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"title":"Before"}}]}'>
|
|
@@ -206,6 +317,34 @@ describe('<zn-page-builder>', () => {
|
|
|
206
317
|
expect(el.state.sections[0].data.categories).to.deep.equal(['billing', 'setup']);
|
|
207
318
|
});
|
|
208
319
|
|
|
320
|
+
// zn-icon-picker keeps `value` as a plain accessor over its reactive `icon`,
|
|
321
|
+
// so it only prefills if the stamped element is upgraded before assignment —
|
|
322
|
+
// otherwise the assignment shadows the setter and the picker renders empty.
|
|
323
|
+
it('should prefill a control whose value is a plain accessor', async () => {
|
|
324
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
325
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{"icon":"receipt"}}]}'>
|
|
326
|
+
<template type="hero" slot="config" label="Hero">
|
|
327
|
+
<zn-icon-picker name="icon" label="Icon" no-color no-library></zn-icon-picker>
|
|
328
|
+
</template>
|
|
329
|
+
</zn-page-builder>`);
|
|
330
|
+
await el.updateComplete;
|
|
331
|
+
|
|
332
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
333
|
+
await el.updateComplete;
|
|
334
|
+
|
|
335
|
+
const picker = el.shadowRoot!.querySelector<HTMLElement & { value: string; icon: string }>(
|
|
336
|
+
'.inspector__form zn-icon-picker[name="icon"]')!;
|
|
337
|
+
expect(picker, 'stamped icon picker').to.exist;
|
|
338
|
+
expect(picker.value).to.equal('receipt');
|
|
339
|
+
expect(picker.icon, 'reached the reactive property, not an own shadowing one').to.equal('receipt');
|
|
340
|
+
|
|
341
|
+
picker.value = 'payments';
|
|
342
|
+
picker.dispatchEvent(new CustomEvent('zn-change', {bubbles: true}));
|
|
343
|
+
await el.updateComplete;
|
|
344
|
+
|
|
345
|
+
expect(el.state.sections[0].data.icon).to.equal('payments');
|
|
346
|
+
});
|
|
347
|
+
|
|
209
348
|
it('should rename a section from the inspector', async () => {
|
|
210
349
|
const el = await fixture<ZnPageBuilder>(html`
|
|
211
350
|
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
|
|
@@ -224,6 +363,83 @@ describe('<zn-page-builder>', () => {
|
|
|
224
363
|
expect(el.state.sections[0].label).to.equal('My Hero');
|
|
225
364
|
});
|
|
226
365
|
|
|
366
|
+
it('should head the inspector with the section name and its type', async () => {
|
|
367
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
368
|
+
<zn-page-builder config='{"sections":[
|
|
369
|
+
{"id":"s1","type":"hero","label":"Top banner","data":{}},
|
|
370
|
+
{"id":"s2","type":"hero","data":{}}]}'>
|
|
371
|
+
<template type="hero" slot="config" label="Hero" icon="star">
|
|
372
|
+
<zn-input name="title" label="Title"></zn-input>
|
|
373
|
+
</template>
|
|
374
|
+
</zn-page-builder>`);
|
|
375
|
+
await el.updateComplete;
|
|
376
|
+
|
|
377
|
+
const cards = el.shadowRoot?.querySelectorAll('zn-page-section-card');
|
|
378
|
+
cards?.[0].dispatchEvent(new Event('click'));
|
|
379
|
+
await el.updateComplete;
|
|
380
|
+
|
|
381
|
+
const head = el.shadowRoot?.querySelector('[part="inspector-header"]');
|
|
382
|
+
expect(head, 'inspector header').to.exist;
|
|
383
|
+
expect(head?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Top banner');
|
|
384
|
+
expect(head?.querySelector('.inspector-head__type')?.textContent?.trim()).to.equal('Hero');
|
|
385
|
+
expect(head?.querySelector('zn-icon')?.getAttribute('src')).to.equal('star');
|
|
386
|
+
expect(el.shadowRoot?.querySelector('[part="inspector-body"] .inspector__rename'), 'rename in body').to.exist;
|
|
387
|
+
|
|
388
|
+
// An unnamed section's title already is the type label — don't print it twice.
|
|
389
|
+
cards?.[1].dispatchEvent(new Event('click'));
|
|
390
|
+
await el.updateComplete;
|
|
391
|
+
const unnamed = el.shadowRoot?.querySelector('[part="inspector-header"]');
|
|
392
|
+
expect(unnamed?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Hero');
|
|
393
|
+
expect(unnamed?.querySelector('.inspector-head__type'), 'no duplicate type line').to.not.exist;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should clear the selection from the inspector close button', async () => {
|
|
397
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
398
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
|
|
399
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
400
|
+
</zn-page-builder>`);
|
|
401
|
+
await el.updateComplete;
|
|
402
|
+
|
|
403
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
404
|
+
await el.updateComplete;
|
|
405
|
+
expect(el.shadowRoot?.querySelector('[part="inspector"]')).to.exist;
|
|
406
|
+
|
|
407
|
+
el.shadowRoot?.querySelector<HTMLButtonElement>('.inspector-close')?.click();
|
|
408
|
+
await el.updateComplete;
|
|
409
|
+
expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'inspector closes').to.not.exist;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should hint when the selected type has no settings', async () => {
|
|
413
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
414
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"divider","data":{}}]}'>
|
|
415
|
+
<template type="divider" slot="config" label="Divider"></template>
|
|
416
|
+
</zn-page-builder>`);
|
|
417
|
+
await el.updateComplete;
|
|
418
|
+
|
|
419
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
420
|
+
await el.updateComplete;
|
|
421
|
+
|
|
422
|
+
expect(el.shadowRoot?.querySelector('.inspector-hint')).to.exist;
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should lay stamped toggles out as a row, honouring a host-set position', async () => {
|
|
426
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
427
|
+
<zn-page-builder config='{"sections":[{"id":"s1","type":"hero","data":{}}]}'>
|
|
428
|
+
<template type="hero" slot="config" label="Hero">
|
|
429
|
+
<zn-toggle name="showSearch" label="Show search"></zn-toggle>
|
|
430
|
+
<zn-toggle name="showNav" label="Show nav" label-position="top"></zn-toggle>
|
|
431
|
+
</template>
|
|
432
|
+
</zn-page-builder>`);
|
|
433
|
+
await el.updateComplete;
|
|
434
|
+
|
|
435
|
+
el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
436
|
+
await el.updateComplete;
|
|
437
|
+
|
|
438
|
+
const form = el.shadowRoot?.querySelector('.inspector__form');
|
|
439
|
+
expect(form?.querySelector('zn-toggle[name="showSearch"]')?.getAttribute('label-position')).to.equal('left');
|
|
440
|
+
expect(form?.querySelector('zn-toggle[name="showNav"]')?.getAttribute('label-position')).to.equal('top');
|
|
441
|
+
});
|
|
442
|
+
|
|
227
443
|
it('should undo and redo a section add', async () => {
|
|
228
444
|
const el = await fixture<ZnPageBuilder>(html`
|
|
229
445
|
<zn-page-builder>
|
|
@@ -257,82 +473,208 @@ describe('<zn-page-builder>', () => {
|
|
|
257
473
|
await expect(el).to.be.accessible();
|
|
258
474
|
});
|
|
259
475
|
|
|
260
|
-
it('should
|
|
476
|
+
it('should pin a required-first section, inserting one when the page has none', async () => {
|
|
261
477
|
const el = await fixture<ZnPageBuilder>(html`
|
|
262
|
-
<zn-page-builder
|
|
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>
|
|
478
|
+
<zn-page-builder required-first="hero">
|
|
265
479
|
<template type="hero" slot="config" label="Hero"></template>
|
|
480
|
+
<template type="rich-text" slot="config" label="Rich Text"></template>
|
|
266
481
|
</zn-page-builder>`);
|
|
267
482
|
await el.updateComplete;
|
|
268
483
|
|
|
269
|
-
expect(el.
|
|
484
|
+
expect(el.state.sections.map(s => s.type), 'inserted into an empty page').to.deep.equal(['hero']);
|
|
485
|
+
// The submitted value carries the pinned section, so a save can't miss it.
|
|
486
|
+
expect((JSON.parse(el.value) as PageState).sections).to.have.length(1);
|
|
270
487
|
|
|
271
|
-
|
|
272
|
-
expect(
|
|
488
|
+
const card = el.shadowRoot!.querySelector('zn-page-section-card')!;
|
|
489
|
+
expect(card.hasAttribute('locked'), 'card is locked').to.be.true;
|
|
490
|
+
expect(card.getAttribute('draggable'), 'card is not draggable').to.equal('false');
|
|
491
|
+
// No "+" strip above it — the first drop zone follows the pinned card.
|
|
492
|
+
expect(el.shadowRoot!.querySelector('.canvas > *')?.tagName.toLowerCase()).to.equal('zn-page-section-card');
|
|
493
|
+
});
|
|
273
494
|
|
|
274
|
-
|
|
495
|
+
it('should hoist an existing required-first section to the top of the page', async () => {
|
|
496
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
497
|
+
<zn-page-builder required-first="hero"
|
|
498
|
+
config='{"sections":[{"id":"t","type":"rich-text","data":{}},{"id":"h","type":"hero","data":{"title":"Help"}}]}'>
|
|
499
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
500
|
+
<template type="rich-text" slot="config" label="Rich Text"></template>
|
|
501
|
+
</zn-page-builder>`);
|
|
275
502
|
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
503
|
|
|
281
|
-
expect(el.
|
|
504
|
+
expect(el.state.sections.map(s => s.id)).to.deep.equal(['h', 't']);
|
|
505
|
+
expect(el.state.sections[0].data, 'hoisted with its data intact').to.deep.equal({title: 'Help'});
|
|
282
506
|
});
|
|
283
507
|
|
|
284
|
-
it('should
|
|
508
|
+
it('should refuse to remove, reorder or displace the pinned section', async () => {
|
|
285
509
|
const el = await fixture<ZnPageBuilder>(html`
|
|
286
|
-
<zn-page-builder
|
|
287
|
-
|
|
288
|
-
<template type="
|
|
510
|
+
<zn-page-builder required-first="hero"
|
|
511
|
+
config='{"sections":[{"id":"h","type":"hero","data":{}},{"id":"t","type":"rich-text","data":{}}]}'>
|
|
512
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
513
|
+
<template type="rich-text" slot="config" label="Rich Text"></template>
|
|
289
514
|
</zn-page-builder>`);
|
|
290
515
|
await el.updateComplete;
|
|
291
516
|
|
|
292
|
-
const
|
|
293
|
-
|
|
517
|
+
const pinnedCard = el.shadowRoot!.querySelector('zn-page-section-card')!;
|
|
518
|
+
expect(pinnedCard.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'no remove action').to.not.exist;
|
|
519
|
+
|
|
520
|
+
pinnedCard.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true}));
|
|
521
|
+
pinnedCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true}));
|
|
294
522
|
await el.updateComplete;
|
|
523
|
+
expect(el.state.sections.map(s => s.id), 'survives remove and Delete').to.deep.equal(['h', 't']);
|
|
295
524
|
|
|
296
|
-
//
|
|
297
|
-
const cards = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card');
|
|
525
|
+
// Dropping another section on the topmost zone lands it below the pinned one.
|
|
298
526
|
const dataTransfer = new DataTransfer();
|
|
299
|
-
dataTransfer.setData('application/x-zn-page-section',
|
|
300
|
-
|
|
527
|
+
dataTransfer.setData('application/x-zn-page-section', 't');
|
|
528
|
+
el.shadowRoot!.querySelector('.drop')!
|
|
529
|
+
.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
|
|
301
530
|
await el.updateComplete;
|
|
531
|
+
expect(el.state.sections.map(s => s.id), 'nothing lands above the pinned section').to.deep.equal(['h', 't']);
|
|
302
532
|
|
|
303
|
-
expect(el.
|
|
304
|
-
expect(el.state.sections[0].
|
|
533
|
+
expect(el.addSection('rich-text', 0)).to.not.be.null;
|
|
534
|
+
expect(el.state.sections[0].id, 'addSection index 0 is clamped').to.equal('h');
|
|
305
535
|
});
|
|
306
536
|
|
|
307
|
-
it('should
|
|
537
|
+
it('should seed layout and cells for an inserted required-first container', async () => {
|
|
308
538
|
const el = await fixture<ZnPageBuilder>(html`
|
|
309
|
-
<zn-page-builder
|
|
310
|
-
<template type="
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
539
|
+
<zn-page-builder required-first="row">
|
|
540
|
+
<template type="row" slot="config" label="Row" container widths="1 2 1"></template>
|
|
541
|
+
</zn-page-builder>`);
|
|
542
|
+
await el.updateComplete;
|
|
543
|
+
|
|
544
|
+
const row = el.state.sections[0];
|
|
545
|
+
expect(row.type).to.equal('row');
|
|
546
|
+
expect(row.layout, 'the one guaranteed section is seeded like any other container').to.deep.equal({widths: [1, 2, 1], grow: false});
|
|
547
|
+
expect(row.cells).to.have.lengthOf(3);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should leave the page alone without required-first', async () => {
|
|
551
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
552
|
+
<zn-page-builder config='{"sections":[{"id":"t","type":"rich-text","data":{}}]}'>
|
|
553
|
+
<template type="rich-text" slot="config" label="Rich Text"></template>
|
|
314
554
|
</zn-page-builder>`);
|
|
315
555
|
await el.updateComplete;
|
|
316
556
|
|
|
317
|
-
el.
|
|
557
|
+
expect(el.state.sections.map(s => s.type)).to.deep.equal(['rich-text']);
|
|
558
|
+
const card = el.shadowRoot!.querySelector('zn-page-section-card')!;
|
|
559
|
+
expect(card.hasAttribute('locked')).to.be.false;
|
|
560
|
+
expect(card.getAttribute('draggable')).to.equal('true');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('should accept a palette drop anywhere on the empty canvas', async () => {
|
|
564
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
565
|
+
<zn-page-builder>
|
|
566
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
567
|
+
</zn-page-builder>`);
|
|
568
|
+
await el.updateComplete;
|
|
569
|
+
|
|
570
|
+
const canvas = el.shadowRoot!.querySelector('.canvas')!;
|
|
571
|
+
const dataTransfer = new DataTransfer();
|
|
572
|
+
dataTransfer.setData('application/x-zn-page-type', 'hero');
|
|
573
|
+
|
|
574
|
+
const over = new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true});
|
|
575
|
+
canvas.dispatchEvent(over);
|
|
576
|
+
expect(over.defaultPrevented, 'canvas accepts the drag').to.be.true;
|
|
577
|
+
|
|
578
|
+
canvas.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
|
|
579
|
+
await el.updateComplete;
|
|
580
|
+
expect(el.state.sections).to.have.length(1);
|
|
581
|
+
expect(el.state.sections[0].type).to.equal('hero');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('round-trips an old-shape config into the new cells shape', async () => {
|
|
585
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
586
|
+
<zn-page-builder config='{"sections":[{"id":"g1","type":"grid","data":{},"children":[{"id":"a","type":"hero","data":{"title":"Kept"}},null,null]}]}'>
|
|
587
|
+
<template type="grid" slot="config" label="Grid" slots="6"></template>
|
|
588
|
+
<template type="hero" slot="config" label="Hero"><zn-input name="title" label="Title"></zn-input></template>
|
|
589
|
+
</zn-page-builder>`);
|
|
590
|
+
await el.updateComplete;
|
|
591
|
+
|
|
592
|
+
const grid = el.state.sections[0];
|
|
593
|
+
expect(grid.children, 'legacy shape not written back').to.be.undefined;
|
|
594
|
+
expect(grid.layout).to.deep.equal({widths: [1, 1, 1], grow: false});
|
|
595
|
+
expect(grid.cells).to.have.lengthOf(6);
|
|
596
|
+
expect(grid.cells?.[0][0].data.title).to.equal('Kept');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('seeds layout and cells when a container is added', async () => {
|
|
600
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
601
|
+
<zn-page-builder>
|
|
602
|
+
<template type="row" slot="config" label="Row" container widths="1 2 1"></template>
|
|
603
|
+
</zn-page-builder>`);
|
|
604
|
+
await el.updateComplete;
|
|
605
|
+
|
|
606
|
+
el.addSection('row');
|
|
607
|
+
await el.updateComplete;
|
|
608
|
+
|
|
609
|
+
const row = el.state.sections[0];
|
|
610
|
+
expect(row.layout).to.deep.equal({widths: [1, 2, 1], grow: false});
|
|
611
|
+
expect(row.cells).to.have.lengthOf(3);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('renames and edits a section nested two container levels deep', async () => {
|
|
615
|
+
const config = JSON.stringify({
|
|
616
|
+
sections: [{
|
|
617
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
618
|
+
cells: [[{
|
|
619
|
+
id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
620
|
+
cells: [[{id: 'deep', type: 'hero', data: {}}], []],
|
|
621
|
+
}], []],
|
|
622
|
+
}],
|
|
623
|
+
});
|
|
624
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
625
|
+
<zn-page-builder config=${config}>
|
|
626
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
627
|
+
<template type="hero" slot="config" label="Hero"><input name="title"></template>
|
|
628
|
+
</zn-page-builder>`);
|
|
629
|
+
await el.updateComplete;
|
|
630
|
+
|
|
631
|
+
(el as unknown as {_select(id: string): void})._select('deep');
|
|
318
632
|
await el.updateComplete;
|
|
319
633
|
|
|
320
634
|
const input = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="title"]');
|
|
321
|
-
expect(input, 'stamped
|
|
322
|
-
|
|
635
|
+
expect(input, 'inspector stamped for the nested section').to.exist;
|
|
636
|
+
input!.value = 'Deep title';
|
|
637
|
+
input!.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
|
|
638
|
+
await el.updateComplete;
|
|
323
639
|
|
|
324
|
-
|
|
325
|
-
|
|
640
|
+
const deep = el.state.sections[0].cells![0][0].cells![0][0];
|
|
641
|
+
expect(deep.data.title).to.equal('Deep title');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('duplicates a container with fresh ids throughout', async () => {
|
|
645
|
+
const config = JSON.stringify({
|
|
646
|
+
sections: [{
|
|
647
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
648
|
+
cells: [[{id: 'a', type: 'hero', data: {}}], []],
|
|
649
|
+
}],
|
|
650
|
+
});
|
|
651
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
652
|
+
<zn-page-builder config=${config}>
|
|
653
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
654
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
655
|
+
</zn-page-builder>`);
|
|
326
656
|
await el.updateComplete;
|
|
327
657
|
|
|
328
|
-
|
|
658
|
+
el.shadowRoot?.querySelector('zn-page-section-card')
|
|
659
|
+
?.dispatchEvent(new CustomEvent('page-card-duplicate', {bubbles: true, composed: true}));
|
|
660
|
+
await el.updateComplete;
|
|
661
|
+
|
|
662
|
+
expect(el.state.sections).to.have.lengthOf(2);
|
|
663
|
+
const ids = el.state.sections.flatMap(s => [s.id, ...(s.cells ?? []).flat().map(c => c.id)]);
|
|
664
|
+
expect(new Set(ids).size, 'every id unique').to.equal(ids.length);
|
|
329
665
|
});
|
|
330
666
|
|
|
331
|
-
it('
|
|
667
|
+
it('removes a section from inside a cell', async () => {
|
|
668
|
+
const config = JSON.stringify({
|
|
669
|
+
sections: [{
|
|
670
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
671
|
+
cells: [[{id: 'a', type: 'hero', data: {}}], []],
|
|
672
|
+
}],
|
|
673
|
+
});
|
|
332
674
|
const el = await fixture<ZnPageBuilder>(html`
|
|
333
|
-
<zn-page-builder config
|
|
334
|
-
<template type="
|
|
335
|
-
<template type="
|
|
675
|
+
<zn-page-builder config=${config}>
|
|
676
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
677
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
336
678
|
</zn-page-builder>`);
|
|
337
679
|
await el.updateComplete;
|
|
338
680
|
|
|
@@ -340,125 +682,463 @@ describe('<zn-page-builder>', () => {
|
|
|
340
682
|
el.addEventListener('zn-page-selection-change', e =>
|
|
341
683
|
(lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
|
|
342
684
|
|
|
343
|
-
|
|
344
|
-
el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
|
|
685
|
+
(el as unknown as {_select(id: string): void})._select('a');
|
|
345
686
|
await el.updateComplete;
|
|
346
|
-
expect(lastSelection).to.equal('
|
|
687
|
+
expect(lastSelection).to.equal('a');
|
|
347
688
|
|
|
348
|
-
el
|
|
349
|
-
?.dispatchEvent(new CustomEvent('page-card-remove'));
|
|
689
|
+
(el as unknown as {_removeSection(id: string): void})._removeSection('a');
|
|
350
690
|
await el.updateComplete;
|
|
351
691
|
|
|
692
|
+
expect(el.state.sections[0].cells?.[0]).to.deep.equal([]);
|
|
352
693
|
expect(lastSelection, 'selection cleared').to.be.null;
|
|
353
|
-
expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'no ghost inspector').to.not.exist;
|
|
354
694
|
});
|
|
355
695
|
|
|
356
|
-
it('
|
|
696
|
+
it('trims a growable container trailing empty row on commit, not only on read', async () => {
|
|
697
|
+
const config = JSON.stringify({
|
|
698
|
+
sections: [{
|
|
699
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: true},
|
|
700
|
+
cells: [
|
|
701
|
+
[{id: 'a', type: 'hero', data: {}}], [{id: 'b', type: 'hero', data: {}}],
|
|
702
|
+
[{id: 'c', type: 'hero', data: {}}], [],
|
|
703
|
+
],
|
|
704
|
+
}],
|
|
705
|
+
});
|
|
357
706
|
const el = await fixture<ZnPageBuilder>(html`
|
|
358
|
-
<zn-page-builder config
|
|
359
|
-
<template type="
|
|
360
|
-
<template type="
|
|
707
|
+
<zn-page-builder config=${config}>
|
|
708
|
+
<template type="row" slot="config" label="Row" container grow></template>
|
|
709
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
361
710
|
</zn-page-builder>`);
|
|
362
711
|
await el.updateComplete;
|
|
363
712
|
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
};
|
|
713
|
+
(el as unknown as {_removeSection(id: string): void})._removeSection('c');
|
|
714
|
+
await el.updateComplete;
|
|
370
715
|
|
|
371
|
-
|
|
716
|
+
// Removing "c" leaves the last row (cells 2 and 3) all-empty — trimmed immediately,
|
|
717
|
+
// not only the next time the container is read out.
|
|
718
|
+
expect(el.state.sections[0].cells, 'trailing empty row trimmed on commit').to.have.lengthOf(2);
|
|
719
|
+
expect(el.state.sections[0].cells?.flat().map(c => c.id)).to.deep.equal(['a', 'b']);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('leaves a fixed container trailing empty row untouched after a commit', async () => {
|
|
723
|
+
const config = JSON.stringify({
|
|
724
|
+
sections: [{
|
|
725
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
726
|
+
cells: [
|
|
727
|
+
[{id: 'a', type: 'hero', data: {}}], [{id: 'b', type: 'hero', data: {}}],
|
|
728
|
+
[{id: 'c', type: 'hero', data: {}}], [],
|
|
729
|
+
],
|
|
730
|
+
}],
|
|
731
|
+
});
|
|
732
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
733
|
+
<zn-page-builder config=${config}>
|
|
734
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
735
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
736
|
+
</zn-page-builder>`);
|
|
372
737
|
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
738
|
|
|
376
|
-
|
|
739
|
+
(el as unknown as {_removeSection(id: string): void})._removeSection('c');
|
|
377
740
|
await el.updateComplete;
|
|
378
|
-
|
|
379
|
-
|
|
741
|
+
|
|
742
|
+
// A fixed container's trailing empty row is its chosen layout — it must survive.
|
|
743
|
+
expect(el.state.sections[0].cells, 'trailing empty row survives').to.have.lengthOf(4);
|
|
744
|
+
expect(el.state.sections[0].cells?.[3]).to.deep.equal([]);
|
|
380
745
|
});
|
|
381
746
|
|
|
382
|
-
it('
|
|
747
|
+
it('clears selection when removing an ancestor of the selected grandchild', async () => {
|
|
748
|
+
const config = JSON.stringify({
|
|
749
|
+
sections: [{
|
|
750
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
751
|
+
cells: [[{
|
|
752
|
+
id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
753
|
+
cells: [[{id: 'deep', type: 'hero', data: {}}], []],
|
|
754
|
+
}], []],
|
|
755
|
+
}],
|
|
756
|
+
});
|
|
383
757
|
const el = await fixture<ZnPageBuilder>(html`
|
|
384
|
-
<zn-page-builder
|
|
385
|
-
|
|
386
|
-
<template type="
|
|
387
|
-
<template type="article-tile" slot="config" label="Article"></template>
|
|
758
|
+
<zn-page-builder config=${config}>
|
|
759
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
760
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
388
761
|
</zn-page-builder>`);
|
|
389
762
|
await el.updateComplete;
|
|
390
763
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
764
|
+
let lastSelection: string | null | undefined;
|
|
765
|
+
el.addEventListener('zn-page-selection-change', e =>
|
|
766
|
+
(lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
|
|
767
|
+
|
|
768
|
+
(el as unknown as {_select(id: string): void})._select('deep');
|
|
396
769
|
await el.updateComplete;
|
|
770
|
+
expect(lastSelection).to.equal('deep');
|
|
397
771
|
|
|
398
|
-
|
|
399
|
-
|
|
772
|
+
// Removing the top-level ancestor must clear selection of a grandchild two cell levels down.
|
|
773
|
+
(el as unknown as {_removeSection(id: string): void})._removeSection('outer');
|
|
774
|
+
await el.updateComplete;
|
|
775
|
+
|
|
776
|
+
expect(el.state.sections).to.have.lengthOf(0);
|
|
777
|
+
expect(lastSelection, 'selection cleared at depth 2').to.be.null;
|
|
400
778
|
});
|
|
401
779
|
|
|
402
|
-
it('
|
|
780
|
+
it('duplicates a section living in a cell directly below itself in the same stack', async () => {
|
|
781
|
+
const config = JSON.stringify({
|
|
782
|
+
sections: [{
|
|
783
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
784
|
+
cells: [[{id: 'a', type: 'hero', data: {}}], []],
|
|
785
|
+
}],
|
|
786
|
+
});
|
|
403
787
|
const el = await fixture<ZnPageBuilder>(html`
|
|
404
|
-
<zn-page-builder config
|
|
405
|
-
<template type="
|
|
406
|
-
<template type="
|
|
788
|
+
<zn-page-builder config=${config}>
|
|
789
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
790
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
407
791
|
</zn-page-builder>`);
|
|
408
792
|
await el.updateComplete;
|
|
409
793
|
|
|
410
|
-
|
|
411
|
-
el.
|
|
412
|
-
|
|
413
|
-
el.
|
|
414
|
-
expect(
|
|
794
|
+
(el as unknown as {_duplicateSection(id: string): void})._duplicateSection('a');
|
|
795
|
+
await el.updateComplete;
|
|
796
|
+
|
|
797
|
+
const stack = el.state.sections[0].cells?.[0] ?? [];
|
|
798
|
+
expect(stack, 'copy lands right after the original in the same stack').to.have.lengthOf(2);
|
|
799
|
+
expect(stack[0].id).to.equal('a');
|
|
800
|
+
expect(stack[1].id, 'fresh id').to.not.equal('a');
|
|
801
|
+
expect(stack[1].type).to.equal('hero');
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('renders one column per width with the weights as fr units', async () => {
|
|
805
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
806
|
+
<zn-page-builder config=${containerConfig()}>
|
|
807
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
808
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
809
|
+
</zn-page-builder>`);
|
|
810
|
+
await el.updateComplete;
|
|
811
|
+
|
|
812
|
+
const grid = el.shadowRoot?.querySelector<HTMLElement>('.cells');
|
|
813
|
+
expect(grid, 'cell grid').to.exist;
|
|
814
|
+
expect(grid!.style.gridTemplateColumns).to.equal('1fr 2fr 1fr');
|
|
815
|
+
expect(el.shadowRoot?.querySelectorAll('.cell')).to.have.lengthOf(3);
|
|
816
|
+
});
|
|
415
817
|
|
|
416
|
-
|
|
818
|
+
it('inserts a palette drop into a cell stack', async () => {
|
|
819
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
820
|
+
<zn-page-builder config=${containerConfig()}>
|
|
821
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
822
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
823
|
+
</zn-page-builder>`);
|
|
417
824
|
await el.updateComplete;
|
|
418
|
-
|
|
419
|
-
|
|
825
|
+
|
|
826
|
+
const cell = el.shadowRoot!.querySelectorAll('.cell')[1]!;
|
|
827
|
+
dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
|
|
420
828
|
await el.updateComplete;
|
|
421
|
-
|
|
422
|
-
expect(
|
|
423
|
-
expect(children[1]?.id).to.not.equal(children[0]?.id);
|
|
829
|
+
|
|
830
|
+
expect(el.state.sections[0].cells?.[1].map(c => c.type)).to.deep.equal(['hero']);
|
|
424
831
|
});
|
|
425
832
|
|
|
426
|
-
it('
|
|
833
|
+
it('stacks a second section into the same cell', async () => {
|
|
427
834
|
const el = await fixture<ZnPageBuilder>(html`
|
|
428
|
-
<zn-page-builder config
|
|
429
|
-
<template type="
|
|
430
|
-
<template type="
|
|
835
|
+
<zn-page-builder config=${containerConfig()}>
|
|
836
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
837
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
431
838
|
</zn-page-builder>`);
|
|
432
839
|
await el.updateComplete;
|
|
433
840
|
|
|
434
|
-
const
|
|
435
|
-
|
|
841
|
+
const cell = el.shadowRoot!.querySelectorAll('.cell')[0]!;
|
|
842
|
+
dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
|
|
436
843
|
await el.updateComplete;
|
|
437
|
-
expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'Enter selects').to.exist;
|
|
438
844
|
|
|
439
|
-
|
|
845
|
+
expect(el.state.sections[0].cells?.[0]).to.have.lengthOf(2);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('accepts a container into a cell at level 1 and refuses one at level 2', async () => {
|
|
849
|
+
const nestedConfig = JSON.stringify({
|
|
850
|
+
sections: [{
|
|
851
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
852
|
+
cells: [[{id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]}], []],
|
|
853
|
+
}],
|
|
854
|
+
});
|
|
855
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
856
|
+
<zn-page-builder config=${nestedConfig}>
|
|
857
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
858
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
859
|
+
</zn-page-builder>`);
|
|
440
860
|
await el.updateComplete;
|
|
441
|
-
|
|
861
|
+
|
|
862
|
+
const outerCell = el.shadowRoot!.querySelector('.cell')!;
|
|
863
|
+
const accepted = dragTo(outerCell, PAGE_TYPE_MIME, 'row', 'dragover');
|
|
864
|
+
expect(accepted.defaultPrevented, 'level 2 accepted').to.be.true;
|
|
865
|
+
|
|
866
|
+
// The inner "row" is itself a container, so its own cells render nested inside
|
|
867
|
+
// the outer cell — a `.cell` inside a `.cell` is deterministically the inner one.
|
|
868
|
+
const innerCell = el.shadowRoot?.querySelectorAll('.cell .cell')[0];
|
|
869
|
+
expect(innerCell, 'nested cell found').to.exist;
|
|
870
|
+
const refused = dragTo(innerCell!, PAGE_TYPE_MIME, 'row', 'dragover');
|
|
871
|
+
expect(refused.defaultPrevented, 'level 3 refused').to.be.false;
|
|
442
872
|
});
|
|
443
873
|
|
|
444
|
-
it('
|
|
874
|
+
it('does not let accepts override the nesting depth cap', async () => {
|
|
875
|
+
const nestedConfig = JSON.stringify({
|
|
876
|
+
sections: [{
|
|
877
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
878
|
+
cells: [[{id: 'inner', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]}], []],
|
|
879
|
+
}],
|
|
880
|
+
});
|
|
445
881
|
const el = await fixture<ZnPageBuilder>(html`
|
|
446
|
-
<zn-page-builder>
|
|
882
|
+
<zn-page-builder config=${nestedConfig}>
|
|
883
|
+
<template type="row" slot="config" label="Row" container accepts="row"></template>
|
|
884
|
+
</zn-page-builder>`);
|
|
885
|
+
await el.updateComplete;
|
|
886
|
+
|
|
887
|
+
// The inner "row" is itself a container at level 2; "row" also being in its own
|
|
888
|
+
// `accepts` list must not let a level-3 drop through.
|
|
889
|
+
const innerCell = el.shadowRoot?.querySelectorAll('.cell .cell')[0];
|
|
890
|
+
expect(innerCell, 'nested cell found').to.exist;
|
|
891
|
+
dragTo(innerCell!, PAGE_TYPE_MIME, 'row', 'drop');
|
|
892
|
+
await el.updateComplete;
|
|
893
|
+
|
|
894
|
+
const inner = el.state.sections[0].cells![0][0];
|
|
895
|
+
expect(inner.cells?.[0], 'level 3 drop refused despite accepts listing its own type').to.deep.equal([]);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// Both caps above drive only PAGE_TYPE_MIME, where a freshly created section always
|
|
899
|
+
// has height 1 — a moved section can already carry nested containers of its own,
|
|
900
|
+
// which is exactly what this exercises via PAGE_SECTION_MIME.
|
|
901
|
+
it('refuses moving an already-placed container whose own nesting would push past the cap', async () => {
|
|
902
|
+
const config = JSON.stringify({
|
|
903
|
+
sections: [
|
|
904
|
+
{
|
|
905
|
+
id: 'A', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
906
|
+
cells: [[{
|
|
907
|
+
id: 'B', type: 'row', data: {}, layout: {widths: [1, 1], grow: false},
|
|
908
|
+
cells: [[{id: 'article', type: 'hero', data: {}}], []],
|
|
909
|
+
}], []],
|
|
910
|
+
},
|
|
911
|
+
{id: 'C', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]},
|
|
912
|
+
],
|
|
913
|
+
});
|
|
914
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
915
|
+
<zn-page-builder config=${config}>
|
|
916
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
447
917
|
<template type="hero" slot="config" label="Hero"></template>
|
|
448
918
|
</zn-page-builder>`);
|
|
449
919
|
await el.updateComplete;
|
|
450
920
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
921
|
+
// Document order for a top-level container "A" (nesting container "B" in its
|
|
922
|
+
// first cell) followed by sibling top-level container "C": [A0, B0, B1, A1, C0, C1].
|
|
923
|
+
const cCell0 = el.shadowRoot!.querySelectorAll('.cell')[4];
|
|
924
|
+
dragTo(cCell0, PAGE_SECTION_MIME, 'A', 'drop');
|
|
925
|
+
await el.updateComplete;
|
|
454
926
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
927
|
+
expect(el.state.sections.map(s => s.id), 'A stays top-level, C stays untouched').to.deep.equal(['A', 'C']);
|
|
928
|
+
expect(el.state.sections[1].cells).to.deep.equal([[], []]);
|
|
929
|
+
});
|
|
458
930
|
|
|
459
|
-
|
|
931
|
+
it('still moves an already-placed flat container into another container cell', async () => {
|
|
932
|
+
const config = JSON.stringify({
|
|
933
|
+
sections: [
|
|
934
|
+
{id: 'flat', type: 'row', data: {}, layout: {widths: [1], grow: false}, cells: [[]]},
|
|
935
|
+
{id: 'target', type: 'row', data: {}, layout: {widths: [1, 1], grow: false}, cells: [[], []]},
|
|
936
|
+
],
|
|
937
|
+
});
|
|
938
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
939
|
+
<zn-page-builder config=${config}>
|
|
940
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
941
|
+
</zn-page-builder>`);
|
|
460
942
|
await el.updateComplete;
|
|
461
|
-
|
|
462
|
-
|
|
943
|
+
|
|
944
|
+
// Document order: [flat0, target0, target1].
|
|
945
|
+
const targetCell0 = el.shadowRoot!.querySelectorAll('.cell')[1];
|
|
946
|
+
dragTo(targetCell0, PAGE_SECTION_MIME, 'flat', 'drop');
|
|
947
|
+
await el.updateComplete;
|
|
948
|
+
|
|
949
|
+
expect(el.state.sections.map(s => s.id), 'flat is no longer top-level').to.deep.equal(['target']);
|
|
950
|
+
expect(el.state.sections[0].cells?.[0].map(c => c.id)).to.deep.equal(['flat']);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('refuses a container type into a legacy slots= container with no explicit accepts', async () => {
|
|
954
|
+
const config = JSON.stringify({
|
|
955
|
+
sections: [{id: 'g1', type: 'grid', data: {}, cells: [[], [], []]}],
|
|
956
|
+
});
|
|
957
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
958
|
+
<zn-page-builder config=${config}>
|
|
959
|
+
<template type="grid" slot="config" label="Grid" slots="3"></template>
|
|
960
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
961
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
962
|
+
</zn-page-builder>`);
|
|
963
|
+
await el.updateComplete;
|
|
964
|
+
|
|
965
|
+
const cell = el.shadowRoot!.querySelectorAll('.cell')[0]!;
|
|
966
|
+
dragTo(cell, PAGE_TYPE_MIME, 'row', 'drop');
|
|
967
|
+
await el.updateComplete;
|
|
968
|
+
expect(el.state.sections[0].cells?.[0], 'container refused by legacy slots alias').to.deep.equal([]);
|
|
969
|
+
|
|
970
|
+
dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
|
|
971
|
+
await el.updateComplete;
|
|
972
|
+
expect(el.state.sections[0].cells?.[0].map(c => c.type), 'non-container still accepted').to.deep.equal(['hero']);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('keeps enforcing accepts', async () => {
|
|
976
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
977
|
+
<zn-page-builder config=${containerConfig()}>
|
|
978
|
+
<template type="row" slot="config" label="Row" container accepts="tile"></template>
|
|
979
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
980
|
+
<template type="tile" slot="config" label="Tile"></template>
|
|
981
|
+
</zn-page-builder>`);
|
|
982
|
+
await el.updateComplete;
|
|
983
|
+
|
|
984
|
+
const cell = el.shadowRoot!.querySelectorAll('.cell')[1]!;
|
|
985
|
+
dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
|
|
986
|
+
await el.updateComplete;
|
|
987
|
+
expect(el.state.sections[0].cells?.[1], 'hero refused').to.deep.equal([]);
|
|
988
|
+
|
|
989
|
+
dragTo(cell, PAGE_TYPE_MIME, 'tile', 'drop');
|
|
990
|
+
await el.updateComplete;
|
|
991
|
+
expect(el.state.sections[0].cells?.[1].map(c => c.type)).to.deep.equal(['tile']);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('offers a trailing row for a growable container and extends on drop', async () => {
|
|
995
|
+
const growConfig = JSON.stringify({
|
|
996
|
+
sections: [{
|
|
997
|
+
id: 'outer', type: 'row', data: {},
|
|
998
|
+
layout: {widths: [1, 1], grow: true},
|
|
999
|
+
cells: [[{id: 'a', type: 'hero', data: {}}], []],
|
|
1000
|
+
}],
|
|
1001
|
+
});
|
|
1002
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1003
|
+
<zn-page-builder config=${growConfig}>
|
|
1004
|
+
<template type="row" slot="config" label="Row" container grow></template>
|
|
1005
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1006
|
+
</zn-page-builder>`);
|
|
1007
|
+
await el.updateComplete;
|
|
1008
|
+
|
|
1009
|
+
expect(el.state.sections[0].cells, 'no trailing empty row persisted').to.have.lengthOf(2);
|
|
1010
|
+
const cells = el.shadowRoot?.querySelectorAll('.cell');
|
|
1011
|
+
expect(cells!.length, 'a trailing row is rendered').to.equal(4);
|
|
1012
|
+
|
|
1013
|
+
dragTo(cells![2], PAGE_TYPE_MIME, 'hero', 'drop');
|
|
1014
|
+
await el.updateComplete;
|
|
1015
|
+
expect(el.state.sections[0].cells).to.have.lengthOf(4);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('shows layout controls only for containers', async () => {
|
|
1019
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1020
|
+
<zn-page-builder config=${containerConfig()}>
|
|
1021
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
1022
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1023
|
+
</zn-page-builder>`);
|
|
1024
|
+
await el.updateComplete;
|
|
1025
|
+
await selectFirst(el);
|
|
1026
|
+
expect(el.shadowRoot?.querySelector('.layout-group'), 'container shows layout').to.exist;
|
|
1027
|
+
|
|
1028
|
+
const plain = await fixture<ZnPageBuilder>(html`
|
|
1029
|
+
<zn-page-builder config='{"sections":[{"id":"h","type":"hero","data":{}}]}'>
|
|
1030
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1031
|
+
</zn-page-builder>`);
|
|
1032
|
+
await plain.updateComplete;
|
|
1033
|
+
await selectFirst(plain);
|
|
1034
|
+
expect(plain.shadowRoot?.querySelector('.layout-group'), 'non-container has none').to.not.exist;
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
it('re-chunks losslessly when the editor narrows the columns', async () => {
|
|
1038
|
+
// Trailing empty cells on the pre-edit row are load-bearing for this test: with
|
|
1039
|
+
// grow false, the downstream normaliseCells pass does not trim, so only
|
|
1040
|
+
// recolumnCells's own trim-then-pad determines the final length. A passthrough
|
|
1041
|
+
// that skips recolumnCells would carry all 5 stacks through and land on 6 cells
|
|
1042
|
+
// after padding to 2 columns, not 4.
|
|
1043
|
+
const config = JSON.stringify({
|
|
1044
|
+
sections: [{
|
|
1045
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [1, 1, 1, 1, 1], grow: false},
|
|
1046
|
+
cells: [
|
|
1047
|
+
[{id: 'a', type: 'hero', data: {}}],
|
|
1048
|
+
[{id: 'b', type: 'hero', data: {}}],
|
|
1049
|
+
[{id: 'c', type: 'hero', data: {}}],
|
|
1050
|
+
[],
|
|
1051
|
+
[],
|
|
1052
|
+
],
|
|
1053
|
+
}],
|
|
1054
|
+
});
|
|
1055
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1056
|
+
<zn-page-builder config=${config}>
|
|
1057
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
1058
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1059
|
+
</zn-page-builder>`);
|
|
1060
|
+
await el.updateComplete;
|
|
1061
|
+
|
|
1062
|
+
(el as unknown as {_setColumns(id: string, n: number): void})._setColumns('outer', 2);
|
|
1063
|
+
await el.updateComplete;
|
|
1064
|
+
|
|
1065
|
+
const row = el.state.sections[0];
|
|
1066
|
+
expect(row.layout?.widths).to.have.lengthOf(2);
|
|
1067
|
+
expect(row.cells?.flat().map(c => c.id), 'nothing lost or reordered').to.deep.equal(['a', 'b', 'c']);
|
|
1068
|
+
expect(row.cells, 'trailing empties trimmed before re-chunking, not carried through').to.have.lengthOf(4);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('sets a per-column width', async () => {
|
|
1072
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1073
|
+
<zn-page-builder config=${containerConfig()}>
|
|
1074
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
1075
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1076
|
+
</zn-page-builder>`);
|
|
1077
|
+
await el.updateComplete;
|
|
1078
|
+
|
|
1079
|
+
(el as unknown as {_setWidth(id: string, col: number, w: number): void})._setWidth('outer', 1, 3);
|
|
1080
|
+
await el.updateComplete;
|
|
1081
|
+
expect(el.state.sections[0].layout?.widths).to.deep.equal([1, 3, 1]);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('clamps a row reduction at the last occupied row', async () => {
|
|
1085
|
+
const config = JSON.stringify({
|
|
1086
|
+
sections: [{
|
|
1087
|
+
id: 'outer', type: 'row', data: {}, layout: {widths: [2], grow: false},
|
|
1088
|
+
cells: [[{id: 'a', type: 'hero', data: {}}], [{id: 'b', type: 'hero', data: {}}], []],
|
|
1089
|
+
}],
|
|
1090
|
+
});
|
|
1091
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1092
|
+
<zn-page-builder config=${config}>
|
|
1093
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
1094
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1095
|
+
</zn-page-builder>`);
|
|
1096
|
+
await el.updateComplete;
|
|
1097
|
+
|
|
1098
|
+
const api = el as unknown as {_setRows(id: string, n: number): void};
|
|
1099
|
+
api._setRows('outer', 3);
|
|
1100
|
+
await el.updateComplete;
|
|
1101
|
+
expect(el.state.sections[0].cells).to.have.lengthOf(3);
|
|
1102
|
+
|
|
1103
|
+
api._setRows('outer', 1);
|
|
1104
|
+
await el.updateComplete;
|
|
1105
|
+
expect(el.state.sections[0].cells?.flat().map(c => c.id), 'content survives').to.deep.equal(['a', 'b']);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it('undoes a layout change', async () => {
|
|
1109
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1110
|
+
<zn-page-builder config=${containerConfig()}>
|
|
1111
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
1112
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1113
|
+
</zn-page-builder>`);
|
|
1114
|
+
await el.updateComplete;
|
|
1115
|
+
|
|
1116
|
+
(el as unknown as {_setColumns(id: string, n: number): void})._setColumns('outer', 2);
|
|
1117
|
+
await el.updateComplete;
|
|
1118
|
+
expect(el.state.sections[0].layout?.widths).to.have.lengthOf(2);
|
|
1119
|
+
|
|
1120
|
+
el.undo();
|
|
1121
|
+
await el.updateComplete;
|
|
1122
|
+
expect(el.state.sections[0].layout?.widths).to.deep.equal([1, 2, 1]);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('flips grow from the layout group toggle', async () => {
|
|
1126
|
+
const el = await fixture<ZnPageBuilder>(html`
|
|
1127
|
+
<zn-page-builder config=${containerConfig()}>
|
|
1128
|
+
<template type="row" slot="config" label="Row" container></template>
|
|
1129
|
+
<template type="hero" slot="config" label="Hero"></template>
|
|
1130
|
+
</zn-page-builder>`);
|
|
1131
|
+
await el.updateComplete;
|
|
1132
|
+
await selectFirst(el);
|
|
1133
|
+
|
|
1134
|
+
const toggle = el.shadowRoot?.querySelector('.layout-group zn-toggle') as HTMLElement & { checked: boolean };
|
|
1135
|
+
expect(toggle, 'grow toggle').to.exist;
|
|
1136
|
+
toggle.checked = true;
|
|
1137
|
+
// zn-toggle only ever emits zn-input (never zn-change) on interaction — this is the
|
|
1138
|
+
// exact event the layout group's listener must be bound to.
|
|
1139
|
+
toggle.dispatchEvent(new CustomEvent('zn-input', {bubbles: true}));
|
|
1140
|
+
await el.updateComplete;
|
|
1141
|
+
|
|
1142
|
+
expect(el.state.sections[0].layout?.grow, 'toggle flips grow in state').to.be.true;
|
|
463
1143
|
});
|
|
464
1144
|
});
|