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.

1,932 lines 86 kB
// @ts-nocheck
/* Creates HTML UI's based on a standardised data input.
  Works stand-alone, with uibuilder or with Node.js/jsdom.
  See: https://totallyinformation.github.io/node-red-contrib-uibuilder/#/client-docs/config-driven-ui

  Author: Julian Knight (Totally Information), March 2023

  License: Apache 2.0
  Copyright (c) 2022-2025 Julian Knight (Totally Information)

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
 */

// Namespaces - See https://stackoverflow.com/a/52572048/1309986
// const NAMESPACES = {
//     svg: 'http://www.w3.org/2000/svg',
//     html: 'http://www.w3.org/1999/xhtml',
//     xml: 'http://www.w3.org/XML/1998/namespace',
//     xlink: 'http://www.w3.org/1999/xlink',
//     xmlns: 'http://www.w3.org/2000/xmlns/' // sic for the final slash...
// }

const Ui = class Ui {
    //#region --- Class variables ---
    version = '7.2.0-src'

    // List of tags and attributes not in sanitise defaults but allowed in uibuilder.
    sanitiseExtraTags = ['uib-var']
    sanitiseExtraAttribs = ['variable', 'report', 'undefined']

    /** Reference to DOM window - must be passed in the constructor
     * Allows for use of this library/class with `jsdom` in Node.JS as well as the browser.
     * @type {Window}
     */
    static win

    /** Reference to the DOM top-level window.document for convenience - set in constructor @type {Document} */
    static doc

    /** Log function - passed in constructor or will be a dummy function
     * @type {Function}
     */
    static log

    /** Options for Markdown-IT if available (set in constructor) */
    static mdOpts
    /** Reference to pre-loaded Markdown-IT library */
    static md
    /** Optional Markdown-IT Plugins */
    ui_md_plugins
    //#endregion --- class variables ---

    /** Called when `new Ui(...)` is called
     * @param {globalThis} win Either the browser global window or jsdom dom.window
     * @param {Function} [extLog] A function that returns a function for logging
     * @param {Function} [jsonHighlight] A function that returns a highlighted HTML of JSON input
     */
    constructor(win, extLog, jsonHighlight) {
        // window must be passed in as an arg to the constructor
        // Should either be the global window for a browser or `dom.window` for jsdom in Node.js
        // @ts-ignore
        if (win) Ui.win = win
        else {
            // Ui.log(0, 'Ui:constructor', 'Current environment does not include `window`, UI functions cannot be used.')()
            // return
            throw new Error('Ui:constructor. Current environment does not include `window`, UI functions cannot be used.')
        }

        // For convenience
        Ui.doc = Ui.win.document

        // If a suitable function not passed in, create a dummy one
        if (extLog) Ui.log = extLog
        else Ui.log = function() { return function() {} }

        // If a JSON HTML highlighting function passed then use it, else a dummy fn
        if (jsonHighlight) this.syntaxHighlight = jsonHighlight
        else this.syntaxHighlight = function() {}

        // If Markdown-IT pre-loaded, then configure it now
        if (Ui.win['markdownit']) {
            Ui.mdOpts = {
                html: true,
                xhtmlOut: false,
                linkify: true,
                _highlight: true,
                _strict: false,
                _view: 'html',
                langPrefix: 'language-',
                // NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work
                highlight: function(str, lang) {
                    // https://highlightjs.org
                    if (lang && window['hljs'] && window['hljs'].getLanguage(lang)) {
                        try {
                            return `<pre class="">
                                    <code class="hljs border">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true, }).value}</code></pre>`
                        } finally { } // eslint-disable-line no-empty
                    }
                    return `<pre class="hljs border"><code>${Ui.md.utils.escapeHtml(str).trim()}</code></pre>`
                },
            }
            Ui.md = Ui.win['markdownit'](Ui.mdOpts)
        }
    }

    //#region ---- Internal Methods ----

    _markDownIt() {
        // If Markdown-IT pre-loaded, then configure it now
        if (!Ui.win['markdownit']) return

        // If plugins not yet defined, check if uibuilder has set them
        if (!this.ui_md_plugins && Ui.win['uibuilder'] && Ui.win['uibuilder'].ui_md_plugins) this.ui_md_plugins = Ui.win['uibuilder'].ui_md_plugins

        Ui.mdOpts = {
            html: true,
            xhtmlOut: false,
            linkify: true,
            _highlight: true,
            _strict: false,
            _view: 'html',
            langPrefix: 'language-',
            // NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work
            highlight: function(str, lang) {
                if (window['hljs']) {
                    if (lang && window['hljs'].getLanguage(lang)) {
                        try {
                            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>`
                        } finally { } // eslint-disable-line no-empty
                    } else {
                        try {
                            const high = window['hljs'].highlightAuto(str)
                            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>`
                        } finally { } // eslint-disable-line no-empty
                    }
                }
                return `<pre><code class="border">${Ui.md.utils.escapeHtml(str).trim()}</code></pre>`
            },
        }
        Ui.md = Ui.win['markdownit'](Ui.mdOpts)
        // Ui.md.use(Ui.win.markdownitTaskLists, {enabled: true})
        if (this.ui_md_plugins) {
            if (!Array.isArray(this.ui_md_plugins)) {
                Ui.log('error', 'Ui:_markDownIt:plugins', 'Could not load plugins, ui_md_plugins is not an array')()
                return
            }
            this.ui_md_plugins.forEach( plugin => {
                if (typeof plugin === 'string') {
                    Ui.md.use(Ui.win[plugin])
                } else {
                    const name = Object.keys(plugin)[0]
                    Ui.md.use(Ui.win[name], plugin[name])
                }
            })
        }
    }

    /** Show a browser notification if the browser and the user allows it
     * @param {object} config Notification config data
     * @returns {Promise} Resolves on close or click event, returns the event.
     */
    _showNotification(config) {
        if ( config.topic && !config.title ) config.title = config.topic
        if ( !config.title ) config.title = 'uibuilder notification'
        if ( config.payload && !config.body ) config.body = config.payload
        if ( !config.body ) config.body = ' No message given.'
        // Wrap in try/catch since Chrome Android may throw an error
        try {
            const notify = new Notification(config.title, config)
            return new Promise( (resolve, reject) => {
                // Doesn't ever seem to fire (at least in Chromium)
                notify.addEventListener('close', ev => {
                    // @ts-ignore
                    ev.currentTarget.userAction = 'close'
                    resolve(ev)
                })
                notify.addEventListener('click', ev => {
                    // @ts-ignore
                    ev.currentTarget.userAction = 'click'
                    resolve(ev)
                })
                notify.addEventListener('error', ev => {
                    // @ts-ignore
                    ev.currentTarget.userAction = 'error'
                    reject(ev)
                })
            })
        } catch (e) {
            return Promise.reject(new Error('Browser refused to create a Notification'))
        }
    }

    // Vue dynamic inserts Don't really work ...
    // _uiAddVue(ui, isRecurse) {

    //     // must be Vue
    //     // must have only 1 root element
    //     const compToAdd = ui.components[0]
    //     const newEl = Ui.doc.createElement(compToAdd.type)

    //     if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload
    //     this._uiComposeComponent(newEl, compToAdd)

    //     // If nested components, go again - but don't pass payload to sub-components
    //     if (compToAdd.components) {
    //         this._uiExtendEl(newEl, compToAdd.components)
    //     }

    //     console.log('MAGIC: ', this.magick, newEl, newEl.outerHTML)()
    //     this.set('magick', newEl.outerHTML)

    //     // if (compToAdd.id) newEl.setAttribute('ref', compToAdd.id)
    //     // if (elParent.id) newEl.setAttribute('data-parent', elParent.id)
    // }

    // TODO Add check if ID already exists
    // TODO Allow single add without using components array
    /** Handle incoming msg._ui add requests
     * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
     * @param {boolean} isRecurse Is this a recursive call?
     */
    _uiAdd(ui, isRecurse) {
        Ui.log('trace', 'Ui:_uiManager:add', 'Starting _uiAdd')()

        // Vue dynamic inserts Don't really work ...
        // if (this.#isVue && !isRecurse) {
        //     this._uiAddVue(ui, false)
        //     return
        // }

        ui.components.forEach((compToAdd, i) => {
            Ui.log('trace', `Ui:_uiAdd:components-forEach:${i}`, 'Component to add: ', compToAdd)()

            /** @type {*} Create the new component - some kind of HTML element */
            let newEl
            switch (compToAdd.type) {
                // If trying to insert raw html, wrap in a div
                case 'html': {
                    compToAdd.ns = 'html'
                    newEl = Ui.doc.createElement('div')
                    break
                }

                // If trying to insert raw svg, need to create in namespace
                case 'svg': {
                    compToAdd.ns = 'svg'
                    newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
                    break
                }

                default: {
                    compToAdd.ns = 'dom'
                    newEl = Ui.doc.createElement(compToAdd.type)
                    break
                }
            }

            if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload

            // const parser = new DOMParser()
            // const newDoc = parser.parseFromString(compToAdd.slot, 'text/html')
            // console.log(compToAdd, newDoc.body)()

            this._uiComposeComponent(newEl, compToAdd)

            /** @type {HTMLElement} Where to add the new element? */
            let elParent
            if (compToAdd.parentEl) {
                elParent = compToAdd.parentEl
            } else if (ui.parentEl) {
                elParent = ui.parentEl
            } else if (compToAdd.parent) {
                elParent = Ui.doc.querySelector(compToAdd.parent)
            } else if (ui.parent) {
                elParent = Ui.doc.querySelector(ui.parent)
            }
            if (!elParent) {
                Ui.log('info', 'Ui:_uiAdd', 'No parent found, adding to body')()
                elParent = Ui.doc.querySelector('body')
            }

            if (compToAdd.position && compToAdd.position === 'first') {
                // Insert new el before the first child of the parent. Ref: https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore#example_3
                elParent.insertBefore(newEl, elParent.firstChild)
            } else if (compToAdd.position && Number.isInteger(Number(compToAdd.position))) {
                elParent.insertBefore(newEl, elParent.children[compToAdd.position])
            } else {
                // Append to the required parent
                elParent.appendChild(newEl)
            }

            // If nested components, go again - but don't pass payload to sub-components
            if (compToAdd.components) {
                // this._uiAdd({
                //     method: ui.method,
                //     parentEl: newEl,
                //     components: compToAdd.components,
                // }, true)
                this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns)
            }
        })
    } // --- end of _uiAdd ---

    /** Enhance an HTML element that is being composed with ui data
     *  such as ID, attribs, event handlers, custom props, etc.
     * @param {*} el HTML Element to enhance
     * @param {*} comp Individual uibuilder ui component spec
     */
    _uiComposeComponent(el, comp) {
        // Add attributes
        if (comp.attributes) {
            Object.keys(comp.attributes).forEach((attrib) => {
                if (attrib === 'class' && Array.isArray(comp.attributes[attrib])) comp.attributes[attrib].join(' ')

                Ui.log('trace', '_uiComposeComponent:attributes-forEach', `Attribute: '${attrib}', value: '${comp.attributes[attrib]}'`)()

                // For values, set the actual value as well since the attrib only changes the DEFAULT value
                if (attrib === 'value') el.value = comp.attributes[attrib]

                if (attrib.startsWith('xlink:')) el.setAttributeNS('http://www.w3.org/1999/xlink', attrib, comp.attributes[attrib])
                else el.setAttribute(attrib, comp.attributes[attrib])
            })
        }

        // ID if set
        if (comp.id) el.setAttribute('id', comp.id)

        // If an SVG tag, ensure we have the appropriate namespaces added
        if (comp.type === 'svg') {
            el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns', 'http://www.w3.org/2000/svg')
            el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xlink', 'http://www.w3.org/1999/xlink')
        }

        // Add event handlers
        if (comp.events) {
            Object.keys(comp.events).forEach((type) => {
                // @ts-ignore  I'm forever getting this wrong!
                if (type.toLowerCase === 'onclick') type = 'click'
                // Add the event listener
                try {
                    el.addEventListener(type, (evt) => {
                        // Use new Function to ensure that esbuild works: https://esbuild.github.io/content-types/#direct-eval
                        (new Function('evt', `${comp.events[type]}(evt)`))(evt)
                    })
                    // newEl.setAttribute( 'onClick', `${comp.events[type]}()` )
                } catch (err) {
                    Ui.log('error', 'Ui:_uiComposeComponent', `Add event '${type}' for element '${comp.type}': Cannot add event handler. ${err.message}`)()
                }
            })
        }

        // Add custom properties to the dataset
        if (comp.properties) {
            Object.keys(comp.properties).forEach((prop) => {
                // TODO break a.b into sub properties
                el[prop] = comp.properties[prop]
                // Auto-dispatch events if changing value or changed since DOM does not do this automatically
                if (['value', 'checked'].includes(prop)) {
                    el.dispatchEvent(new Event('input'))
                    el.dispatchEvent(new Event('change'))
                }
            })
        }

        //#region Add Slot content to innerHTML
        if (comp.slot) {
            this.replaceSlot(el, comp.slot)
        }
        //#endregion

        // TODO Add multi-slot capability (default slot must always be processed first as innerHTML is replaced)

        //#region Add Slot Markdown content to innerHTML IF marked library is available
        if (comp.slotMarkdown) {
            this.replaceSlotMarkdown(el, comp)
        }
        //#endregion
    }

    /** Extend an HTML Element with appended elements using ui components
     * NOTE: This fn follows a strict hierarchy of added components.
     * @param {HTMLElement} parentEl The parent HTML Element we want to append to
     * @param {*} components The ui component(s) we want to add
     * @param {string} [ns] Optional. The namespace to use.
     */
    _uiExtendEl(parentEl, components, ns = '') {
        components.forEach((compToAdd, i) => {
            Ui.log('trace', `Ui:_uiExtendEl:components-forEach:${i}`, compToAdd)()

            /** @type {HTMLElement} Create the new component */
            let newEl

            compToAdd.ns = ns

            if (compToAdd.ns === 'html') {
                newEl = parentEl
                // newEl.outerHTML = compToAdd.slot
                // parentEl.innerHTML = compToAdd.slot
                this.replaceSlot(parentEl, compToAdd.slot)
            } else if (compToAdd.ns === 'svg') {
                newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', compToAdd.type)
                // Updates newEl
                this._uiComposeComponent(newEl, compToAdd)
                parentEl.appendChild(newEl)
            } else {
                newEl = Ui.doc.createElement(compToAdd.type === 'html' ? 'div' : compToAdd.type)
                // Updates newEl
                this._uiComposeComponent(newEl, compToAdd)
                parentEl.appendChild(newEl)
            }

            // If nested components, go again - but don't pass payload to sub-components
            if (compToAdd.components) {
                this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns)
            }
        })
    }

    // TODO Add more error handling and parameter validation
    /** Handle incoming _ui load requests
     * Can load JavaScript modules, JavaScript scripts and CSS.
     * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
     */
    _uiLoad(ui) {
        // Self-loading ECMA Modules (e.g. web components)
        if (ui.components) {
            if (!Array.isArray(ui.components)) ui.components = [ui.components]

            ui.components.forEach(async component => {
                // NOTE: This happens asynchronously but we don't wait
                import(component)
            })
        }
        // Remote Scripts
        if (ui.srcScripts) {
            if (!Array.isArray(ui.srcScripts)) ui.srcScripts = [ui.srcScripts]

            ui.srcScripts.forEach(script => {
                this.loadScriptSrc(script)
            })
        }
        // Scripts passed as text
        if (ui.txtScripts) {
            if (!Array.isArray(ui.txtScripts)) ui.txtScripts = [ui.txtScripts]

            this.loadScriptTxt(ui.txtScripts.join('\n'))
        }
        // Remote Stylesheets
        if (ui.srcStyles) {
            if (!Array.isArray(ui.srcStyles)) ui.srcStyles = [ui.srcStyles]

            ui.srcStyles.forEach(sheet => {
                this.loadStyleSrc(sheet)
            })
        }
        // Styles passed as text
        if (ui.txtStyles) {
            if (!Array.isArray(ui.txtStyles)) ui.txtStyles = [ui.txtStyles]

            this.loadStyleTxt(ui.txtStyles.join('\n'))
        }
    } // --- end of _uiLoad ---

    /** Handle incoming _ui messages and loaded UI JSON files
     * Called from start()
     * @param {*} msg Standardised msg object containing a _ui property object
     */
    _uiManager(msg) {
        if (!msg._ui) return

        // Make sure that _ui is an array
        if (!Array.isArray(msg._ui)) msg._ui = [msg._ui]

        msg._ui.forEach((ui, i) => {
            if (ui.mode && !ui.method) ui.method = ui.mode
            if (!ui.method) {
                Ui.log('error', 'Ui:_uiManager', `No method defined for msg._ui[${i}]. Ignoring. `, ui)()
                return
            }

            ui.payload = msg.payload
            ui.topic = msg.topic
            switch (ui.method) {
                case 'add': {
                    this._uiAdd(ui, false)
                    break
                }

                case 'remove': {
                    this._uiRemove(ui, false)
                    break
                }

                case 'removeAll': {
                    this._uiRemove(ui, true)
                    break
                }

                case 'replace': {
                    this._uiReplace(ui)
                    break
                }

                case 'update': {
                    this._uiUpdate(ui)
                    break
                }

                case 'load': {
                    this._uiLoad(ui)
                    break
                }

                case 'reload': {
                    this._uiReload()
                    break
                }

                case 'notify': {
                    this.showDialog('notify', ui, msg)
                    break
                }

                case 'alert': {
                    this.showDialog('alert', ui, msg)
                    break
                }

                default: {
                    Ui.log('error', 'Ui:_uiManager', `Invalid msg._ui[${i}].method (${ui.method}). Ignoring`)()
                    break
                }
            }
        })
    } // --- end of _uiManager ---

    /** Handle a reload request */
    _uiReload() {
        Ui.log('trace', 'Ui:uiManager:reload', 'reloading')()
        location.reload()
    }

    // TODO Add better tests for failures (see comments)
    /** Handle incoming _ui remove requests
     * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
     * @param {boolean} all Optional, default=false. If true, will remove ALL found elements, otherwise only the 1st is removed
     */
    _uiRemove(ui, all = false) {
        ui.components.forEach((compToRemove) => {
            let els
            if (all !== true) els = [Ui.doc.querySelector(compToRemove)]
            else els = Ui.doc.querySelectorAll(compToRemove)

            els.forEach(el => {
                try {
                    el.remove()
                } catch (err) {
                    // Could not remove. Cannot read properties of null <= no need to report this one
                    // Could not remove. Failed to execute 'querySelector' on 'Ui.doc': '##testbutton1' is not a valid selector
                    Ui.log('trace', 'Ui:_uiRemove', `Could not remove. ${err.message}`)()
                }
            })
        })
    } // --- end of _uiRemove ---

    /** Handle incoming _ui replace requests
     * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
     */
    _uiReplace(ui) {
        Ui.log('trace', 'Ui:_uiReplace', 'Starting')()

        ui.components.forEach((compToReplace, /** @type {number} */ i) => {
            Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Component to replace: ', compToReplace)()

            /** @type {HTMLElement} */
            let elToReplace

            // 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.
            if (compToReplace.id) {
                elToReplace = Ui.doc.getElementById(compToReplace.id) // .querySelector(`#${compToReplace.id}`)
            } else if (compToReplace.selector || compToReplace.select) {
                elToReplace = Ui.doc.querySelector(compToReplace.selector)
            } else if (compToReplace.name) {
                elToReplace = Ui.doc.querySelector(`[name="${compToReplace.name}"]`)
            } else if (compToReplace.type) {
                elToReplace = Ui.doc.querySelector(compToReplace.type)
            }

            Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Element to replace: ', elToReplace)()

            // Nothing was found so ADD the element instead
            if (elToReplace === undefined || elToReplace === null) {
                Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}:noReplace`, 'Cannot find the DOM element. Adding instead.', compToReplace)()
                this._uiAdd({ components: [compToReplace], }, false)
                return
            }

            /** @type {*} Create the new component - some kind of HTML element */
            let newEl
            switch (compToReplace.type) {
                // If trying to insert raw html, wrap in a div
                case 'html': {
                    compToReplace.ns = 'html'
                    newEl = Ui.doc.createElement('div')
                    break
                }

                // If trying to insert raw svg, need to create in namespace
                case 'svg': {
                    compToReplace.ns = 'svg'
                    newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
                    break
                }

                default: {
                    compToReplace.ns = 'dom'
                    newEl = Ui.doc.createElement(compToReplace.type)
                    break
                }
            }

            // Updates the newEl and maybe the ui
            this._uiComposeComponent(newEl, compToReplace)

            // Replace the current element
            elToReplace.replaceWith(newEl)

            // If nested components, go again - but don't pass payload to sub-components
            if (compToReplace.components) {
                this._uiExtendEl(newEl, compToReplace.components, compToReplace.ns)
            }
        })
    } // --- end of _uiReplace ---

    // TODO Allow single add without using components array
    // TODO Allow sub-components
    // TODO Add multi-slot capability
    /** Handle incoming _ui update requests
     * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
     */
    _uiUpdate(ui) {
        Ui.log('trace', 'UI:_uiUpdate:update', 'Starting _uiUpdate', ui)()

        // We allow an update not to actually need to spec a component
        if (!ui.components) ui.components = [Object.assign({}, ui)]

        ui.components.forEach((compToUpd, i) => {
            Ui.log('trace', '_uiUpdate:components-forEach', `Start loop #${i}`, compToUpd)()

            /** @type {NodeListOf<Element>} */
            let elToUpd

            // If a parent element is passed, use that as the update target (only allowed internally)
            // 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.
            if (compToUpd.parentEl) {
                elToUpd = compToUpd.parentEl
            } else if (compToUpd.id) {
                // NB We don't use get by id because this way the code is simpler later on
                elToUpd = Ui.doc.querySelectorAll(`#${compToUpd.id}`)
            } else if (compToUpd.selector || compToUpd.select) {
                elToUpd = Ui.doc.querySelectorAll(compToUpd.selector)
            } else if (compToUpd.name) {
                elToUpd = Ui.doc.querySelectorAll(`[name="${compToUpd.name}"]`)
            } else if (compToUpd.type) {
                elToUpd = Ui.doc.querySelectorAll(compToUpd.type)
            }

            // @ts-ignore Nothing was found so give up
            if (elToUpd === undefined || elToUpd.length < 1) {
                Ui.log('warn', 'Ui:_uiManager:update', 'Cannot find the DOM element. Ignoring.', compToUpd)()
                return
            }

            Ui.log('trace', '_uiUpdate:components-forEach', `Element(s) to update. Count: ${elToUpd.length}`, elToUpd)()

            // If slot not specified but payload is, use the payload in the slot
            if (!compToUpd.slot && compToUpd.payload) compToUpd.slot = compToUpd.payload

            // Might have >1 element to update - so update them all
            elToUpd.forEach((el, j) => {
                Ui.log('trace', '_uiUpdate:components-forEach', `Updating element #${j}`, el)()
                this._uiComposeComponent(el, compToUpd)
                // Try to go down another level of nesting if needed
                // ! NOT CONVINCED THIS ACTUALLY WORKS !
                if (compToUpd.components) {
                    Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component`, compToUpd, el)()
                    const nc = { _ui: [], }
                    compToUpd.components.forEach((nestedComp, k) => {
                        const method = nestedComp.method || compToUpd.method || ui.method
                        if (nestedComp.method) delete nestedComp.method
                        if (!Array.isArray(nestedComp)) nestedComp = [nestedComp]
                        // nestedComp.parentEl = el
                        // nestedComp.components = [nestedComp]
                        Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component #${k}`, nestedComp)()
                        nc._ui.push( {
                            method: method,
                            parentEl: el,
                            components: nestedComp,
                        })
                    })
                    Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component new manager`, nc)()
                    this._uiManager(nc)
                }
            })

            // If nested components, apply to every found element - but don't pass payload to sub-components
            // if (compToUpd.components) {
            //     compToUpd.components.forEach((el, k) => {
            //         Ui.log('trace', '_uiUpdate:nested-component', `Updating nested-component #${k}`, el)()
            //         this._uiUpdate({
            //             method: el.method || ui.method,
            //             parentEl: el,
            //             components: el.components,
            //         })
            //     })
            // }
        })
    } // --- end of _uiUpdate ---

    //#endregion ---- -------- ----

    //#region ---- External Methods ----

    /** Simplistic jQuery-like document CSS query selector, returns an HTML Element
     * NOTE that this fn returns the element itself. Use $$ to get the properties of 1 or more elements.
     * If the selected element is a <template>, returns the first child element.
     * type {HTMLElement}
     * @param {string} cssSelector A CSS Selector that identifies the element to return
     * @param {"el"|"text"|"html"|"attributes"|"attr"} [output] Optional. What type of output to return. Defaults to "el", the DOM element reference
     * @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element.
     * @returns {HTMLElement|string|Array|null} Selected HTML DOM element, innerText, innerHTML, attribute list or null
     */
    $(cssSelector, output, context) {
        if (!context) context = Ui.doc
        if (!output) output = 'el'

        // if context is not a valid htmlelement, return null
        if (!context || !context.nodeType) {
            Ui.log(1, 'Uib:$', `Invalid context element. Must be a valid HTML element.`, context)()
            return null
        }

        /** @type {HTMLElement} Some kind of HTML element */
        let el = (context).querySelector(cssSelector)

        // if no element found or is not a valid htmlelement, return null
        if (!el || !el.nodeType) {
            Ui.log(1, 'Uib:$', `No element found or element is not an HTML element for CSS selector ${cssSelector}`)()
            return null
        }

        if ( el.nodeName === 'TEMPLATE' ) {
            el = el.content.firstElementChild
            if (!el) {
                Ui.log(0, 'Uib:$', `Template selected for CSS selector ${cssSelector} but it is empty`)()
                return null
            }
        }

        let out

        try {
            switch (output.toLowerCase()) {
                case 'text': {
                    out = el.innerText
                    break
                }

                case 'html': {
                    out = el.innerHTML
                    break
                }

                case 'attr':
                case 'attributes': {
                    out = {}
                    for (const attr of el.attributes) {
                        out[attr.name] = attr.value
                    }
                    break
                }

                default: {
                    out = el
                    break
                }
            }
        } catch (e) {
            out = el
            Ui.log(1, 'Uib:$', `Could not process output type "${output}" for CSS selector ${cssSelector}, returned the DOM element. ${e.message}`, e)()
        }

        return out
    }

    /** CSS query selector that returns ALL found selections. Matches the Chromium DevTools feature of the same name.
     * NOTE that this fn returns an array showing the PROPERTIES of the elements whereas $ returns the element itself
     * @param {string} cssSelector A CSS Selector that identifies the elements to return
     * @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element.
     * @returns {HTMLElement[]} Array of DOM elements/nodes. Array is empty if selector is not found.
     */
    $$(cssSelector, context) {
        if (!context) context = Ui.doc

        // if context is not a valid htmlelement, return null
        if (!context || !context.nodeType) {
            Ui.log(1, 'Uib:$$', `Invalid context element. Must be a valid HTML element.`, context)()
            return null
        }

        return Array.from((context).querySelectorAll(cssSelector))
    }

    /** Add 1 or several class names to an element
     * @param {string|string[]} classNames Single or array of classnames
     * @param {HTMLElement} el HTML Element to add class(es) to
     */
    addClass(classNames, el) {
        if (!Array.isArray(classNames)) classNames = [classNames]
        if (el) el.classList.add(...classNames)
    }

    /** Apply a source template tag to a target html element
     * NOTES:
     * - 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.
     * - When using 'wrap' mode, the target content is placed into the template's 1ST <slot> only (if present).
     * - styles in ALL templates are accessible to all templates & impact the whole page.
     * - scripts in templates are run AT TIME OF APPLICATION (so may run multiple times).
     * - scripts in templates are applied in order of application, so variables may not yet exist if defined in subsequent templates
     * @param {string} sourceId The HTML ID of the source element
     * @param {string} targetId The HTML ID of the target element
     * @param {object} config Configuration options
     *   @param {boolean=} config.onceOnly   If true, the source will be adopted (the source is moved)
     *   @param {object=}  config.attributes A set of key:value pairs that will be applied as attributes to the 1ST ELEMENT ONLY of the target
     *   @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).
     */
    applyTemplate(sourceId, targetId, config) {
        if (!config) config = {}
        if (!config.onlyOnce) config.onlyOnce = false
        if (!config.mode) config.mode = 'insert'

        const template = Ui.doc.getElementById(sourceId)
        if (!template || template.tagName !== 'TEMPLATE') {
            Ui.log('error', 'Ui:applyTemplate', `Source must be a <template>. id='${sourceId}'`)()
            return
        }

        const target = Ui.doc.getElementById(targetId)
        if (!target) {
            Ui.log('error', 'Ui:applyTemplate', `Target not found: id='${targetId}'`)()
            return
        }

        const targetContent = target.innerHTML ?? ''
        if (targetContent && config.mode === 'replace') {
            Ui.log('warn', 'Ui:applyTemplate', `Target element is not empty, content is replaced. id='${targetId}'`)()
        }

        let templateContent
        if (config.onceOnly === true) templateContent = Ui.doc.adoptNode(template.content) // NB content.childElementCount = 0 after adoption
        else templateContent = Ui.doc.importNode(template.content, true)

        if (templateContent) {
            // Apply config.attributes to the 1ST ELEMENT ONLY of the template content
            if (config.attributes) {
                const el = templateContent.firstElementChild
                Object.keys(config.attributes).forEach( attrib => {
                    // Apply each attribute and value
                    el.setAttribute(attrib, config.attributes[attrib])
                })
            }

            if (config.mode === 'insert') {
                target.appendChild(templateContent)
            } else if (config.mode === 'replace') {
                target.innerHTML = ''
                target.appendChild(templateContent)
            } else if (config.mode === 'wrap') {
                target.innerHTML = ''
                target.appendChild(templateContent)
                if (targetContent) {
                    const slot = target.getElementsByTagName('slot')
                    if (slot.length > 0) {
                        slot[0].innerHTML = targetContent
                    }
                }
            }
        } else {
            Ui.log('warn', 'Ui:applyTemplate', `No valid content found in template`)()
        }
    }

    /** Converts markdown text input to HTML if the Markdown-IT library is loaded
     * Otherwise simply returns the text
     * @param {string} mdText The input markdown string
     * @returns {string} HTML (if Markdown-IT library loaded and parse successful) or original text
     */
    convertMarkdown(mdText) {
        if (!mdText) return ''
        if (!Ui.win['markdownit']) return mdText
        if (!Ui.md) this._markDownIt() // To handle case where the library is late loaded
        // Convert from markdown to HTML
        try {
            return Ui.md.render(mdText.trim())
        } catch (e) {
            Ui.log(0, 'uibuilder:convertMarkdown', `Could not render Markdown. ${e.message}`, e)()
            return '<p class="border error">Could not render Markdown<p>'
        }
    }

    /** Include HTML fragment, img, video, text, json, form data, pdf or anything else from an external file or API
     * Wraps the included object in a div tag.
     * PDF's, text or unknown MIME types are also wrapped in an iFrame.
     * @param {string} url The URL of the source file to include
     * @param {object} uiOptions Object containing properties recognised by the _uiReplace function. Must at least contain an id
     * param {string} uiOptions.id The HTML ID given to the wrapping DIV tag
     * param {string} uiOptions.parentSelector The CSS selector for a parent element to insert the new HTML under (defaults to 'body')
     * @returns {Promise<any>} Status
     */
    async include(url, uiOptions) {
        // TODO: src, id, parent must all be a strings
        if (!fetch) {
            Ui.log(0, 'Ui:include', 'Current environment does not include `fetch`, skipping.')()
            return 'Current environment does not include `fetch`, skipping.'
        }
        if (!url) {
            Ui.log(0, 'Ui:include', 'url parameter must be provided, skipping.')()
            return 'url parameter must be provided, skipping.'
        }
        if (!uiOptions || !uiOptions.id) {
            Ui.log(0, 'Ui:include', 'uiOptions parameter MUST be provided and must contain at least an `id` property, skipping.')()
            return 'uiOptions parameter MUST be provided and must contain at least an `id` property, skipping.'
        }

        // Try to get the content via the URL
        let response
        try {
            response = await fetch(url)
        } catch (error) {
            Ui.log(0, 'Ui:include', `Fetch of file '${url}' failed. `, error.message)()
            return error.message
        }
        if (!response.ok) {
            Ui.log(0, 'Ui:include', `Fetch of file '${url}' failed. Status='${response.statusText}'`)()
            return response.statusText
        }

        // Work out what type of data we got
        const contentType = await response.headers.get('content-type')
        let type = null
        if (contentType) {
            if (contentType.includes('text/html')) {
                type = 'html'
            } else if (contentType.includes('application/json')) {
                type = 'json'
            } else if (contentType.includes('multipart/form-data')) {
                type = 'form'
            } else if (contentType.includes('image/')) {
                type = 'image'
            } else if (contentType.includes('video/')) {
                type = 'video'
            } else if (contentType.includes('application/pdf')) {
                type = 'pdf'
            } else if (contentType.includes('text/plain')) {
                type = 'text'
            } // else type = null
        }

        // Create the HTML to include on the page based on type
        let slot = ''
        let txtReturn = 'Include successful'
        let data
        switch (type) {
            case 'html': {
                data = await response.text()
                slot = data
                break
            }

            case 'json': {
                data = await response.json()
                slot = '<pre class="syntax-highlight">'
                slot += this.syntaxHighlight(data)
                slot += '</pre>'
                break
            }

            case 'form': {
                data = await response.formData()
                slot = '<pre class="syntax-highlight">'
                slot += this.syntaxHighlight(data)
                slot += '</pre>'
                break
            }

            case 'image': {
                data = await response.blob()
                slot = `<img src="${URL.createObjectURL(data)}">`
                if (Ui.win['DOMPurify']) {
                    txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.'
                    Ui.log('warn', 'Ui:include:image', txtReturn)()
                }
                break
            }

            case 'video': {
                data = await response.blob()
                slot = `<video controls autoplay><source src="${URL.createObjectURL(data)}"></video>`
                if (Ui.win['DOMPurify']) {
                    txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.'
                    Ui.log('warn', 'Ui:include:video', txtReturn)()
                }
                break
            }

            case 'pdf':
            case 'text':
            default: {
                data = await response.blob()
                slot = `<iframe style="resize:both;width:inherit;height:inherit;" src="${URL.createObjectURL(data)}">`
                if (Ui.win['DOMPurify']) {
                    txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.'
                    Ui.log('warn', `Ui:include:${type}`, txtReturn)()
                }
                break
            }
        }

        // Wrap it all in a <div id="..." class="included">
        uiOptions.type = 'div'
        uiOptions.slot = slot
        if (!uiOptions.parent) uiOptions.parent = 'body'
        if (!uiOptions.attributes) uiOptions.attributes = { class: 'included', }

        // Use uibuilder's standard ui processing to turn the instructions into HTML
        this._uiReplace({
            components: [
                uiOptions
            ],
        })

        Ui.log('trace', `Ui:include:${type}`, txtReturn)()
        return txtReturn
    } // ---- End of include() ---- //

    /** Attach a new remote script to the end of HEAD synchronously
     * NOTE: It takes too long for most scripts to finish loading
     *       so this is pretty useless to work with the dynamic UI features directly.
     * @param {string} url The url to be used in the script src attribute
     */
    loadScriptSrc(url) {
        const newScript = Ui.doc.createElement('script')
        newScript.src = url
        newScript.async = false
        Ui.doc.head.appendChild(newScript)
    }

    /** Attach a new text script to the end of HEAD synchronously
     * NOTE: It takes too long for most scripts to finish loading
     *       so this is pretty useless to work with the dynamic UI features directly.
     * @param {string} textFn The text to be loaded as a script
     */
    loadScriptTxt(textFn) {
        const newScript = Ui.doc.createElement('script')
        newScript.async = false
        newScript.textContent = textFn
        Ui.doc.head.appendChild(newScript)
    }

    /** Attach a new remote stylesheet link to the end of HEAD synchronously
     * NOTE: It takes too long for most scripts to finish loading
     *       so this is pretty useless to work with the dynamic UI features directly.
     * @param {string} url The url to be used in the style link href attribute
     */
    loadStyleSrc(url) {
        const newStyle = Ui.doc.createElement('link')
        newStyle.href = url
        newStyle.rel = 'stylesheet'
        newStyle.type = 'text/css'

        Ui.doc.head.appendChild(newStyle)
    }

    /** Attach a new text stylesheet to the end of HEAD synchronously
     * NOTE: It takes too long for most scripts to finish loading
     *       so this is pretty useless to work with the dynamic UI features directly.
     * @param {string} textFn The text to be loaded as a stylesheet
     */
    loadStyleTxt(textFn) {
        const newStyle = Ui.doc.createElement('style')
        newStyle.textContent = textFn
        Ui.doc.head.appendChild(newStyle)
    }

    /** Load a dynamic UI from a JSON web reponse
     * @param {string} url URL that will return the ui JSON
     */
    loadui(url) {
        if (!fetch) {
            Ui.log(0, 'Ui:loadui', 'Current environment does not include `fetch`, skipping.')()
            return
        }
        if (!url) {
            Ui.log(0, 'Ui:loadui', 'url parameter must be provided, skipping.')()
            return
        }

        fetch(url)
            .then(response => {
                if (response.ok === false) {
                    // Ui.log('warn', 'Ui:loadui:then1', `Could not load '${url}'. Status ${response.status}, Error: ${response.statusText}`)()
                    throw new Error(`Could not load '${url}'. Status ${response.status}, Error: ${response.statusText}`)
                }

                Ui.log('trace', 'Ui:loadui:then1', `Loaded '${url}'. Status ${response.status}, ${response.statusText}`)()
                // Did we get json?
                const contentType = response.headers.get('content-type')
                if (!contentType || !contentType.includes('application/json')) {
                    throw new TypeError(`Fetch '${url}' did not return JSON, ignoring`)
                }
                // Returns parsed json to next .then
                return response.json()
            })
            .then(data => {
                if (data !== undefined) {
                    Ui.log('trace', 'Ui:loadui:then2', 'Parsed JSON successfully obtained')()
                    // Call the _uiManager
                    this._uiManager({ _ui: data, })
                    return true
                }
                return false
            })
            .catch(err => {
                Ui.log('warn', 'Ui:loadui:catch', 'Error. ', err)()
            })
    } // --- end of loadui

    /** ! NOT COMPLETE Move an element from one position to another
     * @param {object} opts Options
     * @param {string} opts.sourceSelector Required, CSS Selector that identifies the element to be moved
     * @param {string} opts.targetSelector Required, CSS Selector that identifies the element to be moved
     */
    moveElement(opts) {
        const { sourceSelector, targetSelector, moveType, position, } = opts
        const sourceEl = document.querySelector(sourceSelector)
        if (!sourceEl) {
            Ui.log(0, 'Ui:moveElement', 'Source element not found')()
            return
        }
        const targetEl = document.querySelector(targetSelector)
        if (!targetEl) {
            Ui.log(0, 'Ui:moveElement', 'Target element not found')()
            return
        }
    }

    /** Get standard data from a DOM node.
     * @param {*} node DOM node to examine
     * @param {string} cssSelector Identify the DOM element to get data from
     * @returns {object} Standardised data object
     */
    nodeGet(node, cssSelector) {
        const thisOut = {
            id: node.id === '' ? undefined : node.id,
            name: node.name,
            children: node.childNodes.length,
            type: node.nodeName,
            attributes: undefined,

            isUserInput: node.validity ? true : false,
            userInput: !node.validity ? undefined : {
                value: node.value,
                validity: undefined,
                willValidate: node.willValidate,
                valueAsDate: node.valueAsDate,
                valueAsNumber: node.valueAsNumber,
                type: node.type,
            },
        }

        if (['UL', 'OL'].includes(node.nodeName)) {
            const listEntries = Ui.doc.querySelectorAll(`${cssSelector} li`)
            if (listEntries) {
                thisOut.list = {
                    'entries': listEntries.length,
                }
            }
        }
        if (node.nodeName === 'DL') {
            const listEntries = Ui.doc.querySelectorAll(`${cssSelector} dt`)
            if (listEntries) {
                thisOut.list = {
                    'entries': listEntries.length,
                }
            }
        }
        if (node.nodeName === 'TABLE') {
            const bodyEntries = Ui.doc.querySelectorAll(`${cssSelector} > tbody > tr`)
            const headEntries = Ui.doc.querySelectorAll(`${cssSelector} > thead > tr`)
            const cols = Ui.doc.querySelectorAll(`${cssSelector} > tbody > tr:last-child > *`)  // #eltest > table > tbody > tr:nth-child(3)
            if (bodyEntries || headEntries || cols) {
                thisOut.table = {
                    'headRows': headEntries ? headEntries.length : 0,
                    'bodyRows': bodyEntries ? bodyEntries.length : 0,
                    'columns': cols ? cols.length : 0,
                }
            }
        }
        if (node.nodeName !== '#text' && node.attributes && node.attributes.length > 0) {
            thisOut.attributes = {}
            // @ts-ignore
            for (const attrib of node.attributes) {
                if (attrib.name !== 'id') {
                    thisOut.attributes[attrib.name] = node.attributes[attrib.name].value
                }
                if (attrib.name === 'class') thisOut.classes = Array.from(node.classList)
            }
        }
        if (node.nodeName === '#text') {
            thisOut.text = node.textContent
        }
        if (node.validity) thisOut.userInput.validity = {}
        for (const v in node.validity) {
            thisOut.userInput.validity[v] = node.validity[v]
        }

        return thisOut
    } // --- end of nodeGet --- //

    /** Show a browser notification if possible. Returns a promise
     * Config can be a simple string, a Node-RED msg (topic as title, payload as body)
     * or a Notifications API options object + config.title string.
     * Config ref: https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification
     * @param {object|string} config Notification config object or simple message string
     * @returns {Promise} Resolves on close or click event, returns the event.
     */
    async notification(config) {
        if (typeof config === 'string') {
            config = { body: config, }
        }
        // Are notifications available?
        if (typeof Notification === 'undefined') return Promise.reject(new Error('Notifications not available in this browser'))
        // Do we have permission? If not, ask for permission
        let permit = Notification.permission
        if (permit === 'denied') {
            return Promise.reject(new Error('Notifications not permitted by user'))
        } else if (permit === 'granted') {
            return this._showNotification(config)
        }
        // if (permit === 'default') {
        permit = await Notification.requestPermission()
        if (permit === 'granted') {
            return this._showNotification(config)
        }
        return Promise.reject(new Error('Notifications not permitted by user'))
    }

    /** Remove All, 1 or more class names from an element
     * @param {undefined|null|""|string|string[]} classNames Single or array of classnames. If undefined, "" or null, remove all classes
     * @param {HTMLElement} el HTML Element to add class(es) to
     */
    removeClass(classNames, el) {
        if (!classNames) {
            el.removeAttribute('class')
            return
        }
        if (!Array.isArray(classNames)) classNames = [classNames]
        if (el) el.classList.remove(...classNames)
    }

    /** Replace or add an HTML element's slot from text or an HTML string
     * WARNING: Executes <script> tags! And will process <style> tags.
     * Will use DOMPurify if that library has been loaded to window.
     * param {*} ui Single entry from the msg._ui property
     * @param {Element} el Reference to the element that we want to update
     * @param {*} slot The slot content we are trying to add/replace (defaults to empty string)
     */
    replaceSlot(el, slot) {
        if (!el) return
        if (!slot) slot = ''

        // If DOMPurify is loaded, apply it now
        slot = this.sanitiseHTML(slot)

        // Create doc frag and apply html string (msg.payload or the slot property)
        const tempFrag = Ui.doc.createRange().createContextualFragment(slot)

        // Remove content of el and replace with tempFrag
        const elRange = Ui.doc.createRange()
        elRange.selectNodeContents(el)
        elRange.deleteContents()
        el.append(tempFrag)
    }

    /** Replace or add an HTML element's slot from a Markdown string
     * Only does something if the markdownit library has been loaded to window.
     * Will use DOMPurify if that library has been loaded to window.
     * @param {Element} el Reference to the element that we want to update
     * @param {*} component The component we are trying to add/replace
     */
    replaceSlotMarkdown(el, component) {
        if (!el) return
        if (!component.slotMarkdown) return

        // Convert from markdown to HTML
        component.slotMarkdown = this.convertMarkdown(component.slotMarkdown)
        // If DOMPurify is loaded, apply it now
        component.slotMarkdown = this.sanitiseHTML(component.slotMarkdown)
        // Set the component content to the the converted slotMarkdown property
        el.innerHTML = component.slotMarkdown
    }

    /** Sanitise HTML to make it safe - if the DOMPurify library is loaded
     * Otherwise just returns that HTML as-is.
     * @param {string} html The input HTML string
     * @returns {string} The sanitised HTML or the original if DOMPurify not loaded
     */
    sanitiseHTML(html) {
        if (!Ui.win['DOMPurify']) return html
        return Ui.win['DOMPurify'].sanitize(html, { ADD_TAGS: this.sanitiseExtraTags, ADD_ATTR: this.sanitiseExtraAttribs, })
    }

    /** Show a pop-over "toast" dialog or a modal alert // TODO - Allow notify to sit in corners rather than take over the screen
     * Refs: https://www.w3.org/WAI/ARIA/apg/example-index/dialog-modal/alertdialog.html,
     *       https://www.w3.org/WAI/ARIA/apg/example-index/dialog-modal/dialog.html,
     *       https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/
     * @param {"notify"|"alert"} type Dialog type
     * @param {object} ui standardised ui data
     * @param {object} [msg] msg.payload/msg.topic - only used if a string. Optional.
     * @returns {void}
     */
    showDialog(type, ui, msg) {

        //#region -- Check properties --

        let content = ''
        // Main body content
        if (msg.payload && typeof msg.payload === 'string') content += `<div>${msg.payload}</div>`
        if (ui.content) content += `<div>${ui.content}</div>`
        // Toast wont show anyway if content is empty, may as well warn user
        if (content === '') {
            Ui.log(1, 'Ui:showDialog', 'Toast content is blank. Not shown.')()
            return
        }

        // Use msg.topic as title if no title provided
        if (!ui.title && msg.topic) ui.title = msg.topic
        if (ui.title) content = `<p class="toast-head">${ui.title}</p><p>${content}</p>`

        // Allow for variants - @since v6.1 - don't bother - since this now sets CSS class, not tied to bootstrap-vue
        // if ( !ui.variant || !['', 'primary', 'secondary', 'success', 'info', 'warn', 'warning', 'failure', 'error', 'danger'].includes(ui.variant)) ui.variant = ''

        // Toasts auto-hide by default after 10s but alerts do not auto-hide
        if (ui.noAutohide) ui.noAutoHide = ui.noAutohide
        if (ui.noAutoHide) ui.autohide = !ui.noAutoHide
        // If set, number of ms until toast is auto-hidden
        if (ui.autoHideDelay) {
            if (!ui.autohide) ui.autohide = true
            ui.delay = ui.autoHideDelay
        } else ui.autoHideDelay = 10000 // default = 10s
        if (!Object.prototype.hasOwnProperty.call(ui, 'autohide')) ui.autohide = true

        if (type === 'alert') {
            ui.modal = true
            ui.autohide = false
            content = `<svg viewBox="0 0 192.146 192.146" style="width:30;background-color:transparent;"><path d="M108.186 144.372c0 7.054-4.729 12.32-12.037 12.32h-.254c-7.054 0-11.92-5.266-11.92-12.32 0-7.298 5.012-12.31 12.174-12.31s11.91 4.992 12.037 12.31zM88.44 125.301h15.447l2.951-61.298H85.46l2.98 61.298zm101.932 51.733c-2.237 3.664-6.214 5.921-10.493 5.921H12.282c-4.426 0-8.51-2.384-10.698-6.233a12.34 12.34 0 0 1 .147-12.349l84.111-149.22c2.208-3.722 6.204-5.96 10.522-5.96h.332c4.445.107 8.441 2.618 10.513 6.546l83.515 149.229c1.993 3.8 1.905 8.363-.352 12.066zm-10.493-6.4L96.354 21.454l-84.062 149.18h167.587z" /></svg> ${content}`
        }

        //#endregion -- -- --

        // Create a toaster container element if not already created - or get a ref to it
        let toaster = Ui.doc.getElementById('toaster')
        if (toaster === null) {
            toaster = Ui.doc.createElement('div')
            toaster.id = 'toaster'
            toaster.title = 'Click to clear all notifcations'
            toaster.setAttribute('class', 'toaster')
            toaster.setAttribute('role', 'dialog')
            toaster.setAttribute('arial-label', 'Toast message')
            toaster.onclick = function () {
                // @ts-ignore
                toaster.remove()
            }
            Ui.doc.body.insertAdjacentElement('afterbegin', toaster)
        }

        // Create a toast element. Would be nice to use <dialog> but that isn't well supported yet - come on Apple!
        const toast = Ui.doc.createElement('div')
        toast.title = 'Click to clear this notifcation'
        toast.setAttribute('class', `toast ${ui.variant ? ui.variant : ''} ${type}`)
        toast.innerHTML = content
        toast.setAttribute('role', 'alertdialog')
        if (ui.modal) toast.setAttribute('aria-modal', ui.modal)
        toast.onclick = function (evt) {
            evt.stopPropagation()
            toast.remove()
            // @ts-ignore
            if (toaster.childElementCount < 1) toaster.remove()
        }

        // TODO
        if (type === 'alert') {
            // newD.setAttribute('aria-labelledby', '')
            // newD.setAttribute('aria-describedby', '')
        }

        toaster.insertAdjacentElement(ui.appendToast === true ? 'beforeend' : 'afterbegin', toast)

        // Auto-hide
        if (ui.autohide === true) {
            setInterval(() => {
                toast.remove()
                // @ts-ignore
                if (toaster.childElementCount < 1) toaster.remove()
            }, ui.autoHideDelay)
        }
    } // --- End of showDialog ---

    /** Directly manage UI via JSON
     * @param {object} json Either an object containing {_ui: {}} or simply simple {} containing ui instructions
     */
    ui(json) {
        // Simulate a msg and process
        let msg = {}
        if (json._ui) msg = json
        else msg._ui = json

        this._uiManager(msg)
    }

    /** Get data from the DOM. Returns selection of useful props unless a specific prop requested.
     * @param {string} cssSelector Identify the DOM element to get data from
     * @param {string} [propName] Optional. Specific name of property to get from the element
     * @returns {Array<*>} Array of objects containing either specific requested property or a selection of useful properties
     */
    uiGet(cssSelector, propName = null) {
        // The type cast below not really correct but it gets rid of the other typescript errors
        const selection = /** @type {NodeListOf<HTMLInputElement>} */ (Ui.doc.querySelectorAll(cssSelector))

        const out = []

        selection.forEach(node => {
            // Specific property asked for ...
            if (propName) {
                if (propName === 'classes') propName = 'class'
                // Try assuming the prop is an attribute first (will return null or "" if not present)
                let prop = node.getAttribute(propName)
                // If not an attribute, try getting as a property of the element
                if (prop === undefined || prop === null) {
                    try {
                        prop = node[propName]
                    } catch (error) {}
                }
                // We didn't find as either an attribute or a property
                if (prop === undefined || prop === null) {
                    // If 'value' was requested, return the innerText
                    if (propName.toLowerCase() === 'value') out.push(node.innerText)
                    else out.push(`Property '${propName}' not found`)
                } else {
                    const p = {}
                    // Nightmare of different object types in a DOM Element!
                    const cType = prop.constructor.name.toLowerCase()
                    if (cType === 'namednodemap') {
                        for (const key of prop) {
                            // @ts-ignore
                            p[key.name] = prop[key.name].value
                        }
                    } else if (!cType.includes('map')) { // Ordinary properties (not a mapped type)
                        p[propName] = prop
                    } else { // Other MAP types
                        const p = {}
                        // @ts-ignore
                        for (const key in prop) {
                            p[key] = prop[key]
                        }
                    }
                    if (p.class) p.classes = Array.from(node.classList)
                    out.push(p)
                }
            } else { // Otherwise, grab everything useful
                out.push(this.nodeGet(node, cssSelector))
            }
        })

        return out
    } // --- end of uiGet --- //

    /** External alias for _uiComposeComponent
     * @param {*} el HTML Element to enhance
     * @param {*} comp Individual uibuilder ui component spec
     */
    uiEnhanceElement(el, comp) {
        this._uiComposeComponent(el, comp)
    }

    //#region --- table handling ---

    /** Column metadata object definition
     * @typedef columnDefinition
     * @property {number} index The column index number
     * @property {boolean} hasName Whether the column has a defined name or not
     * @property {string} title The title of the column. Shown in the table header row
     * @property {string=} name Optional. A defined column name that will be added as the `data-col-name` to all cells in the column if defined
     * @property {string|number=} key Optional. A key value (currently unused)
     * @property {"string"|"date"|"number"|"html"=} dataType FOR FUTURE USE. Optional. What type of data will this column contain?
     * @property {boolean=} editable FOR FUTURE USE. Optional. Can cells in this column be edited?
     */

    /** Directly add a table to a parent element.
     * @param {Array<object>|Array<Array>|object} data  Input data array or object. Object of objects gives named rows. Array of objects named cols. Array of arrays no naming.
     * @param {object} [opts] Build options
     *   @param {Array<columnDefinition>=} opts.cols Column metadata. If not provided will be derived from 1st row of data
     *   @param {HTMLElement|string} opts.parent Default=body. The table will be added as a child. May be an actual HTML element or a CSS Selector
     *   @param {boolean=} opts.allowHTML Optional, default=false. If true, allows HTML cell content, otherwise only allows text. Always sanitise HTML inputs
     */
    createTable(data=[], opts={parent: 'body',}) {
        if (!opts.parent) throw new Error('[ui.js:createTable] opts.parent must be provided')
        this.buildHtmlTable(data, opts)
    }

    // TODO ...
    /** Builds & returns an HTML table element from an array (or object) of objects
     * 1st row is used for columns unless you pass opts.cols to describe them.
     * If an object of objects, inner keys are used to populate th/td `data-col-name` attribs. Outer keys applied as row ID's.
     *
     * TODO
     * - Allow optional caption, heading, footers, optional collapse
     * - Multiple headings, footers
     * - colspans, rowspans
     * - multiple tbody
     *
     * @param {Array<object>|Array<Array>|object} data Input data array or object. Object of objects gives named rows. Array of objects named cols. Array of arrays no naming.
     * @param {object} opts Table options
     *   @param {Array<columnDefinition>=} opts.cols Column metadata. If not provided will be derived from 1st row of data
     *   @param {HTMLElement|string=} opts.parent If provided, the table will be added as a child instead of returned. May be an actual HTML element or a CSS Selector
     *   @param {boolean=} opts.allowHTML Optional, default=false. If true, allows HTML cell content, otherwise only allows text. Always sanitise HTML inputs
     * @returns {HTMLTableElement|HTMLParagraphElement} Output HTML Element
     */
    buildHtmlTable(data, opts={}) {
        // If data is an object of objects, convert it to an array of objects
        let rowKeys
        const dataType = Object.prototype.toString.apply(data)
        if (dataType === '[object Array]' || dataType === '[object Object]') {
            rowKeys = Object.keys(data)
            data = Object.values(data)
        } else {
            const out = Ui.doc.createElement('p')
            out.textContent = 'Input data is not an array or an object, cannot create a table.'
            return out
        }

        if (rowKeys.length > 1000) Ui.log(1, 'Uib:buildHtmlTable', `Warning, data is ${rowKeys.length} rows. Anything over 1,000 can get very slow to complete.`)()

        const tbl = Ui.doc.createElement('table')

        // Heading row
        const thead = Ui.doc.createElement('thead')
        const headerRow = Ui.doc.createElement('tr')

        if (!opts.cols) { // Take the columns from the 1st row of provided data
            // We have to have data
            if (data.length < 1) throw new Error('[ui.js:buildHtmlTable] When no opts.cols is provided, data must contain at least 1 row')

            const hasName = Object.prototype.toString.apply(data[0]) !== '[object Array]'

            // Set this row to be the column reference (so we have a fixed ref if we allow more thead rows in the future)
            headerRow.dataset.colReference = '' // creates attribute data-col-reference

            // Create cols data: Assume 1st row of data
            opts.cols = []
            Object.keys(data[0]).forEach( (col, i) => {
                opts.cols.push({
                    'index': i,
                    'hasName': hasName,
                    'name': hasName ? col : undefined,
                    'key': col ?? i,
                    'title': col,
                })
            })
        }
        // @ts-ignore Add cols metadata to table element as a custom prop
        tbl.cols = opts.cols

        // Add the table column headings
        opts.cols.forEach(col => {
            const thEl = Ui.doc.createElement('th')
            thEl.textContent = col.title
            // @ts-ignore
            if (col.hasName === true) thEl.dataset.colName = name
            headerRow.appendChild(thEl)
        })

        thead.appendChild(headerRow)
        tbl.appendChild(thead)

        // body
        const tbody = Ui.doc.createElement('tbody')
        tbl.appendChild(tbody)

        const rowOpts = {
            allowHTML: true,
            cols: opts.cols, // we only want to get this once
        }

        // Directly adds rows to the tbl element
        data.forEach( (item, i) => {
            if (isNaN(Number(rowKeys[i]))) rowOpts.rowId = rowKeys[i]
            else rowOpts.rowId = undefined
            this.tblAddRow(tbl, item, rowOpts)
        })

        if (opts.parent) {
            // const parentType = Object.prototype.toString.apply(opts.parent)
            let parentEl
            if (typeof opts.parent === 'string') {
                parentEl = Ui.doc.querySelector(opts.parent)
            } else {
                parentEl = opts.parent
            }
            try {
                parentEl.appendChild(tbl)
            } catch (e) {
                throw new Error(`[ui.js:buildHtmlTable] Could not add table to parent. ${e.message}`)
            }
            return
        }

        return tbl
    }

    /** Adds (or replaces) a single row in an existing table>tbody
     * NOTE: Row numbers use the rowIndex property of the row element.
     * @param {string|HTMLTableElement} tbl Either a CSS Selector for the table or a reference to the HTML Table Element
     * @param {object|Array} rowData A single row of column/cell data
     * @param {object} [options] Additional options
     *  @param {number=} options.body Optional, default=0. The tbody section to add the row to.
     *  @param {boolean=} options.allowHTML Optional, default=false. If true, allows HTML cell content, otherwise only allows text. Always sanitise HTML inputs
     *  @param {string=} options.rowId Optional. HTML element ID for the added row
     *  @param {number=} options.afterRow Optional. If provided, the new row will be added after this row number
     *  @param {number=} options.beforeRow Optional. If provided, the new row will be added before this row number. Ignored if afterRow is provided
     *  @param {number=} options.replaceRow Optional. If provided, the specified row will be REPLACED instead of added. Ignored if afterRow or beforeRow is provided
     *  @param {Array<columnDefinition>} [options.cols] Optional. Data about each column. If not provided, will be calculated from the table
     *
     * @returns {HTMLTableRowElement} Reference to the newly added row. Use the `rowIndex` prop for the row number
     */
    tblAddRow(tbl, rowData={}, options={}) {
        const tblType = Object.prototype.toString.apply(tbl)

        if (Object.prototype.toString.apply(options) !== '[object Object]') throw new Error(`[tblAddDataRow] options must be an object`)

        // rowData must be an object or array of column cell data
        const dataType = Object.prototype.toString.apply(rowData)
        if (dataType !== '[object Object]' && dataType !== '[object Array]') throw new Error(`[tblAddDataRow] rowData MUST be an object or an array containing column/cell data for each column`)

        /** @type {HTMLTableElement} */
        let tblEl
        if (tblType === '[object HTMLTableElement]') { // we were passed an actual table el
            // @ts-ignore
            tblEl = tbl
        } else { // we were passed a css selector
            // @ts-ignore
            tblEl = Ui.doc.querySelector(tbl)
            if (!tblEl) throw new Error(`[tblAddDataRow] Table with CSS Selector "${tbl}" not found`)
        }

        if (!options.body) options.body = 0
        if (!('allowHTML' in options)) options.allowHTML = false

        const tbodyEl = tblEl.getElementsByTagName('tbody')[options.body]
        if (!tbodyEl) throw new Error(`[tblAddDataRow] Table must have a tbody tag, tbody section ${options.body} does not exist`)
        // console.log('rows', tbody.rows.length)

        // Get column meta if not provided
        if (!options.cols) options.cols = this.tblGetColMeta(tblEl)
        const colMeta = options.cols
        // console.log('COL-META:', colMeta, '-- COL-META-LENGTH:', colMeta.length)

        const rowEl = Ui.doc.createElement('tr')
        if (options.rowId) rowEl.id = options.rowId

        // Pre-create all the columns in an array
        const cols = []
        for (const col of colMeta) {
            const cellEl = Ui.doc.createElement('td')
            // Attach the col metadata to the col element as a custom prop
            cellEl.colMeta = col
            // If we have a name, add `data-col-name` attribute
            if (col.hasName) cellEl.dataset.colName = col.name

            cols.push(cellEl)
        }
        // console.log('COLS:', cols)

        // walk through provided data
        Object.keys(rowData).forEach( (colKey, i, row) => {
            // console.log('++ ROW KEY. i=', i, 'rowKey=', rowKey, 'rowData[rowKey]=', rowData[rowKey])

            // Match the new input to one of the columns
            let foundEl = cols.find( col => col?.colMeta?.name === colKey )
            let foundRowData
            if (foundEl) { // Find by name
                foundRowData = rowData[colKey]
            } else { // By index
                let numColKey = Number(colKey)
                if (isNaN(numColKey)) numColKey = i
                // NOTE: Ignore if the col index is larger than the number of cols
                if (numColKey <= cols.length - 1) {
                    foundEl = cols[numColKey]
                    foundRowData = Object.values(rowData)[numColKey] // Gets an array even if the original is an object
                }
            }
            if (foundEl) {
                // Attach the text/html
                if (options.allowHTML) foundEl.innerHTML = this.sanitiseHTML(foundRowData)
                else foundEl.textContent = foundRowData
            }
            // Ignore not found columns
        })

        // Append all the columns to the row
        rowEl.append(...cols)

        // If afterRow is provided, insert the row after the specified row number & return reference to the new row
        if ('afterRow' in options) {
            const afterRow = tbodyEl.rows[options.afterRow]
            if (afterRow) return afterRow.after(rowEl)
        } else if ('beforeRow' in options) {
            const beforeRow = tbodyEl.rows[options.beforeRow]
            if (beforeRow) return beforeRow.before(rowEl)
        } else if ('replaceRow' in options) {
            const replaceRow = tbodyEl.rows[options.replaceRow]
            if (replaceRow) return replaceRow.replaceWith(rowEl)
        }
        // Otherwise adds the row to the end of the table and returns the row reference for further processing if needed
        return tbodyEl.appendChild(rowEl)
    }

    /** Add table event listener that returns the text or html content of either the full row or a single cell
     * NOTE: Assumes that the table has a `tbody` element.
     * If cells have a `data-col-name` attribute, it will be used in the output as the column name.
     * @example tblAddListener('#eltest-tbl-table', {}, myVar)
     * @example tblAddListener('#eltest-tbl-table', {eventScope: 'cell'}, myVar2)
     *
     * @param {string} tblSelector The table CSS Selector
     * @param {object} [options] Additional options. Default={}
     *   @param {"row"|"cell"=} options.eventScope Optional, default=row. Return data for either the whole row (as an object) or for the single cell clicked
     *   @param {"text"|"html"=} options.returnType Optional, default=text. Return text or html data
     *   @param {number=} options.pad Optional, default=3. Will be used to front-pad unnamed column references with zeros. e.g. 3 => "C002"/"C012"/"C342"
     *   @param {boolean=} options.send Optional, default=true. If uibuilder is present, will automatically send a message back to Node-RED.
     *   @param {string|number=} options.logLevel Optional, default=3/info. Numeric or string log level matching uibuilder's log levels.
     *   @param {string} [options.eventType] Optional, default=click. What event to listen for.
     * @param {object=} out A variable reference that will be updated with the output data upon a click event
     */
    tblAddListener(tblSelector, options = {}, out = {}) {
        const table = Ui.doc.querySelector(tblSelector)
        if (!table) throw new Error(`Table with CSS Selector "${tblSelector}" not found`)
        if (typeof out !== 'object') throw new Error('The "out" argument MUST be an object')

        if (!options.eventScope) options.eventScope = 'row'
        if (!options.returnType) options.returnType = 'text'
        if (!options.eventType) options.eventType = 'click'
        if (!options.pad) options.pad = 3
        if (!options.logLevel) options.logLevel = 2 // info
        if (!('send' in options)) options.send = true

        // Add event listener to the table's <tbody> to capture clicks on any <tr> within it
        table.querySelector('tbody').addEventListener(options.eventType, (event) => {
            Object.keys(out).forEach(key => delete out[key]) // empty the out object

            const clickedRow = event.target.closest('tr')
            const clickedCell = event.target.closest('td')
            // console.log('element clicked: ', clickedEl)

            if (clickedRow) {
                out.clickType = options.eventScope
                out.eventType = options.eventType

                const rowIndex = out.rowIndex = clickedRow.rowIndex
                const cellIndex = out.cellIndex = clickedCell.cellIndex + 1 // Madness! rowindex starts from 1 but cell index from 0!

                if (clickedRow.id) out.rowId = clickedRow.id

                if (options.eventScope === 'row') {
                    // Loop through each <td> in the clicked row
                    clickedRow.querySelectorAll('td').forEach(cell => {
                        const colName = this.tblGetCellName(cell, options.pad)
                        out[colName] = options.returnType === 'text' ? cell.textContent.trim() : cell.innerHTML
                    })
                } else {
                    const colName = this.tblGetCellName(clickedCell, options.pad)
                    out[colName] = options.returnType === 'text' ? clickedCell.textContent.trim() : clickedCell.innerHTML
                }

                Ui.log(options.logLevel, 'Ui:tblAddClickListener', `${options.eventScope} ${options.eventType} on row=${rowIndex}, col=${cellIndex}, data: `, out)()

                // If UIBUILDER for Node-RED client library, send a message back to Node-RED
                if (options.send === true && Ui.win['uibuilder']) Ui.win['uibuilder'].send({
                    topic: `${tblSelector} ${options.eventScope} ${options.eventType}`,
                    payload: out,
                })
            }
        })
    }

    /** Find the column definition for a single column
     * @param {string|number} rowKey Key or index to use for column search
     * @param {Array<columnDefinition>=} colMeta Array of column definitions. If not provided, will need the HTML table element.
     * @param {HTMLTableElement=} tblEl If the colMeta table not provided, provide the HTML table element to do the lookup
     * @returns {columnDefinition} Column metadata object
     */
    tblFindColMeta(rowKey, colMeta, tblEl) {
        if (!colMeta && !tblEl) throw new Error('[tblFindColMeta] Either the column metadata array or the HTML table element must be provided')
        if (!colMeta && tblEl) colMeta = this.tblGetColMeta(tblEl)

        let colDef
        if (colMeta[rowKey]) colDef = colMeta[rowKey]
        else {
            const find = colMeta.find( c => c.name === rowKey || c.index === Number(rowKey) )
            // console.log('FIND:', find)
            if (find) colDef = find
        }
        // console.log('== COL DEF', colDef)
        return colDef
    }

    /** Return a standardised table cell name. Either from a `data-col-name` attribute or a numeric reference like `C003`
     * @param {HTMLTableCellElement} cellEl The cell element to process
     * @param {number=} pad Optional, default=3. Will be used to front-pad unnamed column references with zeros. e.g. 3 => "C002"/"C012"/"C342"
     * @returns {string} A cell name
     */
    tblGetCellName(cellEl, pad = 3) {
        return cellEl.getAttribute('data-col-name') ?? `C${String(cellEl.cellIndex + 1).padStart(pad, '0')}`
    }

    /** Returns either the existing or calculated column metadata given any table
     * First checks if the data is on the `cols` custom property of the table
     * If not, then looks 1st for a row with a `data-col-reference` attribute. Then for the first TR of the thead. Then for the first TR of the table.
     * @param {HTMLTableElement} tblEl DOM table element
     * @param {object} [options] Additional options. Default={}
     *   @param {number=} options.pad Optional, default=3. Will be used to front-pad unnamed column references with zeros. e.g. 3 => "C002"/"C012"/"C342"
     * @returns {Array<columnDefinition>} Column metadata = array of column definitions
     */
    tblGetColMeta(tblEl, options = {}) {
        if (!options.pad) options.pad = 3

        // @ts-ignore If the table already has the cols custom prop, simply return it
        if (tblEl.cols) return tblEl.cols

        let cols = tblEl.querySelector('tr[data-col-reference]')?.children
        if (!cols) cols = tblEl.querySelector('thead>tr:first-of-type')?.children
        if (!cols) cols = tblEl.querySelector('tr:first-of-type')?.children

        if (!cols) {
            Ui.log(1, 'Ui:tblGetColMeta', 'No columns found in table')()
            return []
        }

        const colData = []

        /** @type {HTMLTableCellElement} */ let cellEl
        for (cellEl of cols) {
            const hasName = !!cellEl.dataset.colName
            const colName = cellEl.dataset.colName
            const colIndex = cellEl.cellIndex + 1
            const colKey = hasName ? colName : `C${String(cellEl.cellIndex + 1).padStart(options.pad, '0')}`

            colData.push({
                index: colIndex,
                hasName: hasName,
                name: colName,
                key: colKey,
                title: cellEl.textContent,
            })
        }

        // @ts-ignore Add the custom prop to the table element
        tblEl.cols = colData

        return colData
    }

    /** Remove a row from an existing table
     * @param {string|HTMLTableElement} tbl Either a CSS Selector for the table or a reference to the HTML Table Element
     * @param {number} rowIndex The row number to remove (1st row is 0, last row is -1)
     * @param {object} [options] Additional options
     *  @param {number=} options.body Optional, default=0. The tbody section to add the row to.
     */
    tblRemoveRow(tbl, rowIndex, options = {}) {
        const tblType = Object.prototype.toString.apply(tbl)

        if (Object.prototype.toString.apply(options) !== '[object Object]') throw new Error(`[tblRemoveRow] options must be an object`)

        /** @type {HTMLTableElement} */
        let tblEl
        if (tblType === '[object HTMLTableElement]') { // we were passed an actual table el
            // @ts-ignore
            tblEl = tbl
        } else { // we were passed a css selector
            // @ts-ignore
            tblEl = Ui.doc.querySelector(tbl)
            if (!tblEl) throw new Error(`[tblRemoveRow] Table with CSS Selector "${tbl}" not found`)
        }

        if (!options.body) options.body = 0

        const tbodyEl = tblEl.getElementsByTagName('tbody')[options.body]
        if (!tbodyEl) throw new Error(`[tblAddDataRow] Table must have a tbody tag, tbody section ${options.body} does not exist`)

        tbodyEl.deleteRow(rowIndex)
    }

    //#endregion --- table handling ---

    //#endregion ---- external methods ----
}

export default Ui

// if (window) window['$ui'] = new Ui(window)

// export Ui