@polight/lego
Version:
Tiny Web Components lib for future-proof HTML mentors
754 lines (693 loc) • 20.4 kB
JavaScript
const EMPTY_OBJECT = {};
const VTYPE_ELEMENT = 1;
const VTYPE_FUNCTION = 2;
const VTYPE_COMPONENT = 4;
const isEmpty = (c) =>
c === null || (Array.isArray(c) && c.length === 0);
const isNonEmptyArray = (c) => Array.isArray(c) && c.length > 0;
const isLeaf = (c) => typeof c === "string" || typeof c === "number";
const isElement = (c) => c?.vtype === VTYPE_ELEMENT;
const isRenderFunction = (c) => c?.vtype === VTYPE_FUNCTION;
const isComponent = (c) => c?.vtype === VTYPE_COMPONENT;
const isValidComponentType = (c) => typeof c?.mount === "function";
function h(type, props, ...children) {
props = props ?? EMPTY_OBJECT;
props =
children.length > 1
? Object.assign({}, props, { children })
: children.length === 1
? Object.assign({}, props, { children: children[0] })
: props;
return jsx(type, props, props.key);
}
function jsx(type, props, key) {
if (key !== key) throw new Error("Invalid NaN key");
const vtype =
typeof type === "string"
? VTYPE_ELEMENT
: isValidComponentType(type)
? VTYPE_COMPONENT
: typeof type === "function"
? VTYPE_FUNCTION
: undefined;
if (vtype === undefined) throw new Error("Invalid VNode type");
return {
vtype,
type,
key,
props,
};
}
const REF_SINGLE = 1; // ref with a single dom node
const REF_ARRAY = 4; // ref with an array od nodes
const REF_PARENT = 8; // ref with a child ref
const SVG_NS = "http://www.w3.org/2000/svg";
function propDirective(prop) {
return {
mount(element, value) {
element[prop] = value;
},
patch(element, newValue, oldValue) {
if (newValue !== oldValue) {
element[prop] = newValue;
}
},
unmount(element, _) {
element[prop] = null;
},
};
}
const DOM_PROPS_DIRECTIVES = {
selected: propDirective("selected"),
checked: propDirective("checked"),
value: propDirective("value"),
innerHTML: propDirective("innerHTML"),
};
/**
TODO: activate full namespaced attributes (not supported in JSX)
const XML_NS = "http://www.w3.org/XML/1998/namespace"
**/
const XLINK_NS = "http://www.w3.org/1999/xlink";
const NS_ATTRS = {
show: XLINK_NS,
actuate: XLINK_NS,
href: XLINK_NS,
};
function getDomNode(ref) {
if (ref.type === REF_SINGLE) {
return ref.node;
} else if (ref.type === REF_ARRAY) {
return getDomNode(ref.children[0]);
} else if (ref.type === REF_PARENT) {
return getDomNode(ref.childRef);
}
throw new Error("Unkown ref type " + JSON.stringify(ref));
}
function getParentNode(ref) {
if (ref.type === REF_SINGLE) {
return ref.node.parentNode;
} else if (ref.type === REF_ARRAY) {
return getParentNode(ref.children[0]);
} else if (ref.type === REF_PARENT) {
return getParentNode(ref.childRef);
}
throw new Error("Unkown ref type " + ref);
}
function getNextSibling(ref) {
if (ref.type === REF_SINGLE) {
return ref.node.nextSibling;
} else if (ref.type === REF_ARRAY) {
return getNextSibling(ref.children[ref.children.length - 1]);
} else if (ref.type === REF_PARENT) {
return getNextSibling(ref.childRef);
}
throw new Error("Unkown ref type " + JSON.stringify(ref));
}
function insertDom(parent, ref, nextSibling) {
if (ref.type === REF_SINGLE) {
parent.insertBefore(ref.node, nextSibling);
} else if (ref.type === REF_ARRAY) {
ref.children.forEach((ch) => {
insertDom(parent, ch, nextSibling);
});
} else if (ref.type === REF_PARENT) {
insertDom(parent, ref.childRef, nextSibling);
} else {
throw new Error("Unkown ref type " + JSON.stringify(ref));
}
}
function removeDom(parent, ref) {
if (ref.type === REF_SINGLE) {
parent.removeChild(ref.node);
} else if (ref.type === REF_ARRAY) {
ref.children.forEach((ch) => {
removeDom(parent, ch);
});
} else if (ref.type === REF_PARENT) {
removeDom(parent, ref.childRef);
} else {
throw new Error("Unkown ref type " + ref);
}
}
function replaceDom(parent, newRef, oldRef) {
insertDom(parent, newRef, getDomNode(oldRef));
removeDom(parent, oldRef);
}
function mountDirectives(domElement, props, env) {
for (let key in props) {
if (key in env.directives) {
env.directives[key].mount(domElement, props[key]);
}
}
}
function patchDirectives(domElement, newProps, oldProps, env) {
for (let key in newProps) {
if (key in env.directives) {
env.directives[key].patch(domElement, newProps[key], oldProps[key]);
}
}
for (let key in oldProps) {
if (key in env.directives && !(key in newProps)) {
env.directives[key].unmount(domElement, oldProps[key]);
}
}
}
function unmountDirectives(domElement, props, env) {
for (let key in props) {
if (key in env.directives) {
env.directives[key].unmount(domElement, props[key]);
}
}
}
function mountAttributes(domElement, props, env) {
for (var key in props) {
if (key === "key" || key === "children" || key in env.directives) continue;
if (key.startsWith("on")) {
domElement[key.toLowerCase()] = props[key];
} else {
setDOMAttribute(domElement, key, props[key], env.isSVG);
}
}
}
function patchAttributes(domElement, newProps, oldProps, env) {
for (var key in newProps) {
if (key === "key" || key === "children" || key in env.directives) continue;
var oldValue = oldProps[key];
var newValue = newProps[key];
if (oldValue !== newValue) {
if (key.startsWith("on")) {
domElement[key.toLowerCase()] = newValue;
} else {
setDOMAttribute(domElement, key, newValue, env.isSVG);
}
}
}
for (key in oldProps) {
if (
key === "key" ||
key === "children" ||
key in env.directives ||
key in newProps
)
continue;
if (key.startsWith("on")) {
domElement[key.toLowerCase()] = null;
} else {
domElement.removeAttribute(key);
}
}
}
function setDOMAttribute(el, attr, value, isSVG) {
if (value === true) {
el.setAttribute(attr, "");
} else if (value === false) {
el.removeAttribute(attr);
} else {
var namespace = isSVG ? NS_ATTRS[attr] : undefined;
if (namespace !== undefined) {
el.setAttributeNS(namespace, attr, value);
} else {
el.setAttribute(attr, value);
}
}
}
const DEFAULT_ENV = {
isSvg: false,
directives: DOM_PROPS_DIRECTIVES,
};
class Renderer {
constructor(props, env) {
this.props = props;
this._STATE_ = {
env,
vnode: null,
parentDomNode: null,
ref: mount(null),
};
this.render = this.render.bind(this);
}
setProps(props) {
this.oldProps = this.props;
this.props = props;
}
render(vnode) {
const state = this._STATE_;
const oldVNode = state.vnode;
state.vnode = vnode;
if (state.parentDomNode == null) {
let parentNode = getParentNode(state.ref);
if (parentNode == null) {
state.ref = mount(vnode, state.env);
return;
} else {
state.parentDomNode = parentNode;
}
}
// here we're sure state.parentDOMNode is defined
state.ref = patchInPlace(
state.parentDomNode,
vnode,
oldVNode,
state.ref,
state.env
);
}
}
function mount(vnode, env = DEFAULT_ENV) {
if (isEmpty(vnode)) {
return {
type: REF_SINGLE,
node: document.createComment("NULL"),
};
} else if (isLeaf(vnode)) {
return {
type: REF_SINGLE,
node: document.createTextNode(vnode),
};
} else if (isElement(vnode)) {
let node;
let { type, props } = vnode;
if (type === "svg" && !env.isSvg) {
env = Object.assign({}, env, { isSVG: true });
}
// TODO : {is} for custom elements
if (!env.isSVG) {
node = document.createElement(type);
} else {
node = document.createElementNS(SVG_NS, type);
}
mountAttributes(node, props, env);
let childrenRef =
props.children == null ? props.children : mount(props.children, env);
/**
* We need to insert content before setting interactive props
* that rely on children been present (e.g select)
*/
if (childrenRef != null) insertDom(node, childrenRef);
mountDirectives(node, props, env);
return {
type: REF_SINGLE,
node,
children: childrenRef,
};
} else if (isNonEmptyArray(vnode)) {
return {
type: REF_ARRAY,
children: vnode.map((child) => mount(child, env)),
};
} else if (isRenderFunction(vnode)) {
let childVNode = vnode.type(vnode.props);
let childRef = mount(childVNode, env);
return {
type: REF_PARENT,
childRef,
childState: childVNode,
};
} else if (isComponent(vnode)) {
let renderer = new Renderer(vnode.props, env);
vnode.type.mount(renderer);
return {
type: REF_PARENT,
childRef: renderer._STATE_.ref,
childState: renderer,
};
} else if (vnode instanceof Node) {
return {
type: REF_SINGLE,
node: vnode,
};
}
if (vnode === undefined) {
throw new Error("mount: vnode is undefined!");
}
throw new Error("mount: Invalid Vnode!");
}
function patch(
parentDomNode,
newVNode,
oldVNode,
ref,
env = DEFAULT_ENV
) {
if (oldVNode === newVNode) {
return ref;
} else if (isEmpty(newVNode) && isEmpty(oldVNode)) {
return ref;
} else if (isLeaf(newVNode) && isLeaf(oldVNode)) {
ref.node.nodeValue = newVNode;
return ref;
} else if (
isElement(newVNode) &&
isElement(oldVNode) &&
newVNode.type === oldVNode.type
) {
if (newVNode.type === "svg" && !env.isSvg) {
env = Object.assign({}, env, { isSVG: true });
}
patchAttributes(ref.node, newVNode.props, oldVNode.props, env);
let oldChildren = oldVNode.props.children;
let newChildren = newVNode.props.children;
if (oldChildren == null) {
if (newChildren != null) {
ref.children = mount(newChildren, env);
insertDom(ref.node, ref.children);
}
} else {
if (newChildren == null) {
ref.node.textContent = "";
unmount(oldChildren, ref.children, env);
ref.children = null;
} else {
ref.children = patchInPlace(
ref.node,
newChildren,
oldChildren,
ref.children,
env
);
}
}
patchDirectives(ref.node, newVNode.props, oldVNode.props, env);
return ref;
} else if (isNonEmptyArray(newVNode) && isNonEmptyArray(oldVNode)) {
patchChildren(parentDomNode, newVNode, oldVNode, ref, env);
return ref;
} else if (
isRenderFunction(newVNode) &&
isRenderFunction(oldVNode) &&
newVNode.type === oldVNode.type
) {
let renderFn = newVNode.type;
let shouldUpdate =
renderFn.shouldUpdate != null
? renderFn.shouldUpdate(oldVNode.props, newVNode.props)
: defaultShouldUpdate(oldVNode.props, newVNode.props);
if (shouldUpdate) {
let childVNode = renderFn(newVNode.props);
let childRef = patch(
parentDomNode,
childVNode,
ref.childState,
ref.childRef,
env
);
// We need to return a new ref in order for parent patches to
// properly replace changing DOM nodes
if (childRef !== ref.childRef) {
return {
type: REF_PARENT,
childRef,
childState: childVNode,
};
} else {
ref.childState = childVNode;
return ref;
}
} else {
return ref;
}
} else if (
isComponent(newVNode) &&
isComponent(oldVNode) &&
newVNode.type === oldVNode.type
) {
const renderer = ref.childState;
const state = renderer._STATE_;
state.env = env;
state.parentNode = parentDomNode;
renderer.setProps(newVNode.props);
newVNode.type.patch(renderer);
if (ref.childRef !== state.ref) {
return {
type: REF_PARENT,
childRef: state.ref,
childState: renderer,
};
} else {
return ref;
}
} else if (newVNode instanceof Node && oldVNode instanceof Node) {
ref.node = newVNode;
return ref;
} else {
return mount(newVNode, env);
}
}
/**
* Execute any compoenent specific unmount code
*/
function unmount(vnode, ref, env) {
// if (vnode instanceof Node || isEmpty(vnode) || isLeaf(vnode)) return;
if (isElement(vnode)) {
unmountDirectives(ref.node, vnode.props, env);
if (vnode.props.children != null)
unmount(vnode.props.children, ref.children, env);
} else if (isNonEmptyArray(vnode)) {
vnode.forEach((childVNode, index) =>
unmount(childVNode, ref.children[index], env)
);
} else if (isRenderFunction(vnode)) {
unmount(ref.childState, ref.childRef, env);
} else if (isComponent(vnode)) {
vnode.type.unmount(ref.childState);
}
}
function patchInPlace(parentDomNode, newVNode, oldVNode, ref, env) {
const newRef = patch(parentDomNode, newVNode, oldVNode, ref, env);
if (newRef !== ref) {
replaceDom(parentDomNode, newRef, ref);
unmount(oldVNode, ref, env);
}
return newRef;
}
function patchChildren(parentDomNode, newChildren, oldchildren, ref, env) {
// We need to retreive the next sibling before the old children
// get eventually removed from the current DOM document
const nextNode = getNextSibling(ref);
const children = Array(newChildren.length);
let refChildren = ref.children;
let newStart = 0,
oldStart = 0,
newEnd = newChildren.length - 1,
oldEnd = oldchildren.length - 1;
let oldVNode, newVNode, oldRef, newRef, refMap;
while (newStart <= newEnd && oldStart <= oldEnd) {
if (refChildren[oldStart] === null) {
oldStart++;
continue;
}
if (refChildren[oldEnd] === null) {
oldEnd--;
continue;
}
oldVNode = oldchildren[oldStart];
newVNode = newChildren[newStart];
if (newVNode?.key === oldVNode?.key) {
oldRef = refChildren[oldStart];
newRef = children[newStart] = patchInPlace(
parentDomNode,
newVNode,
oldVNode,
oldRef,
env
);
newStart++;
oldStart++;
continue;
}
oldVNode = oldchildren[oldEnd];
newVNode = newChildren[newEnd];
if (newVNode?.key === oldVNode?.key) {
oldRef = refChildren[oldEnd];
newRef = children[newEnd] = patchInPlace(
parentDomNode,
newVNode,
oldVNode,
oldRef,
env
);
newEnd--;
oldEnd--;
continue;
}
if (refMap == null) {
refMap = {};
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldchildren[i];
if (oldVNode?.key != null) {
refMap[oldVNode.key] = i;
}
}
}
newVNode = newChildren[newStart];
const idx = newVNode?.key != null ? refMap[newVNode.key] : null;
if (idx != null) {
oldVNode = oldchildren[idx];
oldRef = refChildren[idx];
newRef = children[newStart] = patch(
parentDomNode,
newVNode,
oldVNode,
oldRef,
env
);
insertDom(parentDomNode, newRef, getDomNode(refChildren[oldStart]));
if (newRef !== oldRef) {
removeDom(parentDomNode, oldRef);
unmount(oldVNode, oldRef, env);
}
refChildren[idx] = null;
} else {
newRef = children[newStart] = mount(newVNode, env);
insertDom(parentDomNode, newRef, getDomNode(refChildren[oldStart]));
}
newStart++;
}
const beforeNode =
newEnd < newChildren.length - 1
? getDomNode(children[newEnd + 1])
: nextNode;
while (newStart <= newEnd) {
const newRef = mount(newChildren[newStart], env);
children[newStart] = newRef;
insertDom(parentDomNode, newRef, beforeNode);
newStart++;
}
while (oldStart <= oldEnd) {
oldRef = refChildren[oldStart];
if (oldRef != null) {
removeDom(parentDomNode, oldRef);
unmount(oldchildren[oldStart], oldRef, env);
}
oldStart++;
}
ref.children = children;
}
function defaultShouldUpdate(p1, p2) {
if (p1 === p2) return false;
for (let key in p2) {
if (p1[key] !== p2[key]) return true;
}
return false;
}
function render(vnode, parentDomNode, options = {}) {
let rootRef = parentDomNode.$$PETIT_DOM_REF;
let env = Object.assign({}, DEFAULT_ENV);
Object.assign(env.directives, options.directives);
if (rootRef == null) {
const ref = mount(vnode, env);
parentDomNode.$$PETIT_DOM_REF = { ref, vnode };
parentDomNode.textContent = "";
insertDom(parentDomNode, ref, null);
} else {
rootRef.ref = patchInPlace(
parentDomNode,
vnode,
rootRef.vnode,
rootRef.ref,
env
);
rootRef.vnode = vnode;
}
}
function toCamelCase(name) {
if (!name.includes('-')) return name
return name.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase())
}
function sanitizeAttribute(attrType, attrValue) {
if (attrType === 'object') return sanitizeJsonAttribute(attrValue)
if (attrType === 'boolean') return attrValue === "" || !!attrValue
if (attrType === 'number') return Number(attrValue)
return attrValue
}
function sanitizeJsonAttribute(attrValue) {
try {
return JSON.parse(attrValue)
} catch (_) {
return attrValue
}
}
class Component extends HTMLElement {
state = {}
useShadowDOM = true
#watchProps = []
#isConnected = false
#isInitialized = false
#customEvents = []
#ready() {
this.init?.();
this.#watchProps = Object.keys(this.state);
this.#syncAttributesToState();
this.document = this.useShadowDOM
? this.attachShadow({ mode: "open" })
: this;
this.#isInitialized = true;
}
#syncAttributesToState() {
this.state = Array.from(this.attributes).reduce(
(state, attr) => {
const camelCaseName = toCamelCase(attr.name);
const attrType = typeof this.state[camelCaseName];
return {
...state,
[camelCaseName]: sanitizeAttribute(attrType, attr.value),
}
},
this.state
);
}
get vdom() {
return ({ state }) => ""
}
get vstyle() {
return ({ state }) => ""
}
setAttribute(name, value) {
if (name.match(/@[a-z]+(?:-[a-z]+)*/)) return this.#customEvents.push([name.slice(1), value])
super.setAttribute(name, typeof value === 'object' ? JSON.stringify(value) : value);
const prop = toCamelCase(name);
const attrType = typeof this.state[prop];
if (this.#watchProps.includes(prop)) this.render({ [prop]: sanitizeAttribute(attrType, value) });
}
removeAttribute(name) {
super.removeAttribute(name);
const prop = toCamelCase(name);
const attrType = typeof this.state[prop];
if (this.#watchProps.includes(prop) && prop in this.state) {
this.render({ [prop]: sanitizeAttribute(attrType, null) });
}
}
connectedCallback() {
if (!this.#isInitialized) this.#ready();
this.#isConnected = true;
// Load the DOM
this.render();
this.#customEvents.forEach(([customEvent, listener]) => this.addEventListener(customEvent, listener));
this.connected?.();
}
disconnectedCallback() {
this.#isConnected = false;
this.#customEvents.forEach(([customEvent, listener]) => this.removeEventListener(customEvent, listener));
this.disconnected?.();
}
setState(props = {}) {
Object.assign(this.state, props);
if (this.changed && this.#isConnected) this.changed(props);
}
set state(value) {
this.setState(value);
}
get state() {
return this.state
}
render(state) {
if (state) this.setState(state);
if (!this.#isConnected) return
render(
[this.vdom({ state: this.state }), this.vstyle({ state: this.state })],
this.document
);
this.rendered?.(state);
}
}
export { Component, h, render };