joicomponents
Version:
Design patterns to build native web components
139 lines (124 loc) • 5.76 kB
JavaScript
/**
* Acknowledgments
*
* Many thanks to Jan Miksovsky and the Elix project for input and inspiration.
*/
import {flattenNodes} from "./flattenNodes.js";
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 flattenMap(nodeMap){
var res = {};
var keys = Object.keys(nodeMap);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
res[key] = flattenNodes(nodeMap[key]);
}
return res;
}
function arrayEquals(a, b) {
return b && a && a.length === b.length && a.every((v, i) => v === b[i]);
}
const hostChildrenObserver = Symbol("hostChildrenObserver");
const slotchangeListener = Symbol("slotChangedListener");
const hostChildrenChanged = Symbol("hostChildrenChanged");
const addSlotListeners = Symbol("addSlotListeners");
const removeSlotListeners = Symbol("removeSlotListeners");
const hostChildrenSlots = Symbol("hostChildrenSlots");
const triggerSingleSlotchangeCallback = Symbol("triggerSingleSlotchangeCallback");
const triggerAllSlotchangeCallbacks = Symbol("triggerAllSlotchangeCallbacks");
const triggerCallback = Symbol("triggerCallback");
const map = Symbol("map");
const notFlatMap = Symbol("notFlatMap");
const flatMap = Symbol("flatMap");
/**
* SlottableMixin adds a reactive lifecycle hook .slotchangedCallback(...) to its subclasses.
* This lifecycle hook is triggered every time a potentially assignable node for the element changes.
* .slotchangedCallback(...) triggers manually every time the element is attached to the DOM and
* whenever the a slotchange event would occur inside it.
* The callback does not require neither a `<slot>` nor a shadowRoot to be present on the custom element,
* it will trigger regardless.
*
* .slotchangedCallback(slotname, newAssignedNodes, oldAssignedNodes) is triggered every time:
* 1) the element is connected to the DOM,
* 2) whenever the slotted content of an element changes, but
* 3) except when the content of newAssignedNodes and oldAssignedNodes are equal.
*
* Gold standard: https://github.com/webcomponents/gold-standard/wiki/
* a) Detachment: SlottableMixin always starts observing when it is connected to the DOM and stops when it is disconnected.
* b) 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 {SlotchangeMixin} class that extends HTMLElement
*
* todo when the childListChanges, I need a WeakMap that hold references to the previous values.
* todo This map is setup so that it is triggered created the first time the element is connected, but to the content
*/
export const SlotchangeMixin = function (Base) {
return class SlotchangeMixin extends Base {
// slotchangedCallback(slotName, newNodeList, oldNodeList) {}
constructor() {
super();
this[hostChildrenObserver] = new MutationObserver(() => this[hostChildrenChanged]());
this[slotchangeListener] = (e) => this[triggerSingleSlotchangeCallback](e.currentTarget.name);
this[map] = {};
this[hostChildrenSlots] = [];
this[notFlatMap] = {};
this[flatMap] = {};
}
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
this[hostChildrenObserver].observe(this, {childList: true});
this[hostChildrenChanged]();
}
disconnectedCallback() {
if (super.disconnectedCallback) super.disconnectedCallback();
this[removeSlotListeners]();
this[hostChildrenObserver].disconnect();
}
[addSlotListeners]() {
this[hostChildrenSlots] = [].filter.call(this.children, function (c) {
return c.tagName === "SLOT";
});
for (let i = 0; i < this[hostChildrenSlots].length; i++)
this[hostChildrenSlots][i].addEventListener("slotchange", this[slotchangeListener]);
}
[removeSlotListeners]() {
for (let i = 0; i < this[hostChildrenSlots].length; i++)
this[hostChildrenSlots][i].removeEventListener("slotchange", this[slotchangeListener]);
this[hostChildrenSlots] = [];
}
[triggerAllSlotchangeCallbacks]() {
let newFlatMap = flattenMap(this[notFlatMap]);
for (let slotName in newFlatMap)
this[triggerCallback](slotName, newFlatMap[slotName], this[flatMap][slotName]);
this[flatMap] = newFlatMap;
}
[triggerSingleSlotchangeCallback](slotName) {
let newFlatNodeList = flattenNodes(this[notFlatMap][slotName]);
this[triggerCallback](slotName, newFlatNodeList, this[flatMap][slotName]);
this[flatMap][slotName] = newFlatNodeList;
}
[triggerCallback](slotName, newAssignedNodes, oldAssignedNodes) {
if (!arrayEquals(newAssignedNodes, oldAssignedNodes))
this.slotchangedCallback(slotName, newAssignedNodes, oldAssignedNodes);
}
[hostChildrenChanged]() {
if (!this.isConnected) //if the element is first connected and then disconnected again before the JS stack empties.
return;
this[removeSlotListeners]();
this[addSlotListeners]();
this[notFlatMap] = mapNodesByAttributeValue(this.childNodes, "slot");
Promise.resolve().then(() => this[triggerAllSlotchangeCallbacks]());
//Above is the extra trigger needed to fix the missing initial-`slotchange`-event in Safari.
//We can await this in the microtask que, so that normal slotchange events in Chrome is triggered normally.
//However, if we don't do this, the calls could be batched, making the Mixin slightly more efficient.
}
}
};