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.

1,265 lines (1,260 loc) 66.8 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/components/json-viewer/json-viewer.mjs var json_viewer_exports = {}; __export(json_viewer_exports, { default: () => json_viewer_default, renderToHTML: () => renderToHTML }); module.exports = __toCommonJS(json_viewer_exports); // src/components/ti-base-component.mjs var _HTMLElement = typeof HTMLElement !== "undefined" ? HTMLElement : class { }; var TiBaseComponent = class _TiBaseComponent extends _HTMLElement { /** Component version */ static 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 */ static _iCount = 0; /** Is UIBUILDER for Node-RED loaded? */ uib = !!window["uibuilder"]; 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. */ $; /** 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. */ $$; /** True when instance finishes connecting. * Allows initial calls of attributeChangedCallback to be * ignored if needed. */ connected = false; /** Placeholder for the optional name attribute @type {string} */ name; /** Runtime configuration settings @type {object} */ opts = {}; /** Report the current component version string * @returns {string} The component version & base version as a string */ static get version() { return `${this.componentVersion} (Base: ${this.baseVersion})`; } // 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(); } /** 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() { this.$ = this.shadowRoot?.querySelector.bind(this.shadowRoot); this.$$ = this.shadowRoot?.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(`[${this.localName}] Inherit-style requested. Loading: "${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 = `${this.localName}-${++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(`[${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: `${this.localName}:${evtName}`, payload: data, id: this.id, name: this.name }); } else { console.warn(`[${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(`${this.localName}::${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:${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(`${this.localName}:${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(`[${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="${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) { 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(existing.getAttribute("data-order") ?? "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 ---- }; 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 */ ` 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 \u2014 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: '\u25BC'; 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: '\u25B6'; } 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; } } ` ); 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) { 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] ?? ""; 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 !== void 0 ? 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)}]`; } } 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}`; } function buildPath(parentPath, key, parentType) { if (parentPath === "") return String(key); if (parentType === "array") return `${parentPath}[${key}]`; if (/[^a-zA-Z0-9_$]/.test(String(key))) { return `${parentPath}["${String(key).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; } return `${parentPath}.${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 `(${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="${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="${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> ` : ""; 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> ` : ""; 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); } 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">\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 ${hiddenCount} more ${noun} not shown (max-children=${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-${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">+</button>` : ""}${interactive && editable && depth > 0 ? `<button class="jv-delete" aria-label="Delete entry" title="Delete entry" tabindex="-1">\xD7</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>`; } 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>${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>`; } var JsonViewer = class extends ti_base_component_default { /** 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 = void 0; /** 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(); } // #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(); this.prependStylesheet(STYLES); 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"); this._render(); this.#abortController = new AbortController(); const { signal } = 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() { this.#abortController?.abort(); 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": 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 (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(); all.forEach((n) => n.classList.add("jv-hidden")); all.forEach((node) => { 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"); 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; 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 = ""; this.#searchQuery = ""; this._applySearch(""); if (row) row.classList.add("jv-hidden"); target.setAttribute("aria-expanded", "false"); } else { 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 (target.dataset?.jvKeyEditable || target.dataset?.jvEditable || target.closest("[contenteditable]")) { if (evt.key === "Enter") { evt.preventDefault(); const editable = ( /** @type {HTMLElement} */ target ); editable.blur(); } else if (evt.key === "Tab" && target.dataset?.jvKeyEditable) { evt.preventDefault(); const keyEl = ( /** @type {HTMLElement} */ target ); this._onKeyCommit(keyEl); const path = keyEl.dataset.jvPath ?? ""; const split = this._splitLastSegment(path); const newKey = keyEl.textContent?.trim() ?? (split ? String(split.key) : ""); const parentPath = split?.parentPath ?? ""; const newPath = parentPath ? `${parentPath}.${newKey}` : newKey; const valEl = ( /** @type {HTMLElement|null} */ this.querySelector(`[data-jv-path="${newPath}"] > .jv-val[contenteditable]`) ?? this.querySelector(`[data-jv-path="${newPath}"] > .jv-val`) ); if (valEl) valEl.focus(); } return; } const nodeEl = target.classList.contains("jv-node") ? target : ( /** @type {HTMLElement|null} */ target.closest(".jv-node") ); if (!nodeEl) return; const tree = this.querySelector(".jv-tree"); if (!tree) return; const allNodes = Array.from( /** @type {NodeListOf<HTMLElement>} */ tree.querySelectorAll(".jv-node:not(.jv-hidden)") ); const reachable = allNodes.filter((n) => { let el = n.parentElement; while (el && el !== tree) { if (el.tagName?.toLowerCase() === "details" && !el.hasAttribute("open") && el.classList.contains("jv-node")) return false; el = el.parentElement; } return true; }); const idx = reachable.indexOf(nodeEl); const focusNode = (n) => { if (!n) return; if (n.tagName?.toLowerCase() === "details") n.querySelector(":scope > summary")?.focus(); else n.focus(); }; const isExpandable = nodeEl.tagName?.toLowerCase() === "details"; const isOpen = isExpandable && nodeEl.hasAttribute("open"); switch (evt.key) { case "ArrowDown": { evt.preventDefault(); if (idx < reachable.length - 1) focusNode(reachable[idx + 1]); break; } case "ArrowUp": { evt.preventDefault(); if (idx > 0) focusNode(reachable[idx - 1]); break; } case "ArrowRight": { evt.preventDefault(); if (isExpandable && !isOpen) { nodeEl.setAttribute("open", ""); } else if (isExpandable && isOpen) { const firstChild = ( /** @type {HTMLElement|null} */ nodeEl.querySelector(".jv-children > .jv-node") ); focusNode(firstChild); } break; } case "ArrowLeft": { evt.preventDefault(); if (isExpandable && isOpen) { nodeEl.removeAttribute("open"); } else { const parent = ( /** @type {HTMLElement|null} */ nodeEl.parentElement?.closest(".jv-node") ); focusNode(parent); } break; } case "Home": { evt.preventDefault(); if (reachable.length > 0) focusNode(rea