fix: allow rerender in SlotMixin

This commit is contained in:
Thijs Louisse 2022-11-15 18:08:58 +01:00 committed by Thomas Allmer
parent fbf0bd2237
commit 11436fc01c
4 changed files with 620 additions and 182 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[core] SlotMixin: allow rerenders of templates

View file

@ -6,10 +6,103 @@ import { isTemplateResult } from 'lit/directive-helpers.js';
/** /**
* @typedef {import('../types/SlotMixinTypes.js').SlotMixin} SlotMixin * @typedef {import('../types/SlotMixinTypes.js').SlotMixin} SlotMixin
* @typedef {import('../types/SlotMixinTypes.js').SlotsMap} SlotsMap * @typedef {import('../types/SlotMixinTypes.js').SlotsMap} SlotsMap
* @typedef {import('../types/SlotMixinTypes.js').SlotFunctionResult} SlotFunctionResult
* @typedef {import('../types/SlotMixinTypes.js').SlotRerenderObject} SlotRerenderObject
* @typedef {import('lit').LitElement} LitElement * @typedef {import('lit').LitElement} LitElement
*/ */
const isRerenderConfig = (/** @type {SlotFunctionResult} */ o) =>
!Array.isArray(o) && typeof o === 'object' && 'template' in o;
/** /**
* The SlotMixin is made for solving accessibility challenges that inherently come with the usage of shadow dom.
* Until [AOM](https://wicg.github.io/aom/explainer.html) is not in place yet, it is not possible to create relations between different shadow doms.
* The need for this can occur in the following situations:
* 1. a user defined slot
* For instance:
* `<my-input>
* <label slot="label"><></label>
* </my-input>`.
*
* The label here needs to be connected to the input element that may live in shadow dom. The input needs to have `aria-labelledby="label-id".`
*
* 2. an interplay of multiple nested web components
* For instance:
* `<my-fieldset>
* <my-input></my-input>
* <my-input></my-input>
* <div id="msg">Group errror message</div>
* </my-fieldset>`
* In the case above, all inputs need to be able to refer the error message of their parent
* `
* In a nutshell: SlotMixin helps you with everything related to rendering light dom (i.e. rendering to slots).
* So that you can build accessible ui components with ease, while delegating all edge cases to SlotMixin.
* Edge cases that it solves:
* - rendering light dom in context of scoped customElementRegistries: we respect the customElementRegistry bound to your ShadowRoot
* - the concept of rerendering based on property effects
* - easily render lit templates
*
* So what does the api look like? SlotMixin can be used like this:
*
* @example
* ```js
* class AccessibleControl extends SlotMixin(LitElement) {
* get slots() {
* return {
* ...super.slots,
* 'my-public-slot': () => document.createElement('input'),
* '_my-private-slot': () => html`<wc-rendered-to-light-dom></wc-rendered-to-light-dom>`;
* '' => () => html`<div>default slot</div>`,
* };
* }
* }
* ```
*
* ## Private and public slots
* Some elements provide a property/attribute api with a fallback to content projection as a means to provide more advanced html.
* For instance, a simple text label is provided like this:
* `<my-input label="My label"></my-input>`
*
* A more advanced label can be provided like this:
* `<my-input>
* <label slot="label"><my-icon aria-hidden="true"></my-icon>My label</label>
* </my-input>`
*
* In the property/attribute case, SlotMixin adds the `<label slot="label">` under the hood. **unless** the developer already provided the slot.
* This will make sure that the slot provided by the user always takes precedence and only one slot instance will be available in light dom per slot.
*
* ### Default slot
* As can be seen in the example above, '' can be used to add content to the default slot
*
* ## SlotFunctionResult
*
* The `SlotFunctionResult` is the output of the functions provided in `get slots()`. It can output the following types:
*
* ```ts
* TemplateResult | Element | SlotRerenderObject | undefined;
* ```
*
* ### Element
* For simple cases, an element can be returned. Use this when no web component is needed.
*
* ### TemplateResult
* Return a TemplateResult when you need web components in your light dom. They will be automatically scoped correctly (to the scoped registry belonging to your shadowRoot)
* If your template needs to be rerender, use a `SlotRerenderObject`.
*
* ### SlotRerenderObject
* A `SlotRerenderObject` looks like this:
*
* ```ts
* {
* template: TemplateResult;
* afterRender?: Function;
* };
* ```
* It is meant for complex templates that need rerenders. Normally, when rendering into shadow dom (behavior we could have when [AOM](https://wicg.github.io/aom/explainer.html) was implemented), we would get rerenders
* "for free" when a [property effect](https://lit.dev/docs/components/properties/#when-properties-change) takes place.
* When we configure `SlotFunctionResult` to return a `SlotRerenderObject`, we get the same behavior for light dom.
* For this rerendering to work predictably (no focus and other interaction issues), the slot will be created with a wrapper div.
*
* @type {SlotMixin} * @type {SlotMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<LitElement>} superclass * @param {import('@open-wc/dedupe-mixin').Constructor<LitElement>} superclass
*/ */
@ -25,8 +118,35 @@ const SlotMixinImplementation = superclass =>
constructor() { constructor() {
super(); super();
/** @private */
this.__privateSlots = new Set(null); /**
* The roots that are used to do a rerender in.
* In case of a SlotRerenderObject, this will create a render wrapper in the scoped context (shadow root)
* and connect this under the slot name to the light dom. All (re)renders will happen from here,
* so that all interactions work as intended and no focus issues can arise (which would be the case
* when (cloned) nodes of a render outcome would be moved around)
* @private
* @type { Map<string, HTMLDivElement> } */
this.__scopedRenderRoots = new Map();
/**
* @private
* @type {Set<string>}
*/
this.__slotsThatNeedRerender = new Set();
/**
* Those are slots that should not be touched by SlotMixin
* @private
* @type {Set<string>}
*/
this.__slotsProvidedByUserOnFirstConnected = new Set();
/**
* Those are slots that should be touched by SlotMixin
* The opposite of __slotsProvidedByUserOnFirstConnected,
* also taking into account undefined (a.k.a. conditional) slots
* @private
* @type {Set<string>}
*/
this.__privateSlots = new Set();
} }
connectedCallback() { connectedCallback() {
@ -35,60 +155,159 @@ const SlotMixinImplementation = superclass =>
} }
/** /**
* @private * @param {string} slotName
* @param {import('lit').TemplateResult} template
*/ */
__renderAsNodes(template) { __rerenderSlot(slotName) {
const slotFunctionResult = /** @type {SlotRerenderObject} */ (this.slots[slotName]());
this.__renderTemplateInScopedContext({
template: slotFunctionResult.template,
slotName,
shouldRerender: true,
});
slotFunctionResult.afterRender?.();
}
/**
* Here we rerender slots defined with a `SlotRerenderObject`
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (this.__slotsThatNeedRerender.size) {
for (const slotName of Array.from(this.__slotsThatNeedRerender)) {
this.__rerenderSlot(slotName);
}
}
if (this.__isFirstSlotUpdate) {
this.__isFirstSlotUpdate = false;
}
}
/**
* @private
* @param {object} opts
* @param {import('lit').TemplateResult} opts.template
* @param {string} opts.slotName
* @param {boolean} [opts.shouldRerender] false when TemplateResult, true when SlotRerenderObject
*/
__renderTemplateInScopedContext({ template, slotName, shouldRerender }) {
// @ts-expect-error wait for browser support // @ts-expect-error wait for browser support
const supportsScopedRegistry = !!ShadowRoot.prototype.createElement; const supportsScopedRegistry = !!ShadowRoot.prototype.createElement;
const registryRoot = supportsScopedRegistry ? this.shadowRoot : document; const registryRoot = supportsScopedRegistry ? this.shadowRoot : document;
// @ts-expect-error wait for browser support
const tempRenderTarget = registryRoot.createElement('div'); let renderTarget;
// Reuse the existing offline renderTargets for results consistent with that of rendering to one target (shadow dom)
if (this.__scopedRenderRoots.has(slotName)) {
renderTarget = this.__scopedRenderRoots.get(slotName);
} else {
// @ts-expect-error wait for browser support
renderTarget = registryRoot.createElement('div');
if (shouldRerender) {
renderTarget.slot = slotName;
this.appendChild(renderTarget);
}
this.__scopedRenderRoots.set(slotName, renderTarget);
}
// Providing all options breaks Safari; keep host and creationScope // Providing all options breaks Safari; keep host and creationScope
const { creationScope, host } = this.renderOptions; const { creationScope, host } = this.renderOptions;
render(template, tempRenderTarget, { creationScope, host }); render(template, renderTarget, { creationScope, host });
return Array.from(tempRenderTarget.childNodes);
return renderTarget;
}
/**
* @param {object} options
* @param {Node[]} options.nodes
* @param {Element} [options.renderParent] It's recommended to create a render target in light dom (like <div slot=myslot>),
* 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 * @protected
*/ */
_connectSlotMixin() { _connectSlotMixin() {
if (!this.__isConnectedSlotMixin) { if (this.__isConnectedSlotMixin) {
Object.keys(this.slots).forEach(slotName => { return;
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;
} }
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;
} }
/** /**

View file

@ -4,6 +4,10 @@ import { ScopedElementsMixin } from '@open-wc/scoped-elements';
import { SlotMixin } from '@lion/ui/core.js'; import { SlotMixin } from '@lion/ui/core.js';
import { LitElement } from 'lit'; import { LitElement } from 'lit';
/**
* @typedef {import('../types/SlotMixinTypes.js').SlotHost} SlotHost
*/
const mockedRenderTarget = document.createElement('div'); const mockedRenderTarget = document.createElement('div');
function mockScopedRegistry() { function mockScopedRegistry() {
const outputObj = { createElementCallCount: 0 }; const outputObj = { createElementCallCount: 0 };
@ -26,7 +30,7 @@ function unMockScopedRegistry() {
} }
describe('SlotMixin', () => { 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( const tag = defineCE(
class extends SlotMixin(LitElement) { class extends SlotMixin(LitElement) {
get slots() { get slots() {
@ -41,7 +45,7 @@ describe('SlotMixin', () => {
expect(el.children[0].slot).to.equal('feedback'); expect(el.children[0].slot).to.equal('feedback');
}); });
it("supports unnamed slot with ''", async () => { it("supports default slot with ''", async () => {
const tag = defineCE( const tag = defineCE(
class extends SlotMixin(LitElement) { class extends SlotMixin(LitElement) {
get slots() { get slots() {
@ -57,7 +61,7 @@ describe('SlotMixin', () => {
expect(el.children[0]).dom.to.equal('<div></div>'); expect(el.children[0]).dom.to.equal('<div></div>');
}); });
it('supports unnamed slot in conjunction with named slots', async () => { it('supports default slot in conjunction with named slots', async () => {
const tag = defineCE( const tag = defineCE(
class extends SlotMixin(LitElement) { class extends SlotMixin(LitElement) {
get slots() { get slots() {
@ -111,63 +115,6 @@ describe('SlotMixin', () => {
expect(slot.innerText).to.equal(''); 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 () => { it("allows to check which slots have been created via this._isPrivateSlot('slotname')", async () => {
let renderSlot = true; let renderSlot = true;
class SlotPrivateText extends SlotMixin(LitElement) { class SlotPrivateText extends SlotMixin(LitElement) {
@ -195,102 +142,358 @@ describe('SlotMixin', () => {
expect(elNoSlot.didCreateConditionalSlot()).to.be.false; expect(elNoSlot.didCreateConditionalSlot()).to.be.false;
}); });
it('supports templates', async () => { describe('Rerender', () => {
const tag = defineCE( it('supports rerender when SlotRerenderObject provided', async () => {
class extends SlotMixin(LitElement) { const tag = defineCE(
get slots() { // @ts-expect-error
return { class extends SlotMixin(LitElement) {
...super.slots, static properties = { currentValue: Number };
template: () => html`<span>text</span>`,
};
}
render() { constructor() {
return html`<slot name="template"></slot>`; super();
} this.currentValue = 0;
}, }
);
const el = await fixture(`<${tag}></${tag}>`); get slots() {
const slot = /** @type HTMLSpanElement */ ( return {
Array.from(el.children).find(elm => elm.slot === 'template') ...super.slots,
); template: () => ({ template: html`<span>${this.currentValue}</span> ` }),
expect(slot.slot).to.equal('template'); };
expect(slot.tagName).to.equal('SPAN'); }
render() {
return html`<slot name="template"></slot>`;
}
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`<input /> `,
}),
};
}
render() {
return html`<slot name="focusable-node"></slot>`;
}
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`
<input />
<button>I will be focused</button>
`;
}
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`<slot name="focusable-node"></slot>`;
}
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 () => { describe('SlotFunctionResult types', () => {
const outputObj = mockScopedRegistry(); 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( it('supports conditional slots (type "undefined")', async () => {
class extends ScopedElementsMixin(SlotMixin(LitElement)) { let renderSlot = true;
static get scopedElements() { const tag = defineCE(
return { class extends SlotMixin(LitElement) {
// @ts-expect-error get slots() {
...super.scopedElements, return {
'scoped-el': ScopedEl, ...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() { it('supports templates (type "TemplateResult")', async () => {
return { const tag = defineCE(
...super.slots, class extends SlotMixin(LitElement) {
template: () => html`<scoped-el></scoped-el>`, get slots() {
}; return {
} ...super.slots,
template: () => html`<span>text</span>`,
};
}
render() { render() {
return html`<slot name="template"></slot>`; return html`<slot name="template"></slot>`;
} }
}, },
); );
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); it('supports afterRender logic (type "{ template:TemplateResults; afterRender: Function}" )', async () => {
await fixture(html`<${tag}></${tag}>`); 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`<span>${this.currentValue}</span>, `,
afterRender: () => {
varThatProvesAfterRenderIsCalled = 'called';
},
}),
};
}
render() {
return html`<slot name="template"></slot>`;
}
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 () => { describe('Scoped Registries', () => {
class ScopedEl extends LitElement {} it('supports scoped elements when polyfill loaded', async () => {
const outputObj = mockScopedRegistry();
const tagName = defineCE( class ScopedEl extends LitElement {}
class extends ScopedElementsMixin(SlotMixin(LitElement)) {
static get scopedElements() {
return {
// @ts-expect-error
...super.scopedElements,
'scoped-el': ScopedEl,
};
}
get slots() { const tagName = defineCE(
return { class extends ScopedElementsMixin(SlotMixin(LitElement)) {
...super.slots, static get scopedElements() {
template: () => html`<scoped-el></scoped-el>`, return {
}; // @ts-expect-error
} ...super.scopedElements,
'scoped-elm': ScopedEl,
};
}
render() { get slots() {
return html`<slot name="template"></slot>`; return {
} ...super.slots,
}, template: () => html`<scoped-elm></scoped-elm>`,
); };
}
const renderTarget = document.createElement('div'); render() {
const el = document.createElement(tagName); return html`<slot name="template"></slot>`;
}
},
);
// We don't use fixture, so we limit the amount of calls to document.createElement const tag = unsafeStatic(tagName);
const docSpy = sinon.spy(document, 'createElement'); await fixture(html`<${tag}></${tag}>`);
document.body.appendChild(renderTarget);
renderTarget.appendChild(el);
expect(docSpy.callCount).to.equal(2); expect(outputObj.createElementCallCount).to.equal(1);
document.body.removeChild(renderTarget); unMockScopedRegistry();
docSpy.restore(); });
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`<scoped-el></scoped-el>`,
};
}
render() {
return html`<slot name="template"></slot>`;
}
},
);
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();
});
}); });
}); });

View file

@ -1,7 +1,18 @@
import { Constructor } from '@open-wc/dedupe-mixin'; import { Constructor } from '@open-wc/dedupe-mixin';
import { TemplateResult, LitElement } from 'lit'; 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 = { export type SlotsMap = {
[key: string]: typeof slotFunction; [key: string]: typeof slotFunction;