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.

460 lines (396 loc) 18.4 kB
// @ts-nocheck /** Define the base component extensions for other components in this package. * Used to ensure that standard properties and methods are available in every component. * * Version: See the class code * */ /** Copyright (c) 2024-2025 Julian Knight (Totally Information) * https://it.knightnet.org.uk, https://github.com/TotallyInformation * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** Namespace * @namespace Library */ /** * @class * @augments HTMLElement * @description Define the base component extensions for other components in this package. * * @element ti-base-component * @memberOf Library * STANDARD METHODS: * @function config Update runtime configuration, return complete config * @function createShadowSelectors Creates the jQuery-like $ and $$ methods * @function deepAssign Object deep merger * @function doInheritStyles If requested, add link to an external style sheet * @function ensureId Adds a unique ID to the tag if no ID defined. * @function uibSend Send a message to the Node-RED server via uibuilder if available. * @function _uibMsgHandler Not yet in use * @function _event (name,data) Standardised custom event dispatcher * @function _ready Call from end of connectedCallback. Sets connected prop and outputs events * Standard watched attributes (common across all my components): * @property {string|boolean} inherit-style - Optional. Load external styles into component (only useful if using template). If present but empty, will default to './index.css'. Optionally give a URL to load. * Standard props (common across all my components): * @property {string} baseVersion Static. The component version string (date updated). Also has a getter. * @property {number} _iCount Static. The count of instances of this component that weren't given an id. Creates a unique id as needed. * @property {boolean} uib True if UIBUILDER for Node-RED is loaded * @property {object} uibuilder Reference to loaded UIBUILDER for Node-RED client library if loaded (else undefined) * @property {function(string): Element} $ jQuery-like shadow dom selector * @property {function(string): NodeList} $$ jQuery-like shadow dom multi-selector * @property {boolean} connected False until connectedCallback finishes * @property {string} name Placeholder for the optional name attribute * @property {object} opts This components controllable options - get/set using the `config()` method * * @property {string} version Getter that returns the class version & baseVersion static strings. * Other props: * By default, all attributes are also created as properties * See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc */ // Guard allows this module to be imported in Node.js/SSR contexts (e.g. for // components that expose a pure renderToHTML export) without a DOM being present. // In a browser, HTMLElement is always defined so behaviour is unchanged. const _HTMLElement = typeof HTMLElement !== 'undefined' ? HTMLElement : class {} class TiBaseComponent extends _HTMLElement { /** Component version */ static baseVersion = '2025-09-20' /** Holds a count of how many instances of this component are on the page that don't have their own id * Used to ensure a unique id if needing to add one dynamically */ static _iCount = 0 /** Is UIBUILDER for Node-RED loaded? */ uib = !!window['uibuilder'] uibuilder = window['uibuilder'] /** Mini jQuery-like shadow dom selector (see constructor) * @type {function(string): Element} * @param {string} selector - A CSS selector to match the element within the shadow DOM. * @returns {Element} The first element that matches the specified selector. */ $ /** Mini jQuery-like shadow dom multi-selector (see constructor) * @type {function(string): NodeList} * @param {string} selector - A CSS selector to match the element within the shadow DOM. * @returns {NodeList} A STATIC list of all shadow dom elements that match the selector. */ $$ /** True when instance finishes connecting. * Allows initial calls of attributeChangedCallback to be * ignored if needed. */ connected = false /** Placeholder for the optional name attribute @type {string} */ name /** Runtime configuration settings @type {object} */ opts = {} /** Report the current component version string * @returns {string} The component version & base version as a string */ static get version() { // @ts-ignore return `${this.componentVersion} (Base: ${this.baseVersion})` } // get id() { // return this.id // } // set id(value) { // // this.id = value // console.log('>> SETTING ID:', value, this.id, this.getAttribute('id')) // } /** NB: Attributes not available here - use connectedCallback to reference */ constructor() { super() } /** OPTIONAL. Update runtime configuration, return complete config * @param {object|undefined} config If present, partial or full set of options. If undefined, fn returns the current full option settings * @returns {object} The full set of options */ config(config) { // Merge config but ensure that default states always present // if (config) this.opts = { ...this.opts, ...config } if (config) this.opts = TiBaseComponent.deepAssign(this.opts, config) return this.opts } /** Creates the $ and $$ fns that do css selections against the shadow dom */ createShadowSelectors() { this.$ = this.shadowRoot?.querySelector.bind(this.shadowRoot) this.$$ = this.shadowRoot?.querySelectorAll.bind(this.shadowRoot) } /** Utility object deep merge fn * @param {object} target Merge target object * @param {...object} sources 1 or more source objects to merge * @returns {object} Deep merged object */ static deepAssign(target, ...sources) { for (let source of sources) { // eslint-disable-line prefer-const for (let k in source) { // eslint-disable-line prefer-const const vs = source[k] const vt = target[k] if (Object(vs) == vs && Object(vt) === vt) { target[k] = TiBaseComponent.deepAssign(vt, vs) continue } target[k] = source[k] } } return target } /** Optionally apply an external linked style sheet for Shadow DOM (called from connectedCallback) * param {*} url The URL for the linked style sheet */ async doInheritStyles() { if (!this.shadowRoot) return if (!this.hasAttribute('inherit-style')) return let url = this.getAttribute('inherit-style') if (!url) url = './index.css' const linkEl = document.createElement('link') linkEl.setAttribute('type', 'text/css') linkEl.setAttribute('rel', 'stylesheet') linkEl.setAttribute('href', url) this.shadowRoot.appendChild(linkEl) console.info(`[${this.localName}] Inherit-style requested. Loading: "${url}"`) } /** Ensure that the component instance has a unique ID & check again if uib loaded */ ensureId() { // Check again if UIBUILDER for Node-RED is loaded this.uib = !!window['uibuilder'] if (!this.id) { // if (!this.name) this.name = this.getAttribute('name') // if (this.name) this.id = this.name.toLowerCase().replace(/\s/g, '_') // else this.id = `${this.localName}-${++this.constructor._iCount}` // @ts-ignore this.id = `${this.localName}-${++this.constructor._iCount}` } } /** Check if slot has meaningful content (not just whitespace) * @returns {boolean} True if slot has non-empty content */ hasSlotContent() { const slot = this.shadowRoot.querySelector('slot') const assignedNodes = slot.assignedNodes() return assignedNodes.some(node => { if (node.nodeType === Node.ELEMENT_NODE) { return true } if (node.nodeType === Node.TEXT_NODE) { return node.textContent.trim().length > 0 } return false }) } /** Attaches a new stylesheet before all other stylesheets in the light DOM * @param {string} cssText - CSS text to inject directly * @param {number} order - Optional order/priority for stylesheet placement. Lower numbers = higher priority (inserted first). Defaults to 0. * @returns {Element} The created or existing style element * @throws {Error} If cssText is not provided * @example * // Inject CSS text directly with default order * dataList.prependStylesheet('.custom { color: hsl(0, 100%, 50%); }') * * // Inject CSS with specific order (lower number = higher priority) * dataList.prependStylesheet('.base { font-size: 1rem; }', 1) * dataList.prependStylesheet('.critical { color: hsl(0, 100%, 50%); }', 0) */ prependStylesheet(cssText, order = 0) { if (!cssText) { throw new Error(`[${this.localName}] cssText must be provided`) } // TODO: - Add ability to append after other stylesheets (including those in the HTML head) // Check if same stylesheet already exists const existingStylesheet = this._findExistingStylesheet() // If so, return existing element instead of creating duplicate if (existingStylesheet) return existingStylesheet // Create style element with direct CSS text const styleElement = document.createElement('style') styleElement.textContent = cssText styleElement.setAttribute('data-component', this.localName) styleElement.setAttribute('data-order', order.toString()) // Prepend to light DOM (document head) with order consideration this._prependToDocumentHead(styleElement, order) return styleElement } /** Send a message to the Node-RED server via uibuilder if available * NB: These web components are NEVER dependent on Node-RED or uibuilder. * @param {string} evtName The event name to send * @param {*} data The data to send */ uibSend(evtName, data){ if (this.uib) { if (this.uibuilder.ioConnected) { this.uibuilder.send({ topic: `${this.localName}:${evtName}`, payload: data, id: this.id, name: this.name, }) } else { console.warn(`[${this.localName}] uibuilder not connected to server, cannot send:`, evtName, data) } } } // #region ---- Methods private to extended classes ---- // These are called from a class that extends this base class but should not be called directly by the user. /** Standardised connection. Call from the start of connectedCallback fn */ _connect() { // Make sure instance has an ID. Create an id from name or calculation if needed this.ensureId() // in base class // Apply parent styles from a stylesheet if required - only required if using an applied template this.doInheritStyles() // in base class // Listen for a uibuilder msg that is targetted at this instance of the component if (this.uib) this.uibuilder.onTopic(`${this.localName}::${this.id}`, this._uibMsgHandler.bind(this) ) } /** Standardised constructor. Keep after call to super() * @param {Node|string} template Nodes/string content that will be cloned into the shadow dom * @param {{mode:'open'|'closed',delegatesFocus:boolean}=} shadowOpts Options passed to attachShadow */ _construct(template, shadowOpts) { if (!template) return if (!shadowOpts) shadowOpts = { mode: 'open', delegatesFocus: true, } // Only attach the shadow dom if code and style isolation is needed this.attachShadow(shadowOpts) .append(template) // jQuery-like selectors but for the shadow. NB: Returns are STATIC not dynamic lists this.createShadowSelectors() // in base class } /** Standardised disconnection. Call from the END of disconnectedCallback fn */ _disconnect() { // @ts-ignore Remove optional uibuilder event listener document.removeEventListener(`uibuilder:msg:_ui:update:${this.id}`, this._uibMsgHandler ) // Keep at end. Let everyone know that an instance of the component has been disconnected this._event('disconnected') } /** Custom event dispacher `component-name:name` with detail data * @example * this._event('ready') * @example * this._event('ready', {age: 42, type: 'android'}) * * @param {string} evtName A name to give the event, added to the component-name separated with a : * @param {*=} data Optional data object to pass to event listeners via the evt.detail property */ _event(evtName, data) { this.dispatchEvent(new CustomEvent(`${this.localName}:${evtName}`, { bubbles: true, composed: true, detail: { id: this.id, name: this.name, data: data, }, } ) ) } /** Call from end of connectedCallback */ _ready() { this.connected = true this._event('connected') this._event('ready') } /** Handle a `${this.localName}::${this.id}` custom event * Each prop in the msg.payload is set as a prop on the component instance. * @param {object} msg A uibuilder message object */ _uibMsgHandler(msg) { // if msg.payload is not an object, ignore if (typeof msg.payload !== 'object') { console.warn(`[${this.localName}] Ignoring msg, payload is not an object:`, msg) return } // set properties from the msg Object.keys(msg.payload).forEach(key => { if (key.startsWith('_')) return let key2 = key.toLowerCase() if (key2.startsWith('data-')) key2 = 'data' // special case switch (key2) { case 'value': { this.setAttribute('value', msg.payload[key]) break } case 'class': { this.className = msg.payload[key] break } case 'style': { this.style.cssText = msg.payload[key] break } case 'data': { this.dataset[key.replace('data-', '')] = msg.payload[key] break } default: { this[key] = msg.payload[key] break } } }) } // #endregion ---- Methods private to the extended classes ---- // #region ---- Methods private to the base class only ---- /** Find existing component stylesheet with the same data-component attribute value * Assumes that the style element has a `data-component` attribute set to the component's local name * @returns {Element|null} Existing element or null if not found * @private */ _findExistingStylesheet() { const existing = document.head.querySelector( `style[data-component="${this.localName}"]` ) return existing } /** Helper method to prepend a style element to the document head with order consideration * @param {HTMLElement} styleElement - The style element to prepend * @param {number} order - The order/priority for placement (lower numbers = higher priority) * @private */ _prependToDocumentHead(styleElement, order) { const head = document.head // Find existing injected stylesheets to determine proper insertion point const existingComponentStyles = Array.from(head.querySelectorAll('style[data-component]')) if (existingComponentStyles.length === 0) { // No existing injected styles, insert at the very beginning const firstChild = head.firstChild if (firstChild) { head.insertBefore(styleElement, firstChild) } else { head.appendChild(styleElement) } return } // Find the correct position based on order let insertBefore = null for (const existing of existingComponentStyles) { const existingOrder = parseInt(existing.getAttribute('data-order') ?? '0', 10) if (order < existingOrder) { insertBefore = existing break } } if (insertBefore) { // Insert before the found element head.insertBefore(styleElement, insertBefore) } else { // Insert after all existing component styles but before non-component styles const lastInjected = existingComponentStyles[existingComponentStyles.length - 1] const nextSibling = lastInjected.nextSibling if (nextSibling) { head.insertBefore(styleElement, nextSibling) } else { head.appendChild(styleElement) } } } // #endregion ---- Methods private to the base class only ---- } // ---- end of Class ---- // // Make the class the default export so it can be used elsewhere export default TiBaseComponent // This is a library class so don't self-register, it is only for inclusion in actual components