/* 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);
};