joicomponents
Version:
Design patterns to build native web components
177 lines (158 loc) • 5.56 kB
JavaScript
/**
* Acknowledgments
*
* Many thanks to Jan Miksovsky and the Elix project for input and inspiration.
*/
//pure function to find the last in toRun, that !hasRun
function findLastNotChecked(toRun, hasRun) {
for (let i = toRun.length - 1; i >= 0; i--) {
let el = toRun[i];
if (hasRun.indexOf(el) < 0)
return el;
}
return null;
}
//Ques for batched tasks
let startedQue = [];
let completed = [];
let isStarted = false;
//First, block flushing of the que until DCL, and on DCL, open the que and try to flush it
let dcl = document.readyState === "complete" || document.readyState === "loaded";
dcl || window.addEventListener("DOMContentLoaded", function () {
dcl = true;
flushQue();
});
//process for flushing que
function flushQue() {
//step 1: check that dcl is ready.
if (!dcl) return;
//step 2: all elements started has been processed? reset and end
const fnel = findLastNotChecked(startedQue, completed);
if (!fnel) {
startedQue = [];
completed = [];
return;
}
//step 3: run function, add the element to the completed list, and run again with TCO
fnel[0](fnel[1]);
completed.push(fnel);
flushQue();
}
function batchedConstructorCallback(fn, el) {
startedQue.push([fn, el]);
if (!isStarted) {
isStarted = true;
Promise.resolve().then(() => {
Promise.resolve().then(() => {
flushQue();
isStarted = false;
});
});
}
}
class Slottables {
constructor(name, assigneds) {
this.name = name;
this.assigneds = assigneds;
}
assignedNodes(config) {
if (!(config && config.flatten === true))
return this.assigneds;
let res = [];
if (!this.assigneds)
return res;
for (let n of this.assigneds) {
if (n.tagName === "SLOT" && n.getRootNode().host) { //if(node instanceof HTMLSlotElement) does not work in polyfill.
const flat = n.assignedNodes({flatten: true});
res = res.concat(flat);
} else
res.push(n);
}
return res;
}
assignedElements(config) {
return this.assignedNodes(config).filter(n => n.nodeType === Node.ELEMENT_NODE);
}
}
/**
* SlottableMixin adds a reactive lifecycle hook .slottablesCallback(...) to its subclasses.
* SlottableMixin does not require neither a `<slot>` nor a shadowRoot to be present on the custom element,
* it will trigger regardless.
*
* This lifecycle hook is triggered every time a potentially slottable nodes for the element changes.
* .slottablesCallback(...) is initialized and triggers at .
* Then .slottablesCallback(...) is triggered whenever the childList of the host node changes, or
* .
*
* .slottablesCallback(slottables) is triggered:
* 1) the first requestAnimationFrame after the element is constructed,
* 2) whenever the childNodes of the host element changes, and
* 3) whenever the assigned nodes of a <slot> node that is a child of the host element changes.
*
* Gold standard: https://github.com/webcomponents/gold-standard/wiki/
* a) Content assignment: changes to assignedNodes of slotted children are notified as if the change happened to a normal child.
*
* @param Base class that extends HTMLElement
* @returns {SlottableMixin} class that extends HTMLElement
*/
const slottables = Symbol("slottables");
function mapNodesByAttributeValue(nodes, attributeName) {
var res = {};
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var name = n.getAttribute ? (n.getAttribute(attributeName) || "") : "";
(res[name] || (res[name] = [])).push(n);
}
return res;
}
function arrayEquals(a, b) {
return b && a && a.length === b.length && a.every((v, i) => v === b[i]);
}
function indirectSlottableMutation(el, ev) {
let path = ev.composedPath();
for (let i = 0; i < path.length; i++) {
let slot = path[i];
if (slot.tagName !== "SLOT")
return; //no eavesdropping
if (slot.parentNode === el) {
const slotName = slot.getAttribute("slot") || "";
el.slottablesCallback(el[slottables][slotName], i + 1, ev); //found the right slot and triggering callback
return;
}
}
}
function directSlottableMutation(changes) {
const el = changes[0].target;
const newSlottables = mapNodesByAttributeValue(el.childNodes, "slot");
for (let name in el[slottables]) {
if (newSlottables[name] === undefined) {
delete el[slottables][name]; //deleted
el.slottablesCallback(new Slottables(name, null));
}
}
for (let name in newSlottables) {
let nodes = newSlottables[name];
if (!el[slottables][name]) //added
el.slottablesCallback(el[slottables][name] = new Slottables(name, nodes));
if (!arrayEquals(nodes, el[slottables][name].assigneds)) //changed
el.slottablesCallback(el[slottables][name] = new Slottables(name, nodes));
}
}
const mo = new MutationObserver(directSlottableMutation);
function SlottableCallback (el) {
mo.observe(el, {childList: true});
el.addEventListener("slotchange", e => indirectSlottableMutation(el, e));
const map = mapNodesByAttributeValue(el.childNodes, "slot");
if (Object.keys(map).length === 0) map[""] = [];
for (let name in map)
el.slottablesCallback(el[slottables][name] = new Slottables(name, map[name]));
}
export const SlottableMixin = function (Base) {
return class SlottableMixin extends Base {
constructor() {
super();
this[slottables] = {};
batchedConstructorCallback(SlottableCallback, this);
}
}
};