/** * Create a text node with the contents * @param {string} text The text content * @returns The text node */ export function t(text) { return document.createTextNode(text); } /** * @typedef {"stop"|"prevent"|"self"} EventModifier */ /** * * @param {CallableFunction} fn * @param {EventModifier|EventModifier[]} modifiers * @returns {CallableFunction} */ export function withModifiers(fn, modifiers) { if (!Array.isArray(modifiers)) { modifiers = [modifiers]; } return function (/** @type {Event} */ event , /** @type {any} */ ...args) { for (const modifier of modifiers) { if (modifier === "stop") { event.stopPropagation(); } else if (modifier === "prevent") { event.preventDefault(); } else if (modifier === "self") { if (event.target !== event.currentTarget) { return; } } } return fn(event, ...args); } } /** * @typedef {{ style?: Record|Record[], class?: Record } & Record} HtmlAttrs */ /** * Assign attributes to the node * @param {HTMLElement} node * @param {HtmlAttrs} attrs */ function setAttrs(node, attrs) { for (const key in attrs) { if (!Object.hasOwn(attrs, key)) continue; if (key === "style" && typeof attrs[key] === "object") { let styleAttrs = attrs[key]; if (!Array.isArray(styleAttrs)) { styleAttrs = [styleAttrs]; } for (const styles of styleAttrs) { for (const styleProp in styles) { if (!Object.hasOwn(styles, styleProp)) continue; const val = styles[styleProp]; node.style.setProperty(styleProp, val); } } } else if (key === "class" && typeof attrs[key] === "object" && !Array.isArray(attrs[key])) { for (const className in attrs[key]) { if (!Object.hasOwn(attrs[key], className)) continue; const element = attrs[key][className]; if (element) { node.classList.add(className); } } } else if (typeof attrs[key] === "string") { node.setAttribute(key, attrs[key]); } else if (typeof attrs[key] === "function" && /^on[A-Z]/.test(key)) { const eventSpec = key.substring(2); // Loop through PascalCase substrings const re = /[A-Z][a-z]+/g; const results = [...eventSpec.matchAll(re)]; if (results.length === 0) { throw new Error(`Invalid event listener: on${eventSpec}`); } let eventName = results[0][0].toLowerCase(); let options = /** @type {AddEventListenerOptions} */ ({}); for (const regexResult of results.slice(1)) { let modifier = regexResult[0].toLowerCase(); if (modifier === "capture") { options.capture = true; } else if (modifier === "once") { options.once = true; } else if (modifier === "passive") { options.passive = true; } else { throw new Error(`Unknown modifier: ${modifier}`); } } node.addEventListener(eventName, attrs[key], options); } else { throw new TypeError(`Unsupported attribute type ${typeof attrs[key]}`); } } } /** * Add the children to the node * @param {HTMLElement} node * @param {Array} children */ function setChildren(node, children) { for (const c of children) { if (typeof c === "string") { node.appendChild(t(c)); } else { node.appendChild(c); } } } /** * Build a HTMLElement * @overload * @param {keyof HTMLElementTagNameMap} tag * @returns {HTMLElement} */ /** * @overload * @param {keyof HTMLElementTagNameMap} tag * @param {string} text * @returns {HTMLElement} */ /** * @overload * @param {keyof HTMLElementTagNameMap} tag * @param {Array} children * @returns {HTMLElement} */ /** * @overload * @param {keyof HTMLElementTagNameMap} tag * @param {HtmlAttrs} attrs * @returns {HTMLElement} */ /** * @overload * @param {keyof HTMLElementTagNameMap} tag * @param {HtmlAttrs} attrs * @param {string} text * @returns {HTMLElement} */ /** * @overload * @param {keyof HTMLElementTagNameMap} tag * @param {HtmlAttrs} attrs * @param {Array} children * @returns {HTMLElement} */ /** * @param {keyof HTMLElementTagNameMap} tag * @param {...(HtmlAttrs|Array|string)} args * @returns {HTMLElement} */ export function h(tag, ...args) { let node = document.createElement(tag); if (args.length === 0) { return node; } else if (args.length === 1) { const attrs = args[0]; if (typeof attrs === "object" && Array.isArray(attrs)) { // Children setChildren(node, attrs); return node; } else if (typeof attrs === "object") { // Attributes setAttrs(node, attrs); return node; } else if (typeof attrs === "string") { // Text node child const textNode = document.createTextNode(attrs); node.appendChild(textNode); return node; } else { throw new TypeError(`Invalid second argument type: ${typeof attrs}`); } } else if (args.length === 2) { // Second arg must be attributes, third arg must be children const attrs = args[0]; const children = args[1]; if (typeof attrs !== "object" || Array.isArray(attrs)) { throw new TypeError("Second argument must be an object"); } setAttrs(node, attrs); if (typeof children === "string") { node.appendChild(t(children)); } else if (typeof children === "object" && Array.isArray(children)) { setChildren(node, children); } else { throw new TypeError("Third argument must be an array or string"); } return node; } else { throw new Error("Too many arguments"); } } function getById(id) { let element = document.getElementById(id); if (element) { return element; } else { throw `Element with id '${id}' not found.`; } } function writeError(error) { writeLog(error, "error"); } function writeInfo(msg) { writeLog(msg, "info"); } function writeDebug(msg) { writeLog(msg, "debug"); } /** * Normalise anything writeLog is handed into children `h` accepts. * @param {string|String|Node|Array|Error|unknown} msg * @returns {Array} */ function logChildren(msg) { if (typeof msg === "string" || msg instanceof String) { return [h("span", String(msg))]; } if (msg instanceof Node) { return [msg]; } if (Array.isArray(msg)) { return msg; } // Error, DOMException, or anything else: show its message. const message = typeof msg?.message === "string" ? msg.message : String(msg); return [h("span", message)]; } function writeLog(msg, className) { log.appendChild(h("div", {class: className}, logChildren(msg))); } function resetLog() { log.replaceChildren(); } const log = getById("log"); // https://stackoverflow.com/questions/75988682/debounce-in-javascript // https://www.joshwcomeau.com/snippets/javascript/debounce/ function debounce(callback, wait) { let timeoutId = null; return (...args) => { window.clearTimeout(timeoutId); timeoutId = window.setTimeout(() => { callback(...args); }, wait); }; } export { getById, writeError, writeInfo, writeDebug, resetLog, debounce, };