{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "json-tree-view",
  "title": "Json Tree View",
  "type": "registry:ui",
  "description": "Collapsible JSON tree viewer with color-coded value types, click-to-copy, live search/filter, and expand/collapse-all controls. Renders objects, arrays, and primitives with configurable depth and contained scrolling.",
  "categories": [
    "display",
    "data"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/json-tree-view/json-tree-view.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { Braces, Check, ChevronDown, ChevronRight, 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 JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }\n\nfunction pathKey(path: (string | number)[]): string {\n  return path.length ? path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('') : '$'\n}\n\nfunction typeOf(val: JsonValue): 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' {\n  if (val === null) return 'null'\n  if (Array.isArray(val)) return 'array'\n  return typeof val as 'object' | 'string' | 'number' | 'boolean'\n}\n\nfunction formatValue(val: JsonValue): string {\n  if (val === null) return 'null'\n  if (typeof val === 'string') return JSON.stringify(val)\n  return String(val)\n}\n\nconst typeColor: Record<string, string> = {\n  string: 'text-emerald-600 dark:text-emerald-400',\n  number: 'text-blue-600 dark:text-blue-400',\n  boolean: 'text-amber-600 dark:text-amber-400',\n  null: 'text-muted-foreground italic',\n  object: 'text-foreground',\n  array: 'text-foreground',\n}\n\nconst keyColor = 'text-violet-600 dark:text-violet-400'\n\n/**\n * <uip-json-tree-view> — Interactive JSON explorer with search, syntax coloring,\n * collapsible nodes, path copy, and depth management.\n */\nexport class UipJsonTreeView 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: Object, attribute: false },\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  }\n\n  data: JsonValue = {}\n  expandDepth = 1\n  maxDepth = 100\n  showSearch = true\n  showToolbar = true\n  rootLabel = 'root'\n\n  search = ''\n  expanded = new Set<string>()\n  copiedPath: string | null = 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', 'json-tree-view')\n    this.initExpanded()\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.initExpanded()\n    }\n  }\n\n  private initExpanded() {\n    const next = new Set<string>()\n    const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => {\n      if (depth >= this.expandDepth) return\n      if (val !== null && typeof val === 'object') {\n        next.add(pathKey(path))\n        const entries = Array.isArray(val) ? val.map((v, i) => [i, v] as const) : Object.entries(val)\n        for (const [k, v] of entries) {\n          walk(v as JsonValue, [...path, k], depth + 1)\n        }\n      }\n    }\n    walk(this.data)\n    this.expanded = next\n  }\n\n  private toggle(path: (string | number)[]) {\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    const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => {\n      if (depth >= this.maxDepth) return\n      if (val !== null && typeof val === 'object') {\n        next.add(pathKey(path))\n        const entries = Array.isArray(val) ? val.map((v, i) => [i, v] as const) : Object.entries(val)\n        for (const [k, v] of entries) {\n          walk(v as JsonValue, [...path, k], depth + 1)\n        }\n      }\n    }\n    walk(this.data)\n    this.expanded = next\n  }\n\n  collapseAll() {\n    this.expanded = new Set()\n  }\n\n  private matchesSearch(val: JsonValue): boolean {\n    if (!this.search) return true\n    const term = this.search.toLowerCase()\n    const walk = (v: JsonValue): boolean => {\n      if (v === null) return 'null'.includes(term)\n      if (typeof v === 'string') return v.toLowerCase().includes(term)\n      if (typeof v === 'number' || typeof v === 'boolean') return String(v).includes(term)\n      if (Array.isArray(v)) return v.some(walk)\n      if (typeof v === 'object')\n        return Object.entries(v).some(([k, value]) => k.toLowerCase().includes(term) || walk(value))\n      return false\n    }\n    return walk(val)\n  }\n\n  private async copyValue(val: JsonValue, path: (string | number)[]) {\n    const str = typeof val === 'string' ? val : JSON.stringify(val, null, 2)\n    const p = pathKey(path)\n    try {\n      await navigator.clipboard.writeText(str)\n      this.copiedPath = p\n      this.dispatchEvent(\n        new CustomEvent('copy', {\n          detail: { value: str, 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 countMatches(val: JsonValue): number {\n    if (!this.search) return 0\n    let count = 0\n    const term = this.search.toLowerCase()\n    const walk = (v: JsonValue) => {\n      if (v === null) {\n        if ('null'.includes(term)) count++\n        return\n      }\n      if (typeof v === 'string') {\n        if (v.toLowerCase().includes(term)) count++\n        return\n      }\n      if (typeof v === 'number' || typeof v === 'boolean') {\n        if (String(v).includes(term)) count++\n        return\n      }\n      if (Array.isArray(v)) {\n        v.forEach(walk)\n        return\n      }\n      if (typeof v === 'object') {\n        Object.entries(v).forEach(([k, item]) => {\n          if (k.toLowerCase().includes(term)) count++\n          walk(item)\n        })\n      }\n    }\n    walk(val)\n    return count\n  }\n\n  private renderNode(\n    data: JsonValue,\n    path: (string | number)[],\n    label: string,\n    depth: number,\n    isRoot = false,\n  ): unknown {\n    const key = pathKey(path)\n    const type = typeOf(data)\n    const isContainer = type === 'object' || type === 'array'\n    const open = this.expanded.has(key)\n    const dimmed = Boolean(this.search && !this.matchesSearch(data))\n\n    const entries: [string | number, JsonValue][] = isContainer\n      ? Array.isArray(data)\n        ? data.map((v, i) => [i, v] as [number, JsonValue])\n        : (Object.entries(data as object) as [string, JsonValue][])\n      : []\n\n    const count = entries.length\n    const indent = isRoot ? 0 : 16\n\n    // Collapsed preview\n    let collapsedPreview = ''\n    if (!open && isContainer) {\n      const items = entries.slice(0, 3)\n      const parts = items.map(([k, v]) => {\n        const vt = typeOf(v)\n        let valStr = ''\n        if (vt === 'string') valStr = `\"${String(v).slice(0, 20)}\"`\n        else if (vt === 'array') valStr = '[…]'\n        else if (vt === 'object') valStr = '{…}'\n        else valStr = formatValue(v)\n        return `${Array.isArray(data) ? '' : `\"${k}\": `}${valStr}`\n      })\n      const suffix = count > 3 ? ', …' : ''\n      const openBr = type === 'array' ? '[' : '{'\n      const closeBr = type === 'array' ? ']' : '}'\n      collapsedPreview = `${openBr}${parts.join(', ')}${suffix}${closeBr}`\n    }\n\n    const isCopied = this.copiedPath === key\n\n    return html`\n      <div\n        class=${cn(\n          'group flex flex-col font-mono text-xs select-none',\n          dimmed && 'opacity-40',\n        )}\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=${() => (isContainer ? this.toggle(path) : this.copyValue(data, path))}\n        >\n          <!-- Toggle icon / spacer -->\n          ${isContainer\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          <!-- Key / Label -->\n          <span class=${cn('font-semibold', keyColor)}>${label}:</span>\n\n          <!-- Value / Preview -->\n          ${isContainer\n            ? html`\n                <span class=\"text-muted-foreground\">\n                  ${open ? (type === 'array' ? `[${count}]` : `{${count}}`) : collapsedPreview}\n                </span>\n              `\n            : html`<span class=${typeColor[type] || 'text-foreground'}>${formatValue(data)}</span>`}\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 value\"\n            aria-label=\"Copy value\"\n            @click=${(e: Event) => {\n              e.stopPropagation()\n              this.copyValue(data, 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        ${isContainer && open\n          ? html`\n              <div class=\"flex flex-col\">\n                ${entries.map(([childKey, childVal]) =>\n                  this.renderNode(childVal, [...path, childKey], String(childKey), depth + 1),\n                )}\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n\n  render() {\n    const t = typeOf(this.data)\n    const summary =\n      t === 'array'\n        ? `Array(${(this.data as JsonValue[]).length})`\n        : t === 'object'\n          ? `Object(${Object.keys(this.data as object).length})`\n          : t\n\n    const matchCount = this.countMatches(this.data)\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(Braces, 'braces', '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 JSON tree\"\n                            .value=${this.search}\n                            @input=${(e: Event) => (this.search = (e.target as HTMLInputElement).value)}\n                          />\n                        </div>\n                      `\n                    : nothing}\n                  ${this.search\n                    ? html`\n                        <span class=\"text-muted-foreground text-xs\">\n                          ${matchCount} match${matchCount === 1 ? '' : 'es'}\n                        </span>\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\" aria-label=${this.rootLabel}>\n          ${this.renderNode(this.data, [], this.rootLabel, 0, true)}\n        </div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-json-tree-view') || customElements.define('uip-json-tree-view', UipJsonTreeView)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-json-tree-view': UipJsonTreeView\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/json-tree-view/json-tree-view.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
