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.
241 lines (214 loc) • 7.87 kB
JavaScript
/** A web component that displays page metadata.
* Metadata comes from makdown frontmatter, with some from the system.
*
* @example
* // HTML usage:
* <show-meta></show-meta>
*
* // Start collapsed:
* <show-meta closed></show-meta>
*
* // With custom metadata object:
* const el = document.querySelector('show-meta')
* el.metadata = { title: 'My Page', author: 'John Doe', created: '2026-01-01' }
*
* Copyright (c) 2026-2026 Julian Knight (Totally Information)
* Licensed under the Apache License, Version 2.0
*/
/** @type {string} Component tag name */
const componentName = 'show-meta'
/** @type {string} Component version */
const componentVersion = '1.0.0'
/** ShowMeta web component - displays metadata in a grid layout
* @class
* @augments HTMLElement
*/
class ShowMeta extends HTMLElement {
/** @type {object} Internal metadata storage */
#metadata = {}
#pageDataChangeHandler = null
/** Observed attributes list
* @returns {string[]} Attribute names to observe
*/
static get observedAttributes() {
return ['closed']
}
/** Component constructor */
constructor() {
super()
this.#metadata = {}
}
/** Called when observed attributes change
* @param {string} _name - Attribute name
* @param {string|null} _oldValue - Previous value
* @param {string|null} _newValue - New value
*/
attributeChangedCallback(_name, _oldValue, _newValue) {
this.#render()
}
/** Called when element is added to the DOM */
connectedCallback() {
// Watch for future changes to the metadata object and re-render when it changes
this.#pageDataChangeHandler = uibuilder.onChange('pageData', (newPageData) => {
// console.log(`🪲[show-meta] uibuilder.pageData has changed (From: ${newPageData.from}): `, newPageData)
this.metadata = newPageData ?? {}
})
// Also render immediately with current pageData if available.
// This handles the case where <show-meta> is inserted into the DOM
// AFTER pageData was already set (e.g. via innerHTML from uib-var),
// meaning the onChange listener above missed the initial change event.
const currentPageData = uibuilder.get('pageData')
if (currentPageData) {
this.metadata = currentPageData
}
}
/** Called when element is removed from the DOM */
disconnectedCallback() {
if (this.#pageDataChangeHandler) {
uibuilder.cancelChange('pageData', this.#pageDataChangeHandler)
}
this.#pageDataChangeHandler = null
}
/** Get the current metadata object
* @returns {object} The metadata object
*/
get metadata() {
return this.#metadata
}
/** Set the metadata object and re-render
* @param {object} value - The metadata object to display
*/
set metadata(value) {
if (typeof value === 'object' && value !== null) {
this.#metadata = value
this.#render()
}
}
/** Format a value for display
* @param {*} value - The value to format
* @returns {string} Formatted value as HTML string
*/
#formatValue(value) {
if (value === null || value === undefined) {
return '<em>Not set</em>'
}
if (Array.isArray(value)) {
if (value.length === 0) return '<em>None</em>'
if (window['uibuilder']) {
return uibuilder.syntaxHighlight(value)
}
return value
.map(v => `<span class="show-meta-tag">${this.#escapeHtml(String(v))}</span>`)
.join(' ')
}
if (typeof value === 'object') {
if (window['uibuilder']) {
return uibuilder.syntaxHighlight(value)
}
return `<pre>${this.#escapeHtml(JSON.stringify(value, null, 2))}</pre>`
}
if (typeof value === 'boolean') {
return value ? 'True' : 'False'
}
return this.#escapeHtml(String(value))
}
/** Escape HTML special characters to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
#escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
/** Format a field name for display (convert camelCase/snake_case to Title Case)
* @param {string} name - The field name
* @returns {string} Formatted field name
*/
#formatFieldName(name) {
return name
// Insert space before capitals (camelCase)
.replace(/([A-Z])/g, ' $1')
// Replace underscores with spaces
.replace(/_/g, ' ')
// Capitalize first letter of each word
.replace(/\b\w/g, c => c.toUpperCase())
.trim()
}
/** Render the component content */
#render() {
const meta = this.#metadata
const entries = Object.entries(meta)
const isOpen = !this.hasAttribute('closed')
const openAttr = isOpen ? ' open' : ''
if (entries.length === 0) {
this.innerHTML = `<article class="show-meta"><details${openAttr}><summary>Page Metadata</summary><p><em>No metadata available</em></p></details></article>`
return
}
// Filter out internal/private fields (starting with _ or containing 'body'/'content' which can be large)
const displayEntries = entries.filter(([key]) => {
return !key.startsWith('_')
&& !['body', 'content', 'htmlbody'].includes(key.toLowerCase())
})
const rows = displayEntries.map(([key, value]) => {
return `
<dt class="show-meta-label">${this.#formatFieldName(key)}</dt>
<dd class="show-meta-value">${this.#formatValue(value)}</dd>
`
}).join('')
this.innerHTML = /* html */`
<article class="show-meta">
<details${openAttr}>
<summary>Page Metadata</summary>
<dl class="show-meta-grid">
${rows}
</dl>
</details>
</article>
<style>
.show-meta {
font-size: 0.7em;
padding: 0 var(--border-pad);
summary {
font-size: 1.5em;
font-weight: bold;
cursor: pointer;
padding-bottom: 0;
margin-bottom: 0;
list-style: revert;
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0rem 1rem;
}
dt {
font-weight: bolder;
text-align: right;
}
dd {
margin: 0;
}
}
.show-meta-value pre {
margin: 0;
padding: 0.5rem;
border-radius: 0.25rem;
overflow-x: auto;
font-size: 0.875em;
}
.show-meta-tag {
display: inline-block;
padding: 0.125rem 0.5rem;
margin: 0.125rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
</style>
`
}
}
// Register the custom element
customElements.define(componentName, ShowMeta)
export { ShowMeta, componentName, componentVersion }
export default ShowMeta