feat(core): scoped templates in SlotMixin

This commit is contained in:
Thijs Louisse 2021-08-13 09:55:05 +02:00
parent 42f7026b58
commit ec03d209c8
4 changed files with 138 additions and 24 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/core': patch
---
Support scoped templates in SlotMixin

View file

@ -1,18 +1,20 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import { dedupeMixin } from '@open-wc/dedupe-mixin'; import { dedupeMixin } from '@open-wc/dedupe-mixin';
import { render } from 'lit';
import { isTemplateResult } from 'lit/directive-helpers.js';
/** /**
* @typedef {import('../types/SlotMixinTypes').SlotMixin} SlotMixin * @typedef {import('../types/SlotMixinTypes').SlotMixin} SlotMixin
* @typedef {import('../types/SlotMixinTypes').SlotsMap} SlotsMap * @typedef {import('../types/SlotMixinTypes').SlotsMap} SlotsMap
* @typedef {import('../index').LitElement} LitElement
*/ */
/** /**
* @type {SlotMixin} * @type {SlotMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<HTMLElement>} superclass * @param {import('@open-wc/dedupe-mixin').Constructor<LitElement>} superclass
*/ */
const SlotMixinImplementation = superclass => const SlotMixinImplementation = superclass =>
// eslint-disable-next-line no-unused-vars, no-shadow class SlotMixin extends superclass {
class extends superclass {
/** /**
* @return {SlotsMap} * @return {SlotsMap}
*/ */
@ -27,29 +29,47 @@ const SlotMixinImplementation = superclass =>
} }
connectedCallback() { connectedCallback() {
// @ts-ignore checking this in case we pass LitElement, found no good way to type this... super.connectedCallback();
if (super.connectedCallback) {
// @ts-ignore checking this in case we pass LitElement, found no good way to type this...
super.connectedCallback();
}
this._connectSlotMixin(); this._connectSlotMixin();
} }
/**
* @private
* @param {import('@lion/core').TemplateResult} template
*/
__renderAsNodes(template) {
const tempRenderTarget = document.createElement('div');
render(template, tempRenderTarget, this.renderOptions);
return Array.from(tempRenderTarget.childNodes);
}
/** /**
* @protected * @protected
*/ */
_connectSlotMixin() { _connectSlotMixin() {
if (!this.__isConnectedSlotMixin) { if (!this.__isConnectedSlotMixin) {
Object.keys(this.slots).forEach(slotName => { Object.keys(this.slots).forEach(slotName => {
if (!this.querySelector(`[slot=${slotName}]`)) { if (!Array.from(this.children).find(el => el.slot === slotName)) {
const slotFactory = this.slots[slotName]; const slotContent = this.slots[slotName]();
const slotContent = slotFactory(); /** @type {Node[]} */
// ignore non-elements to enable conditional slots let nodes = [];
if (slotContent instanceof Element) {
slotContent.setAttribute('slot', slotName); if (isTemplateResult(slotContent)) {
this.appendChild(slotContent); nodes = this.__renderAsNodes(slotContent);
this.__privateSlots.add(slotName); } else if (!Array.isArray(slotContent)) {
nodes = [/** @type {Node} */ (slotContent)];
} }
nodes.forEach(node => {
if (!(node instanceof Node)) {
return;
}
if (node instanceof Element) {
node.setAttribute('slot', slotName);
}
this.appendChild(node);
this.__privateSlots.add(slotName);
});
} }
}); });
this.__isConnectedSlotMixin = true; this.__isConnectedSlotMixin = true;

View file

@ -1,10 +1,12 @@
import sinon from 'sinon';
import { defineCE, expect, fixture } from '@open-wc/testing'; import { defineCE, expect, fixture } from '@open-wc/testing';
import { SlotMixin } from '../src/SlotMixin.js'; import { SlotMixin } from '../src/SlotMixin.js';
import { LitElement, ScopedElementsMixin, html } from '../index.js';
describe('SlotMixin', () => { describe('SlotMixin', () => {
it('inserts provided element into lightdom and sets slot', async () => { it('inserts provided element into lightdom and sets slot', async () => {
const tag = defineCE( const tag = defineCE(
class extends SlotMixin(HTMLElement) { class extends SlotMixin(LitElement) {
get slots() { get slots() {
return { return {
...super.slots, ...super.slots,
@ -19,7 +21,7 @@ describe('SlotMixin', () => {
it('does not override user provided slots', async () => { it('does not override user provided slots', async () => {
const tag = defineCE( const tag = defineCE(
class extends SlotMixin(HTMLElement) { class extends SlotMixin(LitElement) {
get slots() { get slots() {
return { return {
...super.slots, ...super.slots,
@ -33,9 +35,28 @@ describe('SlotMixin', () => {
expect(/** @type HTMLParagraphElement */ (el.children[0]).innerText).to.equal('user-content'); expect(/** @type HTMLParagraphElement */ (el.children[0]).innerText).to.equal('user-content');
}); });
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}><p><span slot="content">user-content</span></p></${tag}>`);
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('supports complex dom trees as element', async () => { it('supports complex dom trees as element', async () => {
const tag = defineCE( const tag = defineCE(
class extends SlotMixin(HTMLElement) { class extends SlotMixin(LitElement) {
constructor() { constructor() {
super(); super();
this.foo = 'bar'; this.foo = 'bar';
@ -67,7 +88,7 @@ describe('SlotMixin', () => {
it('supports conditional slots', async () => { it('supports conditional slots', async () => {
let renderSlot = true; let renderSlot = true;
const tag = defineCE( const tag = defineCE(
class extends SlotMixin(HTMLElement) { class extends SlotMixin(LitElement) {
get slots() { get slots() {
return { return {
...super.slots, ...super.slots,
@ -92,7 +113,7 @@ describe('SlotMixin', () => {
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(HTMLElement) { class SlotPrivateText extends SlotMixin(LitElement) {
get slots() { get slots() {
return { return {
...super.slots, ...super.slots,
@ -116,4 +137,71 @@ describe('SlotMixin', () => {
const elNoSlot = /** @type {SlotPrivateText} */ (await fixture(`<${tag}><${tag}>`)); const elNoSlot = /** @type {SlotPrivateText} */ (await fixture(`<${tag}><${tag}>`));
expect(elNoSlot.didCreateConditionalSlot()).to.be.false; expect(elNoSlot.didCreateConditionalSlot()).to.be.false;
}); });
it('supports templates', async () => {
const tag = defineCE(
class extends SlotMixin(LitElement) {
get slots() {
return {
...super.slots,
template: () => html`<span>text</span>`,
};
}
render() {
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');
});
it('supports scoped elements', async () => {
const scopedSpy = sinon.spy();
class ScopedEl extends LitElement {
connectedCallback() {
super.connectedCallback();
scopedSpy();
}
}
const tag = 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>`,
};
}
connectedCallback() {
super.connectedCallback();
// Not rendered to shadowRoot, notScopedSpy should not be called
const notScoped = document.createElement('not-scoped');
this.appendChild(notScoped);
}
render() {
return html`<slot name="template"></slot>`;
}
},
);
await fixture(`<${tag}><${tag}>`);
expect(scopedSpy).to.have.been.called;
});
}); });

View file

@ -1,6 +1,7 @@
import { Constructor } from '@open-wc/dedupe-mixin'; import { Constructor } from '@open-wc/dedupe-mixin';
import { TemplateResult, LitElement } from '../index.js';
declare function slotFunction(): HTMLElement | undefined; declare function slotFunction(): HTMLElement | Node[] | TemplateResult | undefined;
export type SlotsMap = { export type SlotsMap = {
[key: string]: typeof slotFunction; [key: string]: typeof slotFunction;
@ -48,11 +49,11 @@ export declare class SlotHost {
* }; * };
* } * }
*/ */
export declare function SlotMixinImplementation<T extends Constructor<HTMLElement>>( export declare function SlotMixinImplementation<T extends Constructor<LitElement>>(
superclass: T, superclass: T,
): T & ): T &
Constructor<SlotHost> & Constructor<SlotHost> &
Pick<typeof SlotHost, keyof typeof SlotHost> & Pick<typeof SlotHost, keyof typeof SlotHost> &
Pick<typeof HTMLElement, keyof typeof HTMLElement>; Pick<typeof LitElement, keyof typeof LitElement>;
export type SlotMixin = typeof SlotMixinImplementation; export type SlotMixin = typeof SlotMixinImplementation;