@kubex/zinc 1.1.94 → 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.
Files changed (36) hide show
  1. package/dist/custom-elements.json +995 -196
  2. package/dist/vscode.html-custom-data.json +74 -19
  3. package/dist/web-types.json +147 -37
  4. package/dist/zn.d.ts +335 -59
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +467 -355
  7. package/docs/pages/components/page-builder.md +121 -19
  8. package/docs/pages/components/slash-menu.md +132 -6
  9. package/docs/pages/components/textarea.md +16 -0
  10. package/package.json +1 -1
  11. package/scss/_root.scss +7 -1
  12. package/src/components/button/button.scss +5 -2
  13. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  14. package/src/components/input/input.component.ts +12 -2
  15. package/src/components/page/page.scss +7 -2
  16. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  17. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  18. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  19. package/src/components/page-builder/page-builder.component.ts +483 -225
  20. package/src/components/page-builder/page-builder.scss +64 -10
  21. package/src/components/page-builder/page-builder.test.ts +656 -110
  22. package/src/components/page-builder/page-tree.test.ts +483 -0
  23. package/src/components/page-builder/page-tree.ts +329 -0
  24. package/src/components/page-builder/page.types.ts +75 -7
  25. package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
  26. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  27. package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
  28. package/src/components/settings-container/settings-container.scss +2 -1
  29. package/src/components/slash-item/slash-item.component.ts +1 -1
  30. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  31. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  32. package/src/components/slash-menu/slash-menu.scss +90 -12
  33. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  34. package/src/components/textarea/textarea.component.ts +12 -2
  35. package/src/components/textarea/textarea.test.ts +2 -2
  36. package/src/components/translations/translations.component.ts +5 -1
@@ -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>
@@ -391,82 +473,208 @@ describe('<zn-page-builder>', () => {
391
473
  await expect(el).to.be.accessible();
392
474
  });
393
475
 
394
- it('should render a slot grid for container sections and fill slots via addSectionToSlot', async () => {
476
+ it('should pin a required-first section, inserting one when the page has none', async () => {
395
477
  const el = await fixture<ZnPageBuilder>(html`
396
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
397
- <template type="article-grid" slot="config" label="Article Grid" slots="6" accepts="article-tile"></template>
398
- <template type="article-tile" slot="config" label="Article"></template>
478
+ <zn-page-builder required-first="hero">
399
479
  <template type="hero" slot="config" label="Hero"></template>
480
+ <template type="rich-text" slot="config" label="Rich Text"></template>
400
481
  </zn-page-builder>`);
401
482
  await el.updateComplete;
402
483
 
403
- expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(6);
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);
404
487
 
405
- expect(el.addSectionToSlot('hero', 'grid', 0), 'not in accepts').to.be.null;
406
- expect(el.addSectionToSlot('article-grid', 'grid', 0), 'no nested containers').to.be.null;
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
+ });
407
494
 
408
- const child = el.addSectionToSlot('article-tile', 'grid', 1);
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>`);
409
502
  await el.updateComplete;
410
- expect(child?.type).to.equal('article-tile');
411
- expect(el.state.sections[0].children?.[1]?.id).to.equal(child?.id);
412
- expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(5);
413
- expect(el.shadowRoot?.querySelectorAll('.slots zn-page-section-card')).to.have.length(1);
414
503
 
415
- expect(el.addSectionToSlot('article-tile', 'grid', 1), 'occupied slot').to.be.null;
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'});
416
506
  });
417
507
 
418
- it('should swap children when dropping one filled slot onto another', async () => {
508
+ it('should refuse to remove, reorder or displace the pinned section', async () => {
419
509
  const el = await fixture<ZnPageBuilder>(html`
420
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
421
- <template type="article-grid" slot="config" label="Article Grid" slots="4"></template>
422
- <template type="article-tile" slot="config" label="Article"></template>
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>
423
514
  </zn-page-builder>`);
424
515
  await el.updateComplete;
425
516
 
426
- const a = el.addSectionToSlot('article-tile', 'grid', 0)!;
427
- const b = el.addSectionToSlot('article-tile', 'grid', 1)!;
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}));
428
522
  await el.updateComplete;
523
+ expect(el.state.sections.map(s => s.id), 'survives remove and Delete').to.deep.equal(['h', 't']);
429
524
 
430
- // Drag child A onto child B's card (slot 1) they swap places.
431
- const cards = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card');
525
+ // Dropping another section on the topmost zone lands it below the pinned one.
432
526
  const dataTransfer = new DataTransfer();
433
- dataTransfer.setData('application/x-zn-page-section', a.id);
434
- cards[1].dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
527
+ dataTransfer.setData('application/x-zn-page-section', 't');
528
+ el.shadowRoot!.querySelector('.drop')!
529
+ .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
435
530
  await el.updateComplete;
531
+ expect(el.state.sections.map(s => s.id), 'nothing lands above the pinned section').to.deep.equal(['h', 't']);
436
532
 
437
- expect(el.state.sections[0].children?.[0]?.id).to.equal(b.id);
438
- expect(el.state.sections[0].children?.[1]?.id).to.equal(a.id);
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');
439
535
  });
440
536
 
441
- it('should edit a slotted child through the inspector', async () => {
537
+ it('should seed layout and cells for an inserted required-first container', async () => {
442
538
  const el = await fixture<ZnPageBuilder>(html`
443
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{"title":"Old"}}]}]}'>
444
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
445
- <template type="article-tile" slot="config" label="Article">
446
- <input name="title">
447
- </template>
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>
554
+ </zn-page-builder>`);
555
+ await el.updateComplete;
556
+
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>
448
628
  </zn-page-builder>`);
449
629
  await el.updateComplete;
450
630
 
451
- el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
631
+ (el as unknown as {_select(id: string): void})._select('deep');
452
632
  await el.updateComplete;
453
633
 
454
634
  const input = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="title"]');
455
- expect(input, 'stamped input for child').to.exist;
456
- expect(input!.value).to.equal('Old');
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;
457
639
 
458
- input!.value = 'New';
459
- input!.dispatchEvent(new Event('change', {bubbles: true}));
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>`);
460
656
  await el.updateComplete;
461
657
 
462
- expect(el.state.sections[0].children?.[0]?.data.title).to.equal('New');
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);
463
665
  });
464
666
 
465
- it('should clear selection when removing a container holding the selected child', async () => {
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
+ });
466
674
  const el = await fixture<ZnPageBuilder>(html`
467
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
468
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
469
- <template type="article-tile" slot="config" label="Article"></template>
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>
470
678
  </zn-page-builder>`);
471
679
  await el.updateComplete;
472
680
 
@@ -474,125 +682,463 @@ describe('<zn-page-builder>', () => {
474
682
  el.addEventListener('zn-page-selection-change', e =>
475
683
  (lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
476
684
 
477
- // Select the child, then remove its parent container.
478
- el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
685
+ (el as unknown as {_select(id: string): void})._select('a');
479
686
  await el.updateComplete;
480
- expect(lastSelection).to.equal('c1');
687
+ expect(lastSelection).to.equal('a');
481
688
 
482
- el.shadowRoot?.querySelector('.container > zn-page-section-card')
483
- ?.dispatchEvent(new CustomEvent('page-card-remove'));
689
+ (el as unknown as {_removeSection(id: string): void})._removeSection('a');
484
690
  await el.updateComplete;
485
691
 
692
+ expect(el.state.sections[0].cells?.[0]).to.deep.equal([]);
486
693
  expect(lastSelection, 'selection cleared').to.be.null;
487
- expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'no ghost inspector').to.not.exist;
488
694
  });
489
695
 
490
- it('should move a top-level section into an empty slot but not an occupied one', async () => {
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
+ });
706
+ const el = await fixture<ZnPageBuilder>(html`
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>
710
+ </zn-page-builder>`);
711
+ await el.updateComplete;
712
+
713
+ (el as unknown as {_removeSection(id: string): void})._removeSection('c');
714
+ await el.updateComplete;
715
+
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
+ });
491
732
  const el = await fixture<ZnPageBuilder>(html`
492
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}},{"id":"t1","type":"article-tile","data":{}},{"id":"t2","type":"article-tile","data":{}}]}'>
493
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
494
- <template type="article-tile" slot="config" label="Article"></template>
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>`);
737
+ await el.updateComplete;
738
+
739
+ (el as unknown as {_removeSection(id: string): void})._removeSection('c');
740
+ await el.updateComplete;
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([]);
745
+ });
746
+
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
+ });
757
+ const el = await fixture<ZnPageBuilder>(html`
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>
495
761
  </zn-page-builder>`);
496
762
  await el.updateComplete;
497
763
 
498
- const dropOnSlot = (sectionId: string, slotSelector: string) => {
499
- const dataTransfer = new DataTransfer();
500
- dataTransfer.setData('application/x-zn-page-section', sectionId);
501
- el.shadowRoot!.querySelector(slotSelector)!
502
- .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
503
- };
764
+ let lastSelection: string | null | undefined;
765
+ el.addEventListener('zn-page-selection-change', e =>
766
+ (lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
504
767
 
505
- dropOnSlot('t1', '.slot--empty'); // into first empty slot
768
+ (el as unknown as {_select(id: string): void})._select('deep');
506
769
  await el.updateComplete;
507
- expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
508
- expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
770
+ expect(lastSelection).to.equal('deep');
509
771
 
510
- dropOnSlot('t2', '.slots zn-page-section-card'); // onto the occupied slot rejected
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');
511
774
  await el.updateComplete;
512
- expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
513
- expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
775
+
776
+ expect(el.state.sections).to.have.lengthOf(0);
777
+ expect(lastSelection, 'selection cleared at depth 2').to.be.null;
514
778
  });
515
779
 
516
- it('should swap children across different containers', async () => {
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
+ });
517
787
  const el = await fixture<ZnPageBuilder>(html`
518
- <zn-page-builder
519
- 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":{}}]}]}'>
520
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
521
- <template type="article-tile" slot="config" label="Article"></template>
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>
522
791
  </zn-page-builder>`);
523
792
  await el.updateComplete;
524
793
 
525
- // Drag a1 (in g1) onto b1's card (slot 0 of g2) — they trade containers.
526
- const dataTransfer = new DataTransfer();
527
- dataTransfer.setData('application/x-zn-page-section', 'a1');
528
- const g2Card = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card')[1];
529
- g2Card.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
794
+ (el as unknown as {_duplicateSection(id: string): void})._duplicateSection('a');
530
795
  await el.updateComplete;
531
796
 
532
- expect(el.state.sections[0].children?.[0]?.id).to.equal('b1');
533
- expect(el.state.sections[1].children?.[0]?.id).to.equal('a1');
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');
534
802
  });
535
803
 
536
- it('should undo a slot mutation and duplicate a child into the next empty slot', async () => {
804
+ it('renders one column per width with the weights as fr units', async () => {
537
805
  const el = await fixture<ZnPageBuilder>(html`
538
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
539
- <template type="article-grid" slot="config" label="Article Grid" slots="3"></template>
540
- <template type="article-tile" slot="config" label="Article"></template>
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>
541
809
  </zn-page-builder>`);
542
810
  await el.updateComplete;
543
811
 
544
- const child = el.addSectionToSlot('article-tile', 'grid', 0)!;
545
- el.undo();
546
- expect(el.state.sections[0].children?.[0] ?? null, 'undo empties the slot').to.be.null;
547
- el.redo();
548
- expect(el.state.sections[0].children?.[0]?.id).to.equal(child.id);
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
+ });
549
817
 
550
- // Duplicate the child: the copy takes the next empty slot with a fresh id.
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>`);
551
824
  await el.updateComplete;
552
- el.shadowRoot?.querySelector('.slots zn-page-section-card')
553
- ?.dispatchEvent(new CustomEvent('page-card-duplicate'));
825
+
826
+ const cell = el.shadowRoot!.querySelectorAll('.cell')[1]!;
827
+ dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
554
828
  await el.updateComplete;
555
- const children = el.state.sections[0].children ?? [];
556
- expect(children[1]?.type).to.equal('article-tile');
557
- 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']);
558
831
  });
559
832
 
560
- it('should select a card with Enter and delete a child with Delete via keyboard', async () => {
833
+ it('stacks a second section into the same cell', async () => {
561
834
  const el = await fixture<ZnPageBuilder>(html`
562
- <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
563
- <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
564
- <template type="article-tile" slot="config" label="Article"></template>
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>
565
838
  </zn-page-builder>`);
566
839
  await el.updateComplete;
567
840
 
568
- const childCard = el.shadowRoot!.querySelector('.slots zn-page-section-card')!;
569
- childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
841
+ const cell = el.shadowRoot!.querySelectorAll('.cell')[0]!;
842
+ dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
570
843
  await el.updateComplete;
571
- expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'Enter selects').to.exist;
572
844
 
573
- childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true}));
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>`);
574
860
  await el.updateComplete;
575
- expect(el.state.sections[0].children?.[0] ?? null, 'Delete removes the child').to.be.null;
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;
576
872
  });
577
873
 
578
- it('should accept a palette drop anywhere on the empty canvas', async () => {
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
+ });
579
881
  const el = await fixture<ZnPageBuilder>(html`
580
- <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>
581
917
  <template type="hero" slot="config" label="Hero"></template>
582
918
  </zn-page-builder>`);
583
919
  await el.updateComplete;
584
920
 
585
- const canvas = el.shadowRoot!.querySelector('.canvas')!;
586
- const dataTransfer = new DataTransfer();
587
- dataTransfer.setData('application/x-zn-page-type', 'hero');
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;
588
926
 
589
- const over = new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true});
590
- canvas.dispatchEvent(over);
591
- expect(over.defaultPrevented, 'canvas accepts the drag').to.be.true;
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
+ });
592
930
 
593
- canvas.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
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>`);
594
942
  await el.updateComplete;
595
- expect(el.state.sections).to.have.length(1);
596
- expect(el.state.sections[0].type).to.equal('hero');
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;
597
1143
  });
598
1144
  });