lion/packages/ui/components/core/src/SlotMixin.js

365 lines
14 KiB
JavaScript

/* eslint-disable class-methods-use-this */
import { isTemplateResult } from 'lit/directive-helpers.js';
import { dedupeMixin } from '@open-wc/dedupe-mixin';
import { render } from 'lit';
/**
* @typedef {{renderBefore:Comment; renderTargetThatRespectsShadowRootScoping: HTMLDivElement}} RenderMetaObj
* @typedef {import('../types/SlotMixinTypes.js').SlotFunctionResult} SlotFunctionResult
* @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
*/
/**
* Sometimes, we want to provide best DX (direct slottables) and be accessible
* at the same time.
* In the first example below, we need to wrap our options in light dom in an element with
* [role=listbox]. We could achieve this via the second example, but it would affect our
* public api negatively. not allowing us to be forward compatible with the AOM spec:
* https://wicg.github.io/aom/explainer.html
* With this method, it's possible to watch elements in the default slot and move them
* to the desired target (the element with [role=listbox]) in light dom.
*
* @example
* # desired api
* <sel-ect>
* <opt-ion></opt-ion>
* </sel-ect>
* # desired end state
* <sel-ect>
* <div role="listbox" slot="lisbox">
* <opt-ion></opt-ion>
* </div>
* </sel-ect>
*
* Note, the function does not move the nodes specified by a subclasser in the `slots` getter
* @param {HTMLElement} source host of ShadowRoot with default <slot>
* @param {HTMLElement} target the desired target in light dom
*/
export function moveUserProvidedDefaultSlottablesToTarget(source, target) {
/**
* Nodes injected via `slots` getter are going to be added as host's children
* starting by a comment node like <!--_start_slot_*-->
* and ending by a comment node like <!--_end_slot_*-->
* So we ignore everything that comes between those `start_slot` and `end_slot` comments
*/
let isInsideSlotSection = false;
Array.from(source.childNodes).forEach((/** @type {* & Element} */ c) => {
const isNamedSlottable = c.hasAttribute && c.hasAttribute('slot');
const isComment = c.nodeType === Node.COMMENT_NODE;
if (isComment && !isInsideSlotSection) {
isInsideSlotSection = c.textContent.includes('_start_slot_');
}
if (isInsideSlotSection) {
if (c.textContent.includes('_end_slot_')) {
isInsideSlotSection = false;
}
return;
}
if (!isNamedSlottable) {
target.appendChild(c);
}
});
}
/**
* @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:
* - mediates between light dom provided by the user ('public slots') and light dom provided by the component author ('private slots').
* - allows to hook into the reactive update loop of LitElement (rerendering on property changes)
* - respects the scoped registry belonging to the shadow root.
*
* Be sure to read all details about SlotMixin in the [SlotMixin documentation](docs/fundamentals/systems/core/SlotMixin.md)
*
* @type {SlotMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<LitElement>} superclass
*/
const SlotMixinImplementation = superclass =>
// @ts-ignore https://github.com/microsoft/TypeScript/issues/36821#issuecomment-588375051
class SlotMixin extends superclass {
/**
* @return {SlotsMap}
*/
get slots() {
return {};
}
constructor() {
super();
/**
* 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, RenderMetaObj> } */
this.__renderMetaPerSlot = 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() {
super.connectedCallback();
this._connectSlotMixin();
}
/**
* @param {string} slotName
*/
__rerenderSlot(slotName) {
const slotFunctionResult = /** @type {SlotRerenderObject} */ (this.slots[slotName]());
this.__renderTemplateInScopedContext({
renderAsDirectHostChild: slotFunctionResult.renderAsDirectHostChild,
template: slotFunctionResult.template,
slotName,
});
// TODO: this is deprecated, remove later
slotFunctionResult.afterRender?.();
}
/**
* Here we rerender slots defined with a `SlotRerenderObject`
* @param {import('lit-element').PropertyValues } changedProperties
*/
update(changedProperties) {
super.update(changedProperties);
for (const slotName of this.__slotsThatNeedRerender) {
this.__rerenderSlot(slotName);
}
}
/**
* @private
* @param {object} opts
* @param {import('lit').TemplateResult} opts.template
* @param {string} opts.slotName
* @param {boolean} [opts.renderAsDirectHostChild] when false, the render parent (wrapper div) will be kept in the light dom
* @returns {void}
*/
__renderTemplateInScopedContext({ template, slotName, renderAsDirectHostChild }) {
const isFirstRender = !this.__renderMetaPerSlot.has(slotName);
if (isFirstRender) {
// @ts-expect-error wait for browser support
const supportsScopedRegistry = !!ShadowRoot.prototype.createElement;
const hasShadowRoot = Boolean(this.shadowRoot);
if (!hasShadowRoot) {
// TODO: throw an error in a breaking release
// eslint-disable-next-line no-console
console.error(`[SlotMixin] No shadowRoot was found`);
}
const registryRoot = supportsScopedRegistry ? this.shadowRoot : document;
// @ts-expect-error wait for browser support
const renderTargetThatRespectsShadowRootScoping = registryRoot.createElement('div');
const startComment = document.createComment(`_start_slot_${slotName}_`);
const endComment = document.createComment(`_end_slot_${slotName}_`);
renderTargetThatRespectsShadowRootScoping.appendChild(startComment);
renderTargetThatRespectsShadowRootScoping.appendChild(endComment);
// Providing all options breaks Safari; keep host and creationScope
const { creationScope, host } = this.renderOptions;
render(template, renderTargetThatRespectsShadowRootScoping, {
renderBefore: endComment,
creationScope,
host,
});
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 });
// Assume we had this config:
// `'my-slot': () => ({ template: myBool ? html`<div id=a></div>` : html`<span id=b></span>`, renderAsDirectHostChild: true })`
// If myBool started as true, <div id=a></div> would be rendered in first render above, a slot would be applied,
// resulting in <div id=a slot=my-slot></div>
// However, when myBool changes to false, the <span id=b></span> would be rendered as root instead...
// We need to make sure that this "replaced root" gets the slot applied as well => <span id=b slot=my-slot></span>
const isRerenderingRootOfTemplate =
renderAsDirectHostChild &&
renderBefore.previousElementSibling &&
!renderBefore.previousElementSibling.slot;
if (isRerenderingRootOfTemplate) {
renderBefore.previousElementSibling.slot = slotName;
}
}
/**
* @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
*/
__appendNodes({ nodes, renderParent = this, slotName }) {
for (const node of nodes) {
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) {
// eslint-disable-next-line no-continue
continue;
}
if (!this.__isConnectedSlotMixin) {
this.__privateSlots.add(slotName);
}
const slotFunctionResultType = determineSlotFunctionResultType(slotFunctionResult);
switch (slotFunctionResultType) {
case 'template-result':
this.__renderTemplateInScopedContext({
template: /** @type {import('lit').TemplateResult} */ (slotFunctionResult),
renderAsDirectHostChild: true,
slotName,
});
break;
case 'node':
this.__appendNodes({
nodes: [/** @type {Node} */ (slotFunctionResult)],
renderParent: this,
slotName,
});
break;
case 'slot-rerender-object':
// Rerenderable slots are scheduled in the "update loop"
this.__slotsThatNeedRerender.add(slotName);
// For backw. compat, we allow a first render on connectedCallback
if (/** @type {SlotRerenderObject} */ (slotFunctionResult).firstRenderOnConnected) {
this.__rerenderSlot(slotName);
}
break;
default:
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.
You provided: ${slotFunctionResult}`,
);
}
}
}
/**
* @protected
*/
_connectSlotMixin() {
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;
}
/**
* @param {string} slotName Name of the slot
* @return {boolean} true if given slot name been created by SlotMixin
* @protected
*/
_isPrivateSlot(slotName) {
return this.__privateSlots.has(slotName);
}
};
export const SlotMixin = dedupeMixin(SlotMixinImplementation);