@kubex/zinc 1.1.95 → 1.1.96

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.
@@ -1,37 +1,8 @@
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
+ import type {PageState} from './page.types';
5
4
  import type ZnPageBuilder from './page-builder.component';
6
5
 
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
-
35
6
  describe('<zn-page-builder>', () => {
36
7
  it('should render the shell', async () => {
37
8
  const el = await fixture<ZnPageBuilder>(html`
@@ -57,58 +28,6 @@ describe('<zn-page-builder>', () => {
57
28
  expect(categories).to.deep.equal(['Headers', 'Content']);
58
29
  });
59
30
 
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
-
112
31
  it('should tuck the palette away via the canvas-edge chevron', async () => {
113
32
  const el = await fixture<ZnPageBuilder>(html`
114
33
  <zn-page-builder>
@@ -473,208 +392,82 @@ describe('<zn-page-builder>', () => {
473
392
  await expect(el).to.be.accessible();
474
393
  });
475
394
 
476
- it('should pin a required-first section, inserting one when the page has none', async () => {
395
+ it('should render a slot grid for container sections and fill slots via addSectionToSlot', async () => {
477
396
  const el = await fixture<ZnPageBuilder>(html`
478
- <zn-page-builder required-first="hero">
397
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
398
+ <template type="article-grid" slot="config" label="Article Grid" slots="6" accepts="article-tile"></template>
399
+ <template type="article-tile" slot="config" label="Article"></template>
479
400
  <template type="hero" slot="config" label="Hero"></template>
480
- <template type="rich-text" slot="config" label="Rich Text"></template>
481
401
  </zn-page-builder>`);
482
402
  await el.updateComplete;
483
403
 
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
+ expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(6);
487
405
 
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
- });
406
+ expect(el.addSectionToSlot('hero', 'grid', 0), 'not in accepts').to.be.null;
407
+ expect(el.addSectionToSlot('article-grid', 'grid', 0), 'no nested containers').to.be.null;
494
408
 
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
+ const child = el.addSectionToSlot('article-tile', 'grid', 1);
502
410
  await el.updateComplete;
411
+ expect(child?.type).to.equal('article-tile');
412
+ expect(el.state.sections[0].children?.[1]?.id).to.equal(child?.id);
413
+ expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(5);
414
+ expect(el.shadowRoot?.querySelectorAll('.slots zn-page-section-card')).to.have.length(1);
503
415
 
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
+ expect(el.addSectionToSlot('article-tile', 'grid', 1), 'occupied slot').to.be.null;
506
417
  });
507
418
 
508
- it('should refuse to remove, reorder or displace the pinned section', async () => {
419
+ it('should swap children when dropping one filled slot onto another', async () => {
509
420
  const el = await fixture<ZnPageBuilder>(html`
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>
421
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
422
+ <template type="article-grid" slot="config" label="Article Grid" slots="4"></template>
423
+ <template type="article-tile" slot="config" label="Article"></template>
514
424
  </zn-page-builder>`);
515
425
  await el.updateComplete;
516
426
 
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}));
427
+ const a = el.addSectionToSlot('article-tile', 'grid', 0)!;
428
+ const b = el.addSectionToSlot('article-tile', 'grid', 1)!;
522
429
  await el.updateComplete;
523
- expect(el.state.sections.map(s => s.id), 'survives remove and Delete').to.deep.equal(['h', 't']);
524
430
 
525
- // Dropping another section on the topmost zone lands it below the pinned one.
431
+ // Drag child A onto child B's card (slot 1) they swap places.
432
+ const cards = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card');
526
433
  const dataTransfer = new DataTransfer();
527
- dataTransfer.setData('application/x-zn-page-section', 't');
528
- el.shadowRoot!.querySelector('.drop')!
529
- .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
530
- await el.updateComplete;
531
- expect(el.state.sections.map(s => s.id), 'nothing lands above the pinned section').to.deep.equal(['h', 't']);
532
-
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');
535
- });
536
-
537
- it('should seed layout and cells for an inserted required-first container', async () => {
538
- const el = await fixture<ZnPageBuilder>(html`
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>`);
434
+ dataTransfer.setData('application/x-zn-page-section', a.id);
435
+ cards[1].dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
542
436
  await el.updateComplete;
543
437
 
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);
438
+ expect(el.state.sections[0].children?.[0]?.id).to.equal(b.id);
439
+ expect(el.state.sections[0].children?.[1]?.id).to.equal(a.id);
548
440
  });
549
441
 
550
- it('should leave the page alone without required-first', async () => {
442
+ it('should edit a slotted child through the inspector', async () => {
551
443
  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>
444
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{"title":"Old"}}]}]}'>
445
+ <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
446
+ <template type="article-tile" slot="config" label="Article">
447
+ <input name="title">
448
+ </template>
628
449
  </zn-page-builder>`);
629
450
  await el.updateComplete;
630
451
 
631
- (el as unknown as {_select(id: string): void})._select('deep');
452
+ el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
632
453
  await el.updateComplete;
633
454
 
634
455
  const input = el.shadowRoot?.querySelector<HTMLInputElement>('.inspector__form input[name="title"]');
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;
456
+ expect(input, 'stamped input for child').to.exist;
457
+ expect(input!.value).to.equal('Old');
639
458
 
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>`);
656
- await el.updateComplete;
657
-
658
- el.shadowRoot?.querySelector('zn-page-section-card')
659
- ?.dispatchEvent(new CustomEvent('page-card-duplicate', {bubbles: true, composed: true}));
459
+ input!.value = 'New';
460
+ input!.dispatchEvent(new Event('change', {bubbles: true}));
660
461
  await el.updateComplete;
661
462
 
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
+ expect(el.state.sections[0].children?.[0]?.data.title).to.equal('New');
665
464
  });
666
465
 
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
+ it('should clear selection when removing a container holding the selected child', async () => {
674
467
  const el = await fixture<ZnPageBuilder>(html`
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>
468
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
469
+ <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
470
+ <template type="article-tile" slot="config" label="Article"></template>
678
471
  </zn-page-builder>`);
679
472
  await el.updateComplete;
680
473
 
@@ -682,463 +475,218 @@ describe('<zn-page-builder>', () => {
682
475
  el.addEventListener('zn-page-selection-change', e =>
683
476
  (lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId));
684
477
 
685
- (el as unknown as {_select(id: string): void})._select('a');
478
+ // Select the child, then remove its parent container.
479
+ el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click'));
686
480
  await el.updateComplete;
687
- expect(lastSelection).to.equal('a');
481
+ expect(lastSelection).to.equal('c1');
688
482
 
689
- (el as unknown as {_removeSection(id: string): void})._removeSection('a');
483
+ el.shadowRoot?.querySelector('.container > zn-page-section-card')
484
+ ?.dispatchEvent(new CustomEvent('page-card-remove'));
690
485
  await el.updateComplete;
691
486
 
692
- expect(el.state.sections[0].cells?.[0]).to.deep.equal([]);
693
487
  expect(lastSelection, 'selection cleared').to.be.null;
488
+ expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'no ghost inspector').to.not.exist;
694
489
  });
695
490
 
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
- });
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>`);
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>
761
- </zn-page-builder>`);
762
- await el.updateComplete;
763
-
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');
769
- await el.updateComplete;
770
- expect(lastSelection).to.equal('deep');
771
-
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;
778
- });
779
-
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
- });
787
- const el = await fixture<ZnPageBuilder>(html`
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>
791
- </zn-page-builder>`);
792
- await el.updateComplete;
793
-
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 () => {
491
+ it('should move a top-level section into an empty slot but not an occupied one', async () => {
805
492
  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>
493
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}},{"id":"t1","type":"article-tile","data":{}},{"id":"t2","type":"article-tile","data":{}}]}'>
494
+ <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
495
+ <template type="article-tile" slot="config" label="Article"></template>
809
496
  </zn-page-builder>`);
810
497
  await el.updateComplete;
811
498
 
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
- });
499
+ const dropOnSlot = (sectionId: string, slotSelector: string) => {
500
+ const dataTransfer = new DataTransfer();
501
+ dataTransfer.setData('application/x-zn-page-section', sectionId);
502
+ el.shadowRoot!.querySelector(slotSelector)!
503
+ .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
504
+ };
817
505
 
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>`);
506
+ dropOnSlot('t1', '.slot--empty'); // into first empty slot
824
507
  await el.updateComplete;
508
+ expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
509
+ expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
825
510
 
826
- const cell = el.shadowRoot!.querySelectorAll('.cell')[1]!;
827
- dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
511
+ dropOnSlot('t2', '.slots zn-page-section-card'); // onto the occupied slot — rejected
828
512
  await el.updateComplete;
829
-
830
- expect(el.state.sections[0].cells?.[1].map(c => c.type)).to.deep.equal(['hero']);
513
+ expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']);
514
+ expect(el.state.sections[0].children?.[0]?.id).to.equal('t1');
831
515
  });
832
516
 
833
- it('stacks a second section into the same cell', async () => {
517
+ it('should swap children across different containers', async () => {
834
518
  const el = await fixture<ZnPageBuilder>(html`
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>
519
+ <zn-page-builder
520
+ 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":{}}]}]}'>
521
+ <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
522
+ <template type="article-tile" slot="config" label="Article"></template>
838
523
  </zn-page-builder>`);
839
524
  await el.updateComplete;
840
525
 
841
- const cell = el.shadowRoot!.querySelectorAll('.cell')[0]!;
842
- dragTo(cell, PAGE_TYPE_MIME, 'hero', 'drop');
526
+ // Drag a1 (in g1) onto b1's card (slot 0 of g2) — they trade containers.
527
+ const dataTransfer = new DataTransfer();
528
+ dataTransfer.setData('application/x-zn-page-section', 'a1');
529
+ const g2Card = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card')[1];
530
+ g2Card.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
843
531
  await el.updateComplete;
844
532
 
845
- expect(el.state.sections[0].cells?.[0]).to.have.lengthOf(2);
533
+ expect(el.state.sections[0].children?.[0]?.id).to.equal('b1');
534
+ expect(el.state.sections[1].children?.[0]?.id).to.equal('a1');
846
535
  });
847
536
 
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
- });
537
+ it('should undo a slot mutation and duplicate a child into the next empty slot', async () => {
855
538
  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>
539
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{}}]}'>
540
+ <template type="article-grid" slot="config" label="Article Grid" slots="3"></template>
541
+ <template type="article-tile" slot="config" label="Article"></template>
859
542
  </zn-page-builder>`);
860
543
  await el.updateComplete;
861
544
 
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;
872
- });
545
+ const child = el.addSectionToSlot('article-tile', 'grid', 0)!;
546
+ el.undo();
547
+ expect(el.state.sections[0].children?.[0] ?? null, 'undo empties the slot').to.be.null;
548
+ el.redo();
549
+ expect(el.state.sections[0].children?.[0]?.id).to.equal(child.id);
873
550
 
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
- });
881
- const el = await fixture<ZnPageBuilder>(html`
882
- <zn-page-builder config=${nestedConfig}>
883
- <template type="row" slot="config" label="Row" container accepts="row"></template>
884
- </zn-page-builder>`);
551
+ // Duplicate the child: the copy takes the next empty slot with a fresh id.
885
552
  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');
553
+ el.shadowRoot?.querySelector('.slots zn-page-section-card')
554
+ ?.dispatchEvent(new CustomEvent('page-card-duplicate'));
892
555
  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([]);
556
+ const children = el.state.sections[0].children ?? [];
557
+ expect(children[1]?.type).to.equal('article-tile');
558
+ expect(children[1]?.id).to.not.equal(children[0]?.id);
896
559
  });
897
560
 
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
- });
561
+ it('should select a card with Enter and delete a child with Delete via keyboard', async () => {
914
562
  const el = await fixture<ZnPageBuilder>(html`
915
- <zn-page-builder config=${config}>
916
- <template type="row" slot="config" label="Row" container></template>
917
- <template type="hero" slot="config" label="Hero"></template>
563
+ <zn-page-builder config='{"sections":[{"id":"grid","type":"article-grid","data":{},"children":[{"id":"c1","type":"article-tile","data":{}}]}]}'>
564
+ <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
565
+ <template type="article-tile" slot="config" label="Article"></template>
918
566
  </zn-page-builder>`);
919
567
  await el.updateComplete;
920
568
 
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;
926
-
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
- });
930
-
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>`);
569
+ const childCard = el.shadowRoot!.querySelector('.slots zn-page-section-card')!;
570
+ childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
942
571
  await el.updateComplete;
572
+ expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'Enter selects').to.exist;
943
573
 
944
- // Document order: [flat0, target0, target1].
945
- const targetCell0 = el.shadowRoot!.querySelectorAll('.cell')[1];
946
- dragTo(targetCell0, PAGE_SECTION_MIME, 'flat', 'drop');
574
+ childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true}));
947
575
  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']);
576
+ expect(el.state.sections[0].children?.[0] ?? null, 'Delete removes the child').to.be.null;
951
577
  });
952
578
 
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
- });
579
+ it('should pin a required-first section, inserting one when the page has none', async () => {
957
580
  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>
581
+ <zn-page-builder required-first="hero">
961
582
  <template type="hero" slot="config" label="Hero"></template>
583
+ <template type="rich-text" slot="config" label="Rich Text"></template>
962
584
  </zn-page-builder>`);
963
585
  await el.updateComplete;
964
586
 
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([]);
587
+ expect(el.state.sections.map(s => s.type), 'inserted into an empty page').to.deep.equal(['hero']);
588
+ // The submitted value carries the pinned section, so a save can't miss it.
589
+ expect((JSON.parse(el.value) as PageState).sections).to.have.length(1);
969
590
 
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']);
591
+ const card = el.shadowRoot!.querySelector('zn-page-section-card')!;
592
+ expect(card.hasAttribute('locked'), 'card is locked').to.be.true;
593
+ expect(card.getAttribute('draggable'), 'card is not draggable').to.equal('false');
594
+ // No "+" strip above it — the first drop zone follows the pinned card.
595
+ expect(el.shadowRoot!.querySelector('.canvas > *')?.tagName.toLowerCase()).to.equal('zn-page-section-card');
973
596
  });
974
597
 
975
- it('keeps enforcing accepts', async () => {
598
+ it('should hoist an existing required-first section to the top of the page', async () => {
976
599
  const el = await fixture<ZnPageBuilder>(html`
977
- <zn-page-builder config=${containerConfig()}>
978
- <template type="row" slot="config" label="Row" container accepts="tile"></template>
600
+ <zn-page-builder required-first="hero"
601
+ config='{"sections":[{"id":"t","type":"rich-text","data":{}},{"id":"h","type":"hero","data":{"title":"Help"}}]}'>
979
602
  <template type="hero" slot="config" label="Hero"></template>
980
- <template type="tile" slot="config" label="Tile"></template>
603
+ <template type="rich-text" slot="config" label="Rich Text"></template>
981
604
  </zn-page-builder>`);
982
605
  await el.updateComplete;
983
606
 
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']);
607
+ expect(el.state.sections.map(s => s.id)).to.deep.equal(['h', 't']);
608
+ expect(el.state.sections[0].data, 'hoisted with its data intact').to.deep.equal({title: 'Help'});
992
609
  });
993
610
 
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
- });
611
+ it('should refuse to remove, reorder or displace the pinned section', async () => {
1002
612
  const el = await fixture<ZnPageBuilder>(html`
1003
- <zn-page-builder config=${growConfig}>
1004
- <template type="row" slot="config" label="Row" container grow></template>
613
+ <zn-page-builder required-first="hero"
614
+ config='{"sections":[{"id":"h","type":"hero","data":{}},{"id":"t","type":"rich-text","data":{}}]}'>
1005
615
  <template type="hero" slot="config" label="Hero"></template>
616
+ <template type="rich-text" slot="config" label="Rich Text"></template>
1006
617
  </zn-page-builder>`);
1007
618
  await el.updateComplete;
1008
619
 
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);
620
+ const pinnedCard = el.shadowRoot!.querySelector('zn-page-section-card')!;
621
+ expect(pinnedCard.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'no remove action').to.not.exist;
1012
622
 
1013
- dragTo(cells![2], PAGE_TYPE_MIME, 'hero', 'drop');
623
+ pinnedCard.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true}));
624
+ pinnedCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true}));
1014
625
  await el.updateComplete;
1015
- expect(el.state.sections[0].cells).to.have.lengthOf(4);
1016
- });
626
+ expect(el.state.sections.map(s => s.id), 'survives remove and Delete').to.deep.equal(['h', 't']);
1017
627
 
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>`);
628
+ // Dropping another section on the topmost zone lands it below the pinned one.
629
+ const dataTransfer = new DataTransfer();
630
+ dataTransfer.setData('application/x-zn-page-section', 't');
631
+ el.shadowRoot!.querySelector('.drop')!
632
+ .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
1024
633
  await el.updateComplete;
1025
- await selectFirst(el);
1026
- expect(el.shadowRoot?.querySelector('.layout-group'), 'container shows layout').to.exist;
634
+ expect(el.state.sections.map(s => s.id), 'nothing lands above the pinned section').to.deep.equal(['h', 't']);
1027
635
 
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;
636
+ expect(el.addSection('rich-text', 0)).to.not.be.null;
637
+ expect(el.state.sections[0].id, 'addSection index 0 is clamped').to.equal('h');
1035
638
  });
1036
639
 
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
- });
640
+ it('should keep the pinned section out of container slots', async () => {
1055
641
  const el = await fixture<ZnPageBuilder>(html`
1056
- <zn-page-builder config=${config}>
1057
- <template type="row" slot="config" label="Row" container></template>
642
+ <zn-page-builder required-first="hero"
643
+ config='{"sections":[{"id":"h","type":"hero","data":{}},{"id":"grid","type":"article-grid","data":{}}]}'>
1058
644
  <template type="hero" slot="config" label="Hero"></template>
645
+ <template type="article-grid" slot="config" label="Article Grid" slots="2"></template>
1059
646
  </zn-page-builder>`);
1060
647
  await el.updateComplete;
1061
648
 
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>`);
649
+ const dataTransfer = new DataTransfer();
650
+ dataTransfer.setData('application/x-zn-page-section', 'h');
651
+ el.shadowRoot!.querySelector('.slot--empty')!
652
+ .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
1077
653
  await el.updateComplete;
1078
654
 
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]);
655
+ expect(el.state.sections[0].id, 'still leading the page').to.equal('h');
656
+ expect(el.state.sections[1].children?.[0] ?? null, 'slot left empty').to.be.null;
1082
657
  });
1083
658
 
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
- });
659
+ it('should leave the page alone without required-first', async () => {
1091
660
  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>
661
+ <zn-page-builder config='{"sections":[{"id":"t","type":"rich-text","data":{}}]}'>
662
+ <template type="rich-text" slot="config" label="Rich Text"></template>
1095
663
  </zn-page-builder>`);
1096
664
  await el.updateComplete;
1097
665
 
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']);
666
+ expect(el.state.sections.map(s => s.type)).to.deep.equal(['rich-text']);
667
+ const card = el.shadowRoot!.querySelector('zn-page-section-card')!;
668
+ expect(card.hasAttribute('locked')).to.be.false;
669
+ expect(card.getAttribute('draggable')).to.equal('true');
1106
670
  });
1107
671
 
1108
- it('undoes a layout change', async () => {
672
+ it('should accept a palette drop anywhere on the empty canvas', async () => {
1109
673
  const el = await fixture<ZnPageBuilder>(html`
1110
- <zn-page-builder config=${containerConfig()}>
1111
- <template type="row" slot="config" label="Row" container></template>
674
+ <zn-page-builder>
1112
675
  <template type="hero" slot="config" label="Hero"></template>
1113
676
  </zn-page-builder>`);
1114
677
  await el.updateComplete;
1115
678
 
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
- });
679
+ const canvas = el.shadowRoot!.querySelector('.canvas')!;
680
+ const dataTransfer = new DataTransfer();
681
+ dataTransfer.setData('application/x-zn-page-type', 'hero');
1124
682
 
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);
683
+ const over = new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true});
684
+ canvas.dispatchEvent(over);
685
+ expect(over.defaultPrevented, 'canvas accepts the drag').to.be.true;
1133
686
 
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}));
687
+ canvas.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true}));
1140
688
  await el.updateComplete;
1141
-
1142
- expect(el.state.sections[0].layout?.grow, 'toggle flips grow in state').to.be.true;
689
+ expect(el.state.sections).to.have.length(1);
690
+ expect(el.state.sections[0].type).to.equal('hero');
1143
691
  });
1144
692
  });