UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.

900 lines (898 loc) 73.8 kB
(() => { 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); } 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