Simple web component example <syntax-highlight>

If you saw my previous post, you know I've been learning about web components and js modules. If you use uibuilder, you will also know that I'm fond of including a msg display on the page that shows a formatted version of the message incoming from Node-RED.

So I thought it would be a great idea to combine things and here it is :grinning:

The web component given below needs no external dependencies and should be able to work with Dashboard and other UI tooling with minimal effort. Here I'm showing it used with the blank template in uibuilder. It shows that you need just 3 lines of code! You don't just have to use it for showing Node-RED msg's of course, you can use it to show any javascript object.

I am intending to introduce a mechanism into the next release of uibuilder that will facilitate the use of these types of components (you can already use the common folder). I will also add some simple components as standard. Watch out for more components to come, I may be on a roll!

index.html

Add this line:

<syntax-highlight>Latest msg from Node-RED:</syntax-highlight>

index.js

const {dumpObject} = await import('./syntax-highlight.js')

uibuilder.start()

uibuilder.onChange('msg', function (msg) {
    dumpObject(msg)
})

syntax-highlight.js

Create this file in the same folder as your index.html file. You can use the uibuilder editor or a code editor according to your preference.

/** Web component to show a JavaScript object as a highlighted box in the UI
 * @example HTML:
 *   <syntax-highlight>Latest msg from Node-RED:</syntax-highlight>
 * @example JavaScript:
 *   const {dumpObject} = await import('./components/syntax-highlight.js')
 *   dumpObject(msg)
 * @example Alternative JavaScript:
 *   await import('./components/syntax-highlight.js')
 *   document.getElementsByTagName('syntax-highlight')[0].json = msg
 */

const template = document.createElement('template')
template.innerHTML = `
    <style>
        :host {
            display: block;
            color:white;
            background-color:black;
            margin-top: 0.5rem;
            margin-bottom: 0.5rem;
            padding: 0.5rem;
        }
        pre {
            font-family: Consolas, "ui-monospace", "Lucida Console", monospace;
            white-space: pre;
            margin: 0;
        }
        .key {color:#ffbf35}
        .string {color:#5dff39;}
        .number {color:#70aeff;}
        .boolean {color:#b993ff;}
        .null {color:#93ffe4;}
        .undefined {color:#ff93c9;}
    </style>
    <slot></slot>
    <pre><i>No data</i></pre>
`

// return formatted HTML version of JSON object
const syntaxHighlight = function (json) {
    json = JSON.stringify(json, undefined, 4)
    json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    json = json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
        var 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>'
    })
    const myHtml = document.createElement('pre')
    myHtml.innerHTML = json
    return myHtml
} // --- End of syntaxHighlight --- //

export class SyntaxHighlight extends HTMLElement {

    constructor() {
        super()
        this.attachShadow({ mode: 'open', delegatesFocus: true })
            .appendChild(template.content.cloneNode(true))
    }

    set json(value) {
        //console.log(value, syntaxHighlight(value))
        this.shadowRoot.removeChild(this.shadowRoot.lastElementChild)
        this.shadowRoot.appendChild(syntaxHighlight(value))
    }

} // ---- End of SyntaxHighlight class definition ---- //

// Add the class as a new Custom Element to the window object
customElements.define('syntax-highlight', SyntaxHighlight)

/** Quick and dirty method to dump a JavaScript object to the ui
 * Will put the output to the first <syntax-highlight> tag on the page
 * @param {*} obj Object to dump
 * @param {number} [ref] Optional, default=0. Which tag to use? If >1 <syntax-highlight> tag is on the page.
 */
export let dumpObject = function showJson(obj, ref=0){
    try {
        const showMsg = document.getElementsByTagName('syntax-highlight')[ref]
        showMsg.json = obj
    } catch (e) {
        console.error('[syntax-highlight:dumpObject] Cannot show object - is there a <syntax-highlight> tag?')
    }
}
1 Like

Custom elements are a great step forward. A couple years ago i made a progressive web app(designed to be installed on mobile phones) and it was built with each component being encapsulated as a custom element. For each component, I separated them into 2 files, one for the generated html and the other for the exported class to keep things cleaner. I had always envisioned a dashboard that could take advantage of this modern feature. i haven't tried, but I think that each component could be responsible for making its own socket connection and subscribing to its relevant topic. My only problem was trying to work with stylesheets and the shadow dom. At the time, there were proposals to make things easier for styling, but i don't know what browser progress was made.

That might well be possible but would be relatively resource expensive I think. My approach is to keep uibuilder as the communications hub and have a way to have standard data communications into the components.

So I think we'll need a naming convention for the components so each component has a unique name. A unique ID for each instance of each component. With that, it should be fairly trivial to be able to communicate directly with the components without needing the UI designer to do anything at all. The uibuilderfe library can do it for you.

One step will, I think be to have a function that is capable of generating the set of component instances that make up your UI from a javascript object. So an initial page load would be sent all of the static interface (or indeed might retain it locally for performance) which is processed to create the initial UI. Then further objects could later be sent that would change the ui dynamically.

On top of that, each component would define a standard schema for its internal data - such as that needed for a table or a chart for example. So a message sent from Node-RED with the correct component instance ID and data structure would automatically update the correct component instance. Again, no code to write in the front-end.

With CSS variables (which do cascade inward) and templates which allow localised CSS and some control of the hosting element along with some control over slot formatting, it doesn't seem too bad. Though I've only just started on this journey so I don't know what horrors I'll find along the way.

I should have been more clear. Each component can make its own socket .io connection with forceNew: false, which reuses the existing connection. I use that when transporting large chunks of live streaming video with no delivery problems. I have never yet needed to use forceNew: true.

Hopefully it's better now than when i was playing with it. I vaguely remember having to keep the shadow dom open so that it could use the global styesheets. I didn't like having to insert a style block in each custom element.

How do you get each component to use Socket.IO client? At the moment, the uibuilderfe.js library initialises and uses the client. The web components, being javascript modules, MAY have access to uibuilder and therefore to the socket.io client but ONLY if the master js is NOT loaded as a module. If index.js is loaded as a module and itself then imports uibuilderfe.js, the uibuilder object is constrained to that module.

So I think that you have to pass into the component module a reference to the uibuilder object which is a bit of a pain.

Yeah sorry, i was just brainstorming so that you could explore other options before you are completely set in your code. The custom element can use its own properties/attributes and detect when they change, which could include the relevant socket connection info, topic, connection key, etc. I would run that connection initialization in the connectedCallback method (only called once when it is added to the dom) of the HTMLElement class. I guess I am assuming that it would have access to the global socket io object.

And it is appreciated, believe me :grinning:

It only detects changes on Attributes. Properties need a set function. Only properties can carry complex data. Attributes are all strings.

So I can set a property to pass the uibuilder object into the component but that makes the result rather fragile and certainly tightly coupled which I'm trying to avoid.

Since uibuilder already handles the comms and already has an event system to handle things like incoming messages, it is best, I believe to keep that model and simply pass the required data into the component. This eliminates the coupling - it allows the components to be used in other situations - with Dashboard for example, not just uibuilder.

What we need, and what I've argued for all along for whatever becomes the next Dashboard, is a set of data standards. Then the actual mechanics are all replaceable.

But that's the point, does a component actually need to understand the mechanics of the communications? I don't believe so. With my approach, the comms are kept in 1, specialist library and the UI components in their own libraries. No need to tightly couple the two.

And there is the rub. As you start to move into using JavaScript modules throughout, global is no longer as accessible. While that is harder to program around, it does have the advantage of forcing you to be more mindful of interactions between the components.

Good coversation by the way - I like being able to bounce ideas around and be forced to think through my assumptions :grinning:

I see what you are doing. You have a main communication hub that passes and distributes the data to each element. That should work good for most components.

I guess I was thinking along the lines of having each component being truly independent and could then connect on its own without being dependent on some other data management system. In my mp4 streaming dashboard ui node, i chose to send the connection details and then let the client side initiate the socket. io connection and request video chunks as opposed to pushing the video through the dashboard's comms.

1 Like

Yes, I can see that your way would work in certain situations. But in the case of communication with Node-RED, I still think that it makes the component relatively fragile. What happens if we wanted to update the comms with some new feature? We may end up having to change all the components.

So I think that in the case of uibuilder, the hub approach is best.

Interestingly, on a slightly different front, I think that I am now already seeing the limitations of web components. They seem to be great for simple things but poorly thought out for more complex components. The two that I've already created (html-include - where I borrowed a lot of ideas from another component, and syntax-highlight) work pretty well and are simple and comprehensible. But as soon as I start thinking about nested components (a set of table components for example) and more complex data passing and common CSS (which you already mentioned), then things get a LOT more complex very quickly.

So it seems to me as though the main potential benefits of web components may not really be achievable. I will probably move onto the next stage where I look at tools that help build components. So that the components can be distributed still as web components to remove any runtime dependencies but are composed using tools with more capability. LegoJS for example.

If that isn't enough, I'll have to look at higher-level frameworks again such as Lit.

I assume you have already looked at @cinhcet 's Web-component dashboard ? As I'm sure that was based on Lit node-red-contrib-component-dashboard (node) - Node-RED

1 Like

Thanks Dave. Yes, I've looked at that previously and he already had to rewrite some of it because he used Polymer originally. Which is why I'm slowly working through ideas and options to try and avoid lockin to a specific framework. Might be a vain hope. But it is a fun journey anyway.

Yes - very interesting thread. :popcorn:
Real life kicking my butt just now but still in the game.

2 Likes

The core of component-dashboard is not dependent on any framework.
Most widgets/components have zero dependencies.
Indeed, only one of the core widgets uses Lit. I would also not call Lit a framework, but rather some syntactic sugar to reduce boilerplate when writing web components.

The npm version is horribly outdated. I should update it, I think :slight_smile: Also, the current version on github GitHub - cinhcet/node-red-contrib-component-dashboard: Flexible dashboard for Node-RED based on web-components is a bit dated, since it uses the parcel-bundler 1.0, which has been replaced with parcel 2.0.

Apart from that, all the technology for communicating with Node-RED is there and a good set of basic widgets is provided.
Creating new components is super simple for the most part. Where it gets tricky is if you want to use an existing "normal" CSS/javascript framework within a web component. Especially using CSS frameworks is nearly impossible.

i was just reviewing my old (non-commented) code, and it looks like i skipped the usage of the shadow dom altogether. I see that my html elements are using classes that are styled using the global stylesheets. That may eliminate 1 hurdle.

An update to the web component - if anyone is paying attention you'll get a nice update :grinning:

There are now several ways to pass an object to the component.

However, for uibuilder fans, you can now have automatic displays of incoming and outgoing normal and control msgs simply by setting an attribute on the tag. Notice that all you need is 1 line of HTML and 1 line of script!

/** Zero dependency web component to show a JavaScript object as a highlighted box in the UI
 * Use with uibuilder as:
 *   @example html
 *     <!-- Shows the last incoming msg from Node-RED -->
 *     <syntax-highlight auto="msg">ā‡‡ Latest msg from Node-RED:</syntax-highlight>
 *     <!-- Shows the last outgoing msg to Node-RED -->
 *     <!-- <syntax-highlight auto="sentMsg">ā‡‰ Latest msg to Node-RED:</syntax-highlight> -->
 *     <!-- Shows the last incoming control msg from Node-RED -->
 *     <!-- <syntax-highlight auto="ctrl">ā‡ Latest control msg from Node-RED:</syntax-highlight> -->
 *     <!-- Shows the last outgoing control msg to Node-RED -->
 *     <!-- <syntax-highlight auto="sentCtrlMsg">ā‡’ Latest control msg to Node-RED:</syntax-highlight> -->
 *   @example JavaScript
 *     import('./syntax-highlight')
 * Or use as
 * @example HTML:
 *   <syntax-highlight>Latest msg from Node-RED:</syntax-highlight>
 * @example JavaScript:
 *   const {dumpObject} = await import('./components/syntax-highlight.js')
 *   dumpObject(msg)
 * @example Alternative JavaScript:
 *   await import('./components/syntax-highlight.js')
 *   document.getElementsByTagName('syntax-highlight')[0].json = msg
 * @example Other update methods
 *   const showMsg = document.getElementsByTagName('syntax-highlight')[0]
 *   showMsg.dispatchEvent(new CustomEvent('new-msg', { bubbles: false, detail: msg }))
 *   showMsg.evt('new-msg', msg)
 * 
 */
/*
  Copyright (c) 2022 Julian Knight (Totally Information)

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

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

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

const template = document.createElement('template')
template.innerHTML = `
    <style>
        :host {
            display: block;
            color:white;
            background-color:black;
            margin-top: 0.5rem;
            margin-bottom: 0.5rem;
            padding: 0.5rem;
        }
        pre {
            font-family: Consolas, "ui-monospace", "Lucida Console", monospace;
            white-space: pre;
            margin: 0;
        }
        .key {color:#ffbf35}
        .string {color:#5dff39;}
        .number {color:#70aeff;}
        .boolean {color:#b993ff;}
        .null {color:#93ffe4;}
        .undefined {color:#ff93c9;}
    </style>
    <slot></slot>
    <pre><i>No data</i></pre>
`

// return formatted HTML version of JSON object
const returnHighlight = function (json) {
    json = JSON.stringify(json, undefined, 4)
    json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    json = json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
        var 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>'
    })
    const myHtml = document.createElement('pre')
    myHtml.innerHTML = json
    return myHtml
} // --- End of syntaxHighlight --- //

export class SyntaxHighlight extends HTMLElement {

    constructor() {
        super()
        this.attachShadow({ mode: 'open', delegatesFocus: true })
            .append(template.content.cloneNode(true))

        this.addEventListener('new-msg', evt => {
            this.json = evt.detail
        })

        // Get a reference to the uibuilder FE client library if possible
        try {
            this.uibuilder = window.uibuilder
        } catch (e) {
            this.uibuilder = undefined
        }
    }

    set json(value) {
        //console.log(value, syntaxHighlight(value))
        this.shadowRoot.removeChild(this.shadowRoot.lastElementChild)
        this.shadowRoot.appendChild(returnHighlight(value))
    }

    evt(evtName, evtData) {
        this.dispatchEvent(new CustomEvent(evtName, { bubbles: false, detail: evtData }))
    }

    static get observedAttributes() { return [
        'auto', 
    ]}

    attributeChangedCallback(name, oldVal, newVal) {
        if ( oldVal === newVal ) return

        // auto attrib allows msgs to/from Node-RED to be shown as long as window.uibuilder is available
        if ( name === 'auto' && this.uibuilder ) {
            switch (newVal) {
                case 'message':
                case 'msg': {
                    this.uibuilder.onChange('msg', (msg)=> {
                        this.json = msg
                    })
                    break
                }

                case 'ctrl':
                case 'control':
                case 'ctrlMsg': {
                    this.uibuilder.onChange('ctrlMsg', (msg)=> {
                        this.json = msg
                    })
                    break
                }

                case 'sent':
                case 'sentmsg':
                case 'sentMsg': {
                    this.uibuilder.onChange('sentMsg', (msg)=> {
                        this.json = msg
                    })
                    break
                }

                case 'sentctrlmsg':
                case 'sentCtrlMsg': {
                    this.uibuilder.onChange('sentCtrlMsg', (msg)=> {
                        this.json = msg
                    })
                    break
                }

                default: {
                    break
                }
            } // -- end of switch --
        } // -- end of if name=auto --
        
    } // --- end of attributeChangedCallback --- //

} // ---- End of SyntaxHighlight class definition ---- //

// Add the class as a new Custom Element to the window object
customElements.define('syntax-highlight', SyntaxHighlight)

// document.addEventListener('new-msg', evt => {
//     console.log('GOT A MSG', evt)
// })

/** Quick and dirty method to dump a JavaScript object to the ui
 * Will put the output to the first <syntax-highlight> tag on the page
 * @param {*} obj Object to dump
 * @param {number} [ref] Optional, default=0. Which tag to use? If >1 <syntax-highlight> tag is on the page.
 */
export const dumpObject = function showJson(obj, ref = 0) {
    try {
        const showMsg = document.getElementsByTagName('syntax-highlight')[ref]
        showMsg.json = obj
    } catch (e) {
        console.error('[syntax-highlight:dumpObject] Cannot show object - is there a <syntax-highlight> tag?')
    }
}

// Consider using a std to expose default functionality for a component
export const syntaxHighlight = dumpObject
2 Likes