/* Slot-like component to enable slots in light DOM. * * Usage: * * * *
* * Title *
* * Other content *
*
* * The inner element has a template similar to this and correctly uses these * slot-like components: * *

* * * The resulting DOM will be: * * * *

* * Title *

*
* * Other content *
*
*
*/ import { allComponents } from '../all-components.js'; import { events } from '../events.js'; import { createElement, createDocumentFragment, createTemplate, createTreeWalker, } from '../elements.js'; import { ControllerConstructor } from '../controller-types.js'; import { CustomElementConfigInternal } from '../custom-element-config.js'; import { Emitter } from '../emitter.js'; import { getAttribute, setAttribute } from '../util.js'; import { childScope, getScope, Scope } from '../scope.js'; import { metadata } from '../symbols.js'; import { newSet } from '../sets.js'; import { shorthandWeakMap } from '../maps.js'; export interface SlotInfo { e: Emitter; // Notifications of slot removals n: Record; // Named content s: Scope; // Outer element's scope for Fudgel bindings } const metadataElementSlotContent = shorthandWeakMap< ShadowRoot | HTMLElement, SlotInfo >(); const getFragment = (slotInfo: SlotInfo, name: string) => slotInfo.n[name] || (slotInfo.n[name] = createDocumentFragment()); // Given an element, find its parent element. The parent element may be outside // of a shadow root. const getParent = (element: HTMLElement): HTMLElement | undefined => element.parentElement || (((element.getRootNode() as ShadowRoot) || {}).host as | HTMLElement | undefined); const needsSlotPolyfill = /*@__PURE__*/ newSet(); export const defineSlotComponent = (name = 'slot-like') => { class SlotComponent extends HTMLElement { private _eventRemover?: VoidFunction; private _slotInfo?: SlotInfo; constructor() { super(); // Find the parent custom element that is using this component. // The parent must not change even if this element is later relocated // elsewhere (eg. deeper via content projection into content // projection) in the DOM. let parent = getParent(this); while ( parent && !(this._slotInfo = metadataElementSlotContent(parent)) ) { parent = getParent(parent); } } attributeChangedCallback( _name: string, oldValue: string, newValue: string ) { const slotInfo = this._slotInfo; if (slotInfo && oldValue !== newValue) { this._removeContent(slotInfo, oldValue); this._addContent(slotInfo); } } connectedCallback() { const slotInfo = this._slotInfo; if (slotInfo) { // Set the scope of this element to be a child of the outer // element's scope. childScope(slotInfo.s, this); this._addContent(slotInfo); } } disconnectedCallback() { if (this._slotInfo) { this._removeContent( this._slotInfo, getAttribute(this, 'name') || '' ); } } private _addContent(slotInfo: SlotInfo) { const name = getAttribute(this, 'name') || ''; this.append(slotInfo.n[name] || []); this._eventRemover = slotInfo.e.on(name, () => { this._removeContent(slotInfo, name); this._addContent(slotInfo); }); } private _removeContent(slotInfo: SlotInfo, oldName: string) { this._eventRemover!(); getFragment(slotInfo, oldName).append(...this.childNodes); slotInfo.e.emit(oldName); } } (SlotComponent as any).observedAttributes = ['name']; customElements.define(name, SlotComponent); events.on('init', controller => { if (needsSlotPolyfill.has(controller.constructor)) { const { root: controllerRoot, host: controllerHost, events: controllerEvents, } = controller[metadata]!; controllerEvents.on('parse', () => { // When the controller is destroyed, restore the original HTML // content back to the element. const originalHTML = controllerRoot.innerHTML; controllerEvents.on( 'destroy', () => (controllerRoot.innerHTML = originalHTML) ); // Track information necessary for the slot-like custom element const slotInfo = metadataElementSlotContent(controllerRoot, { // Event emitter e: new Emitter(), // Slots - named ones are set as additional properties. Unnamed // slot content is combined into the '' fragment. n: { '': createDocumentFragment(), }, // Scope for the element. s: getScope(getParent(controllerHost) as Node), }); // Grab all content for named slots for (const child of [ ...controllerRoot.querySelectorAll('[slot]'), ]) { getFragment( slotInfo, getAttribute(child, 'slot') || '' ).append(child); } // Now collect everything else and add it to the default slot for (const child of [...controllerRoot.childNodes]) { slotInfo.n[''].append(child); } }); } }); // Rewrite templates for custom elements that use slots in light DOM. const rewrite = ( _baseClass: new () => HTMLElement, controllerConstructor: ControllerConstructor, config: CustomElementConfigInternal ) => { if (!config.useShadow) { let rewrittenSlotElement = false; let foundSlotLikeElement = false; const template = createTemplate(); template.innerHTML = config.template; const treeWalker = createTreeWalker(template.content, 0x01); let currentNode: HTMLElement | null; while ((currentNode = treeWalker.nextNode() as HTMLElement)) { // Change DOM elements in the template from to the // if (currentNode.nodeName == 'SLOT') { rewrittenSlotElement = true; const slotLike = createElement(name); for (const attr of currentNode.attributes) { setAttribute(slotLike, attr.name, attr.value); } treeWalker.previousNode() as HTMLElement; slotLike.append(...currentNode.childNodes); currentNode.replaceWith(slotLike); } else if (currentNode.nodeName == 'SLOT-LIKE') { foundSlotLikeElement = true; } } if (rewrittenSlotElement) { config.template = template.innerHTML; } if (rewrittenSlotElement || foundSlotLikeElement) { needsSlotPolyfill.add(controllerConstructor); } } }; for (const info of allComponents) { rewrite(...info); } events.on('component', rewrite); };