import { defineCE, expect, fixture, fixtureSync, unsafeStatic, html } from '@open-wc/testing'; import { SlotMixin } from '@lion/ui/core.js'; import { LitElement } from 'lit'; import sinon from 'sinon'; import { ScopedElementsMixin, supportsScopedRegistry } from '../src/ScopedElementsMixin.js'; import { isActiveElement } from '../test-helpers/isActiveElement.js'; /** * @typedef {import('../types/SlotMixinTypes.js').SlotHost} SlotHost */ describe('SlotMixin', () => { it('inserts provided element into light dom and sets slot', async () => { const tag = defineCE( class extends SlotMixin(LitElement) { get slots() { return { ...super.slots, feedback: () => document.createElement('div'), }; } }, ); const el = await fixture(`<${tag}>`); expect(el.children[0].slot).to.equal('feedback'); }); it("supports default slot with ''", async () => { const tag = defineCE( class extends SlotMixin(LitElement) { get slots() { return { ...super.slots, '': () => document.createElement('div'), }; } }, ); const el = await fixture(`<${tag}>`); expect(el.children[0].slot).to.equal(''); expect(el.children[0]).dom.to.equal('
'); }); it('supports default slot in conjunction with named slots', async () => { const tag = defineCE( class extends SlotMixin(LitElement) { get slots() { return { ...super.slots, foo: () => document.createElement('a'), '': () => document.createElement('div'), }; } }, ); const el = await fixture(`<${tag}>`); expect(el.children[0].slot).to.equal('foo'); expect(el.children[1].slot).to.equal(''); expect(el.children[0]).dom.to.equal(''); expect(el.children[1]).dom.to.equal('
'); }); it('does not override user provided slots', async () => { const shouldReturn = false; const tag = defineCE( class extends SlotMixin(LitElement) { get slots() { return { ...super.slots, feedback: () => document.createElement('div'), 'more-feedback': () => { if (shouldReturn) { return document.createElement('div'); } return undefined; }, 'even-more-feedback': () => document.createElement('div'), }; } }, ); const el = await fixture(`<${tag}>

user-content

`); expect(el.children[0].tagName).to.equal('P'); expect(/** @type HTMLParagraphElement */ (el.children[0]).innerText).to.equal('user-content'); expect(el.children[1].tagName).to.equal('DIV'); expect(/** @type HTMLParagraphElement */ (el.children[1]).slot).to.equal('even-more-feedback'); }); it('does add when user provided slots are not direct children', async () => { const tag = defineCE( class extends SlotMixin(LitElement) { get slots() { return { ...super.slots, content: () => document.createElement('div'), }; } }, ); const el = await fixture(`<${tag}>

user-content

`); const slot = /** @type HTMLDivElement */ ( Array.from(el.children).find(elm => elm.slot === 'content') ); expect(slot.tagName).to.equal('DIV'); expect(slot.innerText).to.equal(''); }); it("allows to check which slots have been created via this._isPrivateSlot('slotname')", async () => { let renderSlot = true; class SlotPrivateText extends SlotMixin(LitElement) { get slots() { return { ...super.slots, conditional: () => (renderSlot ? document.createElement('div') : undefined), }; } didCreateConditionalSlot() { return this._isPrivateSlot('conditional'); } } const tag = defineCE(SlotPrivateText); const el = /** @type {SlotPrivateText} */ (await fixture(`<${tag}><${tag}>`)); expect(el.didCreateConditionalSlot()).to.be.true; const elUserSlot = /** @type {SlotPrivateText} */ ( await fixture(`<${tag}>

foo

<${tag}>`) ); expect(elUserSlot.didCreateConditionalSlot()).to.be.false; renderSlot = false; const elNoSlot = /** @type {SlotPrivateText} */ (await fixture(`<${tag}><${tag}>`)); expect(elNoSlot.didCreateConditionalSlot()).to.be.false; }); describe('Rerender', () => { it('supports rerender when SlotRerenderObject provided', async () => { const tag = defineCE( // @ts-expect-error class extends SlotMixin(LitElement) { static properties = { currentValue: Number }; constructor() { super(); this.currentValue = 0; } get slots() { return { ...super.slots, template: () => ({ template: html`${this.currentValue} ` }), }; } render() { return html``; } get _templateNode() { return /** @type HTMLSpanElement */ ( Array.from(this.children).find(elm => elm.slot === 'template') ); } }, ); const el = /** @type {* & SlotHost} */ (await fixture(`<${tag}>`)); await el.updateComplete; expect(el._templateNode.slot).to.equal('template'); expect(el._templateNode.textContent?.trim()).to.equal('0'); el.currentValue = 1; await el.updateComplete; expect(el._templateNode.textContent?.trim()).to.equal('1'); }); it('keeps focus after rerender', async () => { const tag = defineCE( // @ts-expect-error class extends SlotMixin(LitElement) { static properties = { currentValue: Number }; constructor() { super(); this.currentValue = 0; } get slots() { return { ...super.slots, 'focusable-node': () => ({ template: html` `, renderAsDirectHostChild: false, }), }; } render() { return html``; } get _focusableNode() { return /** @type HTMLSpanElement */ ( Array.from(this.children).find(elm => elm.slot === 'focusable-node') ?.firstElementChild ); } }, ); const el = /** @type {* & SlotHost} */ (await fixture(`<${tag}>`)); el._focusableNode.focus(); expect(isActiveElement(el._focusableNode)).to.be.true; el.currentValue = 1; await el.updateComplete; expect(isActiveElement(el._focusableNode)).to.be.true; }); it('keeps focus after rerendering complex shadow root into slot', async () => { const complexSlotTagName = defineCE( class extends LitElement { render() { return html` `; } get _buttonNode() { // @ts-expect-error return this.shadowRoot.querySelector('button'); } }, ); const complexSlotTag = unsafeStatic(complexSlotTagName); const tagName = defineCE( // @ts-expect-error class extends SlotMixin(LitElement) { static properties = { currentValue: Number }; constructor() { super(); this.currentValue = 0; } get slots() { return { ...super.slots, 'focusable-node': () => ({ template: html`<${complexSlotTag}> `, }), }; } render() { return html``; } get _focusableNode() { return /** @type HTMLSpanElement */ ( Array.from(this.children).find(elm => elm.slot === 'focusable-node') ?.firstElementChild ); } }, ); const el = /** @type {* & SlotHost} */ (await fixture(`<${tagName}>`)); el._focusableNode._buttonNode.focus(); expect(isActiveElement(el._focusableNode._buttonNode, { deep: true })).to.be.true; el.currentValue = 1; await el.updateComplete; expect(isActiveElement(el._focusableNode)).to.be.true; expect(isActiveElement(el._focusableNode._buttonNode, { deep: true })).to.be.true; }); it('allows for rerendering complex shadow root into slot as a direct child', async () => { const complexSlotTagName = defineCE( class extends LitElement { render() { return html` `; } get _buttonNode() { // @ts-expect-error return this.shadowRoot.querySelector('button'); } }, ); const complexSlotTag = unsafeStatic(complexSlotTagName); const tagName = defineCE( // @ts-expect-error class extends SlotMixin(LitElement) { static properties = { currentValue: Number }; constructor() { super(); this.currentValue = 0; } get slots() { return { ...super.slots, 'focusable-node': () => ({ template: html`<${complexSlotTag}> `, renderAsDirectHostChild: true, }), }; } render() { return html``; } get _focusableNode() { return /** @type HTMLSpanElement */ ( Array.from(this.children).find(elm => elm.slot === 'focusable-node') ); } }, ); const el = /** @type {* & SlotHost} */ (await fixture(`<${tagName}>`)); el._focusableNode._buttonNode.focus(); expect(isActiveElement(el._focusableNode._buttonNode, { deep: true })).to.be.true; el.currentValue = 1; await el.updateComplete; expect(isActiveElement(el._focusableNode)).to.be.true; expect(isActiveElement(el._focusableNode._buttonNode, { deep: true })).to.be.true; }); describe('firstRenderOnConnected (for backwards compatibility)', () => { it('does render on connected when firstRenderOnConnected:true', async () => { // Start with elem that does not render on connectedCallback const tag = defineCE( // @ts-expect-error class extends SlotMixin(LitElement) { static properties = { currentValue: Number }; constructor() { super(); this.currentValue = 0; } get slots() { return { ...super.slots, template: () => ({ firstRenderOnConnected: true, template: html`${this.currentValue} `, }), }; } render() { return html``; } get _templateNode() { return /** @type HTMLSpanElement */ ( Array.from(this.children).find(elm => elm.slot === 'template') ); } }, ); const el = /** @type {* & SlotHost} */ (fixtureSync(`<${tag}>`)); expect(el._templateNode.slot).to.equal('template'); expect(el._templateNode.textContent?.trim()).to.equal('0'); }); it('does not render on connected when firstRenderOnConnected:false', async () => { // Start with elem that does not render on connectedCallback const tag = defineCE( // @ts-expect-error class extends SlotMixin(LitElement) { static properties = { currentValue: Number }; constructor() { super(); this.currentValue = 0; } get slots() { return { ...super.slots, template: () => ({ template: html`${this.currentValue} ` }), }; } render() { return html``; } get _templateNode() { return /** @type HTMLSpanElement */ ( Array.from(this.children).find(elm => elm.slot === 'template') ); } }, ); const el = /** @type {* & SlotHost} */ (fixtureSync(`<${tag}>`)); expect(el._templateNode).to.be.undefined; await el.updateComplete; expect(el._templateNode.slot).to.equal('template'); expect(el._templateNode.textContent?.trim()).to.equal('0'); }); }); }); describe('SlotFunctionResult types', () => { it('supports complex dom trees as element (type "Element")', async () => { const tag = defineCE( class extends SlotMixin(LitElement) { constructor() { super(); this.foo = 'bar'; } get slots() { return { ...super.slots, feedback: () => { const el = document.createElement('div'); el.setAttribute('foo', this.foo); const subEl = document.createElement('p'); subEl.innerText = 'cat'; el.appendChild(subEl); return el; }, }; } }, ); const el = await fixture(`<${tag}>`); expect(el.children[0].slot).to.equal('feedback'); expect(el.children[0].getAttribute('foo')).to.equal('bar'); expect(/** @type HTMLParagraphElement */ (el.children[0].children[0]).innerText).to.equal( 'cat', ); }); it('supports conditional slots (type "undefined")', async () => { let renderSlot = true; const tag = defineCE( class extends SlotMixin(LitElement) { get slots() { return { ...super.slots, conditional: () => { if (renderSlot) { const el = document.createElement('div'); el.id = 'someSlot'; return el; } return undefined; }, }; } }, ); const elSlot = await fixture(`<${tag}><${tag}>`); expect(elSlot.querySelector('#someSlot')).to.exist; renderSlot = false; const elNoSlot = await fixture(`<${tag}><${tag}>`); expect(elNoSlot.querySelector('#someSlot')).to.not.exist; }); it('supports templates (type "TemplateResult")', async () => { const tag = defineCE( class extends SlotMixin(LitElement) { get slots() { return { ...super.slots, template: () => html`text`, }; } render() { return html``; } }, ); const el = await fixture(`<${tag}>`); const slot = /** @type HTMLSpanElement */ ( Array.from(el.children).find(elm => elm.slot === 'template') ); expect(slot.slot).to.equal('template'); expect(slot.tagName).to.equal('SPAN'); }); it('supports (deprecated) afterRender logic (type "{ template:TemplateResults; afterRender: Function}" )', async () => { let varThatProvesAfterRenderIsCalled = 'not called'; const tag = defineCE( // @ts-expect-error class extends SlotMixin(LitElement) { static properties = { currentValue: Number }; constructor() { super(); this.currentValue = 0; } get slots() { return { ...super.slots, template: () => ({ template: html`${this.currentValue}, `, afterRender: () => { varThatProvesAfterRenderIsCalled = 'called'; }, }), }; } render() { return html``; } get _templateNode() { return /** @type HTMLSpanElement */ ( Array.from(this.children).find(elm => elm.slot === 'template')?.firstElementChild ); } }, ); const el = /** @type {* & SlotHost} */ (await fixture(`<${tag}>`)); expect(el._templateNode.textContent).to.equal('0'); el.currentValue = 1; await el.updateComplete; expect(varThatProvesAfterRenderIsCalled).to.equal('called'); expect(el._templateNode.textContent).to.equal('1'); }); }); describe('Scoped Registries', () => { it('supports scoped elements when scoped registries supported (or polyfill loaded)', async () => { if (!supportsScopedRegistry()) return; // @ts-expect-error const createElSpy = sinon.spy(ShadowRoot.prototype, 'createElement'); class ScopedEl extends LitElement {} const tagName = defineCE( // @ts-ignore class extends ScopedElementsMixin(SlotMixin(LitElement)) { static get scopedElements() { return { // @ts-expect-error ...super.scopedElements, 'scoped-elm': ScopedEl, }; } get slots() { return { ...super.slots, template: () => html``, }; } render() { return html``; } }, ); const tag = unsafeStatic(tagName); await fixture(html`<${tag}>`); expect(createElSpy.callCount).to.equal(1); createElSpy.restore(); }); it('does not scope elements when scoped registries not supported (or polyfill not loaded)', async () => { if (supportsScopedRegistry()) return; class ScopedEl extends LitElement {} const tagName = defineCE( class extends ScopedElementsMixin(SlotMixin(LitElement)) { static get scopedElements() { return { // @ts-expect-error ...super.scopedElements, 'scoped-el': ScopedEl, }; } get slots() { return { ...super.slots, template: () => html``, }; } render() { return html``; } }, ); const renderTarget = document.createElement('div'); const el = document.createElement(tagName); // We don't use fixture, so we limit the amount of calls to document.createElement const docSpy = sinon.spy(document, 'createElement'); document.body.appendChild(renderTarget); renderTarget.appendChild(el); expect(docSpy.callCount).to.equal(2); document.body.removeChild(renderTarget); docSpy.restore(); }); }); });