feat(ui): [SlotMixin] allow to (re)render scoped elements as direct light dom child
This commit is contained in:
parent
37deecd2a3
commit
f0333bbc1c
7 changed files with 187 additions and 67 deletions
5
.changeset/smooth-suits-compare.md
Normal file
5
.changeset/smooth-suits-compare.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
[core/SlotMixin] allow to (re)render scoped elements as direct light dom child
|
||||||
|
|
@ -1,18 +1,37 @@
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
|
import { isTemplateResult } from 'lit/directive-helpers.js';
|
||||||
import { dedupeMixin } from '@open-wc/dedupe-mixin';
|
import { dedupeMixin } from '@open-wc/dedupe-mixin';
|
||||||
import { render } from 'lit';
|
import { render } from 'lit';
|
||||||
import { isTemplateResult } from 'lit/directive-helpers.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('../types/SlotMixinTypes.js').SlotMixin} SlotMixin
|
* @typedef {{renderBefore:Comment; renderTargetThatRespectsShadowRootScoping: HTMLDivElement}} RenderMetaObj
|
||||||
* @typedef {import('../types/SlotMixinTypes.js').SlotsMap} SlotsMap
|
|
||||||
* @typedef {import('../types/SlotMixinTypes.js').SlotFunctionResult} SlotFunctionResult
|
* @typedef {import('../types/SlotMixinTypes.js').SlotFunctionResult} SlotFunctionResult
|
||||||
* @typedef {import('../types/SlotMixinTypes.js').SlotRerenderObject} SlotRerenderObject
|
* @typedef {import('../types/SlotMixinTypes.js').SlotRerenderObject} SlotRerenderObject
|
||||||
|
* @typedef {import('../types/SlotMixinTypes.js').SlotMixin} SlotMixin
|
||||||
|
* @typedef {import('../types/SlotMixinTypes.js').SlotsMap} SlotsMap
|
||||||
* @typedef {import('lit').LitElement} LitElement
|
* @typedef {import('lit').LitElement} LitElement
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isRerenderConfig = (/** @type {SlotFunctionResult} */ o) =>
|
/**
|
||||||
!Array.isArray(o) && typeof o === 'object' && 'template' in o;
|
* @param {SlotFunctionResult} slotFunctionResult
|
||||||
|
* @returns {'template-result'|'node'|'slot-rerender-object'|null}
|
||||||
|
*/
|
||||||
|
function determineSlotFunctionResultType(slotFunctionResult) {
|
||||||
|
if (slotFunctionResult instanceof Node) {
|
||||||
|
return 'node';
|
||||||
|
}
|
||||||
|
if (isTemplateResult(slotFunctionResult)) {
|
||||||
|
return 'template-result';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(slotFunctionResult) &&
|
||||||
|
typeof slotFunctionResult === 'object' &&
|
||||||
|
'template' in slotFunctionResult
|
||||||
|
) {
|
||||||
|
return 'slot-rerender-object';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All intricacies involved in managing light dom can be delegated to SlotMixin. Amongst others, it automatically:
|
* All intricacies involved in managing light dom can be delegated to SlotMixin. Amongst others, it automatically:
|
||||||
|
|
@ -45,8 +64,8 @@ const SlotMixinImplementation = superclass =>
|
||||||
* so that all interactions work as intended and no focus issues can arise (which would be the case
|
* 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)
|
* when (cloned) nodes of a render outcome would be moved around)
|
||||||
* @private
|
* @private
|
||||||
* @type { Map<string, HTMLDivElement> } */
|
* @type { Map<string, RenderMetaObj> } */
|
||||||
this.__scopedRenderRoots = new Map();
|
this.__renderMetaPerSlot = new Map();
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
* @type {Set<string>}
|
* @type {Set<string>}
|
||||||
|
|
@ -79,9 +98,9 @@ const SlotMixinImplementation = superclass =>
|
||||||
__rerenderSlot(slotName) {
|
__rerenderSlot(slotName) {
|
||||||
const slotFunctionResult = /** @type {SlotRerenderObject} */ (this.slots[slotName]());
|
const slotFunctionResult = /** @type {SlotRerenderObject} */ (this.slots[slotName]());
|
||||||
this.__renderTemplateInScopedContext({
|
this.__renderTemplateInScopedContext({
|
||||||
|
renderAsDirectHostChild: slotFunctionResult.renderAsDirectHostChild,
|
||||||
template: slotFunctionResult.template,
|
template: slotFunctionResult.template,
|
||||||
slotName,
|
slotName,
|
||||||
shouldRerender: true,
|
|
||||||
});
|
});
|
||||||
// TODO: this is deprecated, remove later
|
// TODO: this is deprecated, remove later
|
||||||
slotFunctionResult.afterRender?.();
|
slotFunctionResult.afterRender?.();
|
||||||
|
|
@ -94,48 +113,69 @@ const SlotMixinImplementation = superclass =>
|
||||||
update(changedProperties) {
|
update(changedProperties) {
|
||||||
super.update(changedProperties);
|
super.update(changedProperties);
|
||||||
|
|
||||||
if (this.__slotsThatNeedRerender.size) {
|
for (const slotName of this.__slotsThatNeedRerender) {
|
||||||
for (const slotName of Array.from(this.__slotsThatNeedRerender)) {
|
|
||||||
this.__rerenderSlot(slotName);
|
this.__rerenderSlot(slotName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.__isFirstSlotUpdate) {
|
|
||||||
this.__isFirstSlotUpdate = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {import('lit').TemplateResult} opts.template
|
* @param {import('lit').TemplateResult} opts.template
|
||||||
* @param {string} opts.slotName
|
* @param {string} opts.slotName
|
||||||
* @param {boolean} [opts.shouldRerender] false when TemplateResult, true when SlotRerenderObject
|
* @param {boolean} [opts.renderAsDirectHostChild] when false, the render parent (wrapper div) will be kept in the light dom
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
__renderTemplateInScopedContext({ template, slotName, shouldRerender }) {
|
__renderTemplateInScopedContext({ template, slotName, renderAsDirectHostChild }) {
|
||||||
|
const isFirstRender = !this.__renderMetaPerSlot.has(slotName);
|
||||||
|
if (isFirstRender) {
|
||||||
// @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 : document;
|
||||||
|
|
||||||
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
|
// @ts-expect-error wait for browser support
|
||||||
renderTarget = registryRoot.createElement('div');
|
const renderTargetThatRespectsShadowRootScoping = registryRoot.createElement('div');
|
||||||
if (shouldRerender) {
|
const startComment = document.createComment(`_start_slot_${slotName}_`);
|
||||||
renderTarget.slot = slotName;
|
const endComment = document.createComment(`_end_slot_${slotName}_`);
|
||||||
this.appendChild(renderTarget);
|
|
||||||
}
|
renderTargetThatRespectsShadowRootScoping.appendChild(startComment);
|
||||||
this.__scopedRenderRoots.set(slotName, renderTarget);
|
renderTargetThatRespectsShadowRootScoping.appendChild(endComment);
|
||||||
}
|
|
||||||
|
|
||||||
// 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, renderTarget, { creationScope, host });
|
render(template, renderTargetThatRespectsShadowRootScoping, {
|
||||||
|
renderBefore: endComment,
|
||||||
|
creationScope,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
|
||||||
return renderTarget;
|
if (renderAsDirectHostChild) {
|
||||||
|
const nodes = Array.from(renderTargetThatRespectsShadowRootScoping.childNodes);
|
||||||
|
this.__appendNodes({ nodes, renderParent: this, slotName });
|
||||||
|
} else {
|
||||||
|
renderTargetThatRespectsShadowRootScoping.slot = slotName;
|
||||||
|
this.appendChild(renderTargetThatRespectsShadowRootScoping);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.__renderMetaPerSlot.set(slotName, {
|
||||||
|
renderTargetThatRespectsShadowRootScoping,
|
||||||
|
renderBefore: endComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rerender
|
||||||
|
const { renderBefore, renderTargetThatRespectsShadowRootScoping } =
|
||||||
|
/** @type {RenderMetaObj} */ (this.__renderMetaPerSlot.get(slotName));
|
||||||
|
|
||||||
|
const rerenderTarget = renderAsDirectHostChild
|
||||||
|
? this
|
||||||
|
: renderTargetThatRespectsShadowRootScoping;
|
||||||
|
|
||||||
|
// Providing all options breaks Safari: we keep host and creationScope
|
||||||
|
const { creationScope, host } = this.renderOptions;
|
||||||
|
render(template, rerenderTarget, { creationScope, host, renderBefore });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,13 +185,8 @@ const SlotMixinImplementation = superclass =>
|
||||||
* which can be used as a render target for most
|
* which can be used as a render target for most
|
||||||
* @param {string} options.slotName For the first render, it's best to use slotName
|
* @param {string} options.slotName For the first render, it's best to use slotName
|
||||||
*/
|
*/
|
||||||
__appendNodesForOneTimeRender({ nodes, renderParent = this, slotName }) {
|
__appendNodes({ nodes, renderParent = this, slotName }) {
|
||||||
for (const node of nodes) {
|
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 !== '') {
|
if (node instanceof Element && slotName && slotName !== '') {
|
||||||
node.setAttribute('slot', slotName);
|
node.setAttribute('slot', slotName);
|
||||||
}
|
}
|
||||||
|
|
@ -182,25 +217,32 @@ const SlotMixinImplementation = superclass =>
|
||||||
this.__privateSlots.add(slotName);
|
this.__privateSlots.add(slotName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTemplateResult(slotFunctionResult)) {
|
const slotFunctionResultType = determineSlotFunctionResultType(slotFunctionResult);
|
||||||
const renderTarget = this.__renderTemplateInScopedContext({
|
|
||||||
template: slotFunctionResult,
|
switch (slotFunctionResultType) {
|
||||||
|
case 'template-result':
|
||||||
|
this.__renderTemplateInScopedContext({
|
||||||
|
template: /** @type {import('lit').TemplateResult} */ (slotFunctionResult),
|
||||||
|
renderAsDirectHostChild: true,
|
||||||
slotName,
|
slotName,
|
||||||
});
|
});
|
||||||
const nodes = Array.from(renderTarget.childNodes);
|
break;
|
||||||
this.__appendNodesForOneTimeRender({ nodes, renderParent: this, slotName });
|
case 'node':
|
||||||
} else if (slotFunctionResult instanceof Node) {
|
this.__appendNodes({
|
||||||
const nodes = [/** @type {Node} */ (slotFunctionResult)];
|
nodes: [/** @type {Node} */ (slotFunctionResult)],
|
||||||
this.__appendNodesForOneTimeRender({ nodes, renderParent: this, slotName });
|
renderParent: this,
|
||||||
} else if (isRerenderConfig(slotFunctionResult)) {
|
slotName,
|
||||||
// Rerenderable slots are scheduled in the "updated loop"
|
});
|
||||||
|
break;
|
||||||
|
case 'slot-rerender-object':
|
||||||
|
// Rerenderable slots are scheduled in the "update loop"
|
||||||
this.__slotsThatNeedRerender.add(slotName);
|
this.__slotsThatNeedRerender.add(slotName);
|
||||||
|
|
||||||
// For backw. compat, we allow a first render on connectedCallback
|
// For backw. compat, we allow a first render on connectedCallback
|
||||||
if (slotFunctionResult.firstRenderOnConnected) {
|
if (/** @type {SlotRerenderObject} */ (slotFunctionResult).firstRenderOnConnected) {
|
||||||
this.__rerenderSlot(slotName);
|
this.__rerenderSlot(slotName);
|
||||||
}
|
}
|
||||||
} else {
|
break;
|
||||||
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Slot "${slotName}" configured inside "get slots()" (in prototype) of ${this.constructor.name} may return these types: TemplateResult | Node | {template:TemplateResult, afterRender?:function} | undefined.
|
`Slot "${slotName}" configured inside "get slots()" (in prototype) of ${this.constructor.name} may return these types: TemplateResult | Node | {template:TemplateResult, afterRender?:function} | undefined.
|
||||||
You provided: ${slotFunctionResult}`,
|
You provided: ${slotFunctionResult}`,
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ describe('SlotMixin', () => {
|
||||||
...super.slots,
|
...super.slots,
|
||||||
'focusable-node': () => ({
|
'focusable-node': () => ({
|
||||||
template: html`<input /> `,
|
template: html`<input /> `,
|
||||||
|
renderAsDirectHostChild: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +226,6 @@ describe('SlotMixin', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const el = /** @type {* & SlotHost} */ (await fixture(`<${tag}></${tag}>`));
|
const el = /** @type {* & SlotHost} */ (await fixture(`<${tag}></${tag}>`));
|
||||||
|
|
||||||
el._focusableNode.focus();
|
el._focusableNode.focus();
|
||||||
expect(document.activeElement).to.equal(el._focusableNode);
|
expect(document.activeElement).to.equal(el._focusableNode);
|
||||||
|
|
||||||
|
|
@ -298,6 +298,69 @@ describe('SlotMixin', () => {
|
||||||
expect(el._focusableNode.shadowRoot.activeElement).to.equal(el._focusableNode._buttonNode);
|
expect(el._focusableNode.shadowRoot.activeElement).to.equal(el._focusableNode._buttonNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows for rerendering complex shadow root into slot as a direct child', 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}> `,
|
||||||
|
renderAsDirectHostChild: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`<slot name="focusable-node"></slot>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _focusableNode() {
|
||||||
|
return /** @type HTMLSpanElement */ (
|
||||||
|
Array.from(this.children).find(elm => elm.slot === 'focusable-node')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
describe('firstRenderOnConnected (for backwards compatibility)', () => {
|
describe('firstRenderOnConnected (for backwards compatibility)', () => {
|
||||||
it('does render on connected when firstRenderOnConnected:true', async () => {
|
it('does render on connected when firstRenderOnConnected:true', async () => {
|
||||||
// Start with elem that does not render on connectedCallback
|
// Start with elem that does not render on connectedCallback
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ export type SlotRerenderObject = {
|
||||||
* For new components, please align with ReactiveElement/LitElement reactive cycle callbacks.
|
* For new components, please align with ReactiveElement/LitElement reactive cycle callbacks.
|
||||||
*/
|
*/
|
||||||
firstRenderOnConnected?: boolean;
|
firstRenderOnConnected?: boolean;
|
||||||
|
/**
|
||||||
|
* This is recommended to set to true always, as its behavior is usually desired and more in line with slot nodes that
|
||||||
|
* are not configured as rerenderable.
|
||||||
|
* For backward compatibility, it is set to false by default.
|
||||||
|
* When not configured, content is wrapped in a div (this can be problematic for ::slotted css selectors and for
|
||||||
|
* querySelectors that expect [slot=x] to have some semantic or (presentational) value).
|
||||||
|
*/
|
||||||
|
renderAsDirectHostChild?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlotFunctionResult = TemplateResult | Element | SlotRerenderObject | undefined;
|
export type SlotFunctionResult = TemplateResult | Element | SlotRerenderObject | undefined;
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export class LionInputFile extends ScopedElementsMixin(LocalizeMixin(LionField))
|
||||||
.multiple=${this.multiple}
|
.multiple=${this.multiple}
|
||||||
></lion-selected-file-list>
|
></lion-selected-file-list>
|
||||||
`,
|
`,
|
||||||
|
renderAsDirectHostChild: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +184,7 @@ export class LionInputFile extends ScopedElementsMixin(LocalizeMixin(LionField))
|
||||||
*/
|
*/
|
||||||
get _fileListNode() {
|
get _fileListNode() {
|
||||||
return /** @type {LionSelectedFileList} */ (
|
return /** @type {LionSelectedFileList} */ (
|
||||||
Array.from(this.children).find(child => child.slot === 'selected-file-list')?.children[0]
|
Array.from(this.children).find(child => child.slot === 'selected-file-list')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ export class LionInputTelDropdown extends LionInputTel {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template: templates.dropdown(this._templateDataDropdown),
|
template: templates.dropdown(this._templateDataDropdown),
|
||||||
|
renderAsDirectHostChild: Boolean,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ export function runInputTelDropdownSuite({ klass } = { klass: LionInputTelDropdo
|
||||||
it('renders to prefix slot in light dom', async () => {
|
it('renders to prefix slot in light dom', async () => {
|
||||||
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
|
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
|
||||||
const prefixSlot = /** @type {HTMLElement} */ (
|
const prefixSlot = /** @type {HTMLElement} */ (
|
||||||
/** @type {HTMLElement} */ (el.refs.dropdown.value).parentElement
|
/** @type {HTMLElement} */ (el.refs.dropdown.value)
|
||||||
);
|
);
|
||||||
expect(prefixSlot.getAttribute('slot')).to.equal('prefix');
|
expect(prefixSlot.getAttribute('slot')).to.equal('prefix');
|
||||||
expect(prefixSlot.slot).to.equal('prefix');
|
expect(prefixSlot.slot).to.equal('prefix');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue