{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "json-tree-view",
  "title": "Json Tree View",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/json-tree-view/JsonTreeView.vue",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { computed, ref, watch } from 'vue'\nimport { ChevronDown, ChevronRight, Search, Braces, Copy, Check, FoldVertical, UnfoldVertical } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport JsonTreeNode from './JsonTreeNode.vue'\nimport type { JsonValue } from './types'\n\nexport type { JsonValue } from './types'\n\ninterface Props {\n  data: JsonValue\n  expandDepth?: number\n  maxDepth?: number\n  showSearch?: boolean\n  showToolbar?: boolean\n  showPath?: boolean\n  rootLabel?: string\n  class?: HTMLAttributes['class']\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  expandDepth: 1,\n  maxDepth: 100,\n  showSearch: true,\n  showToolbar: true,\n  showPath: true,\n  rootLabel: 'root',\n})\n\nconst emit = defineEmits<{\n  copy: [value: string, path: string]\n}>()\n\nconst expanded = ref<Set<string>>(new Set())\nconst search = ref('')\nconst copiedPath = ref<string | null>(null)\nconst hoveredPath = ref<string | null>(null)\n\nfunction pathKey(path: (string | number)[]): string {\n  return path.length ? path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('') : '$'\n}\n\nfunction defaultExpanded(): Set<string> {\n  const next = new Set<string>()\n  const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => {\n    if (depth >= props.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(props.data)\n  return next\n}\n\nwatch(\n  () => [props.data, props.expandDepth],\n  () => {\n    expanded.value = defaultExpanded()\n  },\n  { immediate: true },\n)\n\nfunction toggle(path: (string | number)[]) {\n  const key = pathKey(path)\n  const next = new Set(expanded.value)\n  if (next.has(key)) next.delete(key)\n  else next.add(key)\n  expanded.value = next\n}\n\nfunction isExpanded(path: (string | number)[]): boolean {\n  return expanded.value.has(pathKey(path))\n}\n\nfunction expandAll() {\n  const next = new Set<string>()\n  const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => {\n    if (depth >= props.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(props.data)\n  expanded.value = next\n}\n\nfunction collapseAll() {\n  expanded.value = new Set()\n}\n\n// Auto-expand nodes that contain search matches\nwatch(search, (q) => {\n  if (!q) {\n    expanded.value = defaultExpanded()\n    return\n  }\n  const next = new Set<string>()\n  const walk = (val: JsonValue, path: (string | number)[] = [], depth = 0) => {\n    if (depth >= props.maxDepth) return\n    if (val !== null && typeof val === 'object') {\n      if (matchesSearch(val)) 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(props.data)\n  expanded.value = next\n})\n\nfunction matchesSearch(val: JsonValue): boolean {\n  if (!search.value) return true\n  const term = search.value.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') return Object.entries(v).some(([k, val]) => k.toLowerCase().includes(term) || walk(val))\n    return false\n  }\n  return walk(val)\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\nasync function 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    copiedPath.value = p\n    emit('copy', str, p)\n    setTimeout(() => {\n      if (copiedPath.value === p) copiedPath.value = null\n    }, 1200)\n  } catch {\n    // clipboard unavailable\n  }\n}\n\nfunction onHover(path: string | null) {\n  hoveredPath.value = path\n}\n\nconst displayPath = computed(() => {\n  if (!props.showPath || !hoveredPath.value) return ''\n  if (hoveredPath.value === '$') return props.rootLabel\n  return hoveredPath.value\n})\n\nconst summary = computed(() => {\n  const t = typeOf(props.data)\n  if (t === 'array') return `Array(${(props.data as JsonValue[]).length})`\n  if (t === 'object') return `Object(${Object.keys(props.data as object).length})`\n  return t\n})\n\nconst searchMatchCount = computed(() => {\n  if (!search.value) return 0\n  let count = 0\n  const walk = (v: JsonValue) => {\n    if (v === null) { if ('null'.includes(search.value.toLowerCase())) count++; return }\n    if (typeof v === 'string') { if (v.toLowerCase().includes(search.value.toLowerCase())) count++; return }\n    if (typeof v === 'number' || typeof v === 'boolean') { if (String(v).includes(search.value)) count++; return }\n    if (Array.isArray(v)) { v.forEach(walk); return }\n    if (typeof v === 'object') { Object.entries(v).forEach(([k, val]) => { if (k.toLowerCase().includes(search.value.toLowerCase())) count++; walk(val) }) }\n  }\n  walk(props.data)\n  return count\n})\n</script>\n\n<template>\n  <div\n    data-uipkge\n    data-slot=\"json-tree-view\"\n    :class=\"cn('bg-background rounded-lg border font-mono text-sm', props.class)\"\n  >\n    <!-- Toolbar -->\n    <div v-if=\"showToolbar || showSearch\" class=\"border-border flex items-center gap-2 border-b px-3 py-2\">\n      <div class=\"flex items-center gap-1.5\">\n        <Braces class=\"text-muted-foreground size-4\" />\n        <span class=\"text-muted-foreground text-xs\">{{ summary }}</span>\n      </div>\n      <div class=\"ml-auto flex items-center gap-1\">\n        <div v-if=\"showSearch\" class=\"relative\">\n          <Search class=\"text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2\" />\n          <input\n            v-model=\"search\"\n            type=\"text\"\n            placeholder=\"Filter...\"\n            aria-label=\"Filter JSON tree\"\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\"\n          />\n        </div>\n        <span v-if=\"search && searchMatchCount > 0\" class=\"text-muted-foreground text-xs\">{{ searchMatchCount }} matches</span>\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=\"expandAll\"\n        >\n          <UnfoldVertical class=\"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=\"collapseAll\"\n        >\n          <FoldVertical class=\"size-4\" />\n        </button>\n      </div>\n    </div>\n\n    <!-- Path bar -->\n    <div\n      v-if=\"showPath && displayPath\"\n      class=\"border-border bg-muted/30 text-muted-foreground truncate border-b px-3 py-1 text-xs\"\n    >\n      {{ displayPath }}\n    </div>\n\n    <!-- Tree -->\n    <div class=\"overflow-auto p-2\">\n      <JsonTreeNode\n        :data=\"data\"\n        :path=\"[]\"\n        :label=\"rootLabel\"\n        :is-root=\"true\"\n        :search=\"search\"\n        :max-depth=\"maxDepth\"\n        :matches-search=\"matchesSearch\"\n        :is-expanded=\"isExpanded\"\n        :toggle=\"toggle\"\n        :type-of=\"typeOf\"\n        :format-value=\"formatValue\"\n        :type-color=\"typeColor\"\n        :key-color=\"keyColor\"\n        :copied-path=\"copiedPath\"\n        @copy=\"copyValue\"\n        @hover=\"onHover\"\n      />\n    </div>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/json-tree-view/JsonTreeView.vue"
    },
    {
      "path": "packages/registry-vue/components/json-tree-view/JsonTreeNode.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, defineAsyncComponent } from 'vue'\nimport { ChevronDown, ChevronRight, Copy, Check } from 'lucide-vue-next'\nimport type { JsonValue } from './types'\n\n// Self-reference for recursive rendering — use defineAsyncComponent to avoid circular import\nconst JsonTreeNode = defineAsyncComponent(() => import('./JsonTreeNode.vue'))\n\ninterface Props {\n  data: JsonValue\n  path: (string | number)[]\n  label: string\n  isRoot?: boolean\n  search?: string\n  maxDepth?: number\n  matchesSearch: (val: JsonValue) => boolean\n  isExpanded: (path: (string | number)[]) => boolean\n  toggle: (path: (string | number)[]) => void\n  typeOf: (val: JsonValue) => 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'\n  formatValue: (val: JsonValue) => string\n  typeColor: Record<string, string>\n  keyColor: string\n  copiedPath?: string | null\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  isRoot: false,\n  search: '',\n  maxDepth: 100,\n  copiedPath: null,\n})\n\nconst emit = defineEmits<{\n  copy: [value: JsonValue, path: (string | number)[]]\n  hover: [path: string | null]\n}>()\n\nfunction pathKey(path: (string | number)[]): string {\n  return path.length ? path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${p}`)).join('') : '$'\n}\n\nconst key = computed(() => pathKey(props.path))\nconst type = computed(() => props.typeOf(props.data))\nconst open = computed(() => props.isExpanded(props.path))\nconst isContainer = computed(() => type.value === 'object' || type.value === 'array')\nconst dimmed = computed(() => !!props.search && !props.matchesSearch(props.data))\n\nconst entries = computed<[string | number, JsonValue][]>(() => {\n  const val = props.data\n  if (Array.isArray(val)) return val.map((v, i) => [i, v] as const)\n  if (val !== null && typeof val === 'object') return Object.entries(val) as [string, JsonValue][]\n  return []\n})\n\nconst count = computed(() => entries.value.length)\nconst indent = computed(() => (props.isRoot ? 0 : 20))\n\n// Collapsed preview: show first few items inline\nconst collapsedPreview = computed(() => {\n  if (open.value || !isContainer.value) return ''\n  const items = entries.value.slice(0, 3)\n  const parts = items.map(([k, v]) => {\n    const vt = props.typeOf(v)\n    let valStr: string\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 = props.formatValue(v)\n    return `${Array.isArray(props.data) ? '' : `\"${k}\": `}${valStr}`\n  })\n  const suffix = count.value > 3 ? ', …' : ''\n  const open2 = type.value === 'array' ? '[' : '{'\n  const close = type.value === 'array' ? ']' : '}'\n  return `${open2}${parts.join(', ')}${suffix}${close}`\n})\n\nfunction onCopy() {\n  emit('copy', props.data, props.path)\n}\n\nfunction onHover() {\n  emit('hover', key.value)\n}\n\nfunction onLeave() {\n  emit('hover', null)\n}\n</script>\n\n<template>\n  <div :data-dimmed=\"dimmed ? '' : undefined\" :class=\"dimmed ? 'opacity-30' : ''\">\n    <!-- Container header row (object/array) -->\n    <div\n      v-if=\"isContainer\"\n      class=\"group hover:bg-accent/40 flex items-center gap-0.5 rounded px-1 -mx-1 py-0.5 transition-colors\"\n      :style=\"{ paddingLeft: `${indent}px` }\"\n    >\n      <button\n        type=\"button\"\n        class=\"text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-4 shrink-0 items-center justify-center rounded\"\n        :aria-expanded=\"open\"\n        :aria-label=\"open ? 'Collapse' : 'Expand'\"\n        @click=\"toggle(path)\"\n      >\n        <ChevronDown v-if=\"open\" class=\"size-3.5\" />\n        <ChevronRight v-else class=\"size-3.5\" />\n      </button>\n      <span :class=\"keyColor\" class=\"select-none\" @mouseenter=\"onHover\" @mouseleave=\"onLeave\">\n        {{ isRoot ? label : `\"${label}\"` }}\n      </span>\n      <span class=\"text-muted-foreground\">:</span>\n      <span v-if=\"open\" class=\"text-muted-foreground select-none\">{{ type === 'array' ? '[' : '{' }}</span>\n      <span v-else class=\"text-muted-foreground select-none\">{{ collapsedPreview }}</span>\n      <span v-if=\"open\" class=\"text-muted-foreground ml-0.5 text-xs\">{{ count }} {{ count === 1 ? 'item' : 'items' }}</span>\n      <button\n        type=\"button\"\n        class=\"text-muted-foreground hover:text-foreground ml-auto inline-flex size-5 items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100\"\n        title=\"Copy value\"\n        aria-label=\"Copy value\"\n        @click.stop=\"onCopy\"\n      >\n        <Check v-if=\"copiedPath === key\" class=\"size-3 text-emerald-500\" />\n        <Copy v-else class=\"size-3\" />\n      </button>\n    </div>\n\n    <!-- Container children -->\n    <template v-if=\"isContainer && open\">\n      <JsonTreeNode\n        v-for=\"[k, v] in entries\"\n        :key=\"String(k)\"\n        :data=\"v\"\n        :path=\"[...path, k]\"\n        :label=\"String(k)\"\n        :is-root=\"false\"\n        :search=\"search\"\n        :max-depth=\"maxDepth\"\n        :matches-search=\"matchesSearch\"\n        :is-expanded=\"isExpanded\"\n        :toggle=\"toggle\"\n        :type-of=\"typeOf\"\n        :format-value=\"formatValue\"\n        :type-color=\"typeColor\"\n        :key-color=\"keyColor\"\n        :copied-path=\"copiedPath\"\n        @copy=\"(val, p) => emit('copy', val, p)\"\n        @hover=\"(p) => emit('hover', p)\"\n      />\n      <div class=\"text-muted-foreground select-none py-0.5\" :style=\"{ paddingLeft: `${indent}px` }\">\n        {{ type === 'array' ? ']' : '}' }}\n      </div>\n    </template>\n\n    <!-- Primitive leaf -->\n    <div\n      v-if=\"!isContainer\"\n      class=\"group hover:bg-accent/40 flex items-center gap-0.5 rounded px-1 -mx-1 py-0.5 transition-colors\"\n      :style=\"{ paddingLeft: `${indent}px` }\"\n    >\n      <span class=\"inline-flex size-4 shrink-0\" />\n      <span v-if=\"isRoot\" class=\"text-muted-foreground select-none\">{{ label }}</span>\n      <span v-else :class=\"keyColor\" class=\"select-none\">\"{{ label }}\"</span>\n      <span class=\"text-muted-foreground\">:</span>\n      <span\n        :class=\"typeColor[type] ?? 'text-foreground'\"\n        class=\"cursor-pointer\"\n        @click=\"onCopy\"\n        @mouseenter=\"onHover\"\n        @mouseleave=\"onLeave\"\n      >\n        {{ formatValue(data) }}\n      </span>\n      <button\n        type=\"button\"\n        class=\"text-muted-foreground hover:text-foreground ml-auto inline-flex size-5 items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100\"\n        title=\"Copy value\"\n        aria-label=\"Copy value\"\n        @click.stop=\"onCopy\"\n      >\n        <Check v-if=\"copiedPath === key\" class=\"size-3 text-emerald-500\" />\n        <Copy v-else class=\"size-3\" />\n      </button>\n    </div>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/json-tree-view/JsonTreeNode.vue"
    },
    {
      "path": "packages/registry-vue/components/json-tree-view/types.ts",
      "content": "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/json-tree-view/types.ts"
    },
    {
      "path": "packages/registry-vue/components/json-tree-view/index.ts",
      "content": "export { default as JsonTreeView } from './JsonTreeView.vue'\nexport { default as JsonTreeNode } from './JsonTreeNode.vue'\nexport type { JsonValue } from './types'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/json-tree-view/index.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Collapsible JSON tree viewer with color-coded value types, click-to-copy, live search/filter, hover path display, and expand/collapse-all controls. Renders objects, arrays, and primitives with configurable default depth and max depth.",
  "categories": [
    "display",
    "data"
  ]
}