@dobschal/html.js
Version:
Library to create HTML elements with JavaScript that support MVVM binding.
348 lines (302 loc) • 11.4 kB
JavaScript
const {Computed} = require("@dobschal/observable");
let id = 0;
function convertStringToDomNodes(htmlString) {
const fakeParent = document.createElement("template");
fakeParent.innerHTML = htmlString;
return Array.from(fakeParent.content.childNodes);
}
function isArrayOfSupportedValues(value) {
return Array.isArray(value)
&& value.every((item) => ["string", "number", "boolean"].includes(typeof item) || item instanceof HTMLElement || item instanceof Node);
}
function isEmptyArray(value) {
return Array.isArray(value) && value.length === 0;
}
function replaceStringOrHTMLElement(placeholderNode, value) {
if (value === undefined || value === null || value === "" || isEmptyArray(value)) {
const commentNode = document.createComment(" ❤️ ");
placeholderNode.replaceWith(commentNode);
return [commentNode];
} else if (isArrayOfSupportedValues(value)) {
const domNodes = [];
for (const item of value) {
if (item instanceof HTMLElement || item instanceof Node) {
domNodes.push(item);
} else {
domNodes.push(...convertStringToDomNodes(item));
}
}
placeholderNode.replaceWith(...domNodes);
return domNodes;
} else if (value instanceof HTMLElement) {
placeholderNode.replaceWith(value);
return [value];
} else if (["string", "number", "boolean"].includes(typeof value)) {
const domNodes = convertStringToDomNodes(value);
placeholderNode.replaceWith(...domNodes);
return domNodes;
} else {
throw new Error("Unsupported type for template placeholder: " + value);
}
}
function replacePlaceholderNode(placeholderNode, arg) {
if (arg?.isObservable) {
let elements = [placeholderNode];
arg.subscribe((value) => {
for (let i = 1; i < elements.length; i++) {
const element = elements[i];
element.remove();
}
elements = replaceStringOrHTMLElement(elements[0], value);
});
return;
}
if (typeof arg === "function") {
const computed = Computed(arg);
let elements = [placeholderNode];
computed.subscribe((value) => {
for (let i = 1; i < elements.length; i++) {
const element = elements[i];
element.remove();
}
elements = replaceStringOrHTMLElement(elements[0], value);
});
return;
}
replaceStringOrHTMLElement(placeholderNode, arg);
}
function makePlaceholderId(i, instanceId) {
return `_r_${instanceId}_${i}_`;
}
function findNodeByAttributeValue(fakeParent, placeholder) {
const elementWalker = document.createTreeWalker(
fakeParent,
NodeFilter.SHOW_ELEMENT,
);
while (elementWalker.nextNode()) {
const node = elementWalker.currentNode;
for (const attributeName of node.getAttributeNames()) {
const value = node.getAttribute(attributeName);
if (value.includes(placeholder)) {
return [node, attributeName];
}
}
}
return [];
}
function findNodeByAttributeKey(fakeParent, attributeKey) {
const elementWalker = document.createTreeWalker(
fakeParent,
NodeFilter.SHOW_ELEMENT,
);
while (elementWalker.nextNode()) {
const node = elementWalker.currentNode;
if (node.getAttributeNames().includes(attributeKey)) {
return node;
}
}
return undefined;
}
function handleClassList(node, arg, placeholder) {
if (arg?.isObservable) {
let currentClass = placeholder;
arg.subscribe((value) => {
if (currentClass) node.classList.remove(currentClass);
if (value) node.classList.add(value);
currentClass = value;
});
return;
}
if (typeof arg === "function") {
const computed = Computed(arg);
let currentClass = placeholder;
computed.subscribe((value) => {
if (currentClass) node.classList.remove(currentClass);
if (value) node.classList.add(value);
currentClass = value;
});
return;
}
if(arg) node.classList.add(arg);
node.classList.remove(placeholder);
}
// this handles the case that the whole attribute including key and value is
// spliced in the template. Inside the placeholder attribute we store the
// actual returned attribute key and be able to update it
function handleDynamicAttribute(node, arg, placeholder) {
function update(val) {
let [key, value] = val.split("=", 2);
value = (value ?? "''").slice(1, -1);
const lastKey = node.getAttribute(placeholder);
if (lastKey && node.hasAttribute(lastKey)) {
node.removeAttribute(lastKey);
}
node.setAttribute(placeholder, key);
if(key) {
node.setAttribute(key, value);
}
}
if (arg?.isObservable) {
arg.subscribe((value) => update(value));
return;
}
if (typeof arg === "function") {
const computed = Computed(arg);
computed.subscribe((value) => update(value));
return;
}
update(arg);
}
function handleIfAttribute(node, attributeKey, arg) {
const nextSiblingHasElseAttribute = node.nextElementSibling?.getAttributeNames().includes("else");
const placeholderNode = document.createComment(" ❤️ ");
let nextSibling = node.nextElementSibling;
const siblingPlaceholder = document.createComment(" ❤️ ");
if (node.tagName === "HOLD-PASS") {
const child = node.firstElementChild;
node.replaceWith(child);
node = child;
}
if (nextSiblingHasElseAttribute && nextSibling.tagName === "HOLD-PASS") {
const child = nextSibling.firstElementChild;
nextSibling.replaceWith(child);
nextSibling = child;
}
node.removeAttribute(attributeKey);
function update(value) {
if (attributeKey === "if-not") value = !value;
if (!value) {
node.replaceWith(placeholderNode);
if (nextSiblingHasElseAttribute) {
siblingPlaceholder.replaceWith(nextSibling);
}
} else {
placeholderNode.replaceWith(node);
if (nextSiblingHasElseAttribute) {
nextSibling.replaceWith(siblingPlaceholder);
}
}
}
if (arg?.isObservable) {
arg.subscribe((value) => update(value));
return;
}
if (typeof arg === "function") {
const computed = Computed(arg);
computed.subscribe((value) => update(value));
return;
}
update(arg);
}
function replaceAttributePlaceholder(node, attributeKey, arg, placeholder) {
// If no attribute key is given, the whole attribute will be replaced
if(!attributeKey) {
handleDynamicAttribute(node, arg, placeholder);
return;
}
if (attributeKey === "if") {
handleIfAttribute(node, attributeKey, arg);
return;
}
if (attributeKey === "if-not") {
handleIfAttribute(node, attributeKey, arg);
return;
}
if (attributeKey === "class") {
if (!node.classList.contains(placeholder)) {
throw new Error("Fatal: Could not find placeholder in class attribute: " + placeholder);
}
handleClassList(node, arg, placeholder);
return;
}
if (attributeKey.startsWith("on")) {
if (typeof arg !== "function") {
throw new Error("Attribute " + attributeKey + " expects a function, but got: " + arg);
}
node.addEventListener(attributeKey.slice(2), arg);
node.removeAttribute(attributeKey);
return;
}
const [before, after] = node.getAttribute(attributeKey).split(placeholder);
if (arg?.isObservable) {
if (attributeKey === "value") {
node.addEventListener("input", (event) => arg.value = event.target.value);
}
arg.subscribe((value) => {
setNodeAttribute(node, attributeKey, before + value + after);
placeholder = value;
});
return;
}
if (typeof arg === "function") {
const computed = Computed(arg);
computed.subscribe((value) => {
setNodeAttribute(node, attributeKey, before + value + after);
placeholder = value;
});
return;
}
setNodeAttribute(node, attributeKey, before + arg + after);
}
function setNodeAttribute(node, attributeKey, value) {
if (attributeKey === "value") {
node.value = value;
} else {
node.setAttribute(attributeKey, value);
}
}
function html(templateParts, ...args) {
const instanceId = id++;
const htmlPlaceholderIndices = new Set();
const htmlWithPlaceholders = templateParts.reduce((acc, part, i) => {
if (i === 0) {
part = part.trimStart();
}
if (i === templateParts.length - 1) {
part = part.trimEnd();
}
if (args[i] === undefined) {
if (i === templateParts.length - 1) {
return acc + part;
}
args[i] = "";
}
const amountCloseTags = ((acc + part).match(/>/g) || []).length;
const amountOpenTags = ((acc + part).match(/</g) || []).length;
const isAttribute = amountCloseTags !== amountOpenTags;
if (isAttribute) {
return acc + part + makePlaceholderId(i, instanceId);
}
htmlPlaceholderIndices.add(i);
return acc + part + `<template id="${makePlaceholderId(i, instanceId)}"></template>`;
}, "");
const fakeParent = document.createElement("template");
fakeParent.innerHTML = htmlWithPlaceholders;
args.forEach((arg, i) => {
if (htmlPlaceholderIndices.has(i)) {
const placeholderNode = fakeParent.content.querySelector("#" + makePlaceholderId(i, instanceId));
if (!placeholderNode) {
throw new Error("Fatal: Could not find placeholder for argument: " + i);
}
replacePlaceholderNode(placeholderNode, arg);
} else {
let [node, attributeKey] = findNodeByAttributeValue(fakeParent.content, makePlaceholderId(i, instanceId));
// Sometimes the attribute key itself is dynamic --> so we need to find the node by the attribute key
if (!node) {
node = findNodeByAttributeKey(fakeParent.content, makePlaceholderId(i, instanceId));
if (!node) {
throw new Error("Fatal: Could not find placeholder for argument: " + i + " (" + makePlaceholderId(i, instanceId) + ")");
}
}
if (node.tagName === "HOLD-PASS") {
setTimeout(() => replaceAttributePlaceholder(node, attributeKey, arg, makePlaceholderId(i, instanceId)));
} else {
replaceAttributePlaceholder(node, attributeKey, arg, makePlaceholderId(i, instanceId));
}
}
});
return fakeParent.content.childNodes.length > 1 ? Array.from(fakeParent.content.childNodes) : fakeParent.content.firstChild;
}
customElements.define("hold-pass", class extends HTMLElement {
});
module.exports = html;