/**
* A Module that abstracts Virtual DOM interactions.
* It's purpose is to perform actions on DOM-like Objects
*
* @module vdom
*/
export { createDomElement, h, toVDOM, render, mount, diff, changed, mountMVC };
/**
* @typedef {{ tagName: string, attributes: object, children: any }} VNode
*/
/**
* Creates a new HTML Element.
* If the attribute is a function it will add it as an EventListener.
* Otherwise as an attribute.
*
* @param {string} tagName name of the tag
* @param {object} attributes attributes or listeners to set in element
* @param {*} innerHTML content of the tag
*
* @returns {HTMLElement}
*/
const createDomElement = (tagName, attributes = {}, innerHTML = '') => {
const $element = document.createElement(tagName);
$element.innerHTML = innerHTML;
Object.keys(attributes)
.filter(key => null != attributes[key]) // don't create attributes with value null/undefined
.forEach(key => {
if (typeof attributes[key] === 'function') {
$element.addEventListener(key, attributes[key]);
} else {
$element.setAttribute(key, attributes[key]);
}
});
return $element;
};
/**
* Creates a node object which can be rendered
*
* @param {string} tagName
* @param {object} attributes
* @param {VNode[] | VNode | any} nodes
*
* @returns {VNode}
*/
const vNode = (tagName, attributes = {}, ...nodes) => ({
tagName,
attributes: null == attributes ? {} : attributes,
children: null == nodes ? [] : [].concat(...nodes), // collapse nested arrays.
});
const h = vNode;
/**
* Converts a DOM Node to a Virtual Node
*
* @param {HTMLElement} $node
*
* @returns {VNode}
*/
const toVDOM = $node => {
const tagName = $node.tagName;
const $children = $node.children;
const attributes = Object.values($node.attributes).reduce((attributes, attribute) => {
attributes[attribute.name] = attribute.value;
return attributes;
}, {});
if ($children.length > 0) {
return vNode(tagName, attributes, Array.from($children).map(toVDOM));
}
return vNode(tagName, attributes, $node.textContent);
};
/**
* Renders a given node object
* Considers ELEMENT_NODE AND TEXT_NODE https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
*
* @param {VNode} node
*
* @returns {HTMLElement}
*/
const render = node => {
if (null == node) {
return document.createTextNode('');
}
if (typeof node === 'string' || typeof node === 'number') {
return document.createTextNode(node);
}
const $element = createDomElement(node.tagName, node.attributes);
node.children.forEach(c => $element.appendChild(render(c)));
return $element;
};
/**
* Renders given stateful view into given container
*
* @param {HTMLElement} $root
* @param {function(): VNode} view
* @param {object} state
* @param {boolean} diffing
*/
const mount = ($root, view, state, diffing = true) => {
const params = {
get state() {
return state;
},
setState,
};
let vDom = view(params);
$root.prepend(render(vDom));
function setState(newState) {
if (typeof newState === 'function') {
state = { ...state, ...newState(state) };
} else {
state = { ...state, ...newState };
}
refresh();
}
function refresh() {
const newVDom = view(params);
if (diffing) {
diff($root, newVDom, vDom);
} else {
$root.replaceChild(render(newVDom), $root.firstChild);
}
vDom = newVDom;
}
};
/**
* Renders given stateful view into given container (MVC approach)
*
* @param {HTMLElement} $root
* @param {object} model
* @param {function(): VNode} view
* @param {any} controller
* @param {boolean} diffing
*/
const mountMVC = ($root, model, view, controller, diffing = true) => {
let vDom = view(controller(model, refresh));
$root.prepend(render(vDom));
function refresh(model) {
const newVDom = view(controller(model, refresh));
if (diffing) {
diff($root, newVDom, vDom);
} else {
$root.replaceChild(render(newVDom), $root.firstChild);
}
vDom = newVDom;
}
};
/**
* Compares two VDOM nodes and applies the differences to the dom
*
* @param {HTMLElement} $parent
* @param {VNode} oldNode
* @param {VNode} newNode
* @param {number} index
*/
const diff = ($parent, newNode, oldNode, index = 0) => {
if (null == oldNode) {
$parent.appendChild(render(newNode));
return;
}
if (null == newNode) {
$parent.removeChild($parent.childNodes[index]);
return;
}
if (changed(oldNode, newNode)) {
$parent.replaceChild(render(newNode), $parent.childNodes[index]);
return;
}
if (newNode.tagName) {
newNode.children.forEach((newNode, i) => {
diff($parent.childNodes[index], newNode, oldNode.children[i], i);
});
}
};
/**
* compares two VDOM nodes and returns true if they are different
*
* @param {VNode} node1
* @param {VNode} node2
*/
const changed = (node1, node2) => {
const nodeChanged =
typeof node1 !== typeof node2 ||
((typeof node1 === 'string' || typeof node1 === 'number') && node1 !== node2) ||
node1.type !== node2.type;
const attributesChanged =
!!node1.attributes &&
!!node2.attributes &&
(Object.keys(node1.attributes).length !== Object.keys(node2.attributes).length ||
Object.keys(node1.attributes).some(
a =>
node1.attributes[a] !== node2.attributes[a] &&
(null == node1.attributes[a] ? '' : node1.attributes[a]).toString() !==
(null == node2.attributes[a] ? '' : node2.attributes[a]).toString()
));
return nodeChanged || attributesChanged;
};