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.
900 lines (898 loc) • 73.8 kB
JavaScript
(() => {
var __defProp = Object.defineProperty;
var __typeError = (msg) => {
throw TypeError(msg);
};
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
// src/components/ti-base-component.mjs
var _HTMLElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {
};
var _TiBaseComponent = class _TiBaseComponent extends _HTMLElement {
// 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();
/** Is UIBUILDER for Node-RED loaded? */
__publicField(this, "uib", !!window["uibuilder"]);
__publicField(this, "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.
*/
__publicField(this, "$");
/** 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.
*/
__publicField(this, "$$");
/** True when instance finishes connecting.
* Allows initial calls of attributeChangedCallback to be
* ignored if needed.
*/
__publicField(this, "connected", false);
/** Placeholder for the optional name attribute @type {string} */
__publicField(this, "name");
/** Runtime configuration settings @type {object} */
__publicField(this, "opts", {});
}
/** Report the current component version string
* @returns {string} The component version & base version as a string
*/
static get version() {
return "".concat(this.componentVersion, " (Base: ").concat(this.baseVersion, ")");
}
/** 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) {
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() {
var _a, _b;
this.$ = (_a = this.shadowRoot) == null ? void 0 : _a.querySelector.bind(this.shadowRoot);
this.$$ = (_b = this.shadowRoot) == null ? void 0 : _b.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) {
for (let k in source) {
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("[".concat(this.localName, '] Inherit-style requested. Loading: "').concat(url, '"'));
}
/** Ensure that the component instance has a unique ID & check again if uib loaded */
ensureId() {
this.uib = !!window["uibuilder"];
if (!this.id) {
this.id = "".concat(this.localName, "-").concat(++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("[".concat(this.localName, "] cssText must be provided"));
}
const existingStylesheet = this._findExistingStylesheet();
if (existingStylesheet) return existingStylesheet;
const styleElement = document.createElement("style");
styleElement.textContent = cssText;
styleElement.setAttribute("data-component", this.localName);
styleElement.setAttribute("data-order", order.toString());
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: "".concat(this.localName, ":").concat(evtName),
payload: data,
id: this.id,
name: this.name
});
} else {
console.warn("[".concat(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() {
this.ensureId();
this.doInheritStyles();
if (this.uib) this.uibuilder.onTopic("".concat(this.localName, "::").concat(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 };
this.attachShadow(shadowOpts).append(template);
this.createShadowSelectors();
}
/** Standardised disconnection. Call from the END of disconnectedCallback fn */
_disconnect() {
document.removeEventListener("uibuilder:msg:_ui:update:".concat(this.id), this._uibMsgHandler);
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("".concat(this.localName, ":").concat(evtName), {
bubbles: true,
composed: true,
detail: {
id: this.id,
name: this.name,
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 (typeof msg.payload !== "object") {
console.warn("[".concat(this.localName, "] Ignoring msg, payload is not an object:"), msg);
return;
}
Object.keys(msg.payload).forEach((key) => {
if (key.startsWith("_")) return;
let key2 = key.toLowerCase();
if (key2.startsWith("data-")) key2 = "data";
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="'.concat(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) {
var _a;
const head = document.head;
const existingComponentStyles = Array.from(head.querySelectorAll("style[data-component]"));
if (existingComponentStyles.length === 0) {
const firstChild = head.firstChild;
if (firstChild) {
head.insertBefore(styleElement, firstChild);
} else {
head.appendChild(styleElement);
}
return;
}
let insertBefore = null;
for (const existing of existingComponentStyles) {
const existingOrder = parseInt((_a = existing.getAttribute("data-order")) != null ? _a : "0", 10);
if (order < existingOrder) {
insertBefore = existing;
break;
}
}
if (insertBefore) {
head.insertBefore(styleElement, insertBefore);
} else {
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 ----
};
/** Component version */
__publicField(_TiBaseComponent, "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
*/
__publicField(_TiBaseComponent, "_iCount", 0);
var TiBaseComponent = _TiBaseComponent;
var ti_base_component_default = TiBaseComponent;
// src/components/json-viewer/json-viewer.mjs
var CONFIGMAXCHILDREN = 1e3;
var CONFIGMAXTOTAL = 5e4;
var COMPONENT_VERSION = "2026-05-09";
var STYLES = (
/* css */
"\njson-viewer {\n display: block;\n font-family: var(--jv-font-family, 'Cascadia Code', 'Fira Code', 'Consolas', 'Monaco', monospace);\n font-size: var(--jv-font-size, 0.875rem);\n line-height: 1.5;\n background: var(--jv-bg, transparent);\n color: var(--jv-color, inherit);\n overflow: auto;\n}\n.jv-tree-wrap {\n display: flow-root;\n}\n.jv-controls {\n float: right;\n position: relative;\n z-index: 1;\n display: flex;\n align-items: center;\n gap: 0.25rem;\n padding: 0.2rem 0 0.2rem 0.4rem;\n}\n.jv-search-row {\n padding: 0.25rem 0.5rem;\n border-bottom: 1px solid hsl(0 0% 50% / 0.2);\n}\n.jv-search {\n display: block;\n width: 100%;\n box-sizing: border-box;\n padding: 0.2rem 0.4rem;\n border: 1px solid hsl(0 0% 60%);\n border-radius: 3px;\n font-family: inherit;\n font-size: inherit;\n background: var(--jv-bg, transparent);\n color: var(--jv-color, inherit);\n}\n.jv-btn {\n padding: 0.15rem 0.5rem;\n border: 1px solid hsl(0 0% 60%);\n border-radius: 3px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n font-size: 0.8em;\n white-space: nowrap;\n}\n.jv-btn:hover, .jv-btn:focus-visible {\n background: hsl(0 0% 50% / 0.15);\n outline: 2px solid hsl(200 100% 50%);\n outline-offset: 1px;\n}\n.jv-tree { padding: 0.25rem 0.5rem; }\n.jv-node {\n padding-left: var(--jv-indent, 1.25rem);\n outline: none;\n}\n.jv-node.jv-leaf:focus-visible,\ndetails.jv-node > summary:focus-visible {\n outline: 2px solid hsl(200 100% 50%);\n outline-offset: 1px;\n border-radius: 2px;\n}\n/* <details>/<summary> expand/collapse \u2014 no JavaScript required */\ndetails.jv-node > summary {\n list-style: none;\n margin-left: calc(-1 * var(--jv-indent, 1.25rem));\n cursor: pointer;\n display: block;\n}\ndetails.jv-node > summary::-webkit-details-marker { display: none; }\ndetails.jv-node > summary::marker { content: ''; }\ndetails.jv-node > summary::before {\n content: '\u25BC';\n display: inline-block;\n width: var(--jv-indent, 1.25rem);\n text-align: center;\n font-size: 0.65em;\n color: var(--jv-toggle-color, hsl(0 0% 55%));\n user-select: none;\n}\ndetails.jv-node:not([open]) > summary::before { content: '\u25B6'; }\ndetails.jv-node[open] > summary .jv-hint { display: none; }\ndetails.jv-node:not([open]) > summary .jv-hint { display: inline; }\n.jv-key { color: var(--jv-key-color, hsl(230 60% 45%)); cursor: pointer; }\n.jv-key:hover { text-decoration: underline; }\n.jv-key[contenteditable='true'] {\n border-bottom: 1px dashed hsl(0 0% 60%);\n cursor: text;\n outline: none;\n min-width: 2ch;\n text-decoration: none !important;\n}\n.jv-key[contenteditable='true']:focus { border-bottom-color: hsl(200 100% 50%); }\n.jv-sep { color: hsl(0 0% 50%); margin: 0 0.1em; }\n.jv-string { color: var(--jv-string-color, hsl(10 80% 40%)); }\n.jv-val.jv-string::before,\n.jv-val.jv-string::after { content: '\"'; }\n.jv-number { color: var(--jv-number-color, hsl(260 70% 50%)); }\n.jv-val.jv-bigint { color: var(--jv-number-color, hsl(260 70% 50%)); }\n.jv-val.jv-bigint::after { content: 'n'; }\n.jv-boolean { color: var(--jv-boolean-color, hsl(200 80% 40%)); font-weight: bold; }\n.jv-null,\n.jv-undefined { color: var(--jv-null-color, hsl(0 0% 55%)); font-style: italic; }\n.jv-special,\n.jv-circular { color: var(--jv-special-color, hsl(30 80% 40%)); font-style: italic; }\n.jv-regexp { color: var(--jv-regexp-color, hsl(330 70% 45%)); }\n.jv-bracket,\n.jv-bracket-close { color: hsl(0 0% 45%); }\n.jv-hint { color: hsl(0 0% 60%); font-size: 0.85em; margin-left: 0.3em; }\n.jv-copy {\n appearance: none;\n -webkit-appearance: none;\n opacity: 0;\n border: none;\n box-shadow: none;\n background: transparent;\n cursor: pointer;\n font-size: 0.8em;\n padding: 0 0.15rem;\n margin: 0;\n color: hsl(0 0% 60%);\n display: inline-flex;\n align-items: center;\n line-height: 1;\n transition: opacity 0.15s;\n border-radius: 2px;\n}\n.jv-node:hover > .jv-copy,\n.jv-node.jv-leaf:focus-visible > .jv-copy,\ndetails.jv-node:focus-within > .jv-copy { opacity: 1; }\n.jv-copy:hover,\n.jv-copy:focus-visible { color: hsl(200 100% 40%); opacity: 1; outline: 1px solid hsl(200 100% 50%); }\n.jv-add {\n appearance: none;\n -webkit-appearance: none;\n opacity: 0;\n border: none;\n box-shadow: none;\n background: transparent;\n cursor: pointer;\n font-size: 0.85em;\n font-weight: bold;\n padding: 0 0.2rem;\n margin: 0 0.1rem;\n color: hsl(120 50% 40%);\n display: inline-flex;\n align-items: center;\n line-height: 1;\n transition: opacity 0.15s;\n border-radius: 2px;\n vertical-align: middle;\n}\ndetails.jv-node:not([open]) > summary .jv-add { display: none !important; }\ndetails.jv-node[open] > summary:hover .jv-add,\ndetails.jv-node[open] > summary:focus-visible .jv-add { opacity: 1; }\n.jv-add:hover,\n.jv-add:focus-visible { color: hsl(120 70% 30%); opacity: 1; outline: 1px solid hsl(120 70% 50%); }\n.jv-delete {\n appearance: none;\n -webkit-appearance: none;\n opacity: 0;\n border: none;\n box-shadow: none;\n background: transparent;\n cursor: pointer;\n font-size: 0.8em;\n padding: 0 0.15rem;\n margin: 0;\n color: hsl(0 60% 55%);\n display: inline-flex;\n align-items: center;\n line-height: 1;\n transition: opacity 0.15s;\n border-radius: 2px;\n}\n/* Leaf nodes: delete is a direct child of the node div */\n.jv-node.jv-leaf:hover > .jv-delete,\n.jv-node.jv-leaf:focus-visible > .jv-delete { opacity: 1; }\n/* Expandable nodes: delete is inside <summary> */\ndetails.jv-node > summary:hover .jv-delete,\ndetails.jv-node > summary:focus-visible .jv-delete { opacity: 1; }\n.jv-delete:hover,\n.jv-delete:focus-visible { color: hsl(0 80% 45%); opacity: 1; outline: 1px solid hsl(0 80% 50%); }\n.jv-children { padding-left: 0; }\n.jv-hl { background: var(--jv-hl-bg, hsl(50 100% 70% / 0.6)); border-radius: 2px; }\n.jv-hidden { display: none !important; }\n.jv-truncated {\n color: var(--jv-truncated-color, hsl(0 0% 55%));\n font-style: italic;\n font-size: 0.85em;\n cursor: default;\n user-select: none;\n}\n.jv-val[contenteditable='true'] {\n border-bottom: 1px dashed hsl(0 0% 60%);\n cursor: text;\n outline: none;\n min-width: 2ch;\n}\n.jv-val[contenteditable='true']:focus { border-bottom-color: hsl(200 100% 50%); }\n\n/* Dark mode support */\n@media (prefers-color-scheme: dark) {\n .jv-key { color: var(--jv-key-color, hsl(220 80% 75%)); }\n .jv-string { color: var(--jv-string-color, hsl(30 90% 65%)); }\n .jv-number { color: var(--jv-number-color, hsl(270 80% 75%)); }\n .jv-val.jv-bigint { color: var(--jv-number-color, hsl(270 80% 75%)); }\n .jv-boolean { color: var(--jv-boolean-color, hsl(200 80% 70%)); }\n .jv-null,\n .jv-undefined { color: var(--jv-null-color, hsl(0 0% 60%)); }\n .jv-special,\n .jv-circular { color: var(--jv-special-color, hsl(40 80% 65%)); }\n .jv-regexp { color: var(--jv-regexp-color, hsl(330 80% 70%)); }\n .jv-truncated { color: var(--jv-truncated-color, hsl(0 0% 60%)); }\n .jv-bracket,\n .jv-bracket-close { color: hsl(0 0% 65%); }\n details.jv-node > summary::before { color: var(--jv-toggle-color, hsl(0 0% 70%)); }\n}\n\n/* Print styles: expand everything and hide controls */\n@media print {\n .jv-controls { display: none; }\n .jv-search-row { display: none; }\n details.jv-node > :not(summary) { display: block !important; }\n details.jv-node > summary .jv-hint { display: none !important; }\n .jv-copy { display: none; }\n .jv-add { display: none; }\n .jv-delete { display: none; }\n}\n"
);
function escHtml(str) {
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function typeOf(value) {
if (value === null) return "null";
if (value === void 0) 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;
}
function renderLeafValue(val, type, editable = false) {
var _a;
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: ".concat(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 = (_a = kindMap[val.constructor.name]) != null ? _a : "";
const src = val.toString().trimStart().slice(0, 20);
const arrow = !src.startsWith("function") && !src.startsWith("async function") ? "arrow " : "";
const fnType = fnKind || arrow ? "{".concat("".concat(arrow).concat(fnKind).trim(), "}") : "";
return escHtml("[f ".concat(fnName, " ").concat(fnType, " ]"));
}
case "error":
return escHtml("[".concat(val.name, ": ").concat(val.message, "]"));
case "urllike":
return escHtml("[URL: ".concat(val.toString(), "]"));
case "symbol": {
const desc = val.description !== void 0 ? escHtml(val.description) : "";
return "Symbol(".concat(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 "[".concat(escHtml(type), "]");
}
}
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 "".concat(n, " ").concat(noun);
}
function buildPath(parentPath, key, parentType) {
if (parentPath === "") return String(key);
if (parentType === "array") return "".concat(parentPath, "[").concat(key, "]");
if (/[^a-zA-Z0-9_$]/.test(String(key))) {
return "".concat(parentPath, '["').concat(String(key).replace(/\\/g, "\\\\").replace(/"/g, '\\"'), '"]');
}
return "".concat(parentPath, ".").concat(key);
}
function parseInput(input) {
if (typeof input === "string") {
try {
return JSON.parse(input);
} catch (_) {
}
}
return input;
}
function mapKeyLabel(k) {
if (k === null) return "null";
if (k === void 0) 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 "(".concat(typeOf(k), ")");
}
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 !== void 0 ? String(key) : null;
const pathAttr = escHtml(path !== "" ? path : "(root)");
const displayKey = safeKey !== null ? escHtml(safeKey) : null;
const isObjectKey = key !== null && key !== void 0 && typeof key !== "number" && parentType === "object";
const keyEditable = editable && interactive && isObjectKey;
if (budget.remaining <= 0) {
return '<div class="jv-node jv-leaf jv-truncated" role="treeitem" tabindex="-1" data-jv-path="'.concat(pathAttr, '" data-jv-type="truncated">\u2026 node limit reached</div>');
}
budget.remaining--;
const keyHtml = displayKey !== null ? keyEditable ? '<span class="jv-key" contenteditable="true" spellcheck="false" data-jv-key-editable="true" data-jv-path="'.concat(pathAttr, '" aria-label="Edit key name">').concat(displayKey, '</span><span class="jv-sep">:</span> ') : interactive ? '<span class="jv-key" data-jv-copy="path" title="Copy path: '.concat(pathAttr, '">').concat(displayKey, '</span><span class="jv-sep">:</span> ') : '<span class="jv-key">'.concat(displayKey, '</span><span class="jv-sep">:</span> ') : "";
const expandKeyHtml = displayKey !== null ? keyEditable ? '<span class="jv-key" contenteditable="true" spellcheck="false" data-jv-key-editable="true" data-jv-path="'.concat(pathAttr, '" aria-label="Edit key name">').concat(displayKey, '</span><span class="jv-sep">:</span> ') : '<span class="jv-key">'.concat(displayKey, '</span><span class="jv-sep">:</span> ') : "";
if (isExpandable) {
if (seen.has(val)) {
return '<div class="jv-node jv-leaf jv-circular" role="treeitem" tabindex="0" data-jv-path="'.concat(pathAttr, '" data-jv-type="').concat(type, '">') + "".concat(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);
}
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="'.concat(type, '" aria-label="Edit ').concat(type, ' value"') : "";
const valHtml = '<span class="jv-val jv-'.concat(type, '"').concat(editAttrs, ">").concat(valContent, "</span>");
return '<div class="jv-node jv-leaf jv-'.concat(type, '" role="treeitem" tabindex="0" data-jv-path="').concat(pathAttr, '" data-jv-type="').concat(type, '">') + "".concat(keyHtml).concat(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">\xD7</button>' : "") + "</div>";
}
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);
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);
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">\u2026 '.concat(hiddenCount, " more ").concat(noun, " not shown (max-children=").concat(maxChildren, ")</div>");
}
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;
seen.delete(val);
return '<details class="jv-node jv-'.concat(type, '"').concat(openAttr, ' data-jv-path="').concat(pathAttr, '" data-jv-type="').concat(type, '">') + "<summary>".concat(expandKeyHtml, '<span class="jv-bracket">').concat(typePrefix).concat(bracketOpen, "</span>").concat(interactive && editable && (type === "object" || type === "array") ? '<button class="jv-add" aria-label="Add entry to '.concat(escHtml(type), '" title="Add entry" tabindex="-1">+</button>') : "").concat(interactive && editable && depth > 0 ? '<button class="jv-delete" aria-label="Delete entry" title="Delete entry" tabindex="-1">\xD7</button>' : "", '<span class="jv-hint"> ').concat(bracketClose, " ").concat(hint, "</span></summary>") + '<div class="jv-children" role="group">'.concat(childrenHtml, "</div>") + '<span class="jv-bracket-close">'.concat(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>";
}
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">\u{1F50D}</button></div>' : "";
const treeHtml = renderNode(value, {
maxDepth,
collapsed,
editable,
interactive,
key: null,
path: "",
depth: 0,
seen: /* @__PURE__ */ new WeakSet(),
parentType: null,
maxChildren,
budget
});
const styleTag = includeStyles ? "<style>".concat(STYLES, "</style>") : "";
return "".concat(styleTag).concat(searchHtml, '<div class="jv-tree-wrap">').concat(controlsHtml, '<div class="jv-tree" role="tree" aria-label="JSON data tree">').concat(treeHtml, "</div></div>");
}
var _data, _maxDepth, _collapsed, _filterType, _editable, _maxChildren, _searchQuery, _abortController;
var JsonViewer = class extends ti_base_component_default {
// #endregion
// #region ── Constructor ───────────────────────────────────────────────
constructor() {
super();
// #region ── Private fields ────────────────────────────────────────────
/** Current parsed data value @type {*} */
__privateAdd(this, _data);
/** Maximum auto-expand depth @type {number} */
__privateAdd(this, _maxDepth, 2);
/** Whether all expandable nodes are initially collapsed @type {boolean} */
__privateAdd(this, _collapsed, false);
/** Active data-type filter (null = show all) @type {string|null} */
__privateAdd(this, _filterType, null);
/** Whether scalar leaf values are editable @type {boolean} */
__privateAdd(this, _editable, false);
/** Maximum number of children to render per expandable node (0 = unlimited) @type {number} */
__privateAdd(this, _maxChildren, CONFIGMAXCHILDREN);
/** Current search query @type {string} */
__privateAdd(this, _searchQuery, "");
/** AbortController used to clean up event listeners on disconnect @type {AbortController|null} */
__privateAdd(this, _abortController, null);
}
/** Watched HTML attributes
* @returns {string[]} Attribute names that trigger attributeChangedCallback
*/
static get observedAttributes() {
return ["data", "max-depth", "max-children", "collapsed", "filter-type", "editable", "name"];
}
// #endregion
// #region ── Getters / Setters ─────────────────────────────────────────
/** Get the current rendered data
* @returns {*} Current data value
*/
get data() {
return __privateGet(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) {
__privateSet(this, _data, parseInput(val));
__privateSet(this, _searchQuery, "");
if (this.connected) this._render();
}
/** Get the current max-depth setting @returns {number} */
get maxDepth() {
return __privateGet(this, _maxDepth);
}
/** Set max-depth and re-render
* @param {number|string} val - New depth value
*/
set maxDepth(val) {
const n = parseInt(val, 10);
__privateSet(this, _maxDepth, isNaN(n) ? 2 : Math.max(0, n));
if (this.connected) this._render();
}
/** Get collapsed state @returns {boolean} */
get collapsed() {
return __privateGet(this, _collapsed);
}
/** Set collapsed state and re-render @param {boolean|string} val */
set collapsed(val) {
__privateSet(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 __privateGet(this, _filterType);
}
/** Set the type filter and apply it @param {string|null} val */
set filterType(val) {
__privateSet(this, _filterType, val && val !== "all" ? val : null);
if (this.connected) this._applyTypeFilter();
}
/** Get editable state @returns {boolean} */
get editable() {
return __privateGet(this, _editable);
}
/** Set editable state and re-render @param {boolean|string} val */
set editable(val) {
__privateSet(this, _editable, val === true || val === "" || val === "true" || val === "editable");
if (this.connected) this._render();
}
/** Get max-children setting @returns {number} */
get maxChildren() {
return __privateGet(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);
__privateSet(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();
this.prependStylesheet(STYLES);
if (this.hasAttribute("data")) __privateSet(this, _data, parseInput(this.getAttribute("data")));
if (this.hasAttribute("max-depth")) this.maxDepth = this.getAttribute("max-depth");
if (this.hasAttribute("collapsed")) __privateSet(this, _collapsed, true);
if (this.hasAttribute("filter-type")) __privateSet(this, _filterType, this.getAttribute("filter-type") || null);
if (this.hasAttribute("editable")) __privateSet(this, _editable, true);
if (this.hasAttribute("max-children")) this.maxChildren = this.getAttribute("max-children");
this._render();
__privateSet(this, _abortController, new AbortController());
const { signal } = __privateGet(this, _abortController);
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 });
this.addEventListener("toggle", this._onToggle.bind(this), { signal, capture: true });
this._ready();
}
/** Called when the element is removed from the document */
disconnectedCallback() {
var _a;
(_a = __privateGet(this, _abortController)) == null ? void 0 : _a.abort();
__privateSet(this, _abortController, null);
this._disconnect();
}
/** 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":
__privateSet(this, _data, parseInput(newVal));
__privateSet(this, _searchQuery, "");
if (this.connected) this._render();
break;
case "max-depth":
this.maxDepth = newVal;
break;
case "collapsed":
__privateSet(this, _collapsed, newVal !== null);
if (this.connected) this._render();
break;
case "filter-type":
__privateSet(this, _filterType, newVal && newVal !== "all" ? newVal : null);
if (this.connected) this._applyTypeFilter();
break;
case "editable":
__privateSet(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();
__privateSet(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(__privateGet(this, _data), {
maxDepth: __privateGet(this, _maxDepth),
collapsed: __privateGet(this, _collapsed),
editable: __privateGet(this, _editable),
interactive: true,
maxChildren: __privateGet(this, _maxChildren)
});
if (__privateGet(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 = __privateGet(this, _searchQuery);
this._applySearch(__privateGet(this, _searchQuery));
}
if (__privateGet(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();
all.forEach((n) => n.classList.add("jv-hidden"));
all.forEach((node) => {
var _a, _b, _c, _d, _e, _f, _g;
const keyEl = (_a = node.querySelector(":scope > .jv-key")) != null ? _a : node.querySelector(":scope > summary > .jv-key");
const valEl = (_b = node.querySelector(":scope > .jv-val")) != null ? _b : node.querySelector(":scope > summary > .jv-val");
const keyText = ((_c = keyEl == null ? void 0 : keyEl.textContent) != null ? _c : "").toLowerCase();
const valText = ((_d = valEl == null ? void 0 : valEl.textContent) != null ? _d : "").toLowerCase();
if (keyText.includes(lq) || valText.includes(lq)) {
node.classList.remove("jv-hidden");
let parent = (_e = node.parentElement) == null ? void 0 : _e.closest(".jv-node");
while (parent) {
parent.classList.remove("jv-hidden");
if (((_f = parent.tagName) == null ? void 0 : _f.toLowerCase()) === "details") parent.setAttribute("open", "");
parent = (_g = parent.parentElement) == null ? void 0 : _g.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 (!__privateGet(this, _filterType)) {
all.forEach((n) => n.classList.remove("jv-hidden"));
return;
}
const ft = __privateGet(this, _filterType);
all.forEach((n) => n.classList.add("jv-hidden"));
all.forEach((node) => {
var _a, _b, _c;
if (node.dataset.jvType === ft) {
node.classList.remove("jv-hidden");
let parent = (_a = node.parentElement) == null ? void 0 : _a.closest(".jv-node");
while (parent) {
parent.classList.remove("jv-hidden");
if (((_b = parent.tagName) == null ? void 0 : _b.toLowerCase()) === "details") parent.setAttribute("open", "");
parent = (_c = parent.parentElement) == null ? void 0 : _c.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;
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
);
if (target.classList.contains("jv-copy")) {
evt.preventDefault();
evt.stopPropagation();
this._handleCopy(target);
return;
}
if (target.classList.contains("jv-key") && target.dataset.jvCopy === "path") {
evt.preventDefault();
const node = target.closest(".jv-node");
if (node) this._copyPath(node);
return;
}
if (target.classList.contains("jv-collapse-all")) {
this.collapseAll();
return;
}
if (target.classList.contains("jv-expand-all")) {
this.expandAll();
return;
}
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) {
const input = (
/** @type {HTMLInputElement|null} */
this.querySelector(".jv-search")
);
if (input) input.value = "";
__privateSet(this, _searchQuery, "");
this._applyS