joicomponents
Version:
Design patterns to build native web components
269 lines (228 loc) • 9.64 kB
Markdown
# Pattern: SlottablesEvent
## Theory: Slottables vs. Slotted
Before we dive into the implementation of this mixin, we need to establish two terms:
* **Slotted**: DOM nodes that *have been* transposed ('slotted') into a `<slot>` element in the
shadowDOM of a web component. The `slotchange` event alert about nodes that *has been transposed*.
In the flattened DOM, the nodes that are transposed/slotted into a `<slot>` element is contained
within that `<slot>` and hence its parent web component. The transposed nodes is part of
the *inner state* of a web component in the flattened DOM. This means that the `slotchange` event:
* **directly** reflect a change of the **inner state** of the web component's shadowDOM that is
* **indirectly** a reflection of a change of the **outer state** surrounding the web component's
host node in the lightDOM.
* **Slottables**: DOM nodes that *can be* transposed ('slotted') into a `<slot>` element in the
shadowDOM of a web component. Slottables are nodes that *has the potential* to be slotted if
a corresponding `<slot>` element either exists or will be created. There are no events about such
an occurrence.
In the DOM, the slottable nodes are part of the *outer state* of a web component.
The slottable nodes *might* be slotted, but they also may not. Thus, if there existed an event
`slottableschanged`, then this event would:
* **directly** reflect a change of the **outer state** surrounding the web component's
host node in the lightDOM.
## Implementation: `slottables-changed` event
To implement an event `slottables-changed` cannot be done globally as a ComposedEvent as it requires
the web component to both setup a `MutationObserver(childList)` on its host node, and listen for
the `slotchange` event that is `{composed: false}` and hence will not be available on neither
the `window` nor `document`. Instead, we use the EventRecording pattern to create a mixin that will
dispatch a `{composed: false, bubbles: false}` event on the web component host node.
The mixin will:
1. when the element is constructed, process it at the end of its construction cycle, just like
SlotchangeMixin.
2. Instead of adding an event listener on the `shadowRoot`, the mixin will register the element with a
`MutationObserver({childList: true})` instance.
3. In addition, the mixin will listen for `slotchange` events on the host node(!) As you may know,
this event listener *will not* capture `slotchange` events from inside the web component. But(!),
due to the particulars of the flattened DOM and SlotMatroska, the `slotchange` events from the
chained `<slot>` elements *will* pass by the host node. Thus, we are using the extremely strange
behavior of `slotchange` events to replace it, turning a big weakness into a boon.
This is so clever I am falling of the chair, blowing my own mind and feeling very good about
myself right now! :)
4. As the `slottables-changed` event is fired on the host node, it is accessible both from the
lightDOM and shadowDOM.
5. As the `slottables-changed` event doesn't bubble, it is quite safe and will not be susceptible for
SlotchangeNipSlip-like problems nor SlotchangeSurprises.
6. At startup, the mixin will need to sort the list of childNodes into `slot`->`name` groups.
7. Whenever the `slottables-changed` event arises, the mixin will need check which `slot`->`name` group(s)
where affected by the change. When the MutationObserver triggers, this will require a full check of
the childNodes. When the slotchange event triggers, this will require the check of the `<slot>`
elements among those children.
8. Before dispatching the `slottables-changed` event, the mixin will try to find a `<slot>` element in
the shadowDOM matching the given `slot`->`name` group.
* If it does, it will put that slot as part of the `slottables-changed` event detail.
* If it does not, it will instead add a Slottables object which has methods for
`assignedNodes({flatten: true})` and `.childNodes`.
9. The `slottables-changed` event does **not depend on the web component having a shadowDOM**.
It is practically independent (only relies on MutationObserver and slotchange events themselves).
```javascript
class Slottables {
constructor(name, assignables) {
this.name = name;
this.assignables = assignables || [];
}
assignedNodes(opt) {
if (!opt || !opt.flatten)
return this.assignables;
let res = [];
for (let n of this.assignables) {
if (n.tagName === "SLOT") {
const flat = n.assignedNodes({flatten: true}).length;
flat.length ? res = res.concat(flat) : res.push(flat);
} else
res.push(n);
}
return res;
}
}
function notNipSlip(composedPath, host) {
for (let node of composedPath) {
if (node.tagName !== "SLOT")
return null;
if (node.parentNode === host)
return node;
}
return null;
}
function mapNodesBySlotAttribute(nodes) {
const res = {};
for (let n of nodes) {
let name = n.getAttribute ? (n.getAttribute("slot") || "") : "";
(res[name] || (res[name] = [])).push(n);
}
return res;
}
function getSlotIfAny(el, name) {
const q = name === "" ? 'slot:not([name]), slot[name=""]' : 'slot[name="' + name + '"]';
return el.shadowRoot ? el.shadowRoot.querySelector(q): undefined;
}
function dispatchSlottablesEvent(el, name) {
const slot = getSlotIfAny(el, name) || new Slottables(name, el[nameNodesMap][name]);
const event = new CustomEvent("slottables-changed", {composed: false, bubbles: false, detail: {slot}});
el.dispatchEvent(event);
}
function checkSlotchange(slotchange) {
const slot = notNipSlip(slotchange.composedPath(), this);
const slotName = slot.getAttribute("slot") || "";
slot && dispatchSlottablesEvent(this, slotName);
}
function childListsAreTheSame(oldList, newList) {
if (!oldList || !newList || newList.length !== oldList.length)
return false;
for (let i = 0; i < newList.length; i++) {
if (newList[i] !== oldList[i])
return false;
}
return true;
}
function compareSlotNameMaps (newMap, oldMap) {
if (!oldMap)
return Object.keys(newMap);
// if (!newMap)
// return Object.keys(oldMap);
const res = [];
for (let key of Object.keys(newMap)) {
if (!childListsAreTheSame(newMap[key], oldMap[key]))
res.push(key);
}
for (let key of Object.keys(oldMap)) {
if (!newMap[key])
res.push(key);
}
return res;
}
function childNodesChanged(el) {
const newMap = mapNodesBySlotAttribute(el.childNodes);
const diffs = compareSlotNameMaps(newMap, el[nameNodesMap]);
el[nameNodesMap] = newMap;
for (let slotName of diffs)
dispatchSlottablesEvent(el, slotName);
}
function childNodesChangedObserver(data) {
for (let d of data)
childNodesChanged(d.target);
}
const childNodesObs = new MutationObserver(childNodesChangedObserver);
function setupNow(el) {
childNodesObs.observe(el, {childList: true});
el.addEventListener("slotchange", checkSlotchange.bind(el));
childNodesChanged(el);
}
function setup(el) {
Promise.resolve().then(function () {
Promise.resolve().then(function () {
setupNow(el);
});
});
}
if (document.readyState === "loading") {
const que = [];
const cachedSetup = setup;
setup = function (el) {
que.push(el);
};
document.addEventListener("DOMContentLoaded", function () {
setup = cachedSetup;
for (let el of que)
setupNow(el);
});
}
const nameNodesMap = Symbol("nameNodesMap");
export function SlottablesEvent(base) {
return class SlottablesEvent extends base {
constructor() {
super();
setup(this);
this[nameNodesMap] = {}; //slotNameString -> [nonFlattenNodes]
}
}
}
```
## Demo: SlottablesEvent
```html
<template>
<style>
div { border: 4px solid green; }
</style>
<div>
<slot>Hello sunshine</slot>
</div>
</template>
<script type="module">
import {SlottablesEvent} from "../../src/slot/SlottablesEvent.js";
class GreenFrame extends SlottablesEvent(HTMLElement) {
constructor() {
super();
this.attachShadow({mode: "open"});
const templ = document.querySelector("template").content.cloneNode(true);
this.shadowRoot.appendChild(templ);
this.addEventListener("slottables-changed", function(e){
console.log(this.id, e.detail, e);
}.bind(this));
}
}
customElements.define("green-frame", GreenFrame);
setTimeout(function () {
const two = document.querySelector("#two");
two.innerHTML = "";
const one = document.querySelector("#one");
one.innerHTML = "<green-frame id=\"four\"></green-frame>";
}, 1000);
</script>
<green-frame id="one"></green-frame>
<green-frame id="two"><h1>hello sunshine</h1>¯\_(ツ)_/¯</green-frame>
<green-frame id="three"><h1>hello sunshine</h1><h2 slot="nowYouSeeMe"></h2>¯\_(ツ)_/¯</green-frame>
<pre>
two {slot: slot}
three {slot: slot}
three {slot: Slottables}
two {slot: slot}
one {slot: slot}
//it doesn't print #four, nor "one, because there never are any slottables being added.
</pre>
```
`slottables-changed` event does not alert the developer:
1. when the element starts with its fallback nodes, it doesn't handle the initial fallback nodes state.
2. when a slot element is dynamically added to the shadowRoot after setup, *and* this `<slot>` uses
its fallback nodes by default.
3. when the childNodes of a slot element in fallbackMode is dynamically altered.
`slottables-changed` event only handles the outer changes.
## References
*