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.

3,365 lines • 146 kB
// @ts-nocheck
/**
 * @kind module
 * @module uibuilder
 * @description The client-side Front-End JavaScript for uibuilder in HTML Module form
 *   It provides a number of global objects that can be used in your own javascript.
 *   see the docs folder `./docs/uibuilder.module.md` for details of how to use this fully.
 *
 *   Please use the default index.js file for your own code and leave this as-is.
 *   See Uib._meta for client version string
 * @version 1.0.0
 * @license Apache-2.0
 * @author Julian Knight (Totally Information)
 * @copyright (c) 2022-2025 Julian Knight (Totally Information)
 */

//#region --- Type Defs --- //
/**
 * A string containing HTML markup
 * @typedef {string} html
 */
//#endregion --- Type Defs --- //

// We need the Socket.IO & ui libraries & the uib-var component  --- //
// @ts-ignore - Note: Only works when using esbuild to bundle
import Ui from './ui'
import io from 'socket.io-client' // eslint-disable-line import/no-named-as-default
import UibVar from '../components/uib-var'
import UibMeta from '../components/uib-meta'
import ApplyTemplate from '../components/apply-template'
// import { dom } from './tinyDom'

// Incorporate the logger module - NB: This sets a global `log` object for use if it can.
// import logger from './logger'

const version = '7.2.0-src'

//#region --- Module-level utility functions --- //

// Detect whether the loaded library is minified or not
const isMinified = !(/param/).test(function (param) { })

// TODO - switch to logger module
//#region --- print/console - debugging output functions --- //

/** Custom logging. e.g. log(2, 'here:there', 'jiminy', {fred:'jim'})()
 * @returns {Function} Log function @example log(2, 'here:there', 'jiminy', {fred:'jim'})()
 */
function log() {
    // Get the args
    const args = Array.from(arguments)
    // 1st arg is the log level/type
    let level = args.shift()
    let strLevel
    switch (level) {
        case 'trace':
        case 5: {
            if (log.level < 5) break
            level = 5 // make sure level is numeric
            strLevel = 'trace'
            break
        }

        case 'debug':
        case 4: {
            if (log.level < 4) break
            level = 4
            strLevel = 'debug'
            break
        }

        case 'log':
        case 3: {
            if (log.level < 3) break
            level = 3
            strLevel = 'log'
            break
        }

        case 'info':
        case '':
        case 2: {
            if (log.level < 2) break
            level = 2
            strLevel = 'info'
            break
        }

        case 'warn':
        case 1: {
            if (log.level < 1) break
            level = 1
            strLevel = 'warn'
            break
        }

        case 'error':
        case 'err':
        case 0: {
            if (log.level < 0) break
            level = 0
            strLevel = 'error'
            break
        }

        default: {
            level = -1
            break
        }
    }

    // If set to something unknown, no log output
    if (strLevel === undefined) return function () { }

    // 2nd arg is a heading that will be colour highlighted
    const head = args.shift()

    // Bind back to console.log (could use console[strLevel] but some levels ignore some formatting, use console.xxx directly or dedicated fn)
    return Function.prototype.bind.call(
        console[log.LOG_STYLES[strLevel].console],
        console,
        `%c${log.LOG_STYLES[strLevel].pre}${strLevel}%c [${head}]`, `${log.LOG_STYLES.level} ${log.LOG_STYLES[strLevel].css}`, `${log.LOG_STYLES.head} ${log.LOG_STYLES[strLevel].txtCss}`,
        ...args
    )
}

// Nice console styling
log.LOG_STYLES = {
    // 0
    error: {
        css: 'background: red; color: black;',
        txtCss: 'color: red; ',
        pre: '⛔ ',
        console: 'error',  // or trace
    },
    // 1
    warn: {
        css: 'background: darkorange; color: black;',
        txtCss: 'color: darkorange; ',
        pre: '⚠ ',
        console: 'warn',
    },
    // 2
    info: {
        css: 'background: aqua; color: black;',
        txtCss: 'color: aqua;',
        pre: '❗ ',
        console: 'info',
    },
    // 3
    log: {
        css: 'background: grey; color: yellow;',
        txtCss: 'color: grey;',
        pre: '',
        console: 'log',
    },
    // 4
    debug: {
        css: 'background: chartreuse; color: black;',
        txtCss: 'color: chartreuse;',
        pre: '',
        console: 'debug',
    },
    // 5
    trace: {
        css: 'background: indigo; color: yellow;',
        txtCss: 'color: hotpink;',
        pre: '',
        console: 'log',
    },

    names: ['error', 'warn', 'info', 'log', 'debug', 'trace'],
    reset: 'color: inherit;',
    head: 'font-weight:bold; font-style:italic;',
    level: 'font-weight:bold; border-radius: 3px; padding: 2px 5px; display:inline-block;',
}

/** Default log level - Warn (@since v7.1.0) */
log.default = 1
let ll

// Check if the script element was found and get the data-log-level attribute (only numeric levels allowed here)
let scriptElement
try {
    scriptElement = document.currentScript
    ll = scriptElement.getAttribute('logLevel')
} catch (e) {}

// Otherwise check if the import url (for ESM only) has a logLevel query param
if (ll === undefined) {
    try {
        const url = new URL(import.meta.url).searchParams
        ll = url.get('logLevel')
    } catch (e) {}
}

// If either found, check numeric and set default level if so
if (ll !== undefined) {
    ll = Number(ll)
    if (isNaN(ll)) {
        console.warn( `[Uib:constructor] Cannot set logLevel to "${scriptElement?.getAttribute('logLevel')}". Defaults to 0 (error).`)
        log.default = 0
    } else log.default = ll
}

// Set current level to default
log.level = log.default

// log.default = isMinified ? 0 : 1  // When using minified lib, assume production and only log errors otherwise also log warn

//#endregion

/** A hack to dynamically load a remote module and wait until it is loaded
 * @param {string} url The URL of the module to load
 * @returns {object|null} Either the result object or null (if the load fails)
 */
// function loadModule(url) { // eslint-disable-line no-unused-vars
//     let done

//     import(url)
//         .then(res => {
//             log('debug', '>> then >>', res)()
//             done = res
//         })
//         .catch(err => {
//             console.error(`[uibuilder:loadModule] Could not load module ${url}`, err)
//             done = null
//         })

//     //  eslint-disable-next-line no-empty
//     while (!done) { } // eslint-disable-line no-unmodified-loop-condition

//     return done
// }

/** Convert JSON to Syntax Highlighted HTML
 * @param {object} json A JSON/JavaScript Object
 * @returns {html} Object reformatted as highlighted HTML
 */
function syntaxHighlight(json) {
    if (json === undefined) {
        json = '<span class="undefined">undefined</span>'
    } else {
        try {
            json = JSON.stringify(json, undefined, 4)
            // json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') // eslint-disable-line newline-per-chained-call
            json = json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, function (match) {
                let cls = 'number'
                if ((/^"/).test(match)) {
                    if ((/:$/).test(match)) {
                        cls = 'key'
                    } else {
                        cls = 'string'
                    }
                } else if ((/true|false/).test(match)) {
                    cls = 'boolean'
                } else if ((/null/).test(match)) {
                    cls = 'null'
                }
                return `<span class="${cls}">${match}</span>`
            })
        } catch (e) {
            json = `Syntax Highlight ERROR: ${e.message}`
        }
    }
    return json
}

/** msg._ui handling functions */
const _ui = new Ui(window, log, syntaxHighlight)

//#endregion --- Module-level utility functions --- //

/** Define and export the Uib class - note that an instance of the class is also exported in the wrap-up
 * @typicalname uibuilder
 */
export const Uib = class Uib {

    //#region --- Static variables ---
    static _meta = {
        version: version,
        type: 'module',
        displayName: 'uibuilder',
    }
    //#endregion ---- ---- ---- ----

    //#region private class vars

    // How many times has the loaded instance connected to Socket.IO (detect if not a new load?)
    connectedNum = 0
    // event listener callbacks by property name
    // #events = {}
    // Socket.IO channel names
    _ioChannels = { control: 'uiBuilderControl', client: 'uiBuilderClient', server: 'uiBuilder', }
    /** setInterval holder for pings @type {function|undefined} */
    #pingInterval
    // onChange event callbacks
    #propChangeCallbacks = {}
    // onTopic event callbacks
    #msgRecvdByTopicCallbacks = {}
    // Is Vue available?
    isVue = false
    // What version? Set in startup if Vue is loaded. Won't always work
    vueVersion = undefined
    /** setInterval id holder for Socket.IO checkConnect
     * @type {number|null}
     */
    #timerid = null
    // Holds the reference ID for the internal msg change event handler so that it can be cancelled
    #MsgHandler
    // Placeholder for io.socket - can't make a # var until # fns allowed in all browsers
    _socket
    // Placeholder for an observer that watches the whole DOM for changes - can't make a # var until # fns allowed in all browsers
    _htmlObserver
    // Has showMsg been turned on?
    #isShowMsg = false
    // Has showStatus been turned on?
    #isShowStatus = false
    // If true, URL hash changes send msg back to node-red. Controlled by watchUrlHash()
    #sendUrlHash = false
    // Used to help create unique element ID's if one hasn't been provided, increment on use
    #uniqueElID = 0
    // Externally accessible command functions (NB: Case must match) - remember to update _uibCommand for new commands
    #extCommands = [
        'elementExists', 'get', 'getManagedVarList', 'getWatchedVars', 'htmlSend', 'include',
        'navigate', 'scrollTo', 'set', 'showMsg', 'showStatus', 'uiGet', 'uiWatch', 'watchUrlHash',
    ]

    /** @type {Object<string, string>} Managed uibuilder variables */
    #managedVars = {}

    // What status variables to show via showStatus()
    #showStatus = {
        online: { 'var': 'online', 'label': 'Online?', 'description': 'Is the browser online?', },
        ioConnected: { 'var': 'ioConnected', 'label': 'Socket.IO connected?', 'description': 'Is Socket.IO connected?', },
        connectedNum: { 'var': 'connectedNum', 'label': '# reconnections', 'description': 'How many times has Socket.IO had to reconnect since last page load?', },

        clientId: { 'var': 'clientId', 'label': 'Client ID', 'description': 'Static client unique id set in Node-RED. Only changes when browser is restarted.', },
        tabId: { 'var': 'tabId', 'label': 'Browser tab ID', 'description': 'Static unique id for the browser\'s current tab', },
        cookies: { 'var': 'cookies', 'label': 'Cookies', 'description': 'Cookies set in Node-RED', },
        httpNodeRoot: { 'var': 'httpNodeRoot', 'label': 'httpNodeRoot', 'description': 'From Node-RED\' settings.js, affects URL\'s. May be wrong for pages in sub-folders', },
        pageName: { 'var': 'pageName', 'label': 'Page name', 'description': 'Actual name of this page', },

        ioNamespace: { 'var': 'ioNamespace', 'label': 'SIO namespace', 'description': 'Socket.IO namespace - unique to each uibuilder node instance', },
        // ioPath: { 'var': 'ioPath', 'label': 'SIO path', 'description': '', }, // no longer needed in the modern client
        socketError: { 'var': 'socketError', 'label': 'Socket error', 'description': 'If the Socket.IO connection has failed, says why', },

        msgsSent: { 'var': 'msgsSent', 'label': '# msgs sent', 'description': 'How many standard messages have been sent to Node-RED?', },
        msgsReceived: { 'var': 'msgsReceived', 'label': '# msgs received', 'description': 'How many standard messages have been received from Node-RED?', },
        msgsSentCtrl: { 'var': 'msgsSentCtrl', 'label': '# control msgs sent', 'description': 'How many control messages have been sent to Node-RED?', },
        msgsCtrlReceived: { 'var': 'msgsCtrlReceived', 'label': '# control msgs received', 'description': 'How many control messages have been received from Node-RED?', },
        originator: { 'var': 'originator', 'label': 'Node Originator', 'description': 'If the last msg from Node-RED was from a `uib-sender` node, this will be its node id so that messasges can be returned to it', },
        topic: { 'var': 'topic', 'label': 'Default topic', 'description': 'Optional default topic to be included in outgoing standard messages', },

        started: { 'var': 'started', 'label': 'Has uibuilder client started?', 'description': 'Whether `uibuilder.start()` ran successfully. This should self-run and should not need to be run manually', },
        version: { 'var': 'version', 'label': 'uibuilder client version', 'description': 'The version of the loaded uibuilder client library', },
        serverTimeOffset: { 'var': 'serverTimeOffset', 'label': 'Server time offset (Hrs)', 'description': 'The number of hours difference between the Node-red server and the client', },
    }

    // Track ui observers (see uiWatch)
    #uiObservers = {}

    // List of uib specific attributes that will be watched and processed dynamically
    uibAttribs = ['uib-topic', 'data-uib-topic']
    #uibAttrSel = `[${this.uibAttribs.join('], [')}]`

    //#endregion

    //#region public class vars

    // TODO Move to proper getters
    //#region ---- Externally read-only (via .get method) ---- //
    // version - moved to _meta
    /** Client ID set by uibuilder on connect */
    clientId = ''
    /** The collection of cookies provided by uibuilder */
    cookies = {}
    /** Copy of last control msg object received from sever */
    ctrlMsg = {}
    /** Is Socket.IO client connected to the server? */
    ioConnected = false
    // Is the library running from a minified version?
    isMinified = isMinified
    // Is the browser tab containing this page visible or not?
    isVisible = false
    // Remember the last page (re)load/navigation type: navigate, reload, back_forward, prerender
    lastNavType = ''
    // Max msg size that can be sent over Socket.IO - updated by "client connect" msg receipt
    maxHttpBufferSize = 1048576
    /** Last std msg received from Node-RED */
    msg = {}
    /** number of messages sent to server since page load */
    msgsSent = 0
    /** number of messages received from server since page load */
    msgsReceived = 0
    /** number of control messages sent to server since page load */
    msgsSentCtrl = 0
    /** number of control messages received from server since page load */
    msgsCtrlReceived = 0
    /** Is the client online or offline? */
    online = navigator.onLine
    /** last control msg object sent via uibuilder.send() @since v2.0.0-dev3 */
    sentCtrlMsg = {}
    /** last std msg object sent via uibuilder.send() */
    sentMsg = {}
    /** placeholder to track time offset from server, see fn socket.on(ioChannels.server ...) */
    serverTimeOffset = null
    /** placeholder for a socket error message */
    socketError = null
    // tab identifier from session storage
    tabId = ''
    // Actual name of current page (set in constructor)
    pageName = null
    // Is the DOMPurify library loaded? Updated in start()
    purify = false
    // Is the Markdown-IT library loaded? Updated in start()
    markdown = false
    // Current URL hash. Initial set is done from start->watchHashChanges via a set to make it watched
    urlHash = location.hash
    //#endregion ---- ---- ---- ---- //

    // TODO Move to proper getters/setters
    //#region ---- Externally Writable (via .set method, read via .get method) ---- //
    /** Default originator node id - empty string by default
     * @type {string}
     */
    originator = ''
    /** Default topic - used by send if set and no topic provided
     * @type {(string|undefined)}
     */
    topic = undefined
    /** Either undefined or a reference to a uib router instance
     * Set by uibrouter, do not set manually.
     */
    uibrouterinstance
    /** Set by uibrouter, do not set manually */
    uibrouter_CurrentRoute
    //#endregion ---- ---- ---- ---- //

    //#region ---- These are unlikely to be needed externally: ----
    autoSendReady = true
    httpNodeRoot = '' // Node-RED setting (via cookie)
    ioNamespace = ''
    ioPath = ''
    retryFactor = 1.5       // starting delay factor for subsequent reconnect attempts
    retryMs = 2000          // starting retry ms period for manual socket reconnections workaround
    storePrefix = 'uib_'    // Prefix for all uib-related localStorage
    started = false
    // NOTE: These can only change when a client (re)connects
    socketOptions = {
        path: this.ioPath,
        // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
        // https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API
        // https://socket.io/get-started/webtransport
        // NOTE: webtransport requires HTTP/3 and TLS. HTTP/2 & 3 not yet available in Node.js
        // transports: ['polling', 'websocket', 'webtransport'],
        transports: ['polling', 'websocket'],
        // Using callback so that they are updated automatically on (re)connect
        // Only put things in here that will be valid for a websocket connected session
        auth: (cb) => {
            cb({
                clientVersion: version,
                clientId: this.clientId,
                pathName: window.location.pathname,
                urlParams: Object.fromEntries(new URLSearchParams(location.search)),
                pageName: this.pageName,
                tabId: this.tabId,
                lastNavType: this.lastNavType,
                connectedNum: ++this.connectedNum,
                // Used to calculate the diff between the server and client connection timestamps - reported if >1 minute
                browserConnectTimestamp: (new Date()).toISOString(),
            })
        },
        transportOptions: {
            // Can only set headers when polling
            polling: {
                extraHeaders: {
                    'x-clientid': `${Uib._meta.displayName}; ${Uib._meta.type}; ${Uib._meta.version}; ${this.clientId}`,
                },
            },
        },
    }
    //#endregion -- not external --

    //#endregion --- End of variables ---

    //#region ------- Getters and Setters ------- //

    // Change logging level dynamically (affects both console. and print.)
    set logLevel(level) { log.level = level; console.log('%c❗ info%c [logLevel]', `${log.LOG_STYLES.level} ${log.LOG_STYLES.info.css}`, `${log.LOG_STYLES.head} ${log.LOG_STYLES.info.txtCss}`, `Set to ${level} (${log.LOG_STYLES.names[level]})`) /* changeLogLevel(level)*/ }
    get logLevel() { return log.level }

    get meta() { return Uib._meta }

    /** Function to set uibuilder properties to a new value - works on any property except _* or #*
     * Also triggers any event listeners.
     * Example: this.set('msg', {topic:'uibuilder', payload:42});
     * @param {string} prop Any uibuilder property who's name does not start with a _ or #
     * @param {*} val The set value of the property or a string declaring that a protected property cannot be changed
     * @param {boolean} [store] If true, the variable is also saved to the browser localStorage if possible
     * @param {boolean} [autoload] If true & store is true, on load, uib will try to restore the value from the store automatically
     * @returns {*} Input value
     */
    set(prop, val, store = false, autoload = false) {
        // Check for excluded properties - we don't want people to set these
        // if (this.#excludedSet.indexOf(prop) !== -1) {
        if (prop.startsWith('_') || prop.startsWith('#')) {
            log('warn', 'Uib:set', `Cannot use set() on protected property "${prop}"`)()
            return `Cannot use set() on protected property "${prop}"`
        }

        // Check for an old value
        const oldVal = this[prop] ?? undefined

        // We must add the var to the uibuilder object
        this[prop] = val

        // Keep track of all managed variables
        this.#managedVars[prop] = prop

        // If requested, save to store
        if (store === true) this.setStore(prop, val, autoload)

        log('trace', 'Uib:set', `prop set - prop: ${prop}, val: `, val, ` store: ${store}, autoload: ${autoload}`)()

        // Trigger this prop's event callbacks (listeners which are set by this.onChange)
        // this.emit(prop, val)

        // trigger an event on the prop name, pass both the name and value to the event details
        this._dispatchCustomEvent('uibuilder:propertyChanged', { 'prop': prop, 'value': val, 'oldValue': oldVal, 'store': store, 'autoload': autoload, })
        this._dispatchCustomEvent(`uibuilder:propertyChanged:${prop}`, { 'prop': prop, 'value': val, 'oldValue': oldVal, 'store': store, 'autoload': autoload, })

        return val
    }

    /** Function to get the value of a uibuilder property
     * Example: uibuilder.get('msg')
     * @param {string} prop The name of the property to get as long as it does not start with a _ or #
     * @returns {*|undefined} The current value of the property
     */
    get(prop) {
        if (prop.startsWith('_') || prop.startsWith('#')) {
            log('warn', 'Uib:get', `Cannot use get() on protected property "${prop}"`)()
            return
        }
        if (prop === 'version') return Uib._meta.version
        if (prop === 'msgsCtrl') return this.msgsCtrlReceived
        if (prop === 'reconnections') return this.connectedNum
        if (this[prop] === undefined) {
            log('warn', 'Uib:get', `get() - property "${prop}" is undefined`)()
        }
        return this[prop]
    }

    /** Write to localStorage if possible. console error output if can't write
     * Also uses this.storePrefix
     * @example
     *   uibuilder.setStore('fred', 42)
     *   console.log(uibuilder.getStore('fred'))
     * @param {string} id localStorage var name to be used (prefixed with 'uib_')
     * @param {*} value value to write to localstore
     * @param {boolean} [autoload] If true, on load, uib will try to restore the value from the store
     * @returns {boolean} True if succeeded else false
     */
    setStore(id, value, autoload = false) {
        let autoVars = {}
        if (autoload === true) {
            try {
                autoVars = this.getStore('_uibAutoloadVars') || {}
            } catch (e) {}
        }
        if (typeof value === 'object') {
            try {
                value = JSON.stringify(value)
            } catch (e) {
                log('error', 'Uib:setStore', 'Cannot stringify object, not storing. ', e)()
                return false
            }
        }
        try {
            localStorage.setItem(this.storePrefix + id, value)
            if (autoload) {
                autoVars[id] = id
                try {
                    localStorage.setItem(this.storePrefix + '_uibAutoloadVars', JSON.stringify(autoVars))
                } catch (e) {
                    log('error', 'Uib:setStore', 'Cannot save autoload list. ', e)()
                }
            }
            this._dispatchCustomEvent('uibuilder:propertyStored', { 'prop': id, 'value': value, 'autoload': autoload, })
            return true
        } catch (e) {
            log('error', 'Uib:setStore', 'Cannot write to localStorage. ', e)()
            return false
        }
    } // --- end of setStore --- //

    /** Attempt to get and re-hydrate a key value from localStorage
     * Note that all uib storage is automatically prefixed using this.storePrefix
     * @param {*} id The key of the value to attempt to retrieve
     * @returns {*|null|undefined} The re-hydrated value of the key or null if key not found, undefined on error
     */
    getStore(id) {
        try {
            // @ts-ignore
            return JSON.parse(localStorage.getItem(this.storePrefix + id))
        } catch (e) { }
        try {
            return localStorage.getItem(this.storePrefix + id)
        } catch (e) {
            return undefined
        }
    }

    /** Remove a given id from the uib keys in localStorage
     * @param {*} id The key to remove
     */
    removeStore(id) {
        try {
            localStorage.removeItem(this.storePrefix + id)
        } catch (e) { }
    }

    /** Returns a list of uibuilder properties (variables) that can be watched with onChange
     * @returns {Object<string,string>} List of uibuilder managed variables
     */
    getManagedVarList() {
        return this.#managedVars
    }

    getWatchedVars() {
        return Object.keys(this.#propChangeCallbacks)
    }

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

    //#region ------- Our own event handling system ---------- //

    /** Standard fn to create a custom event with details & dispatch it
     * @param {string} title The event name
     * @param {*} details Any details to pass to event output
     */
    _dispatchCustomEvent(title, details) {
        const event = new CustomEvent(title, { detail: details, })
        document.dispatchEvent(event)
    }

    // See the this.#propChangeCallbacks & msgRecvdByTopicCallbacks private vars

    /** Register on-change event listeners for uibuilder tracked properties
     * Make it possible to register a function that will be run when the property changes.
     * Note that you can create listeners for non-existant properties
     * @example uibuilder.onChange('msg', (msg) => { console.log('uibuilder.msg changed! It is now: ', msg) })
     *
     * @param {string} prop The property of uibuilder that we want to monitor
     * @param {Function} callback The function that will run when the property changes, parameter is the new value of the property after change
     * @returns {number} A reference to the callback to cancel, save and pass to uibuilder.cancelChange if you need to remove a listener
     */
    onChange(prop, callback) {
        // Note: Property does not have to exist yet

        // console.debug(`[Uib:onchange] pushing new callback (event listener) for property: ${prop}`)

        // Create a new array or add to the array of callback functions for the property in the events object
        // if (this.#events[prop]) {
        //     this.#events[prop].push(callback)
        // } else {
        //     this.#events[prop] = [callback]
        // }

        // Make sure we have an object to receive the saved callback, update the latest reference number
        if (!this.#propChangeCallbacks[prop]) this.#propChangeCallbacks[prop] = { _nextRef: 1, }
        else this.#propChangeCallbacks[prop]._nextRef++

        const nextCbRef = this.#propChangeCallbacks[prop]._nextRef

        // Register the callback function. It is saved so that we can remove the event listener if we need to
        const propChangeCallback = this.#propChangeCallbacks[prop][nextCbRef] = function propChangeCallback(e) {
            // If the prop name matches the 1st arg in the onChange fn:
            if (prop === e.detail.prop) {
                const value = e.detail.value
                // console.warn('[Uib:onChange:evt] uibuilder:propertyChanged. ', e.detail)
                // Set the callback fn's `this` and its single argument to the msg
                callback.call(value, value)
            }
        }

        document.addEventListener('uibuilder:propertyChanged', propChangeCallback)

        return nextCbRef
    } // ---- End of onChange() ---- //

    cancelChange(prop, cbRef) {
        document.removeEventListener('uibuilder:propertyChanged', this.#propChangeCallbacks[prop][cbRef])
        delete this.#propChangeCallbacks[prop][cbRef]
        // this.#propChangeCallbacks[topic]._nextRef-- // Don't bother, let the ref# increase
    }

    /** Register a change callback for a specific msg.topic
     * Similar to onChange but more convenient if needing to differentiate by msg.topic.
     * @example let otRef = uibuilder.onTopic('mytopic', function(){ console.log('Received a msg with msg.topic=`mytopic`. msg: ', this) })
     * To cancel a change listener: uibuilder.cancelTopic('mytopic', otRef)
     *
     * @param {string} topic The msg.topic we want to listen for
     * @param {Function} callback The function that will run when an appropriate msg is received. `this` inside the callback as well as the cb's single argument is the received msg.
     * @returns {number} A reference to the callback to cancel, save and pass to uibuilder.cancelTopic if you need to remove a listener
     */
    onTopic(topic, callback) {
        // Make sure we have an object to receive the saved callback, update the latest reference number
        if (!this.#msgRecvdByTopicCallbacks[topic]) this.#msgRecvdByTopicCallbacks[topic] = { _nextRef: 1, }
        else this.#msgRecvdByTopicCallbacks[topic]._nextRef++

        const nextCbRef = this.#msgRecvdByTopicCallbacks[topic]._nextRef

        // Register the callback function. It is saved so that we can remove the event listener if we need to
        const msgRecvdEvtCallback = this.#msgRecvdByTopicCallbacks[topic][nextCbRef] = function msgRecvdEvtCallback(e) {
            const msg = e.detail
            // console.log('[Uib:onTopic:evt] uibuilder:stdMsgReceived where topic matches. ', e.detail)
            if (msg.topic === topic) {
                // Set the callback fn's `this` and its single argument to the msg
                callback.call(msg, msg)
            }
        }

        document.addEventListener('uibuilder:stdMsgReceived', msgRecvdEvtCallback)

        return nextCbRef
    }

    cancelTopic(topic, cbRef) {
        document.removeEventListener('uibuilder:stdMsgReceived', this.#msgRecvdByTopicCallbacks[topic][cbRef])
        delete this.#msgRecvdByTopicCallbacks[topic][cbRef]
        // this.#msgRecvdCallbacks[topic]._nextRef-- // Don't bother, let the ref# increase
    }

    /** Trigger event listener for a given property
     * Called when uibuilder.set is used
     *
     * @param {*} prop The property for which to run the callback functions
     * arguments: Additional arguments contain the value to pass to the event callback (e.g. newValue)
     */
    // emit(prop) {
    //     var evt = this.#events[prop]
    //     if (!evt) {
    //         return
    //     }
    //     var args = Array.prototype.slice.call(arguments, 1)
    //     for (var i = 0; i < evt.length; i++) {
    //         evt[i].apply(this, args)
    //     }
    //     log('trace', 'Uib:emit', `${evt.length} listeners run for prop ${prop} `)()
    // }

    /** Forcibly removes all event listeners from the events array
     * Use if you need to re-initialise the environment
     */
    // clearEventListeners() {
    //     this.#events = []
    // } // ---- End of clearEventListeners() ---- //

    /** Clear a single property event listeners
     * @param {string} prop The property of uibuilder for which we want to clear the event listener
     */
    // clearListener(prop) {
    //     if (this.#events[prop]) delete this.#events[prop]
    // }

    //#endregion ---------- End of event handling system ---------- //

    //#region ------- General Utility Functions -------- //

    /** Check supplied msg from server for a timestamp - if received, work out & store difference to browser time
     * @param {object} receivedMsg A message object recieved from Node-RED
     * @returns {void} Updates self.serverTimeOffset if different to previous value
     */
    _checkTimestamp(receivedMsg) {
        if (Object.prototype.hasOwnProperty.call(receivedMsg, 'serverTimestamp')) {
            const serverTimestamp = new Date(receivedMsg.serverTimestamp)
            // @ts-ignore
            const offset = Math.round(((new Date()) - serverTimestamp) / 3600000) // in ms /3.6m to get hours
            if (offset !== this.serverTimeOffset) {
                log('trace', `Uib:checkTimestamp:${this._ioChannels.server} (server)`, `Offset changed to: ${offset} from: ${this.serverTimeOffset}`)()
                this.set('serverTimeOffset', offset)
            }
        }
    }

    /** Set up an event listener to watch for hash changes
     * and set the watchable urlHash variable
     */
    _watchHashChanges() {
        this.set('urlHash', location.hash)
        window.addEventListener('hashchange', (event) => {
            this.set('urlHash', location.hash)
            if (this.#sendUrlHash === true) {
                this.send({ topic: 'hashChange', payload: location.hash, newHash: this.keepHashFromUrl(event.newURL), oldHash: this.keepHashFromUrl(event.oldURL), })
            }
        })
    }

    /** Returns a new array containing the intersection of the 2 input arrays
     * @param {Array} a1 Array to check
     * @param {Array} a2 Array to intersect
     * @returns {Array} The intersection of the 2 arrays (may be an empty array)
     */
    arrayIntersect(a1, a2) {
        return a1.filter(uName => a2.includes(uName))
    }

    /** Copies a uibuilder variable to the browser clipboard
     * @param {string} varToCopy The name of the uibuilder variable to copy to the clipboard
     */
    copyToClipboard(varToCopy) {
        let data = ''
        try {
            data = JSON.stringify(this.get(varToCopy))
        } catch (e) {
            log('error', 'copyToClipboard', `Could not copy "${varToCopy}" to clipboard.`, e.message)()
        }
        navigator.clipboard.writeText(data)
    } // --- End of copyToClipboard --- //

    /** Does the chosen CSS Selector currently exist?
     * Automatically sends a msg back to Node-RED unless turned off.
     * @param {string} cssSelector Required. CSS Selector to examine for visibility
     * @param {boolean} [msg] Optional, default=true. If true also sends a message back to Node-RED
     * @returns {boolean} True if the element exists
     */
    elementExists(cssSelector, msg = true) {
        const el = document.querySelector(cssSelector)

        let exists = false
        if (el !== null) exists = true

        if (msg === true) {
            this.send({
                payload: exists,
                info: `Element "${cssSelector}" ${exists ? 'exists' : 'does not exist'}`,
            })
        }

        return exists
    } // --- End of elementExists --- //

    /** Format a number using the INTL standard library - compatible with uib-var filter function
     * @param {number} value Number to format
     * @param {number} decimalPlaces Number of decimal places to include. Default=no default
     * @param {string} intl standard locale spec, e.g. "ja-JP" or "en-GB". Default=navigator.language
     * @param {object} opts INTL library options object. Optional
     * @returns {string} formatted number
     * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString
     */
    formatNumber(value, decimalPlaces, intl, opts) {
        if (isNaN(value)) {
            log('error', 'formatNumber', `Value must be a number. Value type: "${typeof value}"`)()
            return 'NaN'
        }
        if (!opts) opts = {}
        if (!intl) intl = navigator.language ? navigator.language : 'en-GB'
        if (decimalPlaces) {
            opts.minimumFractionDigits = decimalPlaces
            opts.maximumFractionDigits = decimalPlaces
        }
        let out
        try {
            out = Number(value).toLocaleString(intl, opts)
        } catch (e) {
            log('error', 'formatNumber', `${e.message}. value=${value}, dp=${decimalPlaces}, intl="${intl}", opts=${JSON.stringify(opts)}`)()
            return 'NaN'
        }
        return out
    }

    /** Attempt to get rough size of an object
     * @param {*} obj Any serialisable object
     * @returns {number|undefined} Rough size of object in bytes or undefined
     */
    getObjectSize(obj) {
        let size
        try {
            const jsonString = JSON.stringify(obj)
            // Encode the string to a Uint8Array and measure its length
            const encoder = new TextEncoder()
            const uint8Array = encoder.encode(jsonString)
            size = uint8Array.length
        } catch (e) {
            log('error', 'uibuilder:getObjectSize', 'Could not stringify, cannot determine size', obj, e)()
        }
        return size
    }

    /** Returns true if a uibrouter instance is loaded, otherwise returns false
     * @returns {boolean} true if uibrouter instance loaded else false
     */
    hasUibRouter() {
        return !!this.uibrouterinstance
    }

    /** Only keep the URL Hash & ignoring query params
     * @param {string} url URL to extract the hash from
     * @returns {string} Just the route id
     */
    keepHashFromUrl(url) {
        if (!url) return ''
        return '#' + url.replace(/^.*#(.*)/, '$1').replace(/\?.*$/, '')
    }

    log() {
        log(...arguments)()
    }

    /** Makes a null or non-object into an object. If thing is already an object.
     * If not null, moves "thing" to {payload:thing}
     * @param {*} thing Thing to check
     * @param {string} [property] property that "thing" is moved to if not null and not an object. Default='payload'
     * @returns {!object} _
     */
    makeMeAnObject(thing, property) {
        if (!property) property = 'payload'
        if (typeof property !== 'string') {
            log('warn', 'uibuilder:makeMeAnObject', `WARNING: property parameter must be a string and not: ${typeof property}`)()
            property = 'payload'
        }
        let out = {}
        if ( thing !== null && thing.constructor.name === 'Object' ) {
            out = thing
        } else if (thing !== null) {
            out[property] = thing
        }
        return out
    } // --- End of make me an object --- //

    /** Navigate to a new page or a new route (hash)
     * @param {string} url URL to navigate to. Can be absolute or relative (to current page) or just a hash for a route change
     * @returns {Location} The new window.location string
     */
    navigate(url) {
        if (url) window.location.href = url
        return window.location
    }

    // ! TODO change ui uib-* attributes to use this
    /** Convert a string attribute into an variable/constant reference
     * Used to resolve data sources in attributes
     * @param {string} path The string path to resolve, must be relative to the `window` global scope
     * @returns {*} The resolved data source or null
     */
    resolveDataSource(path) {
        try {
            const parts = path.split(/[\.\[\]\'\"]/).filter(Boolean)
            let data = window
            for (const part of parts) {
                data = data?.[part]
            }
            return data
        } catch (error) {
            // console.error('Error resolving data source:', error)
            log('error', 'uibuilder:resolveDataSource', `Error resolving data source "${path}", returned 'null'. ${error.message}`)()
            return null
        }
    }

    /** Fast but accurate number rounding (https://stackoverflow.com/a/48764436/1309986 solution 2)
     * Half away from zero method (AKA "commercial" rounding), most common type
     * @param {number} num The number to be rounded
     * @param {number} decimalPlaces Number of DP's to round to
     * @returns {number} Rounded number
     */
    round(num, decimalPlaces) {
        const p = Math.pow(10, decimalPlaces || 0)
        const n = (num * p) * (1 + Number.EPSILON)
        return Math.round(n) / p
    }

    /** Set the default originator. Set to '' to ignore. Used with uib-sender.
     * @param {string} [originator] A Node-RED node ID to return the message to
     */
    setOriginator(originator = '') {
        this.set('originator', originator)
    } // ---- End of setOriginator ---- //

    /** HTTP Ping/Keep-alive - makes a call back to uibuilder's ExpressJS server and receives a 204 response
     * Can be used to keep sessions alive.
     * @example
     *   uibuilder.setPing(2000) // repeat every 2 sec. Re-issue with ping(0) to turn off repeat.
     *   uibuilder.onChange('ping', function(data) {
     *      console.log('pinger', data)
     *   })
     * @param {number} ms Repeat interval in ms
     */
    setPing(ms = 0) {
        const oReq = new XMLHttpRequest()
        oReq.addEventListener('load', () => {
            const headers = (oReq.getAllResponseHeaders()).split('\r\n')
            const elapsedTime = Number(new Date()) - Number((oReq.responseURL.split('='))[1])
            this.set('ping', {
                success: !!((oReq.status === 201) || (oReq.status === 204)), // true if one of the listed codes else false
                status: oReq.status,
                headers: headers,
                url: oReq.responseURL,
                elapsedTime: elapsedTime,
            })
        })

        if (this.#pingInterval) {
            clearInterval(this.#pingInterval)
            this.#pingInterval = undefined
        }

        oReq.open('GET', `${this.httpNodeRoot}/uibuilder/ping?t=${Number(new Date())}`)
        oReq.send()

        if (ms > 0) {
            this.#pingInterval = setInterval(() => {
                oReq.open('GET', `${this.httpNodeRoot}/uibuilder/ping?t=${Number(new Date())}`)
                oReq.send()
            }, ms)
        }
    } // ---- End of ping ---- //

    /** Convert JSON to Syntax Highlighted HTML
     * @param {object} json A JSON/JavaScript Object
     * @returns {html} Object reformatted as highlighted HTML
     */
    syntaxHighlight(json) {
        return syntaxHighlight(json)
    } // --- End of syntaxHighlight --- //

    /** Returns true/false or a default value for truthy/falsy and other values
     * @param {string|number|boolean|*} val The value to test
     * @param {any} deflt Default value to use if the value is not truthy/falsy
     * @returns {boolean|any} The truth! Or the default
     */
    truthy(val, deflt) {
        let ret
        if (['on', 'On', 'ON', 'true', 'True', 'TRUE', '1', true, 1].includes(val)) ret = true
        else if (['off', 'Off', 'OFF', 'false', 'False', 'FALSE', '0', false, 0].includes(val)) ret = false
        else ret = deflt
        return ret
    }

    /** Joins all arguments as a URL string
     * see http://stackoverflow.com/a/28592528/3016654
     * since v1.0.10, fixed potential double // issue
     * arguments {string} URL fragments
     * @returns {string} _
     */
    urlJoin() {
        const paths = Array.prototype.slice.call(arguments)
        const url = '/' + paths.map(function (e) {
            return e.replace(/^\/|\/$/g, '')
        })
            .filter(function (e) {
                return e
            })
            .join('/')
        return url.replace('//', '/')
    } // ---- End of urlJoin ---- //

    /** Turn on/off/toggle sending URL hash changes back to Node-RED
     * @param {string|number|boolean|undefined} [toggle] Optional on/off/etc
     * @returns {boolean} True if we will send a msg to Node-RED on a hash change
     */
    watchUrlHash(toggle) {
        this.#sendUrlHash = this.truthy(toggle, this.#sendUrlHash !== true)
        return this.#sendUrlHash
    }

    /** DEPRECATED FOR NOW - wasn't working properly.
     * Is the chosen CSS Selector currently visible to the user? NB: Only finds the FIRST element of the selection.
     * Requires IntersectionObserver (available to all mainstream browsers from early 2019)
     * Automatically sends a msg back to Node-RED.
     * Requires the element to already exist.
     * @returns {false} False if not visible
     */
    elementIsVisible() {
        const info = 'elementIsVisible has been temporarily DEPRECATED as it was not working correctly and a fix is complex'
        log('error', 'uib:elementIsVisible', info)()
        this.send({ payload: 'elementIsVisible has been temporarily DEPRECATED as it was not working correctly and a fix is complex', })
        return false
    } // --- End of elementIsVisible --- //

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

    //#region ------- UI handlers --------- //

    //#region -- Direct to _ui --
    // ! NOTE: Direct assignments change the target `this` to here. Use with caution
    // However, also note that the window/jsdom and the window.document
    // references are now static in _ui so not impacted by this.

    /** 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
     * @returns {HTMLElement|null} Selected HTML element or null
     */
    $ = _ui.$

    /** 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
     * @returns {HTMLElement[]} Array of DOM elements/nodes. Array is empty if selector is not found.
     */
    $$ = _ui.$$

    /** Reference to the full ui library */
    $ui = _ui

    /** 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 = _ui.addClass

    /** Apply a source template tag to a target html element
     * NOTES:
     * - styles in ALL templates are accessible to all templates.
     * - 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 {HTMLElement} source The source element
     * @param {HTMLElement} target The target element
     * @param {boolean} onceOnly If true, the source will be adopted (the source is moved)
     */
    applyTemplate = _ui.applyTemplate

    /** 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?
     */

    /** Builds an HTML table from an array (or object) of objects
     * 1st row is used for columns.
     * If an object of objects, inner keys are used to populate th/td `data-col-name` attribs.
     * @param {Array<object>|object} data Input data array or object
     * @param {object} opts Table options
     *   @param {Array<columnDefinition>=} opts.cols Column metadata. If not provided will be derived from 1st row of data
     * @returns {HTMLTableElement|HTMLParagraphElement} Output HTML Element
     */
    buildHtmlTable(data, opts={}) {
        return _ui.buildHtmlTable(data, opts)
    }

    /** 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 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
     */
    createTable(data=[], opts={parent: 'body',}) {
        _ui.createTable(data, opts)
    }

    /** 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) {
        return _ui.convertMarkdown(mdText)
    }

    /** ASYNC: 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')
     */
    async include(url, uiOptions) {
        await _ui.include(url, uiOptions)
    }

    /** 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) {
        _ui.loadScriptSrc(url)
    }

    /** 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) {
        _ui.loadStyleSrc(url)
    }

    /** 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) {
        _ui.loadScriptTxt(textFn)
    }

    /** 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) {
        _ui.loadStyleTxt(textFn)
    }

    /** Load a dynamic UI from a JSON web reponse
     * @param {string} url URL that will return the ui JSON
     */
    loadui(url) {
        _ui.loadui(url)
    }

    /** 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 = _ui.removeClass

    /** 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) {
        _ui.replaceSlot(el, slot)
    }

    /** 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) {
        _ui.replaceSlotMarkdown(el, component)
    }

    /** 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) {
        return _ui.sanitiseHTML(html)
    }

    /** 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 = {}) {
        _ui.tblAddListener(tblSelector, options, out)
    }

    /** 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={}) {
        return _ui.tblAddRow(tbl, rowData, options)
    }

    /** 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 = {}) {
        _ui.tblRemoveRow(tbl, rowIndex, options)
    }


    /** Show a pop-over "toast" dialog or a modal alert
     * 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) {
        _ui.showDialog(type, ui, msg)
    }

    /** Directly manage UI via JSON
     * @param {object} json Either an object containing {_ui: {}} or simply simple {} containing ui instructions
     */
    ui(json) {
        _ui.ui(json)
    }

    /** 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) {
        return _ui.uiGet(cssSelector, propName)
    }

    /** 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 {*} component Individual uibuilder ui component spec
     */
    uiEnhanceElement(el, component) {
        _ui.uiEnhanceElement(el, component)
    }

    //#endregion -- direct to _ui --

    /** DOM Mutation observer callback to watch for new/amended elements with uib-* or data-uib-* attributes
     * WARNING: Mutation observers can receive a LOT of mutations very rapidly. So make sure this runs as fast
     *          as possible. Async so that calling function does not need to wait.
     * Observer is set up in the start() function
     * @param {MutationRecord[]} mutations Array of Mutation Records
     */
    async _uibAttribObserver(mutations/* , observer */) {
        mutations.forEach( async m => { // async so process does not wait
            log('trace', 'uibuilder:_uibAttribObserver', 'Mutations ', m)()
            // Deal with attribute changes
            if (m.attributeName && (m.attributeName.startsWith('uib') || m.attributeName.startsWith('data-uib'))) {
                // log(0, 'attribute mutation', m.attributeName, m.target.getAttribute(m.attributeName), m.oldValue, m )()
                this._uibAttrScanOne(m.target)
            } else if (m.addedNodes.length > 0) {
                // And deal with newly added elements (e.g. from route change)
                // Check for added nodes with uib attribs
                m.addedNodes.forEach( async n => { // async so process does not wait
                    // Attributes are a map and we need an array
                    let aNames = []
                    try { // Nodes might not always have attributes
                        aNames = [...n.attributes]
                    } catch (e) {}
                    // Get any added elements that have uib attribs
                    const intersect = this.arrayIntersect(this.uibAttribs, aNames)
                    // Process them
                    intersect.forEach( async el => {
                        this._uibAttrScanOne(el)
                    })
                    // And get any children of the added elements that have uib attribs (a node might not have querySelectorAll method)
                    let uibChildren = []
                    if (n.querySelectorAll) uibChildren = n.querySelectorAll(this.#uibAttrSel)
                    // Process them
                    uibChildren.forEach( async el => {
                        this._uibAttrScanOne(el)
                    })
                })
            }
        })
    }

    /** Check a single HTML element for uib attributes and add auto-processors as needed.
     * Async so that calling function does not need to wait.
     * Understands only uib-topic at present. Msgs received on the topic can have:
     *   msg.payload - replaces innerHTML (but also runs <script>s and applies <style>s)
     *   msg.attributes - An object containing attribute names as keys with attribute values as values. e.g. {title: 'HTML tooltip', href='#route03'}
     * @param {Element} el HTML Element to check for uib-* or data-uib-* attributes
     */
    async _uibAttrScanOne(el) {
        log('trace', 'uibuilder:_uibAttrScanOne', 'Setting up auto-processor for: ', el)()
        const topic = el.getAttribute('uib-topic') || el.getAttribute('data-uib-topic')
        // Create a topic listener
        this.onTopic(topic, (msg) => {
            log('trace', 'uibuilder:_uibAttrScanOne', `Msg with topic "${topic}" received. msg content: `, msg)()
            msg._uib_processed_by = '_uibAttrScanOne' // record that this has already been processed
            // Process msg.attributes
            if (Object.prototype.hasOwnProperty.call(msg, 'attributes')) {
                try {
                    for (const [k, v] of Object.entries(msg.attributes)) {
                        el.setAttribute(k, v)
                    }
                } catch (e) {
                    log(0, 'uibuilder:attribute-processing', 'Failed to set attributes. Ensure that msg.attributes is an object containing key/value pairs with each key a valid attribute name. Note that attribute values have to be a string.')()
                }
            }

            // Process msg.value (or set checked if boolean - for checkboxes)
            // TODO Move this to a common function
            const hasChecked = Object.prototype.hasOwnProperty.call(msg, 'checked')
            const hasValue = Object.prototype.hasOwnProperty.call(msg, 'value')
            if ( hasValue || hasChecked ) {
                if (el.type && (el.type === 'checkbox' || el.type === 'radio')) {
                    if (hasChecked) el.checked = this.truthy(msg.checked, false)
                    else if (hasValue) el.checked = this.truthy(msg.value, false)
                } else {
                    if (hasValue) el.value = msg.value
                    else if (hasChecked) el.value = this.truthy(msg.checked, false)
                }
            }

            // TODO (MAYBE) Process msg.classes and msg.styles. msg.props (for non-string data)?

            // Process msg.payload (applied as slot HTML) - NB: Will process <script> & <style>
            if (Object.prototype.hasOwnProperty.call(msg, 'payload')) this.replaceSlot(el, msg.payload)
        })
    }

    /** Check all children of an array of or a single HTML element(s) for uib attributes and add auto-processors as needed.
     * Async so that calling function does not need to wait.
     * @param {Element|Element[]} parentEl HTML Element to check for uib-* or data-uib-* attributes
     */
    async _uibAttrScanAll(parentEl) {
        if (!Array.isArray(parentEl)) parentEl = [parentEl]
        parentEl.forEach( async p => { // async so process does not wait
            const uibChildren = p.querySelectorAll(this.#uibAttrSel)
            // log('trace', 'uibuilder:_uibAttrScanAll:forEach:children', 'p, uibChildren: ', p, uibChildren)()
            if (uibChildren.length > 0) {
                // console.log('existing elements uib attrib', uibChildren)
                uibChildren.forEach( el => {
                    this._uibAttrScanOne(el) // async so process does not wait
                })
            }
        })
    }

    /** Given a FileList array, send each file to Node-RED and return file metadata
     * @param {HTMLInputElement} srcEl Reference to the source input element
     * @param {boolean=} noSend If true, don't send the file to Node-RED. Default is to send.
     * @returns {Array<object>} Metadata values from all files
     */
    _processFilesInput(srcEl, noSend = false) {
        const value = []
        const files = srcEl.files
        const seqCount = files.length
        let seq = 0
        for (const file of files) {
            const props = {}
            seq++
            // Walk through each file property
            for (const prop in file) {
                props[prop] = file[prop]
            }
            // Create a temp URL allowing access to download the file
            // Automatically destroyed on page reload
            props.tempUrl = window.URL.createObjectURL(file)

            value.push(props)

            const meta = { seq, seqCount, }
            meta.id = srcEl.id
            // meta.srcElIds.name = srcEl.name
            if (srcEl.form) meta.formId = srcEl.form.id
            meta.tempUrl = props.tempUrl
            meta.data = srcEl.dataset

            // Auto-upload to Node-RED over Socket.IO {id: target.id, name: target.name, formId: target.parent.formID,}
            if (noSend !== true) this.uploadFile(file, meta)
        }
        return value
    }

    /** Easy creation/change of DOM elements, @see ./tinyDom.js */
    // dom = dom

    /** Attempt to get target attributs - can fail for certain target types, if so, returns empty object
     * @param {HTMLElement} el Target element
     * @returns {object} Array of key/value HTML attribute objects
     */
    getElementAttributes(el) {
        const ignoreAttribs = ['class', 'id', 'name']
        let attribs
        try {
            attribs = Object.assign({},
                ...Array.from(el.attributes,
                    ( { name, value, } ) => {
                        if ( !ignoreAttribs.includes(name) ) {
                            return ({ [name]: value, })
                        }
                        return undefined
                    }
                )
            )
        } catch (e) {}

        return attribs
    }

    /** Check for CSS Classes and return as array if found or undefined if not
     * @param {HTMLElement} el Target element
     * @returns {Array|undefined} Array of class names
     */
    getElementClasses(el) {
        let classes
        try {
            classes = Array.from(el.classList)
        } catch (e) {}
        return classes
    }

    /** Get target custom properties - only shows custom props not element default ones
     * Excludes custom props starting with _
     * @param {HTMLElement} el Target element
     * @returns {object} Object of propname/value pairs
     */
    getElementCustomProps(el) {
        const props = {}
        Object.keys(el).forEach( key => {
            if (key.startsWith('_')) return // Exclude private
            props[key] = el[key]
        })
        return props
    }

    /** Check for el.value and el.checked. el.checked will also set the value return for ease of use.
     * Only 2 input types use el.checked, different from all other input types - this is annoying.
     * @param {HTMLElement} el HTML Element to be checked
     * @returns {{value:boolean|null, checked:boolean|null}} Return null if properties not present, else the appropriate value
     */
    getFormElementValue(el) {
        let value = null
        let checked = null

        switch (el.type) {
            case 'checkbox':
            case 'radio': {
                value = checked = el.checked
                // HTML does not normally return any value if not checked but we do for ease of use,
                break
            }

            case 'select-multiple': {
                // value = Array.from(el.selectedOptions).forEach( (sel) => sel.value )
                value = Array.from(el.selectedOptions).map(option => option.value)
                break
            }

            default: {
                if (el.value) value = el.value

                // Probably not really needed
                if (el.checked) {
                    value = checked = el.checked
                    // HTML does not normally return any value if not checked but we do for ease of use,
                }

                // If the value is a valid number, use that instead of the text version - probably only applies to range inputs
                if (el.valueAsNumber && !isNaN(el.valueAsNumber)) {
                    value = el.valueAsNumber
                }

                break
            }
        }

        return { value, checked, }
    }

    // ! TODO - Handle fieldsets
    /** For HTML Form elements (e.g. input, textarea, select), return the details
     * @param {HTMLFormElement} el Source form element
     * @returns {object|null} Form element key details
     */
    getFormElementDetails(el) {
        if (!el.type) {
            log(1, 'uibuilder:getFormElementDetails', 'Cannot get form element details as this is not an input type element')()
            return null
        }

        // Get or create a (hopefully) unique ID
        const id = this.returnElementId(el)
        if (!id) {
            log(1, 'uibuilder:getFormElementDetails', 'Cannot get form element details as no id is present and could not be generated')()
            return null
        }

        let { value, checked, } = this.getFormElementValue(el) // eslint-disable-line prefer-const

        // This is now done in eventSend rather than here for consistency with non-form sends.
        // For multi file input, get the file details as the value
        // el.files is a FileList type & each entry is a File type - have to process these manually - stupid HTML!
        // https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications
        // if (el.type === 'file' && el.files.length > 0) {
        //     // Walk through each file in the FileList, get the details and send the file to Node-RED
        //     value = this._processFilesInput(el.files)
        // }

        const formDetails = {
            'id': id,
            'name': el.name,
            'valid': el.checkValidity(),
            'type': el.type,
        }
        if (value !== null) formDetails.value = value
        if (checked !== null) formDetails.checked = checked

        // If the form element has invalid content, try to report why
        if (formDetails.valid === false) {
            const v = el.validity
            formDetails.validity = {
                badInput: v.badInput === true ? v.badInput : undefined,
                customError: v.customError === true ? v.customError : undefined,
                patternMismatch: v.patternMismatch === true ? v.patternMismatch : undefined,
                rangeOverflow: v.rangeOverflow === true ? v.rangeOverflow : undefined,
                rangeUnderflow: v.rangeUnderflow === true ? v.rangeUnderflow : undefined,
                stepMismatch: v.stepMismatch === true ? v.stepMismatch : undefined,
                tooLong: v.tooLong === true ? v.tooLong : undefined,
                tooShort: v.tooShort === true ? v.tooShort : undefined,
                typeMismatch: v.typeMismatch === true ? v.typeMismatch : undefined,
                valueMissing: v.valueMissing === true ? v.valueMissing : undefined,
            }
        }

        // If any data-* attribs defined
        if (Object.keys(el.dataset).length > 0) formDetails.data = el.dataset

        return formDetails
    }

    /** Show a browser notification if possible.
     * 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.
     * @example uibuilder.notify( 'My simple message to the user' )
     * @example uibuilder.notify( {topic: 'My Title', payload: 'My simple message to the user'} )
     * @example uibuilder.notify( {title: 'My Title', body: 'My simple message to the user'} )
     * @example // If config.return = true, a promise is returned.
     * // The resolved promise is only returned if the notification is clicked by the user.
     * // Can be used to send the response back to Node-RED
     * uibuilder.notify(notifyConfig).then( res => uibuilder.eventSend(res) )
     * @see https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification
     * @param {object|string} config Notification config data or simple message string
     * @returns {Promise<Event>|null} A promise that resolves to the click event or null
     */
    notify(config) {
        if (config.return) return _ui.notification(config)

        _ui.notification(config)
            .then( res => { // eslint-disable-line promise/always-return
                log('info', 'Uib:notification', 'Notification completed event', res)()
                // if (config.return) return res
            })
            .catch( err => {
                log('error', 'Uib:notification', 'Notification error event', err)()
            })
        return null
    }

    /** Get or create a (hopefully) unique ID
     * @param {HTMLFormElement} el Source form element
     * @returns {string|null} A hopefully unique element ID
     */
    returnElementId(el) {
        return el.id !== '' ? el.id : (el.name !== '' ? `${el.name}-${++this.#uniqueElID}` : (el.type ? `${el.type}-${++this.#uniqueElID}` : `${el.localName}-${++this.#uniqueElID}`))
    }

    /** Scroll the page
     * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
     * @param {string} [cssSelector] Optional. If not set, scrolls to top of page.
     * @param {{block:(string|undefined),inline:(string|undefined),behavior:(string|undefined)}} [opts] Optional. DOM scrollIntoView options
     * @returns {boolean} True if element was found, false otherwise
     */
    scrollTo(cssSelector, opts) {
        // @ts-ignore
        if (!opts) opts = {}
        if (!cssSelector || cssSelector === 'top'  || cssSelector === 'start') cssSelector = 'body'
        else if (cssSelector === 'bottom' || cssSelector === 'end') {
            cssSelector = 'body'
            opts.block = 'end'
        }
        const el = this.$(cssSelector)
        if (el) {
            el.scrollIntoView(opts)
            return true
        }
        return false
    }

    /** Show/hide a display card on the end of the visible HTML that will dynamically display the last incoming msg from Node-RED
     * The card has the id `uib_last_msg`. Updates are done from a listener set up in the start function.
     * @param {boolean|undefined} showHide true=show, false=hide. undefined=toggle.
     * @param {string|undefined} parent Optional. If not undefined, a CSS selector to attach the display to. Defaults to `body`
     * @returns {boolean} New state
     */
    showMsg(showHide, parent = 'body') {
        if ( showHide === undefined ) showHide = !this.#isShowMsg
        this.#isShowMsg = showHide
        let slot = 'Waiting for a message from Node-RED'

        if (this.msg && Object.keys(this.msg).length > 0) {
            slot = this.syntaxHighlight(this.msg)
        }

        if ( showHide === false ) {
            _ui._uiRemove( {
                components: [
                    '#uib_last_msg_wrap',
                ],
            })
        } else {
            _ui._uiReplace({
                components: [
                    {
                        type: 'div',
                        id: 'uib_last_msg_wrap',
                        parent: parent,
                        attributes: {
                            title: 'Last message from Node-RED',
                        },
                        components: [
                            {
                                type: 'button',
                                attributes: {
                                    onclick: 'uibuilder.copyToClipboard("msg")',
                                    class: 'compact',
                                    style: 'right:3em;',
                                },
                                slot: '📋',
                            },
                            {
                                type: 'button',
                                attributes: {
                                    onclick: 'uibuilder.showMsg()',
                                    class: 'compact',
                                    style: 'right:.5em;',
                                },
                                slot: '⛔',
                            },
                            {
                                type: 'pre',
                                id: 'uib_last_msg',
                                // parent: 'uib_last_msg_wrap',
                                attributes: {
                                    class: 'syntax-highlight',
                                },
                                slot: slot,
                            },
                        ],
                    },
                ],
            })
        }

        return showHide
    }

    /** Show/hide a display card on the end of the visible HTML that will dynamically display the current status of the uibuilder client
     * The card has the id `uib_status`.
     * The display is updated by an event listener created in the class constructor.
     * @param {boolean|undefined} showHide true=show, false=hide. undefined=toggle.
     * @param {string|undefined} parent Optional. If not undefined, a CSS selector to attach the display to. Defaults to `body`
     * @returns {boolean} New state
     */
    showStatus(showHide, parent = 'body') {
        if ( showHide === undefined ) showHide = !this.#isShowStatus
        this.#isShowStatus = showHide

        if ( showHide === false ) {
            _ui._uiRemove( {
                components: [
                    '#uib_status',
                ],
            })
            return showHide
        }

        const root = {
            components: [
                {
                    type: 'div',
                    id: 'uib_status',
                    parent: parent,
                    attributes: {
                        title: 'Current status of the uibuilder client',
                        class: 'text-smaller',
                    },
                    components: [
                        {
                            'type': 'table',
                            'components': [
                                {
                                    'type': 'tbody',
                                    'components': [],
                                }
                            ],
                        }
                    ],
                }
            ],
        }
        const details = root.components[0].components[0].components[0].components

        Object.values(this.#showStatus).forEach( entry => {
            details.push({
                'type': 'tr',
                'attributes': {
                    title: entry.description,
                },
                'components': [
                    {
                        'type': 'th',
                        'slot': entry.label,
                    },
                    {
                        'type': 'td',
                        'attributes': {
                            'data-varType': entry.var,
                        },
                        'slot': entry.var === 'version' ? Uib._meta.version : JSON.stringify(this[entry.var]),
                    }
                ],
            })
        })

        _ui._uiReplace(root)

        return showHide
    }

    /** Use the Mutation Observer browser API to watch for changes to a single element on the page.
     * OMG! It is sooo hard to turn the data into something that successfully serialises so it can be sent back to Node-RED!
     * NB: Each cssSelector creates a unique watcher. Sending the same selector overwrites the previous one.
     * @param {string} cssSelector A CSS Selector that selects the element to watch for changes
     * @param {boolean|"toggle"} [startStop] true=start watching the DOM, false=stop. Default='toggle'
     * @param {boolean} [send] true=Send changes to Node-RED, false=Don't send. Default=true
     * @param {boolean} [showLog] true=Output changes to log, false=stop. Default=true. Log level is 2 (Info)
     * @returns {boolean} True if the watch is on, false otherwise
     */
    uiWatch(cssSelector, startStop = 'toggle', send = true, showLog = true) {
        // Select the node that will be observed for mutations
        const targetNode = document.querySelector(cssSelector)
        if (!targetNode) {
            log('warn', 'uibuilder.module.js:uiWatch', `CSS Selector '${cssSelector}' not found.`)()
            return false
        }

        if (startStop === 'toggle' || startStop === undefined || startStop === null) {
            if (this.#uiObservers[cssSelector]) startStop = false
            else startStop = true
        }

        // Need a ref to the Uib this
        const that = this

        if (startStop === true) {
            // Create an observer instance
            this.#uiObservers[cssSelector] = new MutationObserver( function( mutationList /* , observer */ ) {
                const out = []

                mutationList.forEach( mu => {
                    // console.log({ mu })
                    const oMu = {
                        type: mu.type,
                        oldValue: mu.oldValue !== null ? mu.oldValue : undefined,
                    }

                    if (mu.addedNodes.length > 0) {
                        oMu.addedNodes = []
                        mu.addedNodes.forEach( (an, i) => {
                            oMu.addedNodes.push(_ui.nodeGet(mu.addedNodes[i]))
                        })
                    }

                    if (mu.removedNodes.length > 0) {
                        oMu.removedNodes = []
                        mu.removedNodes.forEach( (an, i) => {
                            oMu.removedNodes.push(_ui.nodeGet(mu.removedNodes[i]))
                        })
                    }

                    if ( mu.type === 'attributes' ) {
                        oMu.attributeName = mu.attributeName
                        // @ts-ignore
                        oMu.newValue = mu.target.attributes[mu.attributeName].value
                    }

                    out.push(oMu)
                })

                // Custom event
                that._dispatchCustomEvent('uibuilder:domChange', out)
                // Send a msg back to node-red
                if (send === true) {
                    that.send({
                        _ui: {
                            cssSelector: cssSelector,
                            uiChanges: out,
                        },
                        topic: that.topic || `DOM Changes for '${cssSelector}'`,
                    })
                }
                // Log to info
                if (showLog === true) {
                    log('info', 'uibuilder.module.js:uiWatch', `DOM Changes for '${cssSelector}'`, { uiChanges: out, }, { mutationList, })()
                }
            } )

            // Start observing the target node for configured mutations
            this.#uiObservers[cssSelector].observe(targetNode, { attributes: true, childList: true, subtree: true, characterData: true, })
            log('trace', 'uibuilder.module.js:uiWatch', `Started Watching DOM changes for '${cssSelector}'`)()
        } else {
            this.#uiObservers[cssSelector].disconnect()
            delete this.#uiObservers[cssSelector]
            log('trace', 'uibuilder.module.js:uiWatch', `Stopped Watching DOM changes for '${cssSelector}'`)()
        }

        return startStop
    } // ---- End of watchDom ---- //

    /** Use the Mutation Observer browser API to watch for and save changes to the HTML
     * Once the observer is created, it will be reused.
     * Sending true or undefined will turn on the observer, false turns it off.
     * saveHtmlCache is called whenever anything changes in the dom. This allows
     * users to call restoreHtmlFromCache() on page load if desired to completely reload
     * to the last saved state.
     * @param {boolean} startStop true=start watching the DOM, false=stop
     */
    watchDom(startStop) {
        // Select the node that will be observed for mutations
        const targetNode = document.documentElement

        // Need a ref to the Uib this
        const that = this

        // Create an observer instance
        if (!this._htmlObserver) {
            this._htmlObserver = new MutationObserver( function(/* mutationList, observer */) {
                // We don't need to know the details - so kill off any outstanding mutation records
                this.takeRecords()
                // Save the updated entire HTML in localStorage
                that.saveHtmlCache()
            } )
        }

        if (startStop === true || startStop === undefined) {
            // Start observing the target node for configured mutations
            this._htmlObserver.observe(targetNode, { attributes: true, childList: true, subtree: true, characterData: true, })
            log('trace', 'uibuilder.module.js:watchDom', 'Started Watching and saving DOM changes')()
        } else {
            this._htmlObserver.disconnect()
            log('trace', 'uibuilder.module.js:watchDom', 'Stopped Watching and saving DOM changes')()
        }
    } // ---- End of watchDom ---- //

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

    //#region ------- HTML cache --------- //

    /** Clear the saved DOM from localStorage */
    clearHtmlCache() {
        this.removeStore('htmlCache')
        log('trace', 'uibuilder.module.js:clearHtmlCache', 'HTML cache cleared')()
    }

    /** Restore the complete DOM (the whole web page) from browser localStorage if available */
    restoreHtmlFromCache() {
        // Is the html cached? If so, restore it
        const htmlCache = this.getStore('htmlCache')
        if (htmlCache) {
            const targetNode = document.getElementsByTagName('html')[0]
            // Restore the entire HTML
            targetNode.innerHTML = htmlCache
            log('trace', 'uibuilder.module.js:restoreHtmlFromCache', 'Restored HTML from cache')()
        } else {
            log('trace', 'uibuilder.module.js:restoreHtmlFromCache', 'No cache to restore')()
        }
    }

    /** Save the current DOM state to browser localStorage.
     * localStorage is persistent and so can be recovered even after a browser restart.
     */
    saveHtmlCache() {
        // Save the updated entire HTML in localStorage
        this.setStore('htmlCache', document.documentElement.innerHTML)
    }

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

    //#region ------- Message Handling (To/From Node-RED) -------- //

    /** Handles original control msgs (not to be confused with "new" msg._uib controls)
     * @param {*} receivedCtrlMsg The msg received on the socket.io control channel
     */
    _ctrlMsgFromServer(receivedCtrlMsg) {

        // Make sure that msg is an object & not null
        if (receivedCtrlMsg === null) {
            receivedCtrlMsg = {}
        } else if (typeof receivedCtrlMsg !== 'object') {
            const msg = {}
            msg['uibuilderCtrl:' + this._ioChannels.control] = receivedCtrlMsg
            receivedCtrlMsg = msg
        }

        // @since 2018-10-07 v1.0.9: Work out local time offset from server
        this._checkTimestamp(receivedCtrlMsg)

        this.set('ctrlMsg', receivedCtrlMsg)
        this.set('msgsCtrlReceived', ++this.msgsCtrlReceived)

        log('trace', 'Uib:ioSetup:_ctrlMsgFromServer', `Channel '${this._ioChannels.control}'. Received control msg #${this.msgsCtrlReceived}`, receivedCtrlMsg)()

        /** Process control msg types */
        switch (receivedCtrlMsg.uibuilderCtrl) {
            // Node-RED is shutting down
            case 'shutdown': {
                log('info', `Uib:ioSetup:${this._ioChannels.control}`, '❌ Received "shutdown" from server')()
                this.set('serverShutdown', undefined)
                break
            }

            /** We are connected to the server - 1st msg from server */
            case 'client connect': {
                log('trace', `Uib:ioSetup:${this._ioChannels.control}`, 'Received "client connect" from server', receivedCtrlMsg)()
                log('info', `Uib:ioSetup:${this._ioChannels.control}`, `✅ Server connected. Version: ${receivedCtrlMsg.version}\nServer time: ${receivedCtrlMsg.serverTimestamp}, Sever time offset: ${this.serverTimeOffset} hours. Max msg size: ${receivedCtrlMsg.maxHttpBufferSize}`)()

                if ( !Uib._meta.version.startsWith(receivedCtrlMsg.version.split('-')[0]) ) {
                    log('warn', `Uib:ioSetup:${this._ioChannels.control}`, `Server version (${receivedCtrlMsg.version}) not the same as the client version (${Uib._meta.version})`)()
                }

                if (this.autoSendReady === true) {
                    log('trace', `Uib:ioSetup:${this._ioChannels.control}/client connect`, 'Auto-sending ready-for-content/replay msg to server')
                    // @since 0.4.8c Add cacheControl property for use with node-red-contrib-infocache
                    // @since 6.1.0 Don't bother, we use the "client connect" msg
                    // this._send({
                    //     'uibuilderCtrl': 'ready for content',
                    //     'cacheControl': 'REPLAY',
                    // }, this._ioChannels.control)
                }

                // Save the current server max msg size - defaults to 1mb, change in settings.js
                this.maxHttpBufferSize = receivedCtrlMsg.maxHttpBufferSize

                break
            }

            // We requested this page's metadata from the server using getPageMeta() - this handles the response
            case 'get page meta': {
                this.set('pageMeta', receivedCtrlMsg.payload)
                break
            }

            default: {
                log('trace', `uibuilder:ioSetup:${this._ioChannels.control}`, `Received ${receivedCtrlMsg.uibuilderCtrl} from server`)
                // Anything else to do for other control msgs?
            }
        } // ---- End of process control msg types ---- //
    } // -- End of websocket receive CONTROL msg from Node-RED -- //

    /** Do we want to process something? Check pageName, clientId, tabId. Defaults to yes.
     * @param {*} obj Either a msg._ui or msg._uib object to check
     * @returns {boolean} True if we should process the inbound _ui/_uib msg, false if not.
     */
    _forThis(obj) {
        let r = true

        // Is this msg for this pageName?
        if (obj.pageName && obj.pageName !== this.pageName) {
            log('trace', 'Uib:_msgRcvdEvents:_uib', 'Not for this page')()
            r = false
        }

        // Is this msg for this clientId?
        if (obj.clientId && obj.clientId !== this.clientId) {
            log('trace', 'Uib:_msgRcvdEvents:_uib', 'Not for this clientId')()
            r = false
        }

        // Is this msg for this tabId?
        if (obj.tabId && obj.tabId !== this.tabId) {
            log('trace', 'Uib:_msgRcvdEvents:_uib', 'Not for this tabId')()
            r = false
        }

        return r
    }

    // Handle received messages - Process some msgs internally, emit specific events on document that make it easy for coders to use
    _msgRcvdEvents(msg) {
        // Message received
        this._dispatchCustomEvent('uibuilder:stdMsgReceived', msg)

        // Topic
        if ( msg.topic ) this._dispatchCustomEvent(`uibuilder:msg:topic:${msg.topic}`, msg)

        // Check whether the msg has already been processed - if so, we don't need to process again
        if (msg._uib_processed_by) return
        msg._uib_processed_by = '_msgRcvdEvents'

        // Handle msg._uib special requests
        if (msg._uib) {
            // Don't process if the inbound msg is not for us
            if (!this._forThis(msg._uib)) return

            /** Process a client reload request from Node-RED - as the page is reloaded, everything else is ignored
             * Note that msg._ui.reload is also actioned via the _ui processing below
             */
            if (msg._uib.reload === true) {
                log('trace', 'Uib:_msgRcvdEvents:_uib:reload', 'reloading')()
                msg._uib_processed_by = '_msgRcvdEvents - reload'
                location.reload()
                return
            }

            // Process msg._uib.command messages - allows Node-RED to run uibuilder FE functions
            if (msg._uib.command) {
                msg._uib_processed_by = '_msgRcvdEvents - remote command'
                this._uibCommand(msg)
                return
            }

            // Better to request via msg._ui - these are for backwards compatibility
            if ( msg._uib.componentRef === 'globalNotification' ) {
                msg._uib_processed_by = '_msgRcvdEvents - globalNotification'
                _ui.showDialog('notify', msg._uib.options, msg)
            }
            if ( msg._uib.componentRef === 'globalAlert' ) {
                msg._uib_processed_by = '_msgRcvdEvents - globalAlert'
                _ui.showDialog('alert', msg._uib.options, msg)
            }
        }

        // Handle msg._ui requests
        if ( msg._ui ) {
            // Don't process if the inbound msg is not for us
            if (!this._forThis(msg._ui)) return

            log('trace', 'Uib:_msgRcvdEvents:_ui', 'Calling _uiManager')()
            msg._uib_processed_by = '_msgRcvdEvents - _ui'
            this._dispatchCustomEvent('uibuilder:msg:_ui', msg)
            _ui._uiManager(msg)
        }
    } // --- end of _msgRcvdEvents ---

    /** Internal send fn. Send a standard or control msg back to Node-RED via Socket.IO
     * NR will generally expect the msg to contain a payload topic
     * @param {object} msgToSend The msg object to send.
     * @param {string} [channel] The Socket.IO channel to use, must be in self.ioChannels or it will be ignored. Default=uiBuilderClient
     * @param {string} [originator] A Node-RED node ID to return the message to. Default=''
     */
    _send(msgToSend, channel, originator = '') {
        if (channel === null || channel === undefined) channel = this._ioChannels.client

        // Make sure msgToSend is an object & add props to control msgs
        if (channel === this._ioChannels.client) {
            msgToSend = this.makeMeAnObject(msgToSend, 'payload')
            if (this.hasUibRouter()) {
                if (!msgToSend._uib) msgToSend._uib = {}
                msgToSend._uib.routeId = this.uibrouter_CurrentRoute
            }
        } else if (channel === this._ioChannels.control) {
            msgToSend = this.makeMeAnObject(msgToSend, 'uibuilderCtrl')
            if (!Object.prototype.hasOwnProperty.call(msgToSend, 'uibuilderCtrl')) {
                msgToSend.uibuilderCtrl = 'manual send'
            }
            // help remember where this came from as ctrl msgs can come from server or client
            msgToSend.from = 'client'
            // Add current route id if needed
            if (this.hasUibRouter()) msgToSend.routeId = this.uibrouter_CurrentRoute
        }

        /** since 2020-01-02 Added _socketId which should be the same as the _socketId on the server */
        msgToSend._socketId = this._socket.id

        // WARNING: You cannot change any of the this._socket.auth settings at this point

        // Add the originator metadata if required
        if (originator === '' && this.originator !== '') originator = this.originator
        if (originator !== '') Object.assign(msgToSend, { '_uib': { 'originator': originator, }, })

        // If the msg does not have a topic - see if we want to add one
        if ( !Object.prototype.hasOwnProperty.call(msgToSend, 'topic') ) {
            // From the default (`uibuilder.set('topic', 'some topic')`)
            if (this.topic !== undefined && this.topic !== '') msgToSend.topic = this.topic
            else {
                // Did the last inbound msg have a topic?
                if ( Object.prototype.hasOwnProperty.call(this, 'msg') && Object.prototype.hasOwnProperty.call(this.msg, 'topic') ) {
                    msgToSend.topic = this.msg.topic
                }
            }
        }

        // If a standard send & _ui property exists, make sure to add _ui.from = 'client' & routerId if needed
        if (msgToSend._ui) {
            msgToSend._ui.from = 'client'
            if (this.hasUibRouter()) msgToSend._ui.routeId = this.uibrouter_CurrentRoute
        }

        // Check size and warn if may be too large - doesn't work if obj contains a buffer
        // const approxMsgSize = this.getObjectSize(msgToSend)
        // console.log(approxMsgSize, this.maxHttpBufferSize, approxMsgSize >= this.maxHttpBufferSize, msgToSend, this.getObjectSize(msgToSend))
        // if (approxMsgSize >= this.maxHttpBufferSize) {
        //     log('error', 'Uib:_send', `Message may be too large to send. Approx msg size: ${approxMsgSize}. Max msg size: ${this.maxHttpBufferSize}`)()
        // }

        // Track how many messages have been sent & last msg sent
        let numMsgs
        if (channel === this._ioChannels.client) {
            this.set('sentMsg', msgToSend)
            numMsgs = this.set('msgsSent', ++this.msgsSent)
        } else if (channel === this._ioChannels.control) {
            this.set('sentCtrlMsg', msgToSend)
            numMsgs = this.set('msgsSentCtrl', ++this.msgsSentCtrl)
        }
        log('trace', 'Uib:_send', ` Channel '${channel}'. Sending msg #${numMsgs}`, msgToSend)()

        this._socket.emit(channel, msgToSend)
    } // --- End of Send Msg Fn --- //

    /** Callback handler for messages from Node-RED
     * NOTE: `this` is the class here rather the `socket` as would be normal since we bind the correct `this` in the call.
     *       Use this._socket if needing reference to the socket.
     * @callback ioSetupFromServer Called from ioSetup/this._socket.on(this.#ioChannels.server, this.stdMsgFromServer.bind(this))
     * @param {object} receivedMsg The msg object from Node-RED
     * @this Uib
     */
    _stdMsgFromServer(receivedMsg) {

        // Make sure that msg is an object & not null
        receivedMsg = this.makeMeAnObject(receivedMsg, 'payload')

        // Don't process if the inbound msg is not for us
        if (receivedMsg._uib && !this._forThis(receivedMsg._uib)) return
        if (receivedMsg._ui && !this._forThis(receivedMsg._ui)) return

        // @since 2018-10-07 v1.0.9: Work out local time offset from server
        this._checkTimestamp(receivedMsg)

        // Track how many messages have been received
        this.set('msgsReceived', ++this.msgsReceived)

        // Emit specific document events on msg receipt that make it easy for coders to use
        this._msgRcvdEvents(receivedMsg)

        if ( !('_ui' in receivedMsg && !('payload' in receivedMsg)) ) {
            // Save the msg for further processing
            this.set('msg', receivedMsg)
        }

        log('info', 'Uib:ioSetup:stdMsgFromServer', `Channel '${this._ioChannels.server}'. Received msg #${this.msgsReceived}.`, receivedMsg)()

        // ! NOTE: Don't try to handle specialist messages here. See _msgRcvdEvents.
    } // -- End of websocket receive DATA msg from Node-RED -- //

    /** Process msg._uib.command - Remember to update #extCommands with new allowed commands
     * @param {object} msg Msg from Node-RED containing a msg._uib object
     */
    _uibCommand(msg) {
        if (!msg._uib || !msg._uib.command) {
            log('error', 'uibuilder:_uibCommand', 'Invalid command message received', { msg, })()
            msg.payload = msg.error = 'Invalid command message received'
            this.send(msg)
            return
        }
        const cmd = msg._uib.command
        // Disallowed command request outputs error and ignores the msg (NB: Case must match)
        if (!this.#extCommands.includes(cmd.trim())) {
            log('error', 'Uib:_uibCommand', `Command '${cmd} is not allowed to be called externally`)()
            return
        }
        const prop = msg._uib.prop
        const value = msg._uib.value
        const quiet = msg._uib.quiet ?? false
        let response, info

        // Don't forget to update `#extCommands`, `docs/client-docs/control-from-node-red.md` & `functions.md`
        switch (cmd) {
            // case 'elementIsVisible': { // temporarily deprecated
            //     response = this.elementIsVisible(prop)
            //     // info = `Element "${prop}" ${response ? 'is visible' : 'is not visible'}`
            //     break
            // }

            case 'elementExists': {
                response = this.elementExists(prop, false)
                info = `Element "${prop}" ${response ? 'exists' : 'does not exist'}`
                break
            }

            case 'get': {
                response = this.get(prop)
                break
            }

            case 'getManagedVarList': {
                if (prop === 'full') response = this.getManagedVarList()
                else response = Object.values(this.getManagedVarList())
                break
            }

            case 'getWatchedVars': {
                if (prop === 'full') response = this.getWatchedVars()
                else response = Object.values(this.getWatchedVars())
                break
            }

            case 'sendHtml':
            case 'htmlSend': {
                response = this.htmlSend('', false)
                break
            }

            case 'include': {
                // include is async
                response = _ui.include(prop, value)
                break
            }

            case 'navigate': {
                let newUrl
                if (prop) newUrl = prop
                else if (value) newUrl = value
                response = this.navigate(newUrl)
                break
            }

            case 'scrollTo': {
                response = this.scrollTo(prop, value)
                break
            }

            case 'set': {
                let store = false
                let autoload = false
                if (msg._uib.options && msg._uib.options.store) {
                    if (msg._uib.options.store === true) store = true
                    if (msg._uib.options.autoload === true) autoload = true
                }
                response = this.set(prop, value, store, autoload)
                break
            }

            case 'showMsg': {
                response = this.showMsg(value, prop)
                break
            }

            case 'showStatus': {
                response = this.showStatus(value, prop)
                break
            }

            case 'uiGet': {
                response = _ui.uiGet(prop, value)
                break
            }

            case 'uiWatch': {
                response = this.uiWatch(prop)
                break
            }

            case 'watchUrlHash': {
                response = this.watchUrlHash(prop)
                break
            }

            default: { // anything else is rejected
                log('warning', 'Uib:_uibCommand', `Command '${cmd}' not yet implemented`)()
                break
            }
        }

        if (quiet !== true) {
            if (response === undefined) {
                response = `'${prop}' is undefined`
            }
            if (Object(response).constructor === Promise) {
                // response = `'${cmd} ${prop}' submitted. Cmd is async, no response available`
                response
                    .then( (/** @type {any} */ data) => {
                        msg.payload = msg._uib.response = data
                        msg.info = msg._uib.info = info
                        if (!msg.topic) msg.topic = this.topic || `uib ${cmd} for '${prop}'`
                        this.send(msg)
                        return true
                    })
                    .catch( err => {
                        log(0, 'Uib:_uibCommand', 'Error: ', err)()
                    })
            } else {
                msg.payload = msg._uib.response = response
                msg.info = msg._uib.info = info
                if (!msg.topic) msg.topic = this.topic || `uib ${cmd} for '${prop}'`
                this.send(msg)
            }
        }
    } // --- end of _uibCommand ---

    /** Send log text to uibuilder's beacon endpoint (works even if socket.io not connected)
     * @param {string} txtToSend Text string to send
     * @param {string|undefined} logLevel Log level to use. If not supplied, will default to debug
     */
    beaconLog(txtToSend, logLevel) {
        if (!logLevel) logLevel = 'debug'
        navigator.sendBeacon('./_clientLog', `${logLevel}::${txtToSend}`)
    }

    /** Request the current page's metadata from the server - response is handled automatically in _ctrlMsgFromServer */
    getPageMeta() {
        this.sendCtrl({
            uibuilderCtrl: 'get page meta',
        })
    }

    /** Easily send the entire DOM/HTML msg back to Node-RED
     * @param {string} [originator] A Node-RED node ID to return the message to
     * @param {boolean} [send] If true (default) directly send response to Node-RED. Is false when calling from Node-RED as a command.
     * @returns {string} The HTML as a string
     */
    htmlSend(originator = '', send = true) {
        const out = `<!doctype html>\n${document.documentElement.outerHTML}`

        // Set up the msg to send - NB: Topic may be added by this._send
        const msg = {
            payload: out,
            length: out.length,
            topic: this.topic,
        }

        log('trace', 'Uib:htmlSend', 'Sending full HTML to Node-RED', msg)()

        if (send === true) this._send(msg, this._ioChannels.client, originator)
        return out
    }

    /** Send log info back to Node-RED over uibuilder's websocket control output (Port #2)
     * @param {...*} arguments All arguments passed to the function are added to the msg.payload
     */
    logToServer() {
        this.sendCtrl({
            uibuilderCtrl: 'client log message',
            payload: arguments,
            // "version":"6.1.0-iife.min",
            _socketId: this._socket.id,
            // "ip":"::1",
            clientId: this.clientId,
            tabId: this.tabId,
            // "url":"esp-test",
            pageName: this.pageName,
            connections: this.connectedNum,
            lastNavType: this.lastNavType,
        })
    }

    /** Easily send a msg back to Node-RED on a DOM event
     * @example In plain HTML/JavaScript
     *    `<button id="button1" name="button 1" data-fred="jim"></button>`
     *    $('#button1').onclick = (evt) => {
     *      uibuilder.eventSend(evt)
     *    }
     * @example
     * In VueJS: `<b-button id="myButton1" @click="doEvent" data-something="hello"></b-button>`
     * In VueJS methods: `doEvent: uibuilder.eventSend,`
     *
     * All `data-` attributes will be passed back to Node-RED,
     *    use them instead of arguments in the click function.
     *    All target._ui custom properties are also passed back to Node-RED.
     *
     * @param {MouseEvent|any} domevent DOM Event object
     * @param {string} [originator] A Node-RED node ID to return the message to
     */
    eventSend(domevent, originator = '') {
        // @ts-ignore Handle case where vue messes up `this`
        if ( this.$attrs ) {
            log('error', 'Uib:eventSend', '`this` has been usurped by VueJS. Make sure that you wrap the call in a function: `doEvent: function (event) { uibuilder.eventSend(event) },`' )()
            return
        }
        if (!domevent && !event) {
            log('warn', 'Uib:eventSend', 'Neither the domevent nor the hidden event properties are set. You probably called this function directly rather than applying to an on click event.' )()
            return
        }
        // Handle no argument, e.g. <button onClick="uibuilder.eventSend()"> - event is a hidden variable when fn used in addEventListener
        if (!domevent || !domevent.constructor) domevent = event

        // The argument must be a DOM event
        if ((!domevent.constructor.name.endsWith('Event')) || (!domevent.currentTarget)) {
            log('warn', 'Uib:eventSend', `ARGUMENT NOT A DOM EVENT - use data attributes not function arguments to pass data. Arg Type: ${domevent.constructor.name}`, domevent)()
            return
        }

        // Prevent default event action
        domevent.preventDefault()

        // The target element
        const target = domevent.currentTarget

        // Get target custom properties - only shows custom props not element default ones
        const props = this.getElementCustomProps(target)

        // Attempt to get target attributs - can fail for certain target types
        const attribs = this.getElementAttributes(target)

        // Msg.payload
        let payload = {}

        // If target is embedded in a form, include ALL the form data in the output
        let formDetails
        if ( target.form ) {
            formDetails = {
                id: this.returnElementId(target.form),
                valid: target.form.checkValidity(),
            }

            Object.values(target.form).forEach( (frmEl, i) => {
                // Ignore <fieldset>, <object> - they don't have values
                if (['fieldset', 'object'].includes(frmEl.type)) return

                // NB: If type=files, this also sends the file to Node-RED
                const details = this.getFormElementDetails(frmEl)
                if (details) {
                    // For type=file, get the file details as the value (array as might be multiple files).
                    // el.files is a FileList type & each entry is a File type - have to process these manually - stupid HTML!
                    // https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications
                    if (frmEl.type === 'file' && frmEl.files.length > 0) {
                        // Walk through each file in the FileList, get the details and send the file to Node-RED
                        // details.value = this._processFilesInput(frmEl.files, frmEl)
                        details.value = this._processFilesInput(frmEl)
                    }

                    formDetails[details.id] = details
                    // simplified for addition to msg.payload
                    payload[details.id] = details.value
                }
            })
        } else {
            if (target.type === 'file') {
                // Walk through each file in the FileList, get the details and send the file to Node-RED
                // payload = this._processFilesInput(target.files, {id: target?.id, name: target?.name,})
                payload = this._processFilesInput(target)
            }
        }

        // Check for CSS Classes
        const classes = this.getElementClasses(target)

        // Each `data-xxxx` attribute is added as a property
        if (Object.keys(target.dataset).length > 0) payload = { ...payload, ...target.dataset, }

        // Check for element value/check props
        const { value, checked, } = this.getFormElementValue(target)
        if (value !== null) payload.value = value
        if (checked !== null) payload.checked = checked

        // Handle Notification events
        let nprops
        if ( Object.prototype.toString.call(target) === '[object Notification]') {
            // Fixed payload for convenience
            payload = `notification-${target.userAction}`
            // Capture the notification properties
            nprops = {
                // userAction: target.userAction, // uib custom prop: click, close or error
                actions: target.actions,
                badge: target.badge,
                body: target.body,
                data: target.data,
                dir: target.dir,
                icon: target.icon,
                image: target.image,
                lang: target.lang,
                renotify: target.renotify,
                requireInteraction: target.requireInteraction,
                silent: target.silent,
                tag: target.tag,
                timestamp: target.timestamp,
                title: target.title,
                vibrate: target.vibrate,
            }
        }

        // Set up the msg to send - NB: Topic may be added by this._send
        const msg = {
            // - this may be an empty Object if no data attributes defined
            payload: payload,

            _ui: {
                type: 'eventSend',
                id: target.id !== '' ? target.id : undefined,
                name: target.name !== '' ? target.name : undefined,
                slotText: target.textContent ? target.textContent.substring(0, 255) : undefined,
                dataset: {...target.dataset,},

                form: formDetails,
                props: props,
                attribs: attribs,
                classes: classes,

                notification: nprops,

                event: domevent.type,
                altKey: domevent.altKey,
                ctrlKey: domevent.ctrlKey,
                shiftKey: domevent.shiftKey,
                metaKey: domevent.metaKey,

                pointerType: domevent.pointerType,
                nodeName: target.nodeName,

                clientId: this.clientId,
                pageName: this.pageName,
                tabId: this.tabId,
            },
        }

        log('trace', 'Uib:eventSend', 'Sending msg to Node-RED', msg)()

        this._send(msg, this._ioChannels.client, originator)
    }

    /** Send a standard message to NR
     * @example uibuilder.send({payload:'Hello'})
     * @param {object} msg Message to send
     * @param {string} [originator] A Node-RED node ID to return the message to
     */
    send(msg, originator = '') {
        this._send(msg, this._ioChannels.client, originator)
    }

    // ! TODO: Rooms do not auto-reconnect. Add tracking and update _onConnect
    // ! TODO: Add receipt handler on joining a room.

    // NOTE: Rooms only understood by server not client so we have to use custom emits
    //       They do not auto-reconnect

    /** Send a msg to a pre-defined Socket.IO room
     * @see https://socket.io/docs/v4/rooms/
     * @param {string} room Name of a Socket.IO pre-defined room.
     * @param {*} msg Message to send
     */
    sendRoom(room, msg) {
        this._socket.emit('uib-room-send', room, msg )
    }

    joinRoom(room) {
        this._socket.emit('uib-room-join', room)
    }

    leaveRoom(room) {
        this._socket.emit('uib-room-leave', room)
    }

    /** Send a control msg to NR
     * @param {object} msg Message to send
     */
    sendCtrl(msg) {
        this._send(msg, this._ioChannels.control)
    }

    /**
     * Send a message to Node-RED on a custom channel - use for UIBUILDER 3rd-party custom nodes
     * @param {string} channel The custom channel name to use
     * @param {object} msg The message to send
     */
    sendCustom(channel, msg) {
        this._socket.emit(channel, msg)
    }

    /** Upload a file to Node-RED over Socket.IO
     * https://developer.mozilla.org/en-US/docs/Web/API/FileReader
     * @param {File} file Reference to File API object to upload
     * @param {object} [meta] Optiona. Additional metadata to send with the file
     */
    uploadFile(file, meta) { // srcElIds, seq, seqCount
        // Create a new FileReader instance
        const reader = new FileReader()

        // Define the onload event for the FileReader
        reader.onload = (e) => {
            // Get the binary content of the file
            const arrayBuffer = e.target.result

            // Send the binary content to the server along with helpful metadata
            const msg = {
                topic: this.topic || 'file-upload',
                payload: arrayBuffer,
                
                fileName: file.name,
                type: file.type,
                lastModified: file.lastModifiedDate,
                size: file.size,

                clientId: this.clientId,
                pageName: this.pageName,
                tabId: this.tabId,
                
                ...meta,
            }

            // Only send if content not too large
            const maxSize = this.maxHttpBufferSize - 500
            if (arrayBuffer.byteLength >= maxSize) {
                msg.payload = undefined
                msg.error = `File is too large to send. File size: ${arrayBuffer.byteLength}. Max msg size: ${maxSize}`
                log('error', 'Uib:uploadFile', msg.error)()

            }

            this.send(msg)
        }

        // Read the file as an ArrayBuffer
        reader.readAsArrayBuffer(file)
    }
    //#endregion -------- ------------ -------- //

    //#region ------- Socket.IO -------- //

    /** Return the Socket.IO namespace
     * The cookie method is the most reliable but this falls back to trying to work it
     * out from the URL if cookies not available. That won't work if page is in a sub-folder.
     * since 2017-10-21 Improve method to cope with more complex paths - thanks to Steve Rickus @shrickus
     * since 2017-11-10 v1.0.1 Check cookie first then url. cookie works even if the path is more complex (e.g. sub-folder)
     * since 2020-01-25 Removed httpRoot from namespace to prevent proxy induced errors
     * @returns {string} Socket.IO namespace
     */
    _getIOnamespace() {

        let ioNamespace

        /** Try getting the namespace cookie. */
        ioNamespace = this.cookies['uibuilder-namespace']

        // if it wasn't available, try using the current url path
        if (ioNamespace === undefined || ioNamespace === '') {
            // split url path & eliminate any blank elements, and trailing or double slashes
            const u = window.location.pathname.split('/')
                .filter(function (t) { return t.trim() !== '' })

            /** since v2.0.5 Extra check for 0 length, Issue #73. since 2017-11-06 If the last element of the path is an .html file name, remove it */
            if (u.length > 0 && (u[u.length - 1].endsWith('.html'))) u.pop()

            // Get the last part of the url path, this MUST match the namespace in uibuilder
            ioNamespace = u.pop()

            log('trace', 'uibuilder.module.js:getIOnamespace', `Socket.IO namespace found via url path: ${ioNamespace}`)()
        } else {
            log('trace', 'uibuilder.module.js:getIOnamespace', `Socket.IO namespace found via cookie: ${ioNamespace}`)()
        }

        this.url = ioNamespace
        // Namespace HAS to start with a /
        ioNamespace = '/' + ioNamespace

        log('trace', 'uibuilder.module.js:getIOnamespace', `Final Socket.IO namespace: ${ioNamespace}`)()

        return ioNamespace
    } // --- End of set IO namespace --- //

    /** Function used to check whether Socket.IO is connected to the server, reconnect if not (recursive)
     * @param {number} [delay] Initial delay before checking (ms). Default=2000ms
     * @param {number} [factor] Multiplication factor for subsequent checks (delay*factor). Default=1.5
     * @param {number} [depth] Recursion depth
     * @returns {boolean|undefined} Whether or not Socket.IO is connected to uibuilder in Node-RED
     */
    _checkConnect(delay, factor, depth = 1) {
        if ( navigator.onLine === false ) return // Don't bother if we know we are offline

        if (!delay) delay = this.retryMs
        if (!factor) factor = this.retryFactor

        log('trace', 'Uib:checkConnect', `Checking connection. Connected: ${this._socket.connected}. Timer: ${this.#timerid}. Depth: ${depth}. Delay: ${delay}. Factor: ${factor}`, this._socket)()

        // If we are connected ...
        if (this._socket.connected === true) {
            // Clear the setTimeout
            if (this.#timerid) {
                window.clearTimeout(this.#timerid)
                this.#timerid = null
            }
            this.set('ioConnected', true)
            this.set('socketError', null)
            return true
        }

        // ... we aren't connected so:
        this.set('ioConnected', false)

        // we only want one running at a time
        if (this.#timerid) window.clearTimeout(this.#timerid)

        // Create the new timer
        this.#timerid = window.setTimeout(() => {
            log('warn', 'Uib:checkConnect:setTimeout', `Socket.IO reconnection attempt. Current delay: ${delay}. Depth: ${depth}`)()

            // this is necessary sometimes when the socket fails to connect on startup
            this._socket.disconnect()

            // Try to reconnect
            this._socket.connect()

            // don't need to check whether we have connected as the timer will have been cleared if we have
            this.#timerid = null

            // @ts-ignore Create new timer for next time round with extended delay
            this._checkConnect(delay * factor, factor, depth++)
        }, delay)

        return false
    } // --- End of checkConnect Fn--- //

    // See message handling section for msg receipt handlers

    /** Called by _ioSetup when Socket.IO connects to Node-RED */
    _onConnect() {
        // WARNING: You cannot change any of the this._socket.auth settings at this point
        log('info', 'Uib:ioSetup', `✅ SOCKET CONNECTED. Connection count: ${this.connectedNum}, Is a Recovery?: ${this._socket.recovered}. \nNamespace: ${this.ioNamespace}`)()
        this._dispatchCustomEvent('uibuilder:socket:connected', { 'numConnections': this.connectedNum, 'isRecovery': this._socket.recovered, })

        this._checkConnect() // resets any reconnection timers & sets connected flag
    }

    /** Called by _ioSetup when Socket.IO disconnects from Node-RED
     * @param {string} reason Disconnection title
     */
    _onDisconnect(reason) {
        // reason === 'io server disconnect' - redeploy of Node instance
        // reason === 'transport close' - Node-RED terminating
        // reason === 'ping timeout' - didn't receive a pong response?
        log('info', 'Uib:ioSetup:socket-disconnect', `⛔ Socket Disconnected. Reason: ${reason}`)()

        this._dispatchCustomEvent('uibuilder:socket:disconnected', reason)

        /** A workaround for SIO's failure to reconnect after a disconnection */
        this._checkConnect()
    }

    /** Setup Socket.io
     * since v2.0.0-beta2 Moved to a function and called by the user (uibuilder.start()) so that namespace & path can be passed manually if needed
     * @returns {boolean} Attaches socket.io manager to self._socket and updates self.ioNamespace & self.ioPath as needed
     */
    _ioSetup() {

        // Just a notification, actual load is done outside the class (see start of file)
        if (io === undefined) {
            log('error', 'Uib:ioSetup', 'Socket.IO client not loaded, Node-RED comms will not work')()
            return false
        }

        // If socket is already set up, close it and remove all of the listeners
        if (this._socket) {
            log('trace', 'Uib:ioSetup', 'Removing listeners in preparation for redoing Socket.IO connections')()
            if (this.#timerid) {
                window.clearTimeout(this.#timerid)
                this.#timerid = null
            }
            this._socket.close()
            this._socket.offAny()
            this._socket = undefined
            this.set('ioConnected', false)
        }

        // Update the URL path to make sure we have the right one
        this.socketOptions.path = this.ioPath

        // Create the socket - make sure client uses Socket.IO version from the uibuilder module (using path)
        log('trace', 'Uib:ioSetup', `About to create IO object. Transports: [${this.socketOptions.transports.join(', ')}]`)()
        this._socket = io(this.ioNamespace, this.socketOptions)

        this._connectGlobal()

        /** When the socket is connected - set ioConnected flag and reset connect timer  */
        this._socket.on('connect', this._onConnect.bind(this))

        // RECEIVE a STANDARD, non-control msg from Node-RED server
        this._socket.on(this._ioChannels.server, this._stdMsgFromServer.bind(this))

        // RECEIVE a CONTROL msg from Node-RED server - see also sendCtrl()
        this._socket.on(this._ioChannels.control, this._ctrlMsgFromServer.bind(this))

        // When the socket is disconnected ..............
        this._socket.on('disconnect', this._onDisconnect.bind(this))

        // Socket.io connection error - probably the wrong ioPath - or client is offline
        this._socket.on('connect_error', (err) => {
            if ( navigator.onLine === false ) return // Don't bother with an error if we know we are offline
            log('error', 'Uib:ioSetup:connect_error', `❌ Socket.IO Connect Error. Reason: ${err.message}`, err)()
            this.set('ioConnected', false)
            this.set('socketError', err)
            this._dispatchCustomEvent('uibuilder:socket:disconnected', err)
        }) // --- End of socket connect error processing ---

        // Socket.io error - from the server (socket.use middleware triggered an error response)
        this._socket.on('error', (err) => {
            log('error', 'Uib:ioSetup:error', `❌ Socket.IO Error. Reason: ${err.message}`, err)()
            this.set('ioConnected', false)
            this.set('socketError', err)
            this._dispatchCustomEvent('uibuilder:socket:disconnected', err)
        }) // --- End of socket error processing ---

        // Ensure we are connected, retry if not
        this._checkConnect()

        return true

        /* We really don't need these, just for interest
            self._socket.io.on('packet', function onPacket(data){
                // we get one of these for each REAL msg (not ping/pong)
                console.debug('PACKET', data)
            })
            self._socket.on('pong', function(latency) {
                console.debug('SOCKET PONG - Latency: ', latency)
                //console.dir(self._socket)
            }) // --- End of socket pong processing ---
            self._socket.io.on('packet', function(data){
                // We get one of these for actual messages, not ping/pong
                console.debug('PACKET', data)
            })
            self._socket.on('connect_timeout', function(timeout) {
                console.debug('SOCKET CONNECT TIMEOUT - Namespace: ' + ioNamespace + ', Timeout: ' + timeout)
            }) // --- End of socket connect timeout processing ---
            self._socket.on('reconnect', function(attemptNum) {
                console.debug('SOCKET RECONNECTED - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
            }) // --- End of socket reconnect processing ---
            self._socket.on('reconnect_attempt', function(attemptNum) {
                console.debug('SOCKET RECONNECT ATTEMPT - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
            }) // --- End of socket reconnect_attempt processing ---
            self._socket.on('reconnecting', function(attemptNum) {
                console.debug('SOCKET RECONNECTING - Namespace: ' + ioNamespace + ', Attempt #: ' + attemptNum)
            }) // --- End of socket reconnecting processing ---
            self._socket.on('reconnect_error', function(err) {
                console.debug('SOCKET RECONNECT ERROR - Namespace: ' + ioNamespace + ', Reason: ' + err.message)
                //console.dir(err)
            }) // --- End of socket reconnect_error processing ---
            self._socket.on('reconnect_failed', function() {
                console.debug('SOCKET RECONNECT FAILED - Namespace: ' + ioNamespace)
            }) // --- End of socket reconnect_failed processing ---
            self._socket.on('ping', function() {
                console.debug('SOCKET PING')
            }) // --- End of socket ping processing ---
            self._socket.on('pong', function(latency) {
                console.debug('SOCKET PONG - Latency: ', latency)
            }) // --- End of socket pong processing ---
            */
    } // ---- End of ioSetup ---- //

    /** Connect to global namespace & create global listener that updates the `globalMsg` var */
    _connectGlobal() {
        this._socketGlobal = io('/', this.socketOptions)
        this._socketGlobal.onAny( (...args) => {
            this.set('globalMsg', args.slice(0, -1))
        })
    }

    /** Manually (re)connect socket.io */
    connect() {
        // ? Should I use this._checkConnect()?
        this._socket.connect()
    }

    /** Manually disconnect socket.io and stop any auto-reconnect timer */
    disconnect() {
        this._socket.disconnect()
        // As this is a manual disconnect, stop any reconnect timers
        if (this.#timerid) window.clearTimeout(this.#timerid)
    }

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

    //#region Watch for and process uib-* or data-uib-* attributes in HTML and auto-process

    /** Attempt to load a service worker
     * https://yonatankra.com/how-service-workers-sped-up-our-website-by-97-5/
     * @param {string} fileName Name of service worker js file (without .js extension)
     */
    // async registerServiceWorker(fileName) {
    //     if (!navigator.serviceWorker) return

    //     await navigator.serviceWorker.register(
    //         `./${fileName}.js`,
    //         {
    //             scope: './',
    //         }
    //     )
    // }

    /** Wrap an object in a JS proxy
     * WARNING: Sadly, `let x = uib.createProxy( [1,2] ); x.push(3);` Does not trigger a send because that is classed as a
     * GET, not a SET.
     * param {*} target The target object to proxy
     * returns {Proxy} A proxied version of the target object
     */
    // createProxy(target) {
    //     return new Proxy(target, {
    //         _doSend(prop, val, oldVal) {
    //             uibuilder.send({
    //                 topic: 'uibuilder/proxy/change',
    //                 _uib: {
    //                     varChange: {
    //                         name: self.$name,
    //                         property: prop,
    //                         oldValue: oldVal,
    //                         newValue: val,
    //                     }
    //                 },
    //                 payload: target,
    //             })
    //         },
    //         get(target, prop, receiver) {
    //             console.log('uib proxy - GET: ', prop, target)
    //             let val = Reflect.get(...arguments)
    //             if (typeof val === 'function') val = val.bind(target)
    //             if (self.$sendChanges) this._doSend(prop, Reflect.get(target))
    //             return val
    //         },
    //         set(target, prop, val, receiver) {
    //             if (prop === '$sendChanges') {
    //                 self.$sendChanges = typeof val === 'boolean' ? val : false
    //                 return true
    //             }
    //             if (prop === '$name') {
    //                 self.$name = typeof val === 'string' ? val : undefined
    //                 return true
    //             }
    //             console.log('uib proxy - SET: ', val, prop, target)
    //             const oldVal = Reflect.get(...arguments)
    //             const worked = Reflect.set(target, prop, val, receiver)
    //             if (self.$sendChanges && worked) {
    //                 self._doSend(prop, val, oldVal)
    //             }
    //             return worked
    //         },
    //     })
    // }

    //#endregion ! EXPERIMENTAL

    //#region ------- Class construction & startup method -------- //

    constructor() {
        log('trace', 'Uib:constructor', 'Starting')()

        // Track whether the client is online or offline
        window.addEventListener('offline', (e) => {
            this.set('online', false)
            this.set('ioConnected', false)
            log('warn', 'Browser', 'DISCONNECTED from network')()
        })
        window.addEventListener('online', (e) => {
            this.set('online', true)
            log('warn', 'Browser', 'Reconnected to network')()
            this._checkConnect()
        })

        document.cookie.split(';').forEach((c) => {
            const splitC = c.split('=')
            this.cookies[splitC[0].trim()] = splitC[1]
        })

        /** Client ID set by uibuilder - lasts until browser profile is closed, applies to all tabs */
        this.set('clientId', this.cookies['uibuilder-client-id'])
        log('trace', 'Uib:constructor', 'Client ID: ', this.clientId)()

        /** Tab ID - lasts while the tab is open (even if reloaded)
         * WARNING: Duplicating a tab retains the same tabId
         */
        this.set('tabId', window.sessionStorage.getItem('tabId'))
        if (!this.tabId) {
            this.set('tabId', `t${Math.floor(Math.random() * 1000000)}`)
            window.sessionStorage.setItem('tabId', this.tabId)
        }

        // document.addEventListener('pagehide', (event) => {
        //     // console.log(`pagehide. From Cache?: ${event.persisted}`)
        //     navigator.sendBeacon('./_clientLog', `pagehide. From Cache?: ${event.persisted}`)
        // })
        // document.addEventListener('pageshow', (event) => {
        //     // console.log(`pageshow. From Cache?: ${event.persisted}`)
        //     navigator.sendBeacon('./_clientLog', `pageshow. From Cache?: ${event.persisted}`)
        // })
        document.addEventListener('load', () => {
            this.set('isVisible', true)
        })
        document.addEventListener('visibilitychange', () => {
            // hidden=unload, minimise. visible=un-minimise (not fired on load)
            this.set('isVisible', document.visibilityState === 'visible')
            this.sendCtrl({ uibuilderCtrl: 'visibility', isVisible: this.isVisible, })
            // navigator.userActivation is experimental Chromium only
            // console.log('visibilitychange', ':', `Document Event: visibilitychange. Visibility State: ${document.visibilityState}. User Activity- Has:${navigator.userActivation.hasBeenActive}, Is:${navigator.userActivation.isActive}`)
            // navigator.sendBeacon('./_clientLog', `${(new Date()).toISOString()} Document Event: visibilitychange. Visibility State: ${document.visibilityState}. User Activity- Has:${navigator.userActivation.hasBeenActive}, Is:${navigator.userActivation.isActive}`)
        })

        // Set a listener to update showStatus table if it is active
        document.addEventListener('uibuilder:propertyChanged', (event) => {
            if (!this.#isShowStatus) return

            // @ts-ignore
            if (event.detail.prop in this.#showStatus) {
                // @ts-ignore
                document.querySelector(`td[data-vartype="${event.detail.prop}"]`).innerText = JSON.stringify(event.detail.value)
            }
        })

        this.set('ioNamespace', this._getIOnamespace())

        //#region - Try to make sure client uses Socket.IO client version from the uibuilder module (using cookie or path) @since v2.0.0 2019-02-24 allows for httpNodeRoot

        /** httpNodeRoot (to set path) */
        if ('uibuilder-webRoot' in this.cookies) {
            this.set('httpNodeRoot', this.cookies['uibuilder-webRoot'])
            log('trace', 'Uib:constructor', `httpNodeRoot set by cookie to "${this.httpNodeRoot}"`)()
        } else {
            // split current url path, eliminate any blank elements and trailing or double slashes
            const fullPath = window.location.pathname.split('/').filter(function (t) { return t.trim() !== '' })
            /** handle url includes file name - @since v2.0.5 Extra check for 0 length, Issue #73. */
            if (fullPath.length > 0 && fullPath[fullPath.length - 1].endsWith('.html')) fullPath.pop()
            fullPath.pop() // gives the last path section of the url
            this.set('httpNodeRoot', `/${fullPath.join('/')}`)
            log('trace', '[Uib:constructor]', `httpNodeRoot set by URL parsing to "${this.httpNodeRoot}". NOTE: This may fail for pages in sub-folders.`)()
        }
        this.set('ioPath', this.urlJoin(this.httpNodeRoot, Uib._meta.displayName, 'vendor', 'socket.io'))
        log('trace', 'Uib:constructor', `ioPath: "${this.ioPath}"`)()

        //#endregion

        // Work out pageName
        this.set('pageName', window.location.pathname.replace(`${this.ioNamespace}/`, ''))
        if ( this.pageName.endsWith('/') ) this.set('pageName', `${this.pageName}index.html`)
        if ( this.pageName === '' ) this.set('pageName', 'index.html')

        // Attempt to restore autoload variables
        try {
            const autoloadVars = this.getStore('_uibAutoloadVars')
            if (Object.keys(autoloadVars).length > 0) {
                Object.keys(autoloadVars).forEach( (id) => {
                    this.set(id, this.getStore(id))
                })
            }
        } catch (e) {}

        // ! Experimental service worker
        // this.registerServiceWorker('sw')

        this._dispatchCustomEvent('uibuilder:constructorComplete')

        log('trace', 'Uib:constructor', 'Ending')()
    }

    /** Start up Socket.IO comms and listeners
     * This has to be done separately because if running from a web page in a sub-folder of src/dist, uibuilder cannot
     * necessarily work out the correct ioPath to use.
     * Also, if cookies aren't permitted in the browser, both ioPath and ioNamespace may need to be specified.
     * @param {object} [options] The start options object.
     * @returns {void}
     */
    start(options) {
        log('trace', 'Uib:start', 'Starting')()

        // Cancel the msg event handler if already present
        if ( this.#MsgHandler ) this.cancelChange('msg', this.#MsgHandler)

        if (this.started === true) {
            log('info', 'Uib:start', 'Start function already called. Resetting Socket.IO and msg handler.')()
        }

        log('log', 'Uib:start', 'Cookies: ', this.cookies, `\nClient ID: ${this.clientId}`)()
        log('trace', 'Uib:start', 'ioNamespace: ', this.ioNamespace, `\nioPath: ${this.ioPath}`)()

        // Handle options
        if (options) {
            if (options.ioNamespace) this.set('ioNamespace', options.ioNamespace)
            if (options.ioPath) this.set('ioPath', options.ioPath)
            if (options.nopolling && this.socketOptions.transports[0] === 'polling') this.socketOptions.transports.shift()
        }

        /** Handle specialist messages like reload and _ui -> Moved to _msgRcvdEvents */

        // Track last browser navigation type: navigate, reload, back_forward, prerender
        // TODO Needs more work - updates on navigation needed?
        const [entry] = performance.getEntriesByType('navigation')
        // @ts-ignore
        this.set('lastNavType', entry.type)

        // Start up (or restart) Socket.IO connections and listeners. Returns false if io not found
        this.set('started', this._ioSetup())

        if ( this.started === true ) {
            log('trace', 'Uib:start', 'Start completed. Socket.IO client library loaded.')()
        } else {
            log('error', 'Uib:start', 'Start completed. ERROR: Socket.IO client library NOT LOADED.')()
        }

        // Watch for URL hash changes in case using a front-end router. Updates watched var `urlHash` and socket.io `auth.urlHash`
        this._watchHashChanges()

        // Check if Vue is present (used for dynamic UI processing)
        if (window['Vue']) {
            this.set('isVue', true)
            try {
                this.set('vueVersion', window['Vue'].version)
            } catch (e) { }
            log('trace', 'Uib:start', `VueJS is loaded. Version: ${this.vueVersion}`)()
        } else {
            log('trace', 'Uib:start', 'VueJS is not loaded.')()
        }

        // Check if DOMPurify library is loaded
        if (window['DOMPurify']) {
            this.set('purify', true)
            log('trace', 'Uib:start', 'DOMPurify is loaded.')()
        } else {
            log('trace', 'Uib:start', 'DOMPurify is not loaded.')()
        }
        // Check if Markdown-IT library is loaded
        if (window['markdownit']) {
            this.set('markdown', true)
            log('trace', 'Uib:start', 'Markdown-IT is loaded.')()
        } else {
            log('trace', 'Uib:start', 'Markdown-IT is not loaded.')()
        }

        // Set up msg listener for the optional showMsg
        this.onChange('msg', (msg) => {
            if (this.#isShowMsg === true) {
                const eMsg = document.getElementById('uib_last_msg')
                if (eMsg) eMsg.innerHTML = this.syntaxHighlight(msg)
            }
        })

        // Initial scan for uib-* attributes & add suitable change processors
        this._uibAttrScanAll(document)
        // Observer to watch for new/changed elements & add suitable change processors
        const observer = new MutationObserver(this._uibAttribObserver.bind(this))
        observer.observe(document, {
            subtree: true,
            attributes: true,
            attributeOldValue: true,
            attributeFilter: this.uibAttribs,
            childList: true,
        })

        this._dispatchCustomEvent('uibuilder:startComplete')
    }

    //#endregion -------- ------------ -------- //
} // ==== End of Class Uib ====

//#region --- Wrap up - get things started, define globals & web components ---

// Create an instance (we will only ever want one)
const uibuilder = new Uib()

// Assign reference to the instance to the global `window` object
// Only useful if loading via <script> tag - prefer loading via `import uibuilder from ...`
if (!window['uibuilder']) {
    window['uibuilder'] = uibuilder
} else {
    log('error', 'uibuilder.module.js', '`uibuilder` already assigned to window. Have you tried to load it more than once?')
}
if (!window['uib']) {
    window['uib'] = uibuilder
} else {
    log('warn', 'uibuilder.module.js', '`uib` shortcut already assigned to window.')
}

// Assign `$` to global window object unless it is already in use.
// Note that this is also available as `uibuilder.$`.
if (!window['$']) {
    /** @type {HTMLElement} */
    window['$'] = window['uibuilder'].$ // document.querySelector.bind(document)
} else {
    log('warn', 'uibuilder.module.js', 'Cannot allocate the global `$`, it is already in use. Use `uibuilder.$` or `uib.$` instead.')
}
// Assign `$$` to global window object unless it is already in use.
// Note that this is also available as `uibuilder.$$`.
if (!window['$$']) {
    /** @type {HTMLElement} */
    window['$$'] = window['uibuilder'].$$ // document.querySelectorAll.bind(document)
} else {
    log('warn', 'uibuilder.module.js', 'Cannot allocate the global `$$`, it is already in use. Use `uibuilder.$$` or `uib.$$` instead.')
}

// Assign `dom` to global window object unless it is already in use.
// if (!window['dom']) {
//     window['dom'] = dom
// } else {
//     log('warn', 'uibuilder.module.js', 'Cannot allocate the global `dom`, it is already in use. Use `uibuilder.dom` or `uib.dom` instead.')
// }

// THIS IS DONE IN THE logger.js Module (Assign `log` to global window object unless it is already in use.)

// Assign `$ui` to global window object unless it is already in use.
if (!window['$ui']) {
    /** @type {HTMLElement} */
    window['$ui'] = window['uibuilder'].$ui
} else {
    log('warn', 'uibuilder.module.js', 'Cannot allocate the global `$ui`, it is already in use. Use `uibuilder.$ui` or `uib.$ui` instead.')
}

// Try to add `on` to the standard window and document prototypes for convenience
// This is an alias for `addEventListener`.
if (!('on' in document)) {
    document.on = function (event, callback) {
        this.addEventListener(event, callback)
    }
}
if (!('on' in window)) {
    window.on = function (event, callback) {
        this.addEventListener(event, callback)
    }
}

// Try to add `query`, `queryAll`, and `on` to the standard Element prototype for convenience
// These are aliases for `querySelector`, `querySelectorAll`, and `addEventListener` respectively.
try {
    if (!('query' in Element)) {
        Element.prototype.query = function(selector) {
            return this.querySelector(selector)
        }
    }
    if (!('queryAll' in Element)) {
        Element.prototype.queryAll = function(selector) {
            return this.querySelectorAll(selector)
        }
    }
    if (!('on' in Element)) {
        Element.prototype.on = function (event, callback) {
            this.addEventListener(event, callback)
        }
    }
} finally { } // eslint-disable-line no-empty

// Can import as `import uibuilder from ...` OR `import {uibuilder} from ...`
export { uibuilder }
export default uibuilder

// Attempt to run start fn
uibuilder.start()

// Add built-in web component classes as a new Custom Element to the window object
customElements.define('uib-var', UibVar)
customElements.define('uib-meta', UibMeta)
customElements.define('apply-template', ApplyTemplate)

//#endregion --- Wrap up ---

// EOF