import '../../../dist/zn.min.js'; import {expect, fixture, html} from '@open-wc/testing'; import type {PageState} from './page.types'; import type ZnPageBuilder from './page-builder.component'; describe('', () => { it('should render the shell', async () => { const el = await fixture(html` `); expect(el.shadowRoot?.querySelector('[part="base"]')).to.exist; expect(el.shadowRoot?.querySelector('[part="palette"]')).to.exist; expect(el.shadowRoot?.querySelector('[part="canvas"]')).to.exist; }); it('should fill the height it is given inside a growing panel', async () => { const parent = await fixture(html`
`); const el = parent.querySelector('zn-page-builder')!; await el.updateComplete; expect(el.getBoundingClientRect().height).to.equal(700); }); it('should build the palette from slotted config templates', async () => { const el = await fixture(html` `); await el.updateComplete; const items = el.shadowRoot?.querySelectorAll('zn-page-palette-item'); expect(items?.length).to.equal(2); expect(items?.[0].getAttribute('label')).to.equal('Hero'); const categories = [...(el.shadowRoot?.querySelectorAll('zn-collapsible.palette__category') ?? [])] .map(c => c.getAttribute('caption')); expect(categories).to.deep.equal(['Headers', 'Content']); }); it('should tuck the palette away via the canvas-edge chevron', async () => { const el = await fixture(html` `); await el.updateComplete; const toggle = el.shadowRoot?.querySelector('.canvas-cell .panel-toggle--left'); expect(toggle, 'edge chevron').to.exist; toggle!.click(); await el.updateComplete; expect(el.paletteCollapsed).to.be.true; expect(el.shadowRoot?.querySelector('.builder--palette-collapsed')).to.exist; expect(toggle!.classList.contains('panel-toggle--tucked'), 'chevron tucks into the canvas').to.be.true; toggle!.click(); await el.updateComplete; expect(el.paletteCollapsed).to.be.false; expect(el.shadowRoot?.querySelector('.builder--palette-collapsed')).to.not.exist; }); it('should offer and restore an auto-saved draft', async () => { const draft = {savedAt: Date.now(), state: {sections: [{id: 'd1', type: 'hero', data: {title: 'Draft'}}]}}; localStorage.setItem('zn-page-builder:pb-autosave-test', JSON.stringify(draft)); try { const el = await fixture(html` `); await el.updateComplete; // The loaded (empty) page differs from the fresh draft — banner offered. expect(el.shadowRoot?.querySelector('.restore-banner'), 'restore banner').to.exist; expect(el.restoreAutoSave()).to.be.true; await el.updateComplete; expect(el.state.sections[0]?.id).to.equal('d1'); expect(el.shadowRoot?.querySelector('.restore-banner')).to.not.exist; } finally { localStorage.removeItem('zn-page-builder:pb-autosave-test'); } }); it('should round-trip state through the config attribute and state property', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.state.sections).to.have.length(1); expect(el.state.sections[0].id).to.equal('s1'); expect(el.shadowRoot?.querySelectorAll('zn-page-section-card')).to.have.length(1); }); it('should add a section via addSection and emit zn-page-change', async () => { const el = await fixture(html` `); await el.updateComplete; let detail: {state: {sections: unknown[]}} | undefined; el.addEventListener('zn-page-change', e => (detail = (e as CustomEvent<{state: {sections: unknown[]}}>).detail)); const section = el.addSection('hero'); await el.updateComplete; expect(section?.type).to.equal('hero'); expect(detail?.state.sections).to.have.length(1); expect(el.shadowRoot?.querySelectorAll('zn-page-section-card')).to.have.length(1); }); it('should render unknown section types as unknown cards without crashing', async () => { const el = await fixture(html` `); await el.updateComplete; const card = el.shadowRoot?.querySelector('zn-page-section-card'); expect(card).to.exist; expect(card?.hasAttribute('unknown')).to.be.true; expect(el.state.sections[0].type).to.equal('gone'); // preserved through save }); it('should summarise a card with the chosen option label, not the stored value', async () => { const el = await fixture(html` `); await el.updateComplete; const card = el.shadowRoot?.querySelector('zn-page-section-card'); expect(card?.getAttribute('summary')).to.equal('Getting Started'); }); it('should fall back to the first string value when nothing matches an option', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.shadowRoot?.querySelector('zn-page-section-card')?.getAttribute('summary')).to.equal('Welcome'); }); it('should round-trip section data through the inspector name binding', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const input = el.shadowRoot?.querySelector('.inspector__form input[name="title"]'); expect(input, 'stamped input').to.exist; expect(input!.value).to.equal('Before'); // prefilled from data input!.value = 'After'; input!.dispatchEvent(new Event('change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].data.title).to.equal('After'); }); it('should write back a boolean control value from the inspector', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const checkbox = el.shadowRoot?.querySelector('.inspector__form input[name="showSearch"]'); expect(checkbox, 'stamped checkbox').to.exist; expect(checkbox!.checked).to.be.false; checkbox!.checked = true; checkbox!.dispatchEvent(new Event('change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].data.showSearch).to.equal(true); }); it('should coerce a number control value from the inspector', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const input = el.shadowRoot?.querySelector('.inspector__form input[name="limit"]'); expect(input, 'stamped input').to.exist; input!.value = '5'; input!.dispatchEvent(new Event('change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].data.limit).to.equal(5); }); it('should round-trip a multi-select array value through the inspector', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const select = el.shadowRoot?.querySelector('.inspector__form zn-select[name="categories"]') as HTMLElement & { value: string | string[] }; expect(select, 'stamped select').to.exist; expect(select.value).to.deep.equal(['billing']); // array prefilled, not stringified select.value = ['billing', 'setup']; select.dispatchEvent(new CustomEvent('zn-change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].data.categories).to.deep.equal(['billing', 'setup']); }); // zn-icon-picker keeps `value` as a plain accessor over its reactive `icon`, // so it only prefills if the stamped element is upgraded before assignment — // otherwise the assignment shadows the setter and the picker renders empty. it('should prefill a control whose value is a plain accessor', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const picker = el.shadowRoot!.querySelector( '.inspector__form zn-icon-picker[name="icon"]')!; expect(picker, 'stamped icon picker').to.exist; expect(picker.value).to.equal('receipt'); expect(picker.icon, 'reached the reactive property, not an own shadowing one').to.equal('receipt'); picker.value = 'payments'; picker.dispatchEvent(new CustomEvent('zn-change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].data.icon).to.equal('payments'); }); it('should rename a section from the inspector', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const rename = el.shadowRoot?.querySelector('.inspector__rename') as HTMLElement & { value: string }; rename.value = 'My Hero'; rename.dispatchEvent(new CustomEvent('zn-change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].label).to.equal('My Hero'); }); it('should head the inspector with the section name and its type', async () => { const el = await fixture(html` `); await el.updateComplete; const cards = el.shadowRoot?.querySelectorAll('zn-page-section-card'); cards?.[0].dispatchEvent(new Event('click')); await el.updateComplete; const head = el.shadowRoot?.querySelector('[part="inspector-header"]'); expect(head, 'inspector header').to.exist; expect(head?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Top banner'); expect(head?.querySelector('.inspector-head__type')?.textContent?.trim()).to.equal('Hero'); expect(head?.querySelector('zn-icon')?.getAttribute('src')).to.equal('star'); expect(el.shadowRoot?.querySelector('[part="inspector-body"] .inspector__rename'), 'rename in body').to.exist; // An unnamed section's title already is the type label — don't print it twice. cards?.[1].dispatchEvent(new Event('click')); await el.updateComplete; const unnamed = el.shadowRoot?.querySelector('[part="inspector-header"]'); expect(unnamed?.querySelector('.inspector-head__title')?.textContent?.trim()).to.equal('Hero'); expect(unnamed?.querySelector('.inspector-head__type'), 'no duplicate type line').to.not.exist; }); it('should resize the inspector by dragging its edge handle', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const builder = el.shadowRoot!.querySelector('.builder')!; const handle = el.shadowRoot!.querySelector('.inspector-resize'); expect(handle, 'resize handle').to.exist; const before = el.shadowRoot!.querySelector('.inspector')!.getBoundingClientRect().width; handle!.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, clientX: 800, pointerId: 1})); window.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, clientX: 600, pointerId: 1})); window.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, pointerId: 1})); await el.updateComplete; expect(builder.style.getPropertyValue('--inspector-col')).to.equal(`${Math.round(before + 200)}px`); }); it('should hold the dragged inspector above its minimum width', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const builder = el.shadowRoot!.querySelector('.builder')!; const handle = el.shadowRoot!.querySelector('.inspector-resize')!; handle.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, clientX: 800, pointerId: 1})); window.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, clientX: 5000, pointerId: 1})); window.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, pointerId: 1})); await el.updateComplete; expect(parseInt(builder.style.getPropertyValue('--inspector-col'), 10)).to.equal(300); }); it('should resize the inspector from the keyboard', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const builder = el.shadowRoot!.querySelector('.builder')!; const handle = el.shadowRoot!.querySelector('.inspector-resize')!; expect(handle.getAttribute('role')).to.equal('separator'); handle.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowLeft', bubbles: true})); await el.updateComplete; expect(parseInt(builder.style.getPropertyValue('--inspector-col'), 10)).to.equal(363); handle.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true})); await el.updateComplete; expect(parseInt(builder.style.getPropertyValue('--inspector-col'), 10)).to.equal(343); }); it('should still tuck a resized inspector fully away', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const handle = el.shadowRoot!.querySelector('.inspector-resize')!; handle.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, clientX: 800, pointerId: 1})); window.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, clientX: 600, pointerId: 1})); window.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, pointerId: 1})); await el.updateComplete; const builder = el.shadowRoot!.querySelector('.builder')!; expect(parseInt(builder.style.getPropertyValue('--inspector-col'), 10), 'resized').to.be.greaterThan(343); // An inline width outranks the collapsed rule's 0px, so it has to step aside. el.inspectorCollapsed = true; await el.updateComplete; expect(getComputedStyle(builder).getPropertyValue('--inspector-col').trim()).to.equal('0px'); }); const richBuilder = (height: number) => fixture(html` `); it('should widen the inspector by default for a remarkd editor', async () => { const el = await richBuilder(600); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const builder = el.shadowRoot!.querySelector('.builder')!; expect(builder.classList.contains('builder--inspector-wide'), 'wide').to.be.true; expect(getComputedStyle(builder).getPropertyValue('--inspector-col').trim()).to.equal('520px'); }); it('should leave the inspector at its usual width without a remarkd editor', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const builder = el.shadowRoot!.querySelector('.builder')!; expect(builder.classList.contains('builder--inspector-wide')).to.be.false; expect(getComputedStyle(builder).getPropertyValue('--inspector-col').trim()).to.equal('343px'); }); it('should prefill translations stamped inside a translation group', async () => { const state = JSON.stringify({ sections: [{ id: 's1', type: 'tile', data: { title: JSON.stringify({en: 'Billing'}), subtitle: JSON.stringify({en: 'How to pay'}) } }] }); const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const fields = [...el.shadowRoot!.querySelectorAll; languages: Record; grouped: boolean; updateComplete: Promise; }>('.inspector__form zn-translations')]; expect(fields.length, 'both fields stamped').to.equal(2); await Promise.all(fields.map(f => f.updateComplete)); expect(fields[0].values.en).to.equal('Billing'); expect(fields[1].values.en).to.equal('How to pay'); // The group owns the select and the language list, so the children drop theirs. fields.forEach(field => { expect(field.grouped, 'grouped').to.be.true; expect(field.languages).to.have.property('fr'); expect(field.shadowRoot!.querySelector('[part="language-select"]'), 'child select').to.not.exist; }); expect(el.shadowRoot!.querySelectorAll('.inspector__form [part="language-select"]').length).to.equal(0); }); it('should not strand the group language select above its fields', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const group = el.shadowRoot!.querySelector }>( '.inspector__form zn-translation-group')!; await group.updateComplete; const select = group.shadowRoot!.querySelector('[part="language-field"]')!; const first = group.querySelector('zn-translations')!; const gap = first.getBoundingClientRect().top - select.getBoundingClientRect().bottom; expect(gap, 'gap under the language select').to.be.at.most(24); }); it('should clear the selection from the inspector close button', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; expect(el.shadowRoot?.querySelector('[part="inspector"]')).to.exist; el.shadowRoot?.querySelector('.inspector-close')?.click(); await el.updateComplete; expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'inspector closes').to.not.exist; }); it('should hint when the selected type has no settings', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; expect(el.shadowRoot?.querySelector('.inspector-hint')).to.exist; }); it('should lay stamped toggles out as a row, honouring a host-set position', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const form = el.shadowRoot?.querySelector('.inspector__form'); expect(form?.querySelector('zn-toggle[name="showSearch"]')?.getAttribute('label-position')).to.equal('left'); expect(form?.querySelector('zn-toggle[name="showNav"]')?.getAttribute('label-position')).to.equal('top'); }); it('should undo and redo a section add', async () => { const el = await fixture(html` `); await el.updateComplete; el.addSection('hero'); expect(el.state.sections).to.have.length(1); el.undo(); expect(el.state.sections).to.have.length(0); el.redo(); expect(el.state.sections).to.have.length(1); expect(el.state.sections[0].type).to.equal('hero'); }); it('should treat undo as a no-op with empty history', async () => { const el = await fixture(html` `); expect(() => el.undo()).to.not.throw(); expect(el.state.sections).to.have.length(0); }); it('should be accessible', async () => { const el = await fixture(html` `); await expect(el).to.be.accessible(); }); it('should render a slot grid for container sections and fill slots via addSectionToSlot', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(6); expect(el.addSectionToSlot('hero', 'grid', 0), 'not in accepts').to.be.null; expect(el.addSectionToSlot('article-grid', 'grid', 0), 'no nested containers').to.be.null; const child = el.addSectionToSlot('article-tile', 'grid', 1); await el.updateComplete; expect(child?.type).to.equal('article-tile'); expect(el.state.sections[0].children?.[1]?.id).to.equal(child?.id); expect(el.shadowRoot?.querySelectorAll('.slot--empty')).to.have.length(5); expect(el.shadowRoot?.querySelectorAll('.slots zn-page-section-card')).to.have.length(1); expect(el.addSectionToSlot('article-tile', 'grid', 1), 'occupied slot').to.be.null; }); it('should grow a container by a row as its last row fills', async () => { const el = await fixture(html` `); await el.updateComplete; const slots = () => el.shadowRoot?.querySelectorAll('.slot, .slot__card').length; expect(slots(), 'one empty row to start').to.equal(3); el.addSectionToSlot('article-tile', 'grid', 0); el.addSectionToSlot('article-tile', 'grid', 1); await el.updateComplete; expect(slots(), 'a part-filled row grows nothing').to.equal(3); el.addSectionToSlot('article-tile', 'grid', 2); await el.updateComplete; expect(slots(), 'the full row adds the next one').to.equal(6); el.addSectionToSlot('article-tile', 'grid', 3); await el.updateComplete; expect(slots(), 'and stays at two rows until that one fills').to.equal(6); }); it('should lay a container out at its own width and reflow children when it changes', async () => { const el = await fixture(html` `); await el.updateComplete; const grid = () => el.shadowRoot?.querySelector('.slots'); expect(grid()?.style.getPropertyValue('--pb-slot-columns'), 'declared width').to.equal('6'); expect(el.shadowRoot?.querySelectorAll('.slot--empty'), 'one row of six').to.have.length(6); el.shadowRoot?.querySelector('.canvas zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const width = el.shadowRoot?.querySelector('.inspector__slots'); expect(width, 'slots-per-row control').to.exist; expect(width!.getAttribute('min')).to.equal('1'); expect(width!.getAttribute('max')).to.equal('6'); expect(width!.value).to.equal('6'); const kept = el.addSectionToSlot('category-tile', 'row', 4)!; await el.updateComplete; width!.value = '3'; width!.dispatchEvent(new Event('zn-change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].columns).to.equal(3); expect(grid()?.style.getPropertyValue('--pb-slot-columns')).to.equal('3'); expect(el.state.sections[0].children?.[4]?.id, 'narrowing reflows rather than drops').to.equal(kept.id); expect(el.shadowRoot?.querySelectorAll('.slot, .slot__card'), 'six slots hold it as two rows of three') .to.have.length(6); }); it('should snap an out-of-range width back on every entry and state the range', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('.canvas zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const width = el.shadowRoot!.querySelector('.inspector__slots')!; expect(width.getAttribute('help-text'), 'the control names its range').to.contain('1 and 4'); const enter = async (value: string) => { width.value = value; width.dispatchEvent(new Event('zn-change', {bubbles: true})); await el.updateComplete; }; await enter('9'); expect(el.state.sections[0].columns, 'clamped to the max').to.equal(4); expect(width.value, 'and shown clamped').to.equal('4'); await enter('9'); expect(width.value, 'a second over-max entry snaps back too').to.equal('4'); expect(el.state.sections[0].columns).to.equal(4); await enter('0'); expect(el.state.sections[0].columns, 'clamped to the min').to.equal(1); expect(width.value).to.equal('1'); }); it('should clamp a container width to the declared bounds', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.state.sections[0].columns, 'clamped to the max').to.equal(6); el.setSectionColumns('row', 0); await el.updateComplete; expect(el.state.sections[0].columns, 'clamped to the min').to.equal(1); }); it('should offer no width control for a fixed-width container', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.shadowRoot?.querySelectorAll('.slot--empty'), 'per-instance width ignored').to.have.length(4); el.shadowRoot?.querySelector('.canvas zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; expect(el.shadowRoot?.querySelector('.inspector__slots')).to.not.exist; }); it('should swap children when dropping one filled slot onto another', async () => { const el = await fixture(html` `); await el.updateComplete; const a = el.addSectionToSlot('article-tile', 'grid', 0)!; const b = el.addSectionToSlot('article-tile', 'grid', 1)!; await el.updateComplete; // Drag child A onto child B's card (slot 1) — they swap places. const cards = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card'); const dataTransfer = new DataTransfer(); dataTransfer.setData('application/x-zn-page-section', a.id); cards[1].dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true})); await el.updateComplete; expect(el.state.sections[0].children?.[0]?.id).to.equal(b.id); expect(el.state.sections[0].children?.[1]?.id).to.equal(a.id); }); it('should edit a slotted child through the inspector', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; const input = el.shadowRoot?.querySelector('.inspector__form input[name="title"]'); expect(input, 'stamped input for child').to.exist; expect(input!.value).to.equal('Old'); input!.value = 'New'; input!.dispatchEvent(new Event('change', {bubbles: true})); await el.updateComplete; expect(el.state.sections[0].children?.[0]?.data.title).to.equal('New'); }); it('should clear selection when removing a container holding the selected child', async () => { const el = await fixture(html` `); await el.updateComplete; let lastSelection: string | null | undefined; el.addEventListener('zn-page-selection-change', e => (lastSelection = (e as CustomEvent<{sectionId: string | null}>).detail.sectionId)); // Select the child, then remove its parent container. el.shadowRoot?.querySelector('.slots zn-page-section-card')?.dispatchEvent(new Event('click')); await el.updateComplete; expect(lastSelection).to.equal('c1'); el.shadowRoot?.querySelector('.container > zn-page-section-card') ?.dispatchEvent(new CustomEvent('page-card-remove')); await el.updateComplete; expect(lastSelection, 'selection cleared').to.be.null; expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'no ghost inspector').to.not.exist; }); it('should move a top-level section into an empty slot but not an occupied one', async () => { const el = await fixture(html` `); await el.updateComplete; const dropOnSlot = (sectionId: string, slotSelector: string) => { const dataTransfer = new DataTransfer(); dataTransfer.setData('application/x-zn-page-section', sectionId); el.shadowRoot!.querySelector(slotSelector)! .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true})); }; dropOnSlot('t1', '.slot--empty'); // into first empty slot await el.updateComplete; expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']); expect(el.state.sections[0].children?.[0]?.id).to.equal('t1'); dropOnSlot('t2', '.slots zn-page-section-card'); // onto the occupied slot — rejected await el.updateComplete; expect(el.state.sections.map(s => s.id)).to.deep.equal(['grid', 't2']); expect(el.state.sections[0].children?.[0]?.id).to.equal('t1'); }); it('should swap children across different containers', async () => { const el = await fixture(html` `); await el.updateComplete; // Drag a1 (in g1) onto b1's card (slot 0 of g2) — they trade containers. const dataTransfer = new DataTransfer(); dataTransfer.setData('application/x-zn-page-section', 'a1'); const g2Card = el.shadowRoot!.querySelectorAll('.slots zn-page-section-card')[1]; g2Card.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true})); await el.updateComplete; expect(el.state.sections[0].children?.[0]?.id).to.equal('b1'); expect(el.state.sections[1].children?.[0]?.id).to.equal('a1'); }); it('should undo a slot mutation and duplicate a child into the next empty slot', async () => { const el = await fixture(html` `); await el.updateComplete; const child = el.addSectionToSlot('article-tile', 'grid', 0)!; el.undo(); expect(el.state.sections[0].children?.[0] ?? null, 'undo empties the slot').to.be.null; el.redo(); expect(el.state.sections[0].children?.[0]?.id).to.equal(child.id); // Duplicate the child: the copy takes the next empty slot with a fresh id. await el.updateComplete; el.shadowRoot?.querySelector('.slots zn-page-section-card') ?.dispatchEvent(new CustomEvent('page-card-duplicate')); await el.updateComplete; const children = el.state.sections[0].children ?? []; expect(children[1]?.type).to.equal('article-tile'); expect(children[1]?.id).to.not.equal(children[0]?.id); }); it('should select a card with Enter and delete a child with Delete via keyboard', async () => { const el = await fixture(html` `); await el.updateComplete; const childCard = el.shadowRoot!.querySelector('.slots zn-page-section-card')!; childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await el.updateComplete; expect(el.shadowRoot?.querySelector('[part="inspector"]'), 'Enter selects').to.exist; childCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true})); await el.updateComplete; expect(el.state.sections[0].children?.[0] ?? null, 'Delete removes the child').to.be.null; }); it('should pin a required-first section, inserting one when the page has none', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.state.sections.map(s => s.type), 'inserted into an empty page').to.deep.equal(['hero']); // The submitted value carries the pinned section, so a save can't miss it. expect((JSON.parse(el.value) as PageState).sections).to.have.length(1); const card = el.shadowRoot!.querySelector('zn-page-section-card')!; expect(card.hasAttribute('locked'), 'card is locked').to.be.true; expect(card.getAttribute('draggable'), 'card is not draggable').to.equal('false'); // No "+" strip above it — the first drop zone follows the pinned card. expect(el.shadowRoot!.querySelector('.canvas > *')?.tagName.toLowerCase()).to.equal('zn-page-section-card'); }); it('should hoist an existing required-first section to the top of the page', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.state.sections.map(s => s.id)).to.deep.equal(['h', 't']); expect(el.state.sections[0].data, 'hoisted with its data intact').to.deep.equal({title: 'Help'}); }); it('should refuse to remove, reorder or displace the pinned section', async () => { const el = await fixture(html` `); await el.updateComplete; const pinnedCard = el.shadowRoot!.querySelector('zn-page-section-card')!; expect(pinnedCard.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'no remove action').to.not.exist; pinnedCard.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true})); pinnedCard.dispatchEvent(new KeyboardEvent('keydown', {key: 'Delete', bubbles: true, cancelable: true})); await el.updateComplete; expect(el.state.sections.map(s => s.id), 'survives remove and Delete').to.deep.equal(['h', 't']); // Dropping another section on the topmost zone lands it below the pinned one. const dataTransfer = new DataTransfer(); dataTransfer.setData('application/x-zn-page-section', 't'); el.shadowRoot!.querySelector('.drop')! .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true})); await el.updateComplete; expect(el.state.sections.map(s => s.id), 'nothing lands above the pinned section').to.deep.equal(['h', 't']); expect(el.addSection('rich-text', 0)).to.not.be.null; expect(el.state.sections[0].id, 'addSection index 0 is clamped').to.equal('h'); }); it('should keep the pinned section out of container slots', async () => { const el = await fixture(html` `); await el.updateComplete; const dataTransfer = new DataTransfer(); dataTransfer.setData('application/x-zn-page-section', 'h'); el.shadowRoot!.querySelector('.slot--empty')! .dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true})); await el.updateComplete; expect(el.state.sections[0].id, 'still leading the page').to.equal('h'); expect(el.state.sections[1].children?.[0] ?? null, 'slot left empty').to.be.null; }); it('should accept any of several required-first types', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.state.sections.map(s => s.id), 'the alternate type is hoisted too').to.deep.equal(['h', 't']); expect(el.shadowRoot!.querySelector('zn-page-section-card')!.hasAttribute('locked')).to.be.true; const empty = await fixture(html` `); await empty.updateComplete; expect(empty.state.sections.map(s => s.type), 'an empty page gets the first type').to.deep.equal(['hero']); }); it('should swap the pinned section for another allowed type, keeping its content', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot!.querySelector('zn-page-section-card')!.dispatchEvent(new Event('click')); await el.updateComplete; const swap = el.shadowRoot!.querySelector('.inspector__swap')!; expect(swap, 'swap control on the pinned section').to.exist; expect([...swap.querySelectorAll('zn-option')].map(o => o.getAttribute('value'))) .to.deep.equal(['hero', 'hero-split']); swap.value = 'hero-split'; swap.dispatchEvent(new CustomEvent('zn-change', {bubbles: true})); await el.updateComplete; const [pinned, rest] = el.state.sections; expect(pinned.type, 'type swapped').to.equal('hero-split'); expect(pinned.id, 'same section').to.equal('h'); expect(pinned.label, 'keeps its name').to.equal('Top'); expect(pinned.data, 'keeps its content').to.deep.equal({title: 'Help', search: true}); expect(rest.id, 'the rest of the page is untouched').to.equal('t'); expect(el.shadowRoot!.querySelector('zn-page-section-card')!.hasAttribute('locked'), 'still pinned').to.be.true; // The inspector now stamps the new type's fields, filled from the kept data. const title = el.shadowRoot!.querySelector('.inspector__form [name="title"]'); expect(title?.value, 'inspector rebuilt for the new type').to.equal('Help'); el.undo(); await el.updateComplete; expect(el.state.sections[0].type, 'undo puts the original type back').to.equal('hero'); }); it('should let the pinned section go once another may lead in its place', async () => { const el = await fixture(html` `); await el.updateComplete; const pinnedCard = el.shadowRoot!.querySelector('zn-page-section-card')!; expect(pinnedCard.hasAttribute('locked'), 'a replaceable hero shows no lock').to.be.false; expect(pinnedCard.getAttribute('draggable'), 'but still cannot be dragged').to.equal('false'); expect(pinnedCard.shadowRoot!.querySelector('zn-button[title="Remove section"]'), 'remove offered').to.exist; pinnedCard.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true})); await el.updateComplete; expect(el.state.sections.map(s => s.id), 'the other hero takes the lead').to.deep.equal(['s', 't']); const nowPinned = el.shadowRoot!.querySelector('zn-page-section-card')!; expect(nowPinned.hasAttribute('locked'), 'the last hero locks again').to.be.true; nowPinned.dispatchEvent(new CustomEvent('page-card-remove', {bubbles: true, composed: true})); await el.updateComplete; expect(el.state.sections.map(s => s.id), 'the last hero cannot go').to.deep.equal(['s', 't']); el.undo(); await el.updateComplete; expect(el.state.sections.map(s => s.id), 'undo brings the removed hero back').to.deep.equal(['h', 't', 's']); }); it('should offer no swap control when only one type may lead', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot!.querySelector('zn-page-section-card')!.dispatchEvent(new Event('click')); await el.updateComplete; expect(el.shadowRoot!.querySelector('.inspector__swap')).to.not.exist; }); it('should leave the page alone without required-first', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.state.sections.map(s => s.type)).to.deep.equal(['rich-text']); const card = el.shadowRoot!.querySelector('zn-page-section-card')!; expect(card.hasAttribute('locked')).to.be.false; expect(card.getAttribute('draggable')).to.equal('true'); }); it('should accept a palette drop anywhere on the empty canvas', async () => { const el = await fixture(html` `); await el.updateComplete; const canvas = el.shadowRoot!.querySelector('.canvas')!; const dataTransfer = new DataTransfer(); dataTransfer.setData('application/x-zn-page-type', 'hero'); const over = new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true}); canvas.dispatchEvent(over); expect(over.defaultPrevented, 'canvas accepts the drag').to.be.true; canvas.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true})); await el.updateComplete; expect(el.state.sections).to.have.length(1); expect(el.state.sections[0].type).to.equal('hero'); }); });