),
+ * which can be used as a render target for most
+ * @param {string} options.slotName For the first render, it's best to use slotName
+ */
+ __appendNodesForOneTimeRender({ nodes, renderParent = this, slotName }) {
+ for (const node of nodes) {
+ if (!(node instanceof Node)) {
+ return;
+ }
+ // Here, we add the slot name to the node that is an element
+ // (ignoring helper comment nodes that might be in our nodes array)
+ if (node instanceof Element && slotName && slotName !== '') {
+ node.setAttribute('slot', slotName);
+ }
+ renderParent.appendChild(node);
+ }
+ }
+
+ /**
+ * Here we look what's inside our `get slots`.
+ * Rerenderable slots get scheduled and "one time slots" get rendered once on connected
+ * @param {string[]} slotNames
+ */
+ __initSlots(slotNames) {
+ for (const slotName of slotNames) {
+ if (this.__slotsProvidedByUserOnFirstConnected.has(slotName)) {
+ // eslint-disable-next-line no-continue
+ continue;
+ }
+
+ const slotFunctionResult = this.slots[slotName]();
+ // Allow to conditionally return a slot
+ if (slotFunctionResult === undefined) {
+ return;
+ }
+
+ if (!this.__isConnectedSlotMixin) {
+ this.__privateSlots.add(slotName);
+ }
+
+ if (isTemplateResult(slotFunctionResult)) {
+ const renderTarget = this.__renderTemplateInScopedContext({
+ template: slotFunctionResult,
+ slotName,
+ });
+ const nodes = Array.from(renderTarget.childNodes);
+ this.__appendNodesForOneTimeRender({ nodes, renderParent: this, slotName });
+ } else if (slotFunctionResult instanceof Node) {
+ const nodes = [/** @type {Node} */ (slotFunctionResult)];
+ this.__appendNodesForOneTimeRender({ nodes, renderParent: this, slotName });
+ } else if (isRerenderConfig(slotFunctionResult)) {
+ // Rerenderable slots are scheduled in the "updated loop"
+ this.__slotsThatNeedRerender.add(slotName);
+ } else {
+ throw new Error(
+ 'Please provide a function inside "get slots()" returning TemplateResult | Node | {template:TemplateResult, afterRender?:function} | undefined',
+ );
+ }
+ }
}
/**
* @protected
*/
_connectSlotMixin() {
- if (!this.__isConnectedSlotMixin) {
- Object.keys(this.slots).forEach(slotName => {
- const hasSlottableFromUser =
- slotName === ''
- ? // for default slot (''), we can't use el.slot because polyfill for IE11
- // will do .querySelector('[slot=]') which produces a fatal error
- // therefore we check if there's children that do not have a slot attr
- Array.from(this.children).find(el => !el.hasAttribute('slot'))
- : Array.from(this.children).find(el => el.slot === slotName);
-
- if (!hasSlottableFromUser) {
- const slotContent = this.slots[slotName]();
- /** @type {Node[]} */
- let nodes = [];
-
- if (isTemplateResult(slotContent)) {
- nodes = this.__renderAsNodes(slotContent);
- } else if (!Array.isArray(slotContent)) {
- nodes = [/** @type {Node} */ (slotContent)];
- }
-
- nodes.forEach(node => {
- if (!(node instanceof Node)) {
- return;
- }
- if (node instanceof Element && slotName !== '') {
- node.setAttribute('slot', slotName);
- }
- this.appendChild(node);
- this.__privateSlots.add(slotName);
- });
- }
- });
- this.__isConnectedSlotMixin = true;
+ if (this.__isConnectedSlotMixin) {
+ return;
}
+
+ const allSlots = Object.keys(this.slots);
+
+ for (const slotName of allSlots) {
+ const hasSlottableFromUser =
+ slotName === ''
+ ? // for default slot (''), we can't use el.slot because polyfill for IE11
+ // will do .querySelector('[slot=]') which produces a fatal error
+ // therefore we check if there's children that do not have a slot attr
+ Array.from(this.children).find(el => !el.hasAttribute('slot'))
+ : Array.from(this.children).find(el => el.slot === slotName);
+
+ if (hasSlottableFromUser) {
+ this.__slotsProvidedByUserOnFirstConnected.add(slotName);
+ }
+ }
+
+ this.__initSlots(allSlots);
+ this.__isConnectedSlotMixin = true;
}
/**
diff --git a/packages/ui/components/core/test/SlotMixin.test.js b/packages/ui/components/core/test/SlotMixin.test.js
index dde8499e7..7c1491978 100644
--- a/packages/ui/components/core/test/SlotMixin.test.js
+++ b/packages/ui/components/core/test/SlotMixin.test.js
@@ -4,6 +4,10 @@ import { ScopedElementsMixin } from '@open-wc/scoped-elements';
import { SlotMixin } from '@lion/ui/core.js';
import { LitElement } from 'lit';
+/**
+ * @typedef {import('../types/SlotMixinTypes.js').SlotHost} SlotHost
+ */
+
const mockedRenderTarget = document.createElement('div');
function mockScopedRegistry() {
const outputObj = { createElementCallCount: 0 };
@@ -26,7 +30,7 @@ function unMockScopedRegistry() {
}
describe('SlotMixin', () => {
- it('inserts provided element into lightdom and sets slot', async () => {
+ it('inserts provided element into light dom and sets slot', async () => {
const tag = defineCE(
class extends SlotMixin(LitElement) {
get slots() {
@@ -41,7 +45,7 @@ describe('SlotMixin', () => {
expect(el.children[0].slot).to.equal('feedback');
});
- it("supports unnamed slot with ''", async () => {
+ it("supports default slot with ''", async () => {
const tag = defineCE(
class extends SlotMixin(LitElement) {
get slots() {
@@ -57,7 +61,7 @@ describe('SlotMixin', () => {
expect(el.children[0]).dom.to.equal('
');
});
- it('supports unnamed slot in conjunction with named slots', async () => {
+ it('supports default slot in conjunction with named slots', async () => {
const tag = defineCE(
class extends SlotMixin(LitElement) {
get slots() {
@@ -111,63 +115,6 @@ describe('SlotMixin', () => {
expect(slot.innerText).to.equal('');
});
- it('supports complex dom trees as 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}>${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', 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("allows to check which slots have been created via this._isPrivateSlot('slotname')", async () => {
let renderSlot = true;
class SlotPrivateText extends SlotMixin(LitElement) {
@@ -195,102 +142,358 @@ describe('SlotMixin', () => {
expect(elNoSlot.didCreateConditionalSlot()).to.be.false;
});
- it('supports templates', async () => {
- const tag = defineCE(
- class extends SlotMixin(LitElement) {
- get slots() {
- return {
- ...super.slots,
- template: () => html`
text `,
- };
- }
+ describe('Rerender', () => {
+ it('supports rerender when SlotRerenderObject provided', async () => {
+ const tag = defineCE(
+ // @ts-expect-error
+ class extends SlotMixin(LitElement) {
+ static properties = { currentValue: Number };
- render() {
- return html`
`;
- }
- },
- );
- const el = await fixture(`<${tag}>${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');
+ 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}>${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`
`,
+ }),
+ };
+ }
+
+ 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}>${tag}>`));
+
+ el._focusableNode.focus();
+ expect(document.activeElement).to.equal(el._focusableNode);
+
+ el.currentValue = 1;
+ await el.updateComplete;
+
+ expect(document.activeElement).to.equal(el._focusableNode);
+ });
+
+ it('keeps focus after rerendering complex shadow root into slot', async () => {
+ const complexSlotTagName = defineCE(
+ class extends LitElement {
+ render() {
+ return html`
+
+
I will be focused
+ `;
+ }
+
+ 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}> ${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}>${tagName}>`));
+
+ el._focusableNode._buttonNode.focus();
+
+ expect(el._focusableNode.shadowRoot.activeElement).to.equal(el._focusableNode._buttonNode);
+
+ el.currentValue = 1;
+ await el.updateComplete;
+
+ expect(document.activeElement).to.equal(el._focusableNode);
+ expect(el._focusableNode.shadowRoot.activeElement).to.equal(el._focusableNode._buttonNode);
+ });
});
- it('supports scoped elements when polyfill loaded', async () => {
- const outputObj = mockScopedRegistry();
+ 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';
+ }
- class ScopedEl extends LitElement {}
+ 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}>${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',
+ );
+ });
- const tagName = defineCE(
- class extends ScopedElementsMixin(SlotMixin(LitElement)) {
- static get scopedElements() {
- return {
- // @ts-expect-error
- ...super.scopedElements,
- 'scoped-el': ScopedEl,
- };
- }
+ 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;
+ });
- get slots() {
- return {
- ...super.slots,
- template: () => html`
`,
- };
- }
+ it('supports templates (type "TemplateResult")', async () => {
+ const tag = defineCE(
+ class extends SlotMixin(LitElement) {
+ get slots() {
+ return {
+ ...super.slots,
+ template: () => html`
text `,
+ };
+ }
- render() {
- return html`
`;
- }
- },
- );
+ render() {
+ return html`
`;
+ }
+ },
+ );
+ const el = await fixture(`<${tag}>${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');
+ });
- const tag = unsafeStatic(tagName);
- await fixture(html`<${tag}>${tag}>`);
+ it('supports afterRender logic (type "{ template:TemplateResults; afterRender: Function}" )', async () => {
+ let varThatProvesAfterRenderIsCalled = 'not called';
- expect(outputObj.createElementCallCount).to.equal(1);
+ const tag = defineCE(
+ // @ts-expect-error
+ class extends SlotMixin(LitElement) {
+ static properties = { currentValue: Number };
- unMockScopedRegistry();
+ 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}>${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');
+ });
});
- it('does not scope elements when polyfill not loaded', async () => {
- class ScopedEl extends LitElement {}
+ describe('Scoped Registries', () => {
+ it('supports scoped elements when polyfill loaded', async () => {
+ const outputObj = mockScopedRegistry();
- const tagName = defineCE(
- class extends ScopedElementsMixin(SlotMixin(LitElement)) {
- static get scopedElements() {
- return {
- // @ts-expect-error
- ...super.scopedElements,
- 'scoped-el': ScopedEl,
- };
- }
+ class ScopedEl extends LitElement {}
- get slots() {
- return {
- ...super.slots,
- template: () => html`
`,
- };
- }
+ const tagName = defineCE(
+ class extends ScopedElementsMixin(SlotMixin(LitElement)) {
+ static get scopedElements() {
+ return {
+ // @ts-expect-error
+ ...super.scopedElements,
+ 'scoped-elm': ScopedEl,
+ };
+ }
- render() {
- return html`
`;
- }
- },
- );
+ get slots() {
+ return {
+ ...super.slots,
+ template: () => html`
`,
+ };
+ }
- const renderTarget = document.createElement('div');
- const el = document.createElement(tagName);
+ render() {
+ return html`
`;
+ }
+ },
+ );
- // 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);
+ const tag = unsafeStatic(tagName);
+ await fixture(html`<${tag}>${tag}>`);
- expect(docSpy.callCount).to.equal(2);
+ expect(outputObj.createElementCallCount).to.equal(1);
- document.body.removeChild(renderTarget);
- docSpy.restore();
+ unMockScopedRegistry();
+ });
+
+ it('does not scope elements when polyfill not loaded', async () => {
+ 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();
+ });
});
});
diff --git a/packages/ui/components/core/types/SlotMixinTypes.ts b/packages/ui/components/core/types/SlotMixinTypes.ts
index e61d09f99..ee8a1e47c 100644
--- a/packages/ui/components/core/types/SlotMixinTypes.ts
+++ b/packages/ui/components/core/types/SlotMixinTypes.ts
@@ -1,7 +1,18 @@
import { Constructor } from '@open-wc/dedupe-mixin';
import { TemplateResult, LitElement } from 'lit';
-declare function slotFunction(): HTMLElement | Node[] | TemplateResult | undefined;
+/**
+ * Implicitly creates a wrapper node that allows for rerenders
+ */
+export type SlotRerenderObject = {
+ template: TemplateResult;
+ /* Add logic that will be performed after the render */
+ afterRender?: Function;
+};
+
+export type SlotFunctionResult = TemplateResult | Element | SlotRerenderObject | undefined;
+
+declare function slotFunction(): SlotFunctionResult;
export type SlotsMap = {
[key: string]: typeof slotFunction;