{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "xml-tree-view",
  "title": "Xml Tree View",
  "type": "registry:ui",
  "description": "Collapsible XML tree viewer with color-coded tags and attributes, click-to-copy, live search/filter, and expand/collapse-all controls. Parses XML strings and renders elements, text, comments, and CDATA with contained scrolling.",
  "categories": [
    "display",
    "data"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/xml-tree-view/xml-tree-view.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { AlertCircle, Check, ChevronDown, ChevronRight, CodeXml, Copy, FoldVertical, Search, UnfoldVertical } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { icon } from '../../lib/icon'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport type XmlNodeType = 'element' | 'text' | 'comment' | 'cdata'\n\nexport interface XmlAttr {\n  name: string\n  value: string\n}\n\nexport interface XmlNode {\n  type: XmlNodeType\n  name: string\n  attributes: XmlAttr[]\n  text: string\n  children: XmlNode[]\n}\n\nexport interface ParseXmlResult {\n  root: XmlNode | null\n  error: string | null\n}\n\nfunction domToNode(node: Node): XmlNode | null {\n  if (node.nodeType === Node.ELEMENT_NODE) {\n    const el = node as Element\n    const attributes: XmlAttr[] = Array.from(el.attributes).map((a) => ({\n      name: a.name,\n      value: a.value,\n    }))\n    const children: XmlNode[] = []\n    for (const child of Array.from(el.childNodes)) {\n      const n = domToNode(child)\n      if (n) children.push(n)\n    }\n    return {\n      type: 'element',\n      name: el.tagName,\n      attributes,\n      text: '',\n      children,\n    }\n  }\n\n  if (node.nodeType === Node.TEXT_NODE) {\n    const text = node.textContent ?? ''\n    if (!text.trim()) return null\n    return { type: 'text', name: '', attributes: [], text, children: [] }\n  }\n\n  if (node.nodeType === Node.COMMENT_NODE) {\n    return {\n      type: 'comment',\n      name: '',\n      attributes: [],\n      text: node.textContent ?? '',\n      children: [],\n    }\n  }\n\n  if (node.nodeType === Node.CDATA_SECTION_NODE) {\n    return {\n      type: 'cdata',\n      name: '',\n      attributes: [],\n      text: node.textContent ?? '',\n      children: [],\n    }\n  }\n\n  return null\n}\n\nexport function parseXml(source: string): ParseXmlResult {\n  const trimmed = source?.trim() ?? ''\n  if (!trimmed) return { root: null, error: 'Empty XML' }\n\n  if (typeof DOMParser === 'undefined') {\n    return { root: null, error: 'DOMParser is not available in this environment' }\n  }\n\n  try {\n    const doc = new DOMParser().parseFromString(trimmed, 'application/xml')\n    const parseError = doc.querySelector('parsererror')\n    if (parseError) {\n      const msg = parseError.textContent?.replace(/\\s+/g, ' ').trim() || 'Invalid XML'\n      return { root: null, error: msg }\n    }\n    const el = doc.documentElement\n    if (!el) return { root: null, error: 'Empty document' }\n    const root = domToNode(el)\n    if (!root) return { root: null, error: 'Could not read document element' }\n    return { root, error: null }\n  } catch (e) {\n    return { root: null, error: e instanceof Error ? e.message : 'Failed to parse XML' }\n  }\n}\n\nfunction escapeAttr(s: string): string {\n  return s.replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;')\n}\n\nfunction escapeText(s: string): string {\n  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')\n}\n\nexport function serializeXml(node: XmlNode, indent = 0): string {\n  const pad = '  '.repeat(indent)\n\n  if (node.type === 'text') return node.text\n  if (node.type === 'comment') return `${pad}<!--${node.text}-->`\n  if (node.type === 'cdata') return `${pad}<![CDATA[${node.text}]]>`\n\n  const attrs =\n    node.attributes.length > 0 ? ' ' + node.attributes.map((a) => `${a.name}=\"${escapeAttr(a.value)}\"`).join(' ') : ''\n\n  if (node.children.length === 0) {\n    return `${pad}<${node.name}${attrs} />`\n  }\n\n  if (node.children.length === 1 && node.children[0]!.type === 'text') {\n    return `${pad}<${node.name}${attrs}>${escapeText(node.children[0]!.text)}</${node.name}>`\n  }\n\n  const inner = node.children.map((c) => serializeXml(c, indent + 1)).join('\\n')\n  return `${pad}<${node.name}${attrs}>\\n${inner}\\n${pad}</${node.name}>`\n}\n\nexport function isExpandable(node: XmlNode): boolean {\n  if (node.type !== 'element') return false\n  if (node.children.length === 0) return false\n  if (node.children.length === 1 && node.children[0]!.type === 'text') return false\n  return true\n}\n\nexport function countElements(node: XmlNode): number {\n  let n = node.type === 'element' ? 1 : 0\n  for (const c of node.children) n += countElements(c)\n  return n\n}\n\nexport function pathKey(path: string[]): string {\n  return path.length ? '/' + path.join('/') : '/'\n}\n\nexport function walkExpandable(\n  node: XmlNode,\n  path: string[],\n  depth: number,\n  max: number,\n  visit: (path: string[], node: XmlNode) => void,\n) {\n  if (depth >= max) return\n  if (isExpandable(node)) {\n    visit(path, node)\n    const counts = new Map<string, number>()\n    const totals = new Map<string, number>()\n    for (const c of node.children) {\n      if (c.type === 'element') totals.set(c.name, (totals.get(c.name) ?? 0) + 1)\n    }\n    node.children.forEach((child, i) => {\n      let segment: string\n      if (child.type === 'element') {\n        const n = (counts.get(child.name) ?? 0) + 1\n        counts.set(child.name, n)\n        const total = totals.get(child.name) ?? 1\n        segment = total > 1 ? `${child.name}[${n}]` : child.name\n      } else if (child.type === 'comment') {\n        segment = `comment()[${i}]`\n      } else {\n        segment = `text()[${i}]`\n      }\n      walkExpandable(child, [...path, segment], depth + 1, max, visit)\n    })\n  }\n}\n\nconst tagColor = 'text-violet-600 dark:text-violet-400'\nconst attrNameColor = 'text-blue-600 dark:text-blue-400'\nconst attrValueColor = 'text-emerald-600 dark:text-emerald-400'\nconst textColor = 'text-emerald-600 dark:text-emerald-400'\n\n/**\n * <uip-xml-tree-view> — Interactive XML inspector with syntax highlighting,\n * node folding, XPath-style breadcrumbs, and search filtering.\n */\nexport class UipXmlTreeView extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    data: { type: String },\n    expandDepth: { type: Number, attribute: 'expand-depth' },\n    maxDepth: { type: Number, attribute: 'max-depth' },\n    showSearch: {\n      type: Boolean,\n      attribute: 'show-search',\n      converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' },\n    },\n    showToolbar: {\n      type: Boolean,\n      attribute: 'show-toolbar',\n      converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' },\n    },\n    rootLabel: { type: String, attribute: 'root-label' },\n    search: { state: true },\n    expanded: { state: true },\n    copiedPath: { state: true },\n    parsedResult: { state: true },\n  }\n\n  data = ''\n  expandDepth = 1\n  maxDepth = 100\n  showSearch = true\n  showToolbar = true\n  rootLabel?: string\n\n  search = ''\n  expanded = new Set<string>()\n  copiedPath: string | null = null\n  parsedResult: ParseXmlResult = { root: null, error: null }\n\n  private copyTimer: ReturnType<typeof setTimeout> | null = null\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'xml-tree-view')\n    this.parseAndInit()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    if (this.copyTimer) clearTimeout(this.copyTimer)\n  }\n\n  updated(changedProperties: Map<string, unknown>) {\n    if (changedProperties.has('data') || changedProperties.has('expandDepth')) {\n      this.parseAndInit()\n    }\n  }\n\n  private parseAndInit() {\n    if (isServer) return\n    this.parsedResult = parseXml(this.data)\n    const next = new Set<string>()\n    if (this.parsedResult.root) {\n      walkExpandable(this.parsedResult.root, [], 0, this.expandDepth, (path) => {\n        next.add(pathKey(path))\n      })\n    }\n    this.expanded = next\n  }\n\n  private toggle(path: string[]) {\n    const k = pathKey(path)\n    const next = new Set(this.expanded)\n    if (next.has(k)) next.delete(k)\n    else next.add(k)\n    this.expanded = next\n  }\n\n  expandAll() {\n    const next = new Set<string>()\n    if (this.parsedResult.root) {\n      walkExpandable(this.parsedResult.root, [], 0, this.maxDepth, (path) => {\n        next.add(pathKey(path))\n      })\n    }\n    this.expanded = next\n  }\n\n  collapseAll() {\n    this.expanded = new Set()\n  }\n\n  private matchesSearch(node: XmlNode): boolean {\n    if (!this.search) return true\n    const term = this.search.toLowerCase()\n    const walk = (n: XmlNode): boolean => {\n      if (n.name.toLowerCase().includes(term)) return true\n      if (n.text.toLowerCase().includes(term)) return true\n      if (n.attributes.some((a) => a.name.toLowerCase().includes(term) || a.value.toLowerCase().includes(term)))\n        return true\n      return n.children.some(walk)\n    }\n    return walk(node)\n  }\n\n  private async copyNode(node: XmlNode, path: string[]) {\n    const text = serializeXml(node)\n    const p = pathKey(path)\n    try {\n      await navigator.clipboard.writeText(text)\n      this.copiedPath = p\n      this.dispatchEvent(\n        new CustomEvent('copy', {\n          detail: { value: text, path: p },\n          bubbles: true,\n          composed: true,\n        }),\n      )\n      if (this.copyTimer) clearTimeout(this.copyTimer)\n      this.copyTimer = setTimeout(() => {\n        this.copiedPath = null\n      }, 1200)\n    } catch {\n      /* ignore */\n    }\n  }\n\n  private renderNode(node: XmlNode, path: string[], depth: number, isRoot = false): unknown {\n    const key = pathKey(path)\n    const expandable = isExpandable(node)\n    const open = this.expanded.has(key)\n    const dimmed = Boolean(this.search && !this.matchesSearch(node))\n    const isCopied = this.copiedPath === key\n    const indent = isRoot ? 0 : 16\n\n    if (node.type === 'comment') {\n      return html`\n        <div\n          class=${cn('flex items-center gap-1 text-muted-foreground italic font-mono text-xs py-0.5 px-1', dimmed && 'opacity-40')}\n          style=\"margin-left: ${indent}px;\"\n        >\n          <span>&lt;!--${node.text}--&gt;</span>\n        </div>\n      `\n    }\n\n    if (node.type === 'cdata') {\n      return html`\n        <div\n          class=${cn('flex items-center gap-1 text-muted-foreground font-mono text-xs py-0.5 px-1', dimmed && 'opacity-40')}\n          style=\"margin-left: ${indent}px;\"\n        >\n          <span>&lt;![CDATA[${node.text}]]&gt;</span>\n        </div>\n      `\n    }\n\n    if (node.type === 'text') {\n      return html`\n        <div\n          class=${cn('flex items-center gap-1 font-mono text-xs py-0.5 px-1', textColor, dimmed && 'opacity-40')}\n          style=\"margin-left: ${indent}px;\"\n        >\n          <span>${node.text}</span>\n        </div>\n      `\n    }\n\n    // Element node\n    const hasSingleText = node.children.length === 1 && node.children[0]!.type === 'text'\n    const singleText = hasSingleText ? node.children[0]!.text : ''\n\n    const counts = new Map<string, number>()\n    const totals = new Map<string, number>()\n    for (const c of node.children) {\n      if (c.type === 'element') totals.set(c.name, (totals.get(c.name) ?? 0) + 1)\n    }\n\n    return html`\n      <div\n        class=${cn('group flex flex-col font-mono text-xs select-none', dimmed && 'opacity-40')}\n        style=\"margin-left: ${indent}px;\"\n      >\n        <div\n          class=\"hover:bg-muted/50 flex items-center gap-1.5 rounded py-0.5 px-1 cursor-pointer transition-colors\"\n          @click=${() => (expandable ? this.toggle(path) : this.copyNode(node, path))}\n        >\n          <!-- Toggle icon / spacer -->\n          ${expandable\n            ? html`\n                <span class=\"text-muted-foreground flex size-4 items-center justify-center\">\n                  ${open\n                    ? icon(ChevronDown, 'chevron-down', 'size-3.5')\n                    : icon(ChevronRight, 'chevron-right', 'size-3.5')}\n                </span>\n              `\n            : html`<span class=\"size-4\"></span>`}\n\n          <!-- Open Tag -->\n          <span class=\"text-muted-foreground\">&lt;</span>\n          <span class=${cn('font-semibold', tagColor)}>${node.name}</span>\n\n          <!-- Attributes -->\n          ${node.attributes.map(\n            (a) => html`\n              <span class=\"ml-1\">\n                <span class=${attrNameColor}>${a.name}</span>\n                <span class=\"text-muted-foreground\">=</span>\n                <span class=${attrValueColor}>\"${a.value}\"</span>\n              </span>\n            `,\n          )}\n\n          ${node.children.length === 0\n            ? html`<span class=\"text-muted-foreground\">/&gt;</span>`\n            : hasSingleText\n              ? html`\n                  <span class=\"text-muted-foreground\">&gt;</span>\n                  <span class=${textColor}>${singleText}</span>\n                  <span class=\"text-muted-foreground\">&lt;/</span>\n                  <span class=${cn('font-semibold', tagColor)}>${node.name}</span>\n                  <span class=\"text-muted-foreground\">&gt;</span>\n                `\n              : html`<span class=\"text-muted-foreground\">&gt;</span>`}\n\n          ${expandable && !open\n            ? html`\n                <span class=\"text-muted-foreground ml-1\">…</span>\n                <span class=\"text-muted-foreground\">&lt;/</span>\n                <span class=${cn('font-semibold', tagColor)}>${node.name}</span>\n                <span class=\"text-muted-foreground\">&gt;</span>\n              `\n            : nothing}\n\n          <!-- Quick Copy icon -->\n          <button\n            type=\"button\"\n            class=\"text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 ml-auto p-0.5 transition-opacity\"\n            title=\"Copy XML\"\n            aria-label=\"Copy XML\"\n            @click=${(e: Event) => {\n              e.stopPropagation()\n              this.copyNode(node, path)\n            }}\n          >\n            ${isCopied ? icon(Check, 'check', 'size-3 text-success') : icon(Copy, 'copy', 'size-3')}\n          </button>\n        </div>\n\n        <!-- Expanded Children -->\n        ${expandable && open\n          ? html`\n              <div class=\"flex flex-col\">\n                ${node.children.map((child, i) => {\n                  let segment: string\n                  if (child.type === 'element') {\n                    const n = (counts.get(child.name) ?? 0) + 1\n                    counts.set(child.name, n)\n                    const total = totals.get(child.name) ?? 1\n                    segment = total > 1 ? `${child.name}[${n}]` : child.name\n                  } else if (child.type === 'comment') {\n                    segment = `comment()[${i}]`\n                  } else {\n                    segment = `text()[${i}]`\n                  }\n                  return this.renderNode(child, [...path, segment], depth + 1)\n                })}\n                <div class=\"text-muted-foreground py-0.5 px-1 ml-4\">\n                  <span>&lt;/</span>\n                  <span class=${cn('font-semibold', tagColor)}>${node.name}</span>\n                  <span>&gt;</span>\n                </div>\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n\n  render() {\n    const { root, error } = this.parsedResult\n\n    if (error) {\n      return html`\n        <div\n          part=\"base\"\n          class=\"border-destructive/50 bg-destructive/5 text-destructive flex items-center gap-3 rounded-lg border p-4 text-xs font-mono\"\n        >\n          ${icon(AlertCircle, 'alert-circle', 'size-5 shrink-0')}\n          <div>\n            <p class=\"font-semibold font-sans\">XML Parse Error</p>\n            <p class=\"mt-0.5 opacity-90\">${error}</p>\n          </div>\n        </div>\n      `\n    }\n\n    const count = root ? countElements(root) : 0\n    const summary = `${count} element${count === 1 ? '' : 's'}`\n\n    return html`\n      <div\n        part=\"base\"\n        class=\"border-border bg-background flex flex-col overflow-hidden rounded-lg border font-mono text-sm\"\n      >\n        <!-- Toolbar -->\n        ${this.showToolbar || this.showSearch\n          ? html`\n              <div class=\"border-border flex items-center gap-2 border-b px-3 py-2\">\n                <div class=\"flex items-center gap-1.5 font-sans\">\n                  ${icon(CodeXml, 'code-xml', 'text-muted-foreground size-4')}\n                  <span class=\"text-muted-foreground text-xs font-mono\">${summary}</span>\n                </div>\n                <div class=\"ml-auto flex items-center gap-1 font-sans\">\n                  ${this.showSearch\n                    ? html`\n                        <div class=\"relative\">\n                          ${icon(\n                            Search,\n                            'search',\n                            'text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2',\n                          )}\n                          <input\n                            type=\"text\"\n                            class=\"border-input bg-muted/40 focus:border-ring focus:ring-ring/30 h-7 w-32 rounded-md pr-2 pl-7 text-xs transition-[width] outline-none focus:w-44 focus:ring-2 font-mono\"\n                            placeholder=\"Filter...\"\n                            aria-label=\"Filter XML tree\"\n                            .value=${this.search}\n                            @input=${(e: Event) => (this.search = (e.target as HTMLInputElement).value)}\n                          />\n                        </div>\n                      `\n                    : nothing}\n                  <button\n                    type=\"button\"\n                    class=\"text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                    title=\"Expand all\"\n                    aria-label=\"Expand all\"\n                    @click=${this.expandAll}\n                  >\n                    ${icon(UnfoldVertical, 'unfold-vertical', 'size-4')}\n                  </button>\n                  <button\n                    type=\"button\"\n                    class=\"text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                    title=\"Collapse all\"\n                    aria-label=\"Collapse all\"\n                    @click=${this.collapseAll}\n                  >\n                    ${icon(FoldVertical, 'fold-vertical', 'size-4')}\n                  </button>\n                </div>\n              </div>\n            `\n          : nothing}\n\n        <!-- Tree Area -->\n        <div class=\"min-h-0 flex-1 overflow-auto p-2\" role=\"tree\">\n          ${root ? this.renderNode(root, [this.rootLabel ?? root.name], 0, true) : nothing}\n        </div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-xml-tree-view') || customElements.define('uip-xml-tree-view', UipXmlTreeView)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-xml-tree-view': UipXmlTreeView\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/xml-tree-view/xml-tree-view.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
