node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.
1,164 lines (1,051 loc) • 74.4 kB
JavaScript
/* eslint-disable jsdoc/require-returns */
/* eslint-disable jsdoc/check-tag-names */
/* eslint-disable @stylistic/max-statements-per-line */
/* eslint-disable jsdoc/valid-types */
// @ts-nocheck
/** A zero-dependency web component that renders JSON/JavaScript data as an
* interactive, collapsible, searchable tree with syntax highlighting.
*
* Also exports a pure {@link renderToHTML} function for SSR/Node.js use —
* it produces an HTML string without touching the DOM.
*
* Version See COMPONENT_VERSION
*/
/*
Copyright (c) 2026-2026 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.
*/
import TiBaseComponent from '../ti-base-component.mjs'
// Max number of object/array entries to render per node before truncating with "…". This is a safeguard against rendering huge objects that can freeze the browser. The total node budget is maxChildren * 500, which allows for some nested expansion while still preventing runaway rendering.
const CONFIGMAXCHILDREN = 1000
// Approx max total nodes to render across the entire tree (including nested children) before truncating with "… node limit reached". This is a hard cap to prevent freezing on extremely large or deeply nested objects, even if maxChildren is set high or unlimited. The default allows for some nested expansion while still providing a safety net against runaway rendering.
const CONFIGMAXTOTAL = 50000
// ── Component version ─────────────────────────────────────────────────────────
/** Date-based component version @type {string} */
const COMPONENT_VERSION = '2026-05-09'
// ── CSS injected into the document head (light DOM, scoped to json-viewer) ────
const STYLES = /* css */`
json-viewer {
display: block;
font-family: var(--jv-font-family, 'Cascadia Code', 'Fira Code', 'Consolas', 'Monaco', monospace);
font-size: var(--jv-font-size, 0.875rem);
line-height: 1.5;
background: var(--jv-bg, transparent);
color: var(--jv-color, inherit);
overflow: auto;
}
.jv-tree-wrap {
display: flow-root;
}
.jv-controls {
float: right;
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0 0.2rem 0.4rem;
}
.jv-search-row {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid hsl(0 0% 50% / 0.2);
}
.jv-search {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.2rem 0.4rem;
border: 1px solid hsl(0 0% 60%);
border-radius: 3px;
font-family: inherit;
font-size: inherit;
background: var(--jv-bg, transparent);
color: var(--jv-color, inherit);
}
.jv-btn {
padding: 0.15rem 0.5rem;
border: 1px solid hsl(0 0% 60%);
border-radius: 3px;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 0.8em;
white-space: nowrap;
}
.jv-btn:hover, .jv-btn:focus-visible {
background: hsl(0 0% 50% / 0.15);
outline: 2px solid hsl(200 100% 50%);
outline-offset: 1px;
}
.jv-tree { padding: 0.25rem 0.5rem; }
.jv-node {
padding-left: var(--jv-indent, 1.25rem);
outline: none;
}
.jv-node.jv-leaf:focus-visible,
details.jv-node > summary:focus-visible {
outline: 2px solid hsl(200 100% 50%);
outline-offset: 1px;
border-radius: 2px;
}
/* <details>/<summary> expand/collapse — no JavaScript required */
details.jv-node > summary {
list-style: none;
margin-left: calc(-1 * var(--jv-indent, 1.25rem));
cursor: pointer;
display: block;
}
details.jv-node > summary::-webkit-details-marker { display: none; }
details.jv-node > summary::marker { content: ''; }
details.jv-node > summary::before {
content: '▼';
display: inline-block;
width: var(--jv-indent, 1.25rem);
text-align: center;
font-size: 0.65em;
color: var(--jv-toggle-color, hsl(0 0% 55%));
user-select: none;
}
details.jv-node:not([open]) > summary::before { content: '▶'; }
details.jv-node[open] > summary .jv-hint { display: none; }
details.jv-node:not([open]) > summary .jv-hint { display: inline; }
.jv-key { color: var(--jv-key-color, hsl(230 60% 45%)); cursor: pointer; }
.jv-key:hover { text-decoration: underline; }
.jv-key[contenteditable='true'] {
border-bottom: 1px dashed hsl(0 0% 60%);
cursor: text;
outline: none;
min-width: 2ch;
text-decoration: none !important;
}
.jv-key[contenteditable='true']:focus { border-bottom-color: hsl(200 100% 50%); }
.jv-sep { color: hsl(0 0% 50%); margin: 0 0.1em; }
.jv-string { color: var(--jv-string-color, hsl(10 80% 40%)); }
.jv-val.jv-string::before,
.jv-val.jv-string::after { content: '"'; }
.jv-number { color: var(--jv-number-color, hsl(260 70% 50%)); }
.jv-val.jv-bigint { color: var(--jv-number-color, hsl(260 70% 50%)); }
.jv-val.jv-bigint::after { content: 'n'; }
.jv-boolean { color: var(--jv-boolean-color, hsl(200 80% 40%)); font-weight: bold; }
.jv-null,
.jv-undefined { color: var(--jv-null-color, hsl(0 0% 55%)); font-style: italic; }
.jv-special,
.jv-circular { color: var(--jv-special-color, hsl(30 80% 40%)); font-style: italic; }
.jv-regexp { color: var(--jv-regexp-color, hsl(330 70% 45%)); }
.jv-bracket,
.jv-bracket-close { color: hsl(0 0% 45%); }
.jv-hint { color: hsl(0 0% 60%); font-size: 0.85em; margin-left: 0.3em; }
.jv-copy {
appearance: none;
-webkit-appearance: none;
opacity: 0;
border: none;
box-shadow: none;
background: transparent;
cursor: pointer;
font-size: 0.8em;
padding: 0 0.15rem;
margin: 0;
color: hsl(0 0% 60%);
display: inline-flex;
align-items: center;
line-height: 1;
transition: opacity 0.15s;
border-radius: 2px;
}
.jv-node:hover > .jv-copy,
.jv-node.jv-leaf:focus-visible > .jv-copy,
details.jv-node:focus-within > .jv-copy { opacity: 1; }
.jv-copy:hover,
.jv-copy:focus-visible { color: hsl(200 100% 40%); opacity: 1; outline: 1px solid hsl(200 100% 50%); }
.jv-add {
appearance: none;
-webkit-appearance: none;
opacity: 0;
border: none;
box-shadow: none;
background: transparent;
cursor: pointer;
font-size: 0.85em;
font-weight: bold;
padding: 0 0.2rem;
margin: 0 0.1rem;
color: hsl(120 50% 40%);
display: inline-flex;
align-items: center;
line-height: 1;
transition: opacity 0.15s;
border-radius: 2px;
vertical-align: middle;
}
details.jv-node:not([open]) > summary .jv-add { display: none !important; }
details.jv-node[open] > summary:hover .jv-add,
details.jv-node[open] > summary:focus-visible .jv-add { opacity: 1; }
.jv-add:hover,
.jv-add:focus-visible { color: hsl(120 70% 30%); opacity: 1; outline: 1px solid hsl(120 70% 50%); }
.jv-delete {
appearance: none;
-webkit-appearance: none;
opacity: 0;
border: none;
box-shadow: none;
background: transparent;
cursor: pointer;
font-size: 0.8em;
padding: 0 0.15rem;
margin: 0;
color: hsl(0 60% 55%);
display: inline-flex;
align-items: center;
line-height: 1;
transition: opacity 0.15s;
border-radius: 2px;
}
/* Leaf nodes: delete is a direct child of the node div */
.jv-node.jv-leaf:hover > .jv-delete,
.jv-node.jv-leaf:focus-visible > .jv-delete { opacity: 1; }
/* Expandable nodes: delete is inside <summary> */
details.jv-node > summary:hover .jv-delete,
details.jv-node > summary:focus-visible .jv-delete { opacity: 1; }
.jv-delete:hover,
.jv-delete:focus-visible { color: hsl(0 80% 45%); opacity: 1; outline: 1px solid hsl(0 80% 50%); }
.jv-children { padding-left: 0; }
.jv-hl { background: var(--jv-hl-bg, hsl(50 100% 70% / 0.6)); border-radius: 2px; }
.jv-hidden { display: none !important; }
.jv-truncated {
color: var(--jv-truncated-color, hsl(0 0% 55%));
font-style: italic;
font-size: 0.85em;
cursor: default;
user-select: none;
}
.jv-val[contenteditable='true'] {
border-bottom: 1px dashed hsl(0 0% 60%);
cursor: text;
outline: none;
min-width: 2ch;
}
.jv-val[contenteditable='true']:focus { border-bottom-color: hsl(200 100% 50%); }
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.jv-key { color: var(--jv-key-color, hsl(220 80% 75%)); }
.jv-string { color: var(--jv-string-color, hsl(30 90% 65%)); }
.jv-number { color: var(--jv-number-color, hsl(270 80% 75%)); }
.jv-val.jv-bigint { color: var(--jv-number-color, hsl(270 80% 75%)); }
.jv-boolean { color: var(--jv-boolean-color, hsl(200 80% 70%)); }
.jv-null,
.jv-undefined { color: var(--jv-null-color, hsl(0 0% 60%)); }
.jv-special,
.jv-circular { color: var(--jv-special-color, hsl(40 80% 65%)); }
.jv-regexp { color: var(--jv-regexp-color, hsl(330 80% 70%)); }
.jv-truncated { color: var(--jv-truncated-color, hsl(0 0% 60%)); }
.jv-bracket,
.jv-bracket-close { color: hsl(0 0% 65%); }
details.jv-node > summary::before { color: var(--jv-toggle-color, hsl(0 0% 70%)); }
}
/* Print styles: expand everything and hide controls */
@media print {
.jv-controls { display: none; }
.jv-search-row { display: none; }
details.jv-node > :not(summary) { display: block !important; }
details.jv-node > summary .jv-hint { display: none !important; }
.jv-copy { display: none; }
.jv-add { display: none; }
.jv-delete { display: none; }
}
`
// ── Pure utility functions (no DOM) ───────────────────────────────────────────
/**
* Escape a string for safe insertion into HTML content or attribute values.
* @param {*} str - Value to escape (coerced to string)
* @returns {string} HTML-safe string
*/
function escHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
/**
* Return a normalised type label for any JavaScript value.
* NB: If updating this, consider updating the equivalent in tilib.cjs
* @param {*} value - Any JavaScript value
* @returns {string} Type label
*/
function typeOf(value) {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'string' || value instanceof String) return 'string'
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'symbol') return 'symbol'
if (typeof value === 'bigint') return 'bigint'
if (typeof value === 'function') return 'function'
if (Number.isFinite(value)) return 'number'
if (Number.isNaN(value)) return 'nan'
if (Array.isArray(value)) return 'array'
if (value instanceof Date) return 'date'
if (value instanceof RegExp) return 'regexp'
if (value instanceof Promise) return 'promise'
if (value instanceof Error) return 'error'
if (value instanceof Map) return 'map'
if (value instanceof Set) return 'set'
if (value instanceof WeakMap) return 'weakmap'
if (value instanceof WeakSet) return 'weakset'
if (value instanceof ArrayBuffer) return 'arraybuffer'
if (ArrayBuffer.isView(value)) return 'typedarray'
if (value instanceof URL || value instanceof URLSearchParams) return 'urllike'
if (!Number.isFinite(value) && typeof value === 'number') return 'infinity'
return typeof value
}
/**
* Render a scalar (leaf) value as a typed HTML span.
* @param {*} val - The primitive value to render
* @param {string} type - Pre-computed {@link typeOf} result
* @param {boolean} [editable] - If true, render content as plain text for editing. Default = false
* @returns {string} HTML span string
*/
function renderLeafValue(val, type, editable = false) {
// Editable scalars: show raw text without HTML formatting so the user can type freely.
// bigint strips the trailing 'n'; null/undefined show their literal text.
if (editable && (type === 'string' || type === 'number' || type === 'boolean' || type === 'bigint' || type === 'null' || type === 'undefined')) {
if (type === 'null') return 'null'
if (type === 'undefined') return 'undefined'
return escHtml(String(val))
}
switch (type) {
case 'string':
return escHtml(val)
case 'number':
return escHtml(String(val))
case 'bigint':
return escHtml(String(val))
case 'boolean':
return String(val)
case 'null':
return 'null'
case 'undefined':
return 'undefined'
case 'date':
return escHtml(`[Date: ${val.toISOString()}]`)
case 'nan':
return `[NaN]`
case 'regexp':
return escHtml(String(val))
case 'function': {
const fnName = val.name || 'anonymous'
const kindMap = { AsyncFunction: 'async', GeneratorFunction: 'generator', AsyncGeneratorFunction: 'async-generator', }
const fnKind = kindMap[val.constructor.name] ?? '' // std sync fns will have empty string
const src = val.toString()
.trimStart()
.slice(0, 20)
const arrow = !src.startsWith('function') && !src.startsWith('async function') ? 'arrow ' : ''
const fnType = fnKind || arrow ? `{${`${arrow}${fnKind}`.trim()}}` : ''
return escHtml(`[f ${fnName} ${fnType} ]`)
}
case 'error':
return escHtml(`[${val.name}: ${val.message}]`)
case 'urllike':
return escHtml(`[URL: ${val.toString()}]`)
case 'symbol': {
const desc = val.description !== undefined ? escHtml(val.description) : ''
return `Symbol(${desc})`
}
case 'weakmap':
return '[WeakMap]'
case 'weakset':
return '[WeakSet]'
case 'arraybuffer':
return escHtml(JSON.stringify(Array.from(new Uint8Array(val))))
case 'typedarray':
return escHtml(JSON.stringify(Array.from(val)))
default:
return `[${escHtml(type)}]`
}
}
/**
* Build a human-readable item-count summary for a collapsed object or array node.
* @param {object|Array} val - Object or array value
* @param {'object'|'array'} type - Value type
* @returns {string} E.g. "3 props" or "5 items"
*/
function countLabel(val, type) {
const n = (type === 'map' || type === 'set') ? val.size : type === 'array' ? val.length : Object.keys(val).length
const noun = (type === 'array' || type === 'set')
? (n === 1 ? 'item' : 'items')
: type === 'map'
? (n === 1 ? 'entry' : 'entries')
: (n === 1 ? 'prop' : 'props')
return `${n} ${noun}`
}
/**
* Build a dot-notation path string for a child node.
* Uses bracket notation for keys that contain non-identifier characters.
* @param {string} parentPath - The parent node's path (empty string for root)
* @param {string|number} key - Current key name or array index
* @param {'object'|'array'} parentType - The parent container type
* @returns {string} Child path string (e.g. "a.b[2].c")
*/
function buildPath(parentPath, key, parentType) {
if (parentPath === '') return String(key)
if (parentType === 'array') return `${parentPath}[${key}]`
// Keys containing non-identifier chars require bracket notation
if (/[^a-zA-Z0-9_$]/.test(String(key))) {
return `${parentPath}["${String(key)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')}"]`
}
return `${parentPath}.${key}`
}
/**
* Parse a raw input into a JavaScript value.
* Accepts: any JS value, a JSON string, or a pre-parsed object.
* Non-JSON strings are returned as-is.
* @param {*} input - The input to parse
* @returns {*} The parsed (or pass-through) JavaScript value
*/
function parseInput(input) {
if (typeof input === 'string') {
try { return JSON.parse(input) } catch (_) { /* not JSON — return as plain string */ }
}
return input
}
/**
* Format a Map key as a display-safe string label for use as a node key.
* @param {*} k - The Map key value
* @returns {string} Display-safe string label
*/
function mapKeyLabel(k) {
if (k === null) return 'null'
if (k === undefined) return 'undefined'
if (typeof k === 'string') return k
if (typeof k === 'number' || typeof k === 'boolean' || typeof k === 'bigint') return String(k)
if (k instanceof RegExp) return String(k)
return `(${typeOf(k)})`
}
// ── HTML renderer (pure / no DOM) ─────────────────────────────────────────────
/**
* @typedef {object} RenderNodeOpts
* @property {number} maxDepth - Max depth to auto-expand (0 = collapse all)
* @property {boolean} collapsed - Force all expandable nodes collapsed
* @property {boolean} editable - Allow inline editing of scalar leaf values
* @property {boolean} interactive - Render interactive elements (copy buttons, key path links)
* @property {string|number|null} key - Key label for this node (null = no key shown)
* @property {string} path - Dot-notation path to this node
* @property {number} depth - Current recursion depth (0 = root)
* @property {WeakSet<object>} seen - Ancestor object set for circular-ref detection
* @property {string|null} parentType - Type of the parent container (null at root)
* @property {number} maxChildren - Max children to render per expandable node (0 = unlimited)
* @property {{ remaining: number }} budget - Shared mutable node budget; rendering stops when exhausted
*/
/**
* Recursively render a JavaScript value as an HTML string.
* This is a **pure function** — it performs no DOM access and is safe for SSR/Node.js use.
*
* Circular reference detection uses a DFS ancestor-set strategy: an object is added to
* `seen` before its children are rendered and removed afterwards. This allows the same
* object to appear in multiple sibling branches (shared reference) while still correctly
* detecting true cycles (where a descendant references an ancestor).
*
* @param {*} val - The value to render
* @param {RenderNodeOpts} opts - Rendering options
* @returns {string} HTML string for this node and all its descendants
*/
function renderNode(val, opts) {
const { maxDepth, collapsed, editable, interactive, key, path, depth, seen, parentType, maxChildren, budget, } = opts
const type = typeOf(val)
const isExpandable = type === 'object' || type === 'array' || type === 'map' || type === 'set'
const safeKey = key !== null && key !== undefined ? String(key) : null
const pathAttr = escHtml(path !== '' ? path : '(root)')
const displayKey = safeKey !== null ? escHtml(safeKey) : null
// Object keys (string keys, not numeric array indices) within plain objects are renameable
const isObjectKey = key !== null && key !== undefined && typeof key !== 'number' && parentType === 'object'
const keyEditable = editable && interactive && isObjectKey
// Node budget guard: stop rendering if the total node limit is exhausted
if (budget.remaining <= 0) {
return `<div class="jv-node jv-leaf jv-truncated" role="treeitem" tabindex="-1" data-jv-path="${pathAttr}" data-jv-type="truncated">… node limit reached</div>`
}
budget.remaining--
// Leaf/circular key: in interactive mode clicking copies the path; in editable mode clicking focuses for rename
const keyHtml = displayKey !== null
? keyEditable
? `<span class="jv-key" contenteditable="true" spellcheck="false" data-jv-key-editable="true" data-jv-path="${pathAttr}" aria-label="Edit key name">${displayKey}</span><span class="jv-sep">:</span> `
: interactive
? `<span class="jv-key" data-jv-copy="path" title="Copy path: ${pathAttr}">${displayKey}</span><span class="jv-sep">:</span> `
: `<span class="jv-key">${displayKey}</span><span class="jv-sep">:</span> `
: ''
// Expandable key: clicking <summary> toggles; in editable mode the key span is renameable
const expandKeyHtml = displayKey !== null
? keyEditable
? `<span class="jv-key" contenteditable="true" spellcheck="false" data-jv-key-editable="true" data-jv-path="${pathAttr}" aria-label="Edit key name">${displayKey}</span><span class="jv-sep">:</span> `
: `<span class="jv-key">${displayKey}</span><span class="jv-sep">:</span> `
: ''
// ── Circular reference guard ─────────────────────────────────────────────
if (isExpandable) {
if (seen.has(val)) {
return (
`<div class="jv-node jv-leaf jv-circular" role="treeitem" tabindex="0" data-jv-path="${pathAttr}" data-jv-type="${type}">`
+ `${keyHtml}<span class="jv-circular" title="Circular reference">[Circular \u21ba]</span>`
+ (interactive ? `<button class="jv-copy" data-jv-copy="path" aria-label="Copy path to clipboard" title="Copy path to clipboard" tabindex="-1">\u2398</button>` : '')
+ `</div>`
)
}
seen.add(val)
}
// ── Leaf node ────────────────────────────────────────────────────────────
if (!isExpandable) {
const canEdit = editable && (type === 'string' || type === 'number' || type === 'boolean' || type === 'bigint' || type === 'null' || type === 'undefined')
const valContent = renderLeafValue(val, type, canEdit)
const editAttrs = canEdit
? ` contenteditable="true" spellcheck="false" data-jv-editable="true" data-jv-type="${type}" aria-label="Edit ${type} value"`
: ''
const valHtml = `<span class="jv-val jv-${type}"${editAttrs}>${valContent}</span>`
return (
`<div class="jv-node jv-leaf jv-${type}" role="treeitem" tabindex="0" data-jv-path="${pathAttr}" data-jv-type="${type}">`
+ `${keyHtml}${valHtml}`
+ (interactive ? `<button class="jv-copy" data-jv-copy="value" aria-label="Copy value to clipboard" title="Copy value to clipboard" tabindex="-1">\u2398</button>` : '')
+ (interactive && editable ? `<button class="jv-delete" aria-label="Delete entry" title="Delete entry" tabindex="-1">\u00D7</button>` : '')
+ `</div>`
)
}
// ── Expandable node (object, array, Map or Set) ──────────────────────────
const isOpen = !collapsed && depth < maxDepth
const openAttr = isOpen ? ' open' : ''
const bracketOpen = (type === 'array' || type === 'set') ? '[' : '{'
const bracketClose = (type === 'array' || type === 'set') ? ']' : '}'
const typePrefix = type === 'map' ? 'Map' : type === 'set' ? 'Set' : ''
const hint = countLabel(val, type)
// Enumerate children. Maps use index-keyed value entries (map key displayed as label).
// Sets and arrays use numeric indices. Objects use string keys.
const mapKeyArr = type === 'map' ? Array.from(val.keys()) : null
const allEntries = (type === 'array' || type === 'set')
? Array.from(val, (v, i) => /** @type {[number, *]} */ ([i, v]))
: type === 'map'
? Array.from(val.values()).map((v, i) => /** @type {[number, *]} */ ([i, v]))
: Object.entries(val)
// Per-container truncation
const hiddenCount = (maxChildren > 0 && allEntries.length > maxChildren) ? allEntries.length - maxChildren : 0
const entries = hiddenCount > 0 ? allEntries.slice(0, maxChildren) : allEntries
let truncationHtml = ''
if (hiddenCount > 0) {
const noun = (type === 'array' || type === 'set') ? (hiddenCount === 1 ? 'item' : 'items') : (hiddenCount === 1 ? 'prop' : 'props')
truncationHtml = `<div class="jv-node jv-leaf jv-truncated" role="treeitem" tabindex="-1">… ${hiddenCount} more ${noun} not shown (max-children=${maxChildren})</div>`
}
// Render children sequentially — DFS order is required for circular-ref tracking
const childrenHtml = entries.map(([k, v]) => {
const childKey = type === 'map' ? mapKeyLabel(mapKeyArr[k]) : k
return renderNode(v, {
...opts,
key: childKey,
path: buildPath(path, k, (type === 'map' || type === 'set') ? 'array' : type),
depth: depth + 1,
parentType: type,
})
}).join('') + truncationHtml
// Remove from ancestor set after subtree is rendered so siblings can share the same object
seen.delete(val)
return (
`<details class="jv-node jv-${type}"${openAttr} data-jv-path="${pathAttr}" data-jv-type="${type}">`
+ `<summary>${expandKeyHtml}<span class="jv-bracket">${typePrefix}${bracketOpen}</span>${interactive && editable && (type === 'object' || type === 'array') ? `<button class="jv-add" aria-label="Add entry to ${escHtml(type)}" title="Add entry" tabindex="-1">\u002B</button>` : ''}${interactive && editable && depth > 0 ? `<button class="jv-delete" aria-label="Delete entry" title="Delete entry" tabindex="-1">\u00D7</button>` : ''}<span class="jv-hint"> ${bracketClose} ${hint}</span></summary>`
+ `<div class="jv-children" role="group">${childrenHtml}</div>`
+ `<span class="jv-bracket-close">${bracketClose}</span>`
+ (interactive ? `<button class="jv-copy" data-jv-copy="value" aria-label="Copy value as JSON to clipboard" title="Copy value as JSON to clipboard" tabindex="-1">\u2398</button>` : '')
+ `</details>`
)
}
/**
* Render any JavaScript value to a self-contained HTML string.
*
* This is a **pure function** — it performs no DOM manipulation and is safe for use
* in Node.js / SSR contexts. Import only this export when a browser environment is
* not available.
*
* @param {*} data - Data to render: any JS value, a JSON string, or a plain object.
* @param {object} [opts] - Rendering options
* @param {number} [opts.maxDepth] - Maximum depth to auto-expand on first render. Default=2
* @param {boolean} [opts.collapsed] - Collapse all expandable nodes on first render. Default=false
* @param {boolean} [opts.editable] - Allow inline editing of scalar leaf values. Default=false
* @param {boolean} [opts.includeStyles] - Prepend a `<style>` tag containing the component CSS. Default=true
* @param {number} [opts.maxChildren] - Max children rendered per container; excess shows a notice. Default=100 (0=unlimited)
* @returns {string} Self-contained HTML string (styles + toolbar + tree)
*
* @example
* // SSR / Node.js usage
* import { renderToHTML } from './json-viewer.mjs'
* const html = renderToHTML({ hello: 'world' }, { maxDepth: 3 })
*/
export function renderToHTML(data, opts = {}) {
const maxDepth = typeof opts.maxDepth === 'number' ? Math.max(0, opts.maxDepth) : 2
const collapsed = !!opts.collapsed
const editable = !!opts.editable
const interactive = opts.interactive === true
const includeStyles = opts.includeStyles !== false
const maxChildren = typeof opts.maxChildren === 'number' ? Math.max(0, opts.maxChildren) : CONFIGMAXCHILDREN
const budget = { remaining: maxChildren > 0 ? maxChildren * 500 : CONFIGMAXTOTAL, }
const value = parseInput(data)
const searchHtml = interactive
? (`<div class="jv-search-row jv-hidden" role="search">`
+ `<input type="search" class="jv-search" placeholder="Search keys or values\u2026" aria-label="Search JSON keys and values">`
+ `</div>`)
: ''
const controlsHtml = interactive
? (`<div class="jv-controls" role="toolbar" aria-label="JSON viewer controls">`
+ `<button class="jv-btn jv-collapse-all" aria-label="Collapse all nodes" title="Collapse all">\u229f</button>`
+ `<button class="jv-btn jv-expand-all" aria-label="Expand all nodes" title="Expand all">\u229e</button>`
+ `<button class="jv-btn jv-search-toggle" aria-label="Toggle search" title="Search" aria-expanded="false">\uD83D\uDD0D</button>`
+ `</div>`)
: ''
const treeHtml = renderNode(value, {
maxDepth,
collapsed,
editable,
interactive,
key: null,
path: '',
depth: 0,
seen: new WeakSet(),
parentType: null,
maxChildren,
budget,
})
const styleTag = includeStyles ? `<style>${STYLES}</style>` : ''
return `${styleTag}${searchHtml}<div class="jv-tree-wrap">${controlsHtml}<div class="jv-tree" role="tree" aria-label="JSON data tree">${treeHtml}</div></div>`
}
// ── Web Component (browser only) ──────────────────────────────────────────────
/**
* @class
* @augments TiBaseComponent
* @description Interactive JSON viewer web component. Renders JSON/JS data as a
* collapsible, searchable, syntax-highlighted tree using the light DOM.
*
* @element json-viewer
* @memberOf Live
*
* METHODS FROM BASE: (see TiBaseComponent)
* STANDARD METHODS:
* @function attributeChangedCallback Called when a watched attribute changes
* @function connectedCallback Called when the element is added to the document
* @function constructor Construct the component
* @function disconnectedCallback Called when the element is removed from the document
*
* PUBLIC API METHODS:
* @function collapseAll Collapse all expandable nodes
* @function expandAll Expand all expandable nodes
* @function search Apply a search query programmatically
* @function renderToHTML Static re-export of the pure renderer (convenience)
*
* CUSTOM EVENTS:
* @fires json-viewer:connected - Instance added to DOM
* @fires json-viewer:disconnected - Instance removed from DOM
* @fires json-viewer:ready - Instance ready for interaction
* @fires json-viewer:attribChanged - Watched attribute changed. evt.detail.data = { attribute, newVal, oldVal }
* @fires json-viewer:toggle - Node expanded or collapsed. evt.detail.data = { path, expanded }
* @fires json-viewer:copy - Path or value copied to clipboard. evt.detail.data = { path, text, kind }
* @fires json-viewer:search - Search query changed. evt.detail.data = { query, matches }
* @fires json-viewer:change - Data changed. evt.detail.data = { path, oldValue, newValue, changeType: 'valueEdited'|'addedProperty'|'removedProperty' }
* @fires json-viewer:rename - Object key renamed. evt.detail.data = { parentPath, oldKey, newKey }
* @fires json-viewer:add - Add entry to object/array requested. evt.detail.data = { path, type }
* @fires json-viewer:delete - Delete entry requested. evt.detail.data = { path, type, oldValue }
*
* WATCHED ATTRIBUTES:
* @attr {string} data - JSON string or serialisable value to display
* @attr {number} max-depth - Maximum auto-expand depth on first render (default: 2)
* @attr {boolean} collapsed - Presence collapses all nodes on first render
* @attr {string} filter-type - Restrict visible nodes to this data type (e.g. "string", "number")
* @attr {boolean} editable - Presence enables inline editing of scalar leaf values
* @attr {string} name - Optional name (inherited from base component)
*
* PROPS FROM BASE: (see TiBaseComponent)
* OTHER PROPS:
* @property {string} componentVersion - Static. Date-based version string.
* @property {*} data - Get/set the rendered data (JS value or JSON string)
* @property {number} maxDepth - Get/set the maximum auto-expand depth
* @property {boolean} collapsed - Get/set collapsed state
* @property {string|null} filterType - Get/set the data-type filter (null = all)
* @property {boolean} editable - Get/set editable mode
*
* @example
* <json-viewer data='{"hello":"world"}' max-depth="3"></json-viewer>
*
* @example
* const el = document.querySelector('json-viewer')
* el.data = { nested: { values: [1, 2, 3] } }
*
* @example
* // Listen for a node being toggled
* el.addEventListener('json-viewer:toggle', evt => console.log(evt.detail.data))
*
* See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc
*/
class JsonViewer extends TiBaseComponent {
/** Component version */
static componentVersion = COMPONENT_VERSION
/** Static re-export of the pure renderer, accessible as JsonViewer.renderToHTML
* @type {typeof renderToHTML}
*/
static renderToHTML = renderToHTML
/** Watched HTML attributes
* @returns {string[]} Attribute names that trigger attributeChangedCallback
*/
static get observedAttributes() {
return ['data', 'max-depth', 'max-children', 'collapsed', 'filter-type', 'editable', 'name']
}
// #region ── Private fields ────────────────────────────────────────────
/** Current parsed data value @type {*} */
#data = undefined
/** Maximum auto-expand depth @type {number} */
#maxDepth = 2
/** Whether all expandable nodes are initially collapsed @type {boolean} */
#collapsed = false
/** Active data-type filter (null = show all) @type {string|null} */
#filterType = null
/** Whether scalar leaf values are editable @type {boolean} */
#editable = false
/** Maximum number of children to render per expandable node (0 = unlimited) @type {number} */
#maxChildren = CONFIGMAXCHILDREN
/** Current search query @type {string} */
#searchQuery = ''
/** AbortController used to clean up event listeners on disconnect @type {AbortController|null} */
#abortController = null
// #endregion
// #region ── Constructor ───────────────────────────────────────────────
constructor() {
super()
// Light DOM — no shadow root; all content rendered directly into this element
}
// #endregion
// #region ── Getters / Setters ─────────────────────────────────────────
/** Get the current rendered data
* @returns {*} Current data value
*/
get data() { return this.#data }
/** Set new data and re-render the tree
* @param {*} val - New data value (JS object, array, primitive, or JSON string)
*/
set data(val) {
this.#data = parseInput(val)
this.#searchQuery = ''
if (this.connected) this._render()
}
/** Get the current max-depth setting @returns {number} */
get maxDepth() { return this.#maxDepth }
/** Set max-depth and re-render
* @param {number|string} val - New depth value
*/
set maxDepth(val) {
const n = parseInt(val, 10)
this.#maxDepth = isNaN(n) ? 2 : Math.max(0, n)
if (this.connected) this._render()
}
/** Get collapsed state @returns {boolean} */
get collapsed() { return this.#collapsed }
/** Set collapsed state and re-render @param {boolean|string} val */
set collapsed(val) {
this.#collapsed = val === true || val === '' || val === 'true' || val === 'collapsed'
if (this.connected) this._render()
}
/** Get the active type filter @returns {string|null} */
get filterType() { return this.#filterType }
/** Set the type filter and apply it @param {string|null} val */
set filterType(val) {
this.#filterType = (val && val !== 'all') ? val : null
if (this.connected) this._applyTypeFilter()
}
/** Get editable state @returns {boolean} */
get editable() { return this.#editable }
/** Set editable state and re-render @param {boolean|string} val */
set editable(val) {
this.#editable = val === true || val === '' || val === 'true' || val === 'editable'
if (this.connected) this._render()
}
/** Get max-children setting @returns {number} */
get maxChildren() { return this.#maxChildren }
/** Set max-children and re-render
* @param {number|string} val - Max children per container (0 = unlimited)
*/
set maxChildren(val) {
const n = parseInt(val, 10)
this.#maxChildren = isNaN(n) ? CONFIGMAXCHILDREN : Math.max(0, n)
if (this.connected) this._render()
}
// #endregion
// #region ── Lifecycle callbacks ───────────────────────────────────────
/** Called when the element is added to the document */
connectedCallback() {
this._connect() // base class — keep at start
// Inject component styles into the document head (idempotent)
this.prependStylesheet(STYLES)
// Read initial attribute values
if (this.hasAttribute('data')) this.#data = parseInput(this.getAttribute('data'))
if (this.hasAttribute('max-depth')) this.maxDepth = this.getAttribute('max-depth')
if (this.hasAttribute('collapsed')) this.#collapsed = true
if (this.hasAttribute('filter-type')) this.#filterType = this.getAttribute('filter-type') || null
if (this.hasAttribute('editable')) this.#editable = true
if (this.hasAttribute('max-children')) this.maxChildren = this.getAttribute('max-children')
// Initial render
this._render()
// Attach delegated event listeners; AbortController removes them on disconnect
this.#abortController = new AbortController()
const { signal, } = this.#abortController
// Capture-phase: intercepts clicks on jv-add/jv-delete inside <summary> before
// the browser's native <details> toggle fires during bubble phase.
this.addEventListener('click', this._onClickCapture.bind(this), { signal, capture: true, })
this.addEventListener('click', this._onClick.bind(this), { signal, })
this.addEventListener('keydown', this._onKeydown.bind(this), { signal, })
this.addEventListener('input', this._onSearchInput.bind(this), { signal, })
this.addEventListener('focusout', this._onValueCommit.bind(this), { signal, })
// <details> toggle events do not bubble; capture phase is required
this.addEventListener('toggle', this._onToggle.bind(this), { signal, capture: true, })
this._ready() // base class — keep at end
}
/** Called when the element is removed from the document */
disconnectedCallback() {
this.#abortController?.abort()
this.#abortController = null
this._disconnect() // base class — keep at end
}
/** Called whenever a watched attribute changes
* @param {string} attrib - Attribute name
* @param {string|null} oldVal - Previous attribute value
* @param {string|null} newVal - New attribute value
*/
attributeChangedCallback(attrib, oldVal, newVal) {
if (oldVal === newVal) return
switch (attrib) {
case 'data':
this.#data = parseInput(newVal)
this.#searchQuery = ''
if (this.connected) this._render()
break
case 'max-depth':
this.maxDepth = newVal
break
case 'collapsed':
this.#collapsed = newVal !== null
if (this.connected) this._render()
break
case 'filter-type':
this.#filterType = (newVal && newVal !== 'all') ? newVal : null
if (this.connected) this._applyTypeFilter()
break
case 'editable':
this.#editable = newVal !== null
if (this.connected) this._render()
break
case 'max-children':
this.maxChildren = newVal
break
default:
break
}
this._event('attribChanged', { attribute: attrib, newVal, oldVal, })
}
// #endregion
// #region ── Public API ────────────────────────────────────────────────
/** Collapse all expandable nodes in the tree */
collapseAll() {
this.querySelectorAll('details.jv-node').forEach(node => node.removeAttribute('open'))
this._event('toggle', { path: '*', expanded: false, })
}
/** Expand all expandable nodes in the tree */
expandAll() {
this.querySelectorAll('details.jv-node').forEach(node => node.setAttribute('open', ''))
this._event('toggle', { path: '*', expanded: true, })
}
/** Apply a search query programmatically (mirrors typing in the search box).
* Opens the search row when a non-empty query is supplied; closes it when cleared.
* @param {string} query - Search string (empty string clears the filter and closes the row)
*/
search(query) {
const trimmed = query.trim()
this.#searchQuery = trimmed
const row = /** @type {HTMLElement|null} */ (this.querySelector('.jv-search-row'))
const btn = /** @type {HTMLElement|null} */ (this.querySelector('.jv-search-toggle'))
const input = /** @type {HTMLInputElement|null} */ (this.querySelector('.jv-search'))
if (input) input.value = trimmed
if (trimmed) {
if (row) row.classList.remove('jv-hidden')
if (btn) btn.setAttribute('aria-expanded', 'true')
} else {
if (row) row.classList.add('jv-hidden')
if (btn) btn.setAttribute('aria-expanded', 'false')
}
this._applySearch(trimmed)
}
// #endregion
// #region ── Private render / filter methods ───────────────────────────
/** Re-render the entire component content from scratch */
_render() {
this.innerHTML = renderToHTML(this.#data, {
maxDepth: this.#maxDepth,
collapsed: this.#collapsed,
editable: this.#editable,
interactive: true,
maxChildren: this.#maxChildren,
})
// If a search was active when re-rendering (e.g. maxDepth change), restore it
if (this.#searchQuery) {
const row = /** @type {HTMLElement|null} */ (this.querySelector('.jv-search-row'))
const btn = /** @type {HTMLElement|null} */ (this.querySelector('.jv-search-toggle'))
const input = /** @type {HTMLInputElement|null} */ (this.querySelector('.jv-search'))
if (row) row.classList.remove('jv-hidden')
if (btn) btn.setAttribute('aria-expanded', 'true')
if (input) input.value = this.#searchQuery
this._applySearch(this.#searchQuery)
}
if (this.#filterType) this._applyTypeFilter()
}
/**
* Show only nodes whose key or value text matches the query.
* Ancestor nodes of matching leaves are also revealed so the tree context is clear.
* Passing an empty string resets all visibility.
* @param {string} query - Search string
*/
_applySearch(query) {
const tree = this.querySelector('.jv-tree')
if (!tree) return
const all = /** @type {NodeListOf<HTMLElement>} */ (tree.querySelectorAll('.jv-node'))
if (!query) {
all.forEach(n => n.classList.remove('jv-hidden'))
return
}
const lq = query.toLowerCase()
// First pass: hide everything
all.forEach(n => n.classList.add('jv-hidden'))
// Second pass: show matching nodes and all their ancestors
all.forEach((node) => {
// Key/val may be direct children (leaf) or inside <summary> (expandable)
const keyEl = node.querySelector(':scope > .jv-key')
?? node.querySelector(':scope > summary > .jv-key')
const valEl = node.querySelector(':scope > .jv-val')
?? node.querySelector(':scope > summary > .jv-val')
const keyText = (keyEl?.textContent ?? '').toLowerCase()
const valText = (valEl?.textContent ?? '').toLowerCase()
if (keyText.includes(lq) || valText.includes(lq)) {
node.classList.remove('jv-hidden')
// Reveal all ancestors and open any closed <details>
let parent = node.parentElement?.closest('.jv-node')
while (parent) {
parent.classList.remove('jv-hidden')
if (parent.tagName?.toLowerCase() === 'details') parent.setAttribute('open', '')
parent = parent.parentElement?.closest('.jv-node')
}
}
})
const matches = this.querySelectorAll('.jv-node.jv-leaf:not(.jv-hidden)').length
this._event('search', { query, matches, })
}
/**
* Show only nodes whose `data-jv-type` matches `this.#filterType`.
* Ancestor nodes of matching leaves are also revealed.
* Clears filter if `this.#filterType` is null.
*/
_applyTypeFilter() {
const tree = this.querySelector('.jv-tree')
if (!tree) return
const all = /** @type {NodeListOf<HTMLElement>} */ (tree.querySelectorAll('.jv-node'))
if (!this.#filterType) {
all.forEach(n => n.classList.remove('jv-hidden'))
return
}
const ft = this.#filterType
all.forEach(n => n.classList.add('jv-hidden'))
all.forEach((node) => {
if (node.dataset.jvType === ft) {
node.classList.remove('jv-hidden')
let parent = node.parentElement?.closest('.jv-node')
while (parent) {
parent.classList.remove('jv-hidden')
if (parent.tagName?.toLowerCase() === 'details') parent.setAttribute('open', '')
parent = parent.parentElement?.closest('.jv-node')
}
}
})
}
// #endregion
// #region ── Private event handlers ────────────────────────────────────
/** Capture-phase click handler: intercepts jv-add and jv-delete button clicks before
* the event reaches a parent `<summary>`, which would otherwise toggle the `<details>`.
* Using capture ensures we fire before the browser's native activation behaviour.
* @param {MouseEvent} evt Mouse click event (capture phase)
*/
_onClickCapture(evt) {
const target = /** @type {HTMLElement} */ (evt.target)
if (!target.classList.contains('jv-add') && !target.classList.contains('jv-delete')) return
// Stop propagation so the parent <summary> never receives this event
evt.stopPropagation()
evt.preventDefault()
const node = /** @type {HTMLElement|null} */ (target.closest('.jv-node'))
if (!node) return
if (target.classList.contains('jv-add')) this._handleAdd(node)
else this._handleDelete(node)
}
/** Delegated click handler
* @param {MouseEvent} evt Mouse click event
*/
_onClick(evt) {
const target = /** @type {HTMLElement} */ (evt.target)
// Copy button (⎘) — stopPropagation prevents <summary> from also toggling
if (target.classList.contains('jv-copy')) {
evt.preventDefault()
evt.stopPropagation()
this._handleCopy(target)
return
}
// Key label click (leaf nodes only) → copy the dot-notation path.
// Expandable node keys live inside <summary> and carry no data-jv-copy attribute;
// clicking them is handled natively by the browser to toggle the <details>.
if (target.classList.contains('jv-key') && target.dataset.jvCopy === 'path') {
evt.preventDefault()
const node = target.closest('.jv-node')
if (node) this._copyPath(node)
return
}
// Toolbar buttons
if (target.classList.contains('jv-collapse-all')) { this.collapseAll(); return }
if (target.classList.contains('jv-expand-all')) { this.expandAll(); return }
// Search toggle button
if (target.classList.contains('jv-search-toggle')) {
const row = /** @type {HTMLElement|null} */ (this.querySelector('.jv-search-row'))
const isOpen = row && !row.classList.contains('jv-hidden')
if (isOpen) {
// Close: clear search and hide row
const input = /** @type {HTMLInputElement|null} */ (this.querySelector('.jv-search'))
if (input) input.value = ''
this.#searchQuery = ''
this._applySearch('')
if (row) row.classList.add('jv-hidden')
target.setAttribute('aria-expanded', 'false')
} else {
// Open: show row and focus input
if (row) row.classList.remove('jv-hidden')
target.setAttribute('aria-expanded', 'true')
const input = /** @type {HTMLInputElement|null} */ (this.querySelector('.jv-search'))
if (input) { input.focus(); input.select() }
}
return
}
}
/** Delegated keyboard handler implementing the ARIA tree keyboard pattern.
* Acts when focus is on a `.jv-node` leaf or a `<summary>` inside a `<details.jv-node>`.
* Enter/Space on expandable nodes are handled natively by `<details>/<summary>`.
* @param {KeyboardEvent} evt Keyboard event
*/
_onKeydown(evt) {
const target = /** @type {HTMLElement} */ (evt.target)
// If focus is inside a contenteditable key or value, handle Enter (commit) and Tab (move to value)
if (target.dataset?.jvKeyEditable || target.dataset?.jvEditable || target.closest('[contenteditable]')) {
if (evt.key === 'Enter') {
evt.preventDefault()
const editable = /** @type {HTMLElement} */ (target)
editable.blur() // triggers focusout → commit
} else if (evt.key === 'Tab' && target.dataset?.jvKeyEditable) {
// Commit the key rename, then move focus to t