UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.

4 lines 767 kB
{ "version": 3, "sources": ["../src/front-end-module/libs/show-overlay.mjs", "../src/front-end-module/ui.mjs", "../node_modules/engine.io-parser/build/esm/commons.js", "../node_modules/engine.io-parser/build/esm/encodePacket.browser.js", "../node_modules/engine.io-parser/build/esm/contrib/base64-arraybuffer.js", "../node_modules/engine.io-parser/build/esm/decodePacket.browser.js", "../node_modules/engine.io-parser/build/esm/index.js", "../node_modules/@socket.io/component-emitter/lib/esm/index.js", "../node_modules/engine.io-client/build/esm/globals.js", "../node_modules/engine.io-client/build/esm/util.js", "../node_modules/engine.io-client/build/esm/contrib/parseqs.js", "../node_modules/engine.io-client/build/esm/transport.js", "../node_modules/engine.io-client/build/esm/transports/polling.js", "../node_modules/engine.io-client/build/esm/contrib/has-cors.js", "../node_modules/engine.io-client/build/esm/transports/polling-xhr.js", "../node_modules/engine.io-client/build/esm/transports/websocket.js", "../node_modules/engine.io-client/build/esm/transports/webtransport.js", "../node_modules/engine.io-client/build/esm/transports/index.js", "../node_modules/engine.io-client/build/esm/contrib/parseuri.js", "../node_modules/engine.io-client/build/esm/socket.js", "../node_modules/engine.io-client/build/esm/index.js", "../node_modules/socket.io-client/build/esm/url.js", "../node_modules/socket.io-parser/build/esm/index.js", "../node_modules/socket.io-parser/build/esm/is-binary.js", "../node_modules/socket.io-parser/build/esm/binary.js", "../node_modules/socket.io-client/build/esm/on.js", "../node_modules/socket.io-client/build/esm/socket.js", "../node_modules/socket.io-client/build/esm/contrib/backo2.js", "../node_modules/socket.io-client/build/esm/manager.js", "../node_modules/socket.io-client/build/esm/index.js", "../src/components/ti-base-component.mjs", "../src/components/uib-var.mjs", "../src/components/uib-meta.mjs", "../src/components/apply-template.mjs", "../src/components/uib-control.mjs", "../src/front-end-module/reactive.mjs", "../src/front-end-module/libs/format-date-time.mjs", "../src/front-end-module/uibuilder.module.mjs", "../src/front-end-module/experimental.mjs"], "sourcesContent": ["/**\n * @description Overlay window for displaying messages and notifications.\n * Included in the UI module and from there into the main uibuilder module.\n * @license Apache-2.0\n * @author Julian Knight (Totally Information)\n * @copyright (c) 2025-2025 Julian Knight (Totally Information)\n */\n\n/** Creates and displays an overlay window with customizable content and behavior\n * @param {object} options - Configuration options for the overlay\n * @param {string} [options.content] - Main content (text or HTML) to display\n * @param {string} [options.title] - Optional title above the main content\n * @param {string} [options.icon] - Optional icon to display left of title (HTML or text)\n * @param {string} [options.type] - Overlay type: 'success', 'info', 'warning', or 'error'\n * @param {boolean} [options.showDismiss] - Whether to show dismiss button (auto-determined if not set)\n * @param {number|null} [options.autoClose] - Auto-close delay in seconds (null for no auto-close)\n * @param {boolean} [options.time] - Show timestamp in overlay (default: true)\n * @returns {object} Object with close() method to manually close the overlay\n */\nexport function showOverlay(options = {}) {\n const {\n content = '',\n title = '',\n icon = '',\n type = 'info',\n showDismiss,\n autoClose = 5,\n time = true,\n } = options\n\n const overlayContainerId = 'uib-info-overlay'\n\n // Get or create the main overlay container\n let overlayContainer = document.getElementById(overlayContainerId)\n if (!overlayContainer) {\n overlayContainer = document.createElement('div')\n overlayContainer.id = overlayContainerId\n document.body.appendChild(overlayContainer)\n console.log('>> SHOW OVERLAY >>', options, document.getElementById(overlayContainerId))\n }\n\n // Generate unique ID for this overlay entry\n const entryId = `overlay-entry-${Date.now()}-${Math.random().toString(36)\n .substr(2, 9)}`\n\n // Create individual overlay entry\n const overlayEntry = document.createElement('div')\n overlayEntry.id = entryId\n overlayEntry.style.marginBottom = '0.5rem'\n\n // Define type-specific styles\n const typeStyles = {\n info: {\n iconDefault: '\u2139\uFE0F',\n titleDefault: 'Information',\n color: 'hsl(188.2deg 77.78% 40.59%)',\n },\n success: {\n iconDefault: '\u2705',\n titleDefault: 'Success',\n color: 'hsl(133.7deg 61.35% 40.59%)',\n },\n warning: {\n iconDefault: '\u26A0\uFE0F',\n titleDefault: 'Warning',\n color: 'hsl(35.19deg 84.38% 62.35%)',\n },\n error: {\n iconDefault: '\u274C',\n titleDefault: 'Error',\n color: 'hsl(2.74deg 92.59% 62.94%)',\n },\n }\n\n // @ts-ignore\n const currentTypeStyle = typeStyles[type] || typeStyles.info\n\n // Determine if dismiss button should be shown\n const shouldShowDismiss = showDismiss !== undefined ? showDismiss : (autoClose === null)\n\n // Create content HTML\n const iconHtml = icon || currentTypeStyle.iconDefault\n const titleText = title || currentTypeStyle.titleDefault\n\n // Generate timestamp if time option is enabled\n let timeHtml = ''\n if (time) {\n const now = new Date()\n const year = now.getFullYear()\n const month = String(now.getMonth() + 1).padStart(2, '0')\n const day = String(now.getDate()).padStart(2, '0')\n const hours = String(now.getHours()).padStart(2, '0')\n const minutes = String(now.getMinutes()).padStart(2, '0')\n const seconds = String(now.getSeconds()).padStart(2, '0')\n const timestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`\n timeHtml = `<div class=\"uib-overlay-time\" style=\"font-size: 0.8em; color: var(--text3, #999); margin-left: auto; margin-right: ${shouldShowDismiss ? '0.5rem' : '0'};\">${timestamp}</div>`\n }\n\n overlayEntry.innerHTML = /* html */ `\n <div class=\"uib-overlay-entry\" style=\"--callout-color:${currentTypeStyle.color};\">\n <div class=\"uib-overlay-header\">\n <div class=\"uib-overlay-icon\">${iconHtml}</div>\n <div class=\"uib-overlay-title\">${titleText}</div>\n ${timeHtml}\n ${shouldShowDismiss\n ? `<button class=\"uib-overlay-dismiss\" data-entry-id=\"${entryId}\" title=\"Close\">\u00D7</button>`\n : ''}\n </div>\n <div class=\"uib-overlay-content\">\n ${content}\n </div>\n </div>\n `\n\n // Add to overlay container at the top, sliding existing entries down\n if (overlayContainer.children.length > 0) {\n // Insert new entry at the top\n overlayContainer.insertBefore(overlayEntry, overlayContainer.firstChild)\n } else {\n // First entry, just add it normally\n overlayContainer.appendChild(overlayEntry)\n }\n\n // Close function for this specific entry\n const closeOverlayEntry = () => {\n const entry = document.getElementById(entryId)\n if (!entry) return\n\n entry.style.animation = 'slideOut 0.3s ease-in'\n setTimeout(() => {\n if (entry.parentNode) {\n entry.remove()\n // Remove the main container if no entries remain\n // const container = document.getElementById(overlayContainerId)\n // if (container && container.children.length === 0) {\n // container.remove()\n // }\n }\n }, 300)\n }\n\n // Add dismiss button event listener\n const dismissBtn = overlayEntry.querySelector('.uib-overlay-dismiss')\n if (dismissBtn) {\n dismissBtn.addEventListener('click', closeOverlayEntry)\n }\n\n // Set up auto-close if specified\n let autoCloseTimer = null\n if (autoClose !== null && autoClose > 0) {\n autoCloseTimer = setTimeout(closeOverlayEntry, autoClose * 1000)\n }\n\n // Return control object\n return {\n close: () => {\n if (autoCloseTimer) {\n clearTimeout(autoCloseTimer)\n }\n closeOverlayEntry()\n },\n id: entryId,\n }\n}\n\nexport default showOverlay\n", "// @ts-nocheck\n/* Creates HTML UI's based on a standardised data input.\n Works stand-alone, with uibuilder or with Node.js/jsdom.\n See: https://totallyinformation.github.io/node-red-contrib-uibuilder/#/client-docs/config-driven-ui\n\n Author: Julian Knight (Totally Information), March 2023\n\n License: Apache 2.0\n Copyright (c) 2022-2025 Julian Knight (Totally Information)\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n */\n\n// Namespaces - See https://stackoverflow.com/a/52572048/1309986\n// const NAMESPACES = {\n// svg: 'http://www.w3.org/2000/svg',\n// html: 'http://www.w3.org/1999/xhtml',\n// xml: 'http://www.w3.org/XML/1998/namespace',\n// xlink: 'http://www.w3.org/1999/xlink',\n// xmlns: 'http://www.w3.org/2000/xmlns/' // sic for the final slash...\n// }\n\nimport { showOverlay } from './libs/show-overlay.mjs'\n\nconst Ui = class Ui {\n // #region --- Class variables ---\n version = '7.7.0-src'\n\n // List of tags and attributes not in sanitise defaults but allowed in uibuilder.\n sanitiseExtraTags = ['uib-var']\n sanitiseExtraAttribs = ['variable', 'report', 'undefined']\n\n /** DOMPurify custom element handling options - allows all valid custom elements (hyphenated tags)\n * @type {{tagNameCheck: RegExp, attributeNameCheck: RegExp, allowCustomizedBuiltInElements: boolean}}\n */\n sanitiseCustomElementHandling = {\n tagNameCheck: /^[a-z][a-z0-9]*-[a-z0-9-]*$/,\n attributeNameCheck: /^[a-z_][\\w.-]*$/i,\n allowCustomizedBuiltInElements: false,\n }\n\n /** Reference to DOM window - must be passed in the constructor\n * Allows for use of this library/class with `jsdom` in Node.JS as well as the browser.\n * @type {Window}\n */\n static win\n\n /** Reference to the DOM top-level window.document for convenience - set in constructor @type {Document} */\n static doc\n\n /** Log function - passed in constructor or will be a dummy function\n * @type {Function}\n */\n static log\n\n /** Options for Markdown-IT if available (set in constructor) */\n static mdOpts\n /** Reference to pre-loaded Markdown-IT library */\n static md\n /** Optional Markdown-IT Plugins */\n ui_md_plugins\n // #endregion --- class variables ---\n\n /** Called when `new Ui(...)` is called\n * @param {globalThis} win Either the browser global window or jsdom dom.window\n * @param {Function} [extLog] A function that returns a function for logging\n * @param {Function} [jsonHighlight] A function that returns a highlighted HTML of JSON input\n */\n constructor(win, extLog, jsonHighlight) {\n // window must be passed in as an arg to the constructor\n // Should either be the global window for a browser or `dom.window` for jsdom in Node.js\n // @ts-ignore\n if (win) Ui.win = win\n else {\n // Ui.log(0, 'Ui:constructor', 'Current environment does not include `window`, UI functions cannot be used.')()\n // return\n throw new Error('Ui:constructor. Current environment does not include `window`, UI functions cannot be used.')\n }\n\n // For convenience\n Ui.doc = Ui.win.document\n\n // If a suitable function not passed in, create a dummy one\n if (extLog) Ui.log = extLog\n else Ui.log = function() { return function() {} } // eslint-disable-line @stylistic/max-statements-per-line\n\n // If a JSON HTML highlighting function passed then use it, else a dummy fn\n if (jsonHighlight) this.syntaxHighlight = jsonHighlight\n else this.syntaxHighlight = function() {}\n\n // If Markdown-IT pre-loaded, then configure it now\n if (Ui.win['markdownit']) {\n Ui.mdOpts = {\n html: true,\n xhtmlOut: false,\n linkify: true,\n _highlight: true,\n _strict: false,\n _view: 'html',\n langPrefix: 'language-',\n // NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work\n highlight: function(str, lang) {\n // https://highlightjs.org\n if (lang && window['hljs'] && window['hljs'].getLanguage(lang)) {\n try {\n return `<pre class=\"\">\n <code class=\"hljs border\">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true, }).value}</code></pre>`\n } finally { } // eslint-disable-line no-empty\n }\n return `<pre class=\"hljs border\"><code>${Ui.md.utils.escapeHtml(str).trim()}</code></pre>`\n },\n }\n Ui.md = Ui.win['markdownit'](Ui.mdOpts)\n }\n }\n\n // #region ---- Internal Methods ----\n\n _markDownIt() {\n // If Markdown-IT pre-loaded, then configure it now\n if (!Ui.win['markdownit']) return\n\n // If plugins not yet defined, check if uibuilder has set them\n if (!this.ui_md_plugins && Ui.win['uibuilder'] && Ui.win['uibuilder'].ui_md_plugins) this.ui_md_plugins = Ui.win['uibuilder'].ui_md_plugins\n\n Ui.mdOpts = {\n html: true,\n xhtmlOut: false,\n linkify: true,\n _highlight: true,\n _strict: false,\n _view: 'html',\n langPrefix: 'language-',\n // NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work\n highlight: function(str, lang) {\n if (window['hljs']) {\n if (lang && window['hljs'].getLanguage(lang)) {\n try {\n return `<pre><code class=\"hljs border language-${lang}\" data-language=\"${lang}\" title=\"Source language: '${lang}'\">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true, }).value}</code></pre>`\n } finally { } // eslint-disable-line no-empty\n } else {\n try {\n const high = window['hljs'].highlightAuto(str)\n return `<pre><code class=\"hljs border language-${high.language}\" data-language=\"${high.language}\" title=\"Source language estimated by HighlightJS: '${high.language}'\">${high.value}</code></pre>`\n } finally { } // eslint-disable-line no-empty\n }\n }\n return `<pre><code class=\"border\">${Ui.md.utils.escapeHtml(str).trim()}</code></pre>`\n },\n }\n Ui.md = Ui.win['markdownit'](Ui.mdOpts)\n // Ui.md.use(Ui.win.markdownitTaskLists, {enabled: true})\n if (this.ui_md_plugins) {\n if (!Array.isArray(this.ui_md_plugins)) {\n Ui.log('error', 'Ui:_markDownIt:plugins', 'Could not load plugins, ui_md_plugins is not an array')()\n return\n }\n this.ui_md_plugins.forEach( (plugin) => {\n if (typeof plugin === 'string') {\n Ui.md.use(Ui.win[plugin])\n } else {\n const name = Object.keys(plugin)[0]\n Ui.md.use(Ui.win[name], plugin[name])\n }\n })\n }\n }\n\n /** Show a browser notification if the browser and the user allows it\n * @param {object} config Notification config data\n * @returns {Promise} Resolves on close or click event, returns the event.\n */\n _showNotification(config) {\n if ( config.topic && !config.title ) config.title = config.topic\n if ( !config.title ) config.title = 'uibuilder notification'\n if ( config.payload && !config.body ) config.body = config.payload\n if ( !config.body ) config.body = ' No message given.'\n // Wrap in try/catch since Chrome Android may throw an error\n try {\n const notify = new Notification(config.title, config)\n return new Promise( (resolve, reject) => {\n // Doesn't ever seem to fire (at least in Chromium)\n notify.addEventListener('close', (ev) => {\n // @ts-ignore\n ev.currentTarget.userAction = 'close'\n resolve(ev)\n })\n notify.addEventListener('click', (ev) => {\n // @ts-ignore\n ev.currentTarget.userAction = 'click'\n resolve(ev)\n })\n notify.addEventListener('error', (ev) => {\n // @ts-ignore\n ev.currentTarget.userAction = 'error'\n reject(ev)\n })\n })\n } catch (e) {\n return Promise.reject(new Error('Browser refused to create a Notification'))\n }\n }\n\n // Vue dynamic inserts Don't really work ...\n // _uiAddVue(ui, isRecurse) {\n\n // // must be Vue\n // // must have only 1 root element\n // const compToAdd = ui.components[0]\n // const newEl = Ui.doc.createElement(compToAdd.type)\n\n // if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload\n // this._uiComposeComponent(newEl, compToAdd)\n\n // // If nested components, go again - but don't pass payload to sub-components\n // if (compToAdd.components) {\n // this._uiExtendEl(newEl, compToAdd.components)\n // }\n\n // console.log('MAGIC: ', this.magick, newEl, newEl.outerHTML)()\n // this.set('magick', newEl.outerHTML)\n\n // // if (compToAdd.id) newEl.setAttribute('ref', compToAdd.id)\n // // if (elParent.id) newEl.setAttribute('data-parent', elParent.id)\n // }\n\n // TODO Add check if ID already exists\n // TODO Allow single add without using components array\n /** Handle incoming msg._ui add requests\n * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object\n * @param {boolean} isRecurse Is this a recursive call?\n */\n _uiAdd(ui, isRecurse) {\n Ui.log('trace', 'Ui:_uiManager:add', 'Starting _uiAdd')()\n\n // Vue dynamic inserts Don't really work ...\n // if (this.#isVue && !isRecurse) {\n // this._uiAddVue(ui, false)\n // return\n // }\n\n ui.components.forEach((compToAdd, i) => {\n Ui.log('trace', `Ui:_uiAdd:components-forEach:${i}`, 'Component to add: ', compToAdd)()\n\n /** @type {*} Create the new component - some kind of HTML element */\n let newEl\n switch (compToAdd.type) {\n // If trying to insert raw html, wrap in a div\n case 'html': {\n compToAdd.ns = 'html'\n newEl = Ui.doc.createElement('div')\n break\n }\n\n // If trying to insert raw svg, need to create in namespace\n case 'svg': {\n compToAdd.ns = 'svg'\n newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg')\n break\n }\n\n default: {\n compToAdd.ns = 'dom'\n newEl = Ui.doc.createElement(compToAdd.type)\n break\n }\n }\n\n if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload\n\n // const parser = new DOMParser()\n // const newDoc = parser.parseFromString(compToAdd.slot, 'text/html')\n // console.log(compToAdd, newDoc.body)()\n\n this._uiComposeComponent(newEl, compToAdd)\n\n /** @type {HTMLElement} Where to add the new element? */\n let elParent\n if (compToAdd.parentEl) {\n elParent = compToAdd.parentEl\n } else if (ui.parentEl) {\n elParent = ui.parentEl\n } else if (compToAdd.parent) {\n elParent = Ui.doc.querySelector(compToAdd.parent)\n } else if (ui.parent) {\n elParent = Ui.doc.querySelector(ui.parent)\n }\n if (!elParent) {\n Ui.log('info', 'Ui:_uiAdd', 'No parent found, adding to body')()\n elParent = Ui.doc.querySelector('body')\n }\n\n if (compToAdd.position && compToAdd.position === 'first') {\n // Insert new el before the first child of the parent. Ref: https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore#example_3\n elParent.insertBefore(newEl, elParent.firstChild)\n } else if (compToAdd.position && Number.isInteger(Number(compToAdd.position))) {\n elParent.insertBefore(newEl, elParent.children[compToAdd.position])\n } else {\n // Append to the required parent\n elParent.appendChild(newEl)\n }\n\n // If nested components, go again - but don't pass payload to sub-components\n if (compToAdd.components) {\n // this._uiAdd({\n // method: ui.method,\n // parentEl: newEl,\n // components: compToAdd.components,\n // }, true)\n this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns)\n }\n })\n } // --- end of _uiAdd ---\n\n /** Enhance an HTML element that is being composed with ui data\n * such as ID, attribs, event handlers, custom props, etc.\n * @param {*} el HTML Element to enhance\n * @param {*} comp Individual uibuilder ui component spec\n */\n _uiComposeComponent(el, comp) {\n // Add attributes\n if (comp.attributes) {\n Object.keys(comp.attributes).forEach((attrib) => {\n if (attrib === 'class' && Array.isArray(comp.attributes[attrib])) comp.attributes[attrib].join(' ')\n\n Ui.log('trace', '_uiComposeComponent:attributes-forEach', `Attribute: '${attrib}', value: '${comp.attributes[attrib]}'`)()\n\n // For values, set the actual value as well since the attrib only changes the DEFAULT value\n if (attrib === 'value') el.value = comp.attributes[attrib]\n\n if (attrib.startsWith('xlink:')) el.setAttributeNS('http://www.w3.org/1999/xlink', attrib, comp.attributes[attrib])\n else el.setAttribute(attrib, comp.attributes[attrib])\n })\n }\n\n // ID if set\n if (comp.id) el.setAttribute('id', comp.id)\n\n // If an SVG tag, ensure we have the appropriate namespaces added\n if (comp.type === 'svg') {\n el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns', 'http://www.w3.org/2000/svg')\n el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xlink', 'http://www.w3.org/1999/xlink')\n }\n\n // Add event handlers\n if (comp.events) {\n Object.keys(comp.events).forEach((type) => {\n // @ts-ignore I'm forever getting this wrong!\n if (type.toLowerCase === 'onclick') type = 'click'\n // Add the event listener\n try {\n el.addEventListener(type, (evt) => {\n // Use new Function to ensure that esbuild works: https://esbuild.github.io/content-types/#direct-eval\n (new Function('evt', `${comp.events[type]}(evt)`))(evt)\n })\n // newEl.setAttribute( 'onClick', `${comp.events[type]}()` )\n } catch (err) {\n Ui.log('error', 'Ui:_uiComposeComponent', `Add event '${type}' for element '${comp.type}': Cannot add event handler. ${err.message}`)()\n }\n })\n }\n\n // Add custom properties to the dataset\n if (comp.properties) {\n Object.keys(comp.properties).forEach((prop) => {\n // TODO break a.b into sub properties\n el[prop] = comp.properties[prop]\n // Auto-dispatch events if changing value or changed since DOM does not do this automatically\n if (['value', 'checked'].includes(prop)) {\n el.dispatchEvent(new Event('input'))\n el.dispatchEvent(new Event('change'))\n }\n })\n }\n\n // #region Add Slot content to innerHTML\n if (comp.slot) {\n this.replaceSlot(el, comp.slot)\n }\n //#endregion\n\n // TODO Add multi-slot capability (default slot must always be processed first as innerHTML is replaced)\n\n // #region Add Slot Markdown content to innerHTML IF marked library is available\n if (comp.slotMarkdown) {\n this.replaceSlotMarkdown(el, comp)\n }\n //#endregion\n }\n\n /** Extend an HTML Element with appended elements using ui components\n * NOTE: This fn follows a strict hierarchy of added components.\n * @param {HTMLElement} parentEl The parent HTML Element we want to append to\n * @param {*} components The ui component(s) we want to add\n * @param {string} [ns] Optional. The namespace to use.\n */\n _uiExtendEl(parentEl, components, ns = '') {\n components.forEach((compToAdd, i) => {\n Ui.log('trace', `Ui:_uiExtendEl:components-forEach:${i}`, compToAdd)()\n\n /** @type {HTMLElement} Create the new component */\n let newEl\n\n compToAdd.ns = ns\n\n if (compToAdd.ns === 'html') {\n newEl = parentEl\n // newEl.outerHTML = compToAdd.slot\n // parentEl.innerHTML = compToAdd.slot\n this.replaceSlot(parentEl, compToAdd.slot)\n } else if (compToAdd.ns === 'svg') {\n newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', compToAdd.type)\n // Updates newEl\n this._uiComposeComponent(newEl, compToAdd)\n parentEl.appendChild(newEl)\n } else {\n newEl = Ui.doc.createElement(compToAdd.type === 'html' ? 'div' : compToAdd.type)\n // Updates newEl\n this._uiComposeComponent(newEl, compToAdd)\n parentEl.appendChild(newEl)\n }\n\n // If nested components, go again - but don't pass payload to sub-components\n if (compToAdd.components) {\n this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns)\n }\n })\n }\n\n // TODO Add more error handling and parameter validation\n /** Handle incoming _ui load requests\n * Can load JavaScript modules, JavaScript scripts and CSS.\n * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object\n */\n _uiLoad(ui) {\n // Self-loading ECMA Modules (e.g. web components)\n if (ui.components) {\n if (!Array.isArray(ui.components)) ui.components = [ui.components]\n\n ui.components.forEach(async (component) => {\n // NOTE: This happens asynchronously but we don't wait\n import(component)\n })\n }\n // Remote Scripts\n if (ui.srcScripts) {\n if (!Array.isArray(ui.srcScripts)) ui.srcScripts = [ui.srcScripts]\n\n ui.srcScripts.forEach((script) => {\n this.loadScriptSrc(script)\n })\n }\n // Scripts passed as text\n if (ui.txtScripts) {\n if (!Array.isArray(ui.txtScripts)) ui.txtScripts = [ui.txtScripts]\n\n this.loadScriptTxt(ui.txtScripts.join('\\n'))\n }\n // Remote Stylesheets\n if (ui.srcStyles) {\n if (!Array.isArray(ui.srcStyles)) ui.srcStyles = [ui.srcStyles]\n\n ui.srcStyles.forEach((sheet) => {\n this.loadStyleSrc(sheet)\n })\n }\n // Styles passed as text\n if (ui.txtStyles) {\n if (!Array.isArray(ui.txtStyles)) ui.txtStyles = [ui.txtStyles]\n\n this.loadStyleTxt(ui.txtStyles.join('\\n'))\n }\n } // --- end of _uiLoad ---\n\n /** Handle incoming _ui messages and loaded UI JSON files\n * Called from start()\n * @param {*} msg Standardised msg object containing a _ui property object\n */\n _uiManager(msg) {\n if (!msg._ui) return\n\n // Make sure that _ui is an array\n if (!Array.isArray(msg._ui)) msg._ui = [msg._ui]\n\n msg._ui.forEach((ui, i) => {\n if (ui.mode && !ui.method) ui.method = ui.mode\n if (!ui.method) {\n Ui.log('error', 'Ui:_uiManager', `No method defined for msg._ui[${i}]. Ignoring. `, ui)()\n return\n }\n\n ui.payload = msg.payload\n ui.topic = msg.topic\n switch (ui.method) {\n case 'add': {\n this._uiAdd(ui, false)\n break\n }\n\n case 'remove': {\n this._uiRemove(ui, false)\n break\n }\n\n case 'removeAll': {\n this._uiRemove(ui, true)\n break\n }\n\n case 'replace': {\n this._uiReplace(ui)\n break\n }\n\n case 'update': {\n this._uiUpdate(ui)\n break\n }\n\n case 'load': {\n this._uiLoad(ui)\n break\n }\n\n case 'reload': {\n this._uiReload()\n break\n }\n\n case 'notify': {\n this.showDialog('notify', ui, msg)\n break\n }\n\n case 'alert': {\n this.showDialog('alert', ui, msg)\n break\n }\n\n default: {\n Ui.log('error', 'Ui:_uiManager', `Invalid msg._ui[${i}].method (${ui.method}). Ignoring`)()\n break\n }\n }\n })\n } // --- end of _uiManager ---\n\n /** Handle a reload request */\n _uiReload() {\n Ui.log('trace', 'Ui:uiManager:reload', 'reloading')()\n location.reload()\n }\n\n // TODO Add better tests for failures (see comments)\n /** Handle incoming _ui remove requests\n * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object\n * @param {boolean} all Optional, default=false. If true, will remove ALL found elements, otherwise only the 1st is removed\n */\n _uiRemove(ui, all = false) {\n ui.components.forEach((compToRemove) => {\n let els\n if (all !== true) els = [Ui.doc.querySelector(compToRemove)]\n else els = Ui.doc.querySelectorAll(compToRemove)\n\n els.forEach((el) => {\n try {\n el.remove()\n } catch (err) {\n // Could not remove. Cannot read properties of null <= no need to report this one\n // Could not remove. Failed to execute 'querySelector' on 'Ui.doc': '##testbutton1' is not a valid selector\n Ui.log('trace', 'Ui:_uiRemove', `Could not remove. ${err.message}`)()\n }\n })\n })\n } // --- end of _uiRemove ---\n\n /** Handle incoming _ui replace requests\n * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object\n */\n _uiReplace(ui) {\n Ui.log('trace', 'Ui:_uiReplace', 'Starting')()\n\n ui.components.forEach((compToReplace, /** @type {number} */ i) => {\n Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Component to replace: ', compToReplace)()\n\n /** @type {HTMLElement} */\n let elToReplace\n\n // Either the id, CSS selector, name or type (element type) must be given in order to identify the element to change. FIRST element matching is updated.\n if (compToReplace.id) {\n elToReplace = Ui.doc.getElementById(compToReplace.id) // .querySelector(`#${compToReplace.id}`)\n } else if (compToReplace.selector || compToReplace.select) {\n elToReplace = Ui.doc.querySelector(compToReplace.selector)\n } else if (compToReplace.name) {\n elToReplace = Ui.doc.querySelector(`[name=\"${compToReplace.name}\"]`)\n } else if (compToReplace.type) {\n elToReplace = Ui.doc.querySelector(compToReplace.type)\n }\n\n Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Element to replace: ', elToReplace)()\n\n // Nothing was found so ADD the element instead\n if (elToReplace === undefined || elToReplace === null) {\n Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}:noReplace`, 'Cannot find the DOM element. Adding instead.', compToReplace)()\n this._uiAdd({ components: [compToReplace], }, false)\n return\n }\n\n /** @type {*} Create the new component - some kind of HTML element */\n let newEl\n switch (compToReplace.type) {\n // If trying to insert raw html, wrap in a div\n case 'html': {\n compToReplace.ns = 'html'\n newEl = Ui.doc.createElement('div')\n break\n }\n\n // If trying to insert raw svg, need to create in namespace\n case 'svg': {\n compToReplace.ns = 'svg'\n newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg')\n break\n }\n\n default: {\n compToReplace.ns = 'dom'\n newEl = Ui.doc.createElement(compToReplace.type)\n break\n }\n }\n\n // Updates the newEl and maybe the ui\n this._uiComposeComponent(newEl, compToReplace)\n\n // Replace the current element\n elToReplace.replaceWith(newEl)\n\n // If nested components, go again - but don't pass payload to sub-components\n if (compToReplace.components) {\n this._uiExtendEl(newEl, compToReplace.components, compToReplace.ns)\n }\n })\n } // --- end of _uiReplace ---\n\n // TODO Allow single add without using components array\n // TODO Allow sub-components\n // TODO Add multi-slot capability\n /** Handle incoming _ui update requests\n * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object\n */\n _uiUpdate(ui) {\n Ui.log('trace', 'UI:_uiUpdate:update', 'Starting _uiUpdate', ui)()\n\n // We allow an update not to actually need to spec a component\n if (!ui.components) ui.components = [Object.assign({}, ui)]\n\n ui.components.forEach((compToUpd, i) => {\n Ui.log('trace', '_uiUpdate:components-forEach', `Start loop #${i}`, compToUpd)()\n\n /** @type {NodeListOf<Element>} */\n let elToUpd\n\n // If a parent element is passed, use that as the update target (only allowed internally)\n // Otherwise either the id, CSS selector, name or type (element type) must be given in order to identify the element to change. ALL elements matching are updated.\n if (compToUpd.parentEl) {\n elToUpd = compToUpd.parentEl\n } else if (compToUpd.id) {\n // NB We don't use get by id because this way the code is simpler later on\n elToUpd = Ui.doc.querySelectorAll(`#${compToUpd.id}`)\n } else if (compToUpd.selector || compToUpd.select) {\n elToUpd = Ui.doc.querySelectorAll(compToUpd.selector)\n } else if (compToUpd.name) {\n elToUpd = Ui.doc.querySelectorAll(`[name=\"${compToUpd.name}\"]`)\n } else if (compToUpd.type) {\n elToUpd = Ui.doc.querySelectorAll(compToUpd.type)\n }\n\n // @ts-ignore Nothing was found so give up\n if (elToUpd === undefined || elToUpd.length < 1) {\n Ui.log('warn', 'Ui:_uiManager:update', 'Cannot find the DOM element. Ignoring.', compToUpd)()\n return\n }\n\n Ui.log('trace', '_uiUpdate:components-forEach', `Element(s) to update. Count: ${elToUpd.length}`, elToUpd)()\n\n // If slot not specified but payload is, use the payload in the slot\n if (!compToUpd.slot && compToUpd.payload) compToUpd.slot = compToUpd.payload\n\n // Might have >1 element to update - so update them all\n elToUpd.forEach((el, j) => {\n Ui.log('trace', '_uiUpdate:components-forEach', `Updating element #${j}`, el)()\n this._uiComposeComponent(el, compToUpd)\n // Try to go down another level of nesting if needed\n // ! NOT CONVINCED THIS ACTUALLY WORKS !\n if (compToUpd.components) {\n Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component`, compToUpd, el)()\n const nc = { _ui: [], }\n compToUpd.components.forEach((nestedComp, k) => {\n const method = nestedComp.method || compToUpd.method || ui.method\n if (nestedComp.method) delete nestedComp.method\n if (!Array.isArray(nestedComp)) nestedComp = [nestedComp]\n // nestedComp.parentEl = el\n // nestedComp.components = [nestedComp]\n Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component #${k}`, nestedComp)()\n nc._ui.push( {\n method: method,\n parentEl: el,\n components: nestedComp,\n })\n })\n Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component new manager`, nc)()\n this._uiManager(nc)\n }\n })\n\n // If nested components, apply to every found element - but don't pass payload to sub-components\n // if (compToUpd.components) {\n // compToUpd.components.forEach((el, k) => {\n // Ui.log('trace', '_uiUpdate:nested-component', `Updating nested-component #${k}`, el)()\n // this._uiUpdate({\n // method: el.method || ui.method,\n // parentEl: el,\n // components: el.components,\n // })\n // })\n // }\n })\n } // --- end of _uiUpdate ---\n\n // #endregion ---- -------- ----\n\n // #region ---- External Methods ----\n\n /** Simplistic jQuery-like document CSS query selector, returns an HTML Element\n * NOTE that this fn returns the element itself. Use $$ to get the properties of 1 or more elements.\n * If the selected element is a <template>, returns the first child element.\n * type {HTMLElement}\n * @param {string} cssSelector A CSS Selector that identifies the element to return\n * @param {\"el\"|\"text\"|\"html\"|\"attributes\"|\"attr\"} [output] Optional. What type of output to return. Defaults to \"el\", the DOM element reference\n * @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element.\n * @returns {HTMLElement|string|Array|null} Selected HTML DOM element, innerText, innerHTML, attribute list or null\n */\n $(cssSelector, output, context) {\n if (!context) context = Ui.doc\n if (!output) output = 'el'\n\n // if context is not a valid htmlelement, return null\n if (!context || !context.nodeType) {\n Ui.log(1, 'Uib:$', `Invalid context element. Must be a valid HTML element.`, context)()\n return null\n }\n\n /** @type {HTMLElement} Some kind of HTML element */\n let el = (context).querySelector(cssSelector)\n\n // if no element found or is not a valid htmlelement, return null\n if (!el || !el.nodeType) {\n Ui.log(1, 'Uib:$', `No element found or element is not an HTML element for CSS selector ${cssSelector}`)()\n return null\n }\n\n if ( el.nodeName === 'TEMPLATE' ) {\n el = el.content.firstElementChild\n if (!el) {\n Ui.log(0, 'Uib:$', `Template selected for CSS selector ${cssSelector} but it is empty`)()\n return null\n }\n }\n\n let out\n\n try {\n switch (output.toLowerCase()) {\n case 'text': {\n out = el.innerText\n break\n }\n\n case 'html': {\n out = el.innerHTML\n break\n }\n\n case 'attr':\n case 'attributes': {\n out = {}\n for (const attr of el.attributes) {\n out[attr.name] = attr.value\n }\n break\n }\n\n default: {\n out = el\n break\n }\n }\n } catch (e) {\n out = el\n Ui.log(1, 'Uib:$', `Could not process output type \"${output}\" for CSS selector ${cssSelector}, returned the DOM element. ${e.message}`, e)()\n }\n\n return out\n }\n\n /** CSS query selector that returns ALL found selections. Matches the Chromium DevTools feature of the same name.\n * NOTE that this fn returns an array showing the PROPERTIES of the elements whereas $ returns the element itself\n * @param {string} cssSelector A CSS Selector that identifies the elements to return\n * @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element.\n * @returns {HTMLElement[]} Array of DOM elements/nodes. Array is empty if selector is not found.\n */\n $$(cssSelector, context) {\n if (!context) context = Ui.doc\n\n // if context is not a valid htmlelement, return null\n if (!context || !context.nodeType) {\n Ui.log(1, 'Uib:$$', `Invalid context element. Must be a valid HTML element.`, context)()\n return null\n }\n\n return Array.from((context).querySelectorAll(cssSelector))\n }\n\n /** Add 1 or several class names to an element\n * @param {string|string[]} classNames Single or array of classnames\n * @param {HTMLElement} el HTML Element to add class(es) to\n */\n addClass(classNames, el) {\n if (!Array.isArray(classNames)) classNames = [classNames]\n if (el) el.classList.add(...classNames)\n }\n\n /** Apply a source template tag to a target html element\n * NOTES:\n * - Any attributes are only applied to the 1ST ELEMENT of the template content. Use a wrapper div if you need to apply to multiple elements.\n * - When using 'wrap' mode, the target content is placed into the template's 1ST <slot> only (if present).\n * - styles in ALL templates are accessible to all templates & impact the whole page.\n * - scripts in templates are run AT TIME OF APPLICATION (so may run multiple times).\n * - scripts in templates are applied in order of application, so variables may not yet exist if defined in subsequent templates\n * @param {string} sourceId The HTML ID of the source element\n * @param {string} targetId The HTML ID of the target element\n * @param {object} config Configuration options\n * @param {boolean=} config.onceOnly If true, the source will be adopted (the source is moved)\n * @param {object=} config.attributes A set of key:value pairs that will be applied as attributes to the 1ST ELEMENT ONLY of the target\n * @param {'insert'|'replace'|'wrap'} config.mode How to apply the template. Default is 'insert'. 'replace' will replace the targets innerHTML. 'wrap' is like 'replace' but will put any target content into the template's 1ST <slot> (if present).\n */\n applyTemplate(sourceId, targetId, config) {\n if (!config) config = {}\n if (!config.onceOnly) config.onceOnly = false\n if (!config.mode) config.mode = 'insert'\n\n const template = Ui.doc.getElementById(sourceId)\n if (!template || template.tagName !== 'TEMPLATE') {\n Ui.log('error', 'Ui:applyTemplate', `Source must be a <template>. id='${sourceId}'`)()\n return\n }\n\n const target = Ui.doc.getElementById(targetId)\n if (!target) {\n Ui.log('error', 'Ui:applyTemplate', `Target not found: id='${targetId}'`)()\n return\n }\n\n const targetContent = target.innerHTML ?? ''\n if (targetContent && config.mode === 'replace') {\n Ui.log('warn', 'Ui:applyTemplate', `Target element is not empty, content is replaced. id='${targetId}'`)()\n }\n\n let templateContent\n if (config.onceOnly === true) templateContent = Ui.doc.adoptNode(template.content) // NB content.childElementCount = 0 after adoption\n else templateContent = Ui.doc.importNode(template.content, true)\n\n if (templateContent) {\n // Apply config.attributes to the 1ST ELEMENT ONLY of the template content\n if (config.attributes) {\n const el = templateContent.firstElementChild\n Object.keys(config.attributes).forEach( (attrib) => {\n // Apply each attribute and value\n el.setAttribute(attrib, config.attributes[attrib])\n })\n }\n\n if (config.mode === 'insert') {\n target.appendChild(templateContent)\n } else if (config.mode === 'replace') {\n target.innerHTML = ''\n target.appendChild(templateContent)\n } else if (config.mode === 'wrap') {\n target.innerHTML = ''\n target.appendChild(templateContent)\n if (targetContent) {\n const slot = target.getElementsByTagName('slot')\n if (slot.length > 0) {\n slot[0].innerHTML = targetContent\n }\n }\n }\n } else {\n Ui.log('warn', 'Ui:applyTemplate', `No valid content found in template`)()\n }\n }\n\n /** Converts markdown text input to HTML if the Markdown-IT library is loaded\n * Otherwise simply returns the text\n * @param {string} mdText The input markdown string\n * @returns {string} HTML (if Markdown-IT library loaded and parse successful) or original text\n */\n convertMarkdown(mdText) {\n if (!mdText) return ''\n if (!Ui.win['markdownit']) return mdText\n if (!Ui.md) this._markDownIt() // To handle case where the library is late loaded\n // Convert from markdown to HTML\n try {\n return Ui.md.render(mdText.trim())\n } catch (e) {\n Ui.log(0, 'uibuilder:convertMarkdown', `Could not render Markdown. ${e.message}`, e)()\n return '<p class=\"border error\">Could not render Markdown<p>'\n }\n }\n\n /** Include HTML fragment, img, video, text, json, form data, pdf or anything else from an external file or API\n * Wraps the included object in a div tag.\n * PDF's, text or unknown MIME types are also wrapped in an iFrame.\n * @param {string} url The URL of the source file to include\n * @param {object} uiOptions Object containing properties recognised by the _uiReplace function. Must at least contain an i