{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "title": "JSON Viewer",
  "description": "Collapsible JSON tree viewer with syntax highlighting, search, and copy path",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/blocks/devtools/json-viewer/components/elements/json-viewer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype JsonValue =\n  | string\n  | number\n  | boolean\n  | null\n  | JsonValue[]\n  | { [key: string]: JsonValue };\n\ninterface JsonViewerProps {\n  data: JsonValue;\n  collapsed?: boolean | number;\n  searchable?: boolean;\n  copyPath?: boolean;\n  maxDepth?: number;\n  className?: string;\n}\n\ninterface JsonNodeProps {\n  keyName?: string;\n  value: JsonValue;\n  depth: number;\n  path: string;\n  defaultCollapsed: boolean | number;\n  maxDepth: number;\n  copyPath: boolean;\n  searchQuery: string;\n}\n\nfunction getValueType(value: JsonValue): string {\n  if (value === null) return \"null\";\n  if (Array.isArray(value)) return \"array\";\n  return typeof value;\n}\n\nfunction getPreview(value: JsonValue): string {\n  if (Array.isArray(value)) return `Array(${value.length})`;\n  if (typeof value === \"object\" && value !== null) {\n    const keys = Object.keys(value);\n    return `{${keys.length > 0 ? keys.slice(0, 3).join(\", \") + (keys.length > 3 ? \"...\" : \"\") : \"\"}}`;\n  }\n  return String(value);\n}\n\nfunction matchesSearch(value: JsonValue, query: string): boolean {\n  if (!query) return true;\n  const lowerQuery = query.toLowerCase();\n\n  if (typeof value === \"string\")\n    return value.toLowerCase().includes(lowerQuery);\n  if (typeof value === \"number\") return String(value).includes(lowerQuery);\n  if (typeof value === \"boolean\") return String(value).includes(lowerQuery);\n  if (value === null) return \"null\".includes(lowerQuery);\n  if (Array.isArray(value)) return value.some((v) => matchesSearch(v, query));\n  if (typeof value === \"object\") {\n    return Object.entries(value).some(\n      ([k, v]) =>\n        k.toLowerCase().includes(lowerQuery) || matchesSearch(v, query),\n    );\n  }\n  return false;\n}\n\nfunction JsonNode({\n  keyName,\n  value,\n  depth,\n  path,\n  defaultCollapsed,\n  maxDepth,\n  copyPath,\n  searchQuery,\n}: JsonNodeProps) {\n  const shouldDefaultCollapse =\n    typeof defaultCollapsed === \"number\"\n      ? depth >= defaultCollapsed\n      : defaultCollapsed;\n\n  const [isCollapsed, setIsCollapsed] = React.useState(shouldDefaultCollapse);\n  const [copied, setCopied] = React.useState(false);\n\n  const type = getValueType(value);\n  const isExpandable = type === \"object\" || type === \"array\";\n  const isVisible =\n    matchesSearch(value, searchQuery) ||\n    (keyName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);\n\n  React.useEffect(() => {\n    if (searchQuery && isExpandable && matchesSearch(value, searchQuery)) {\n      setIsCollapsed(false);\n    }\n  }, [searchQuery, isExpandable, value]);\n\n  const handleToggle = React.useCallback(() => {\n    setIsCollapsed((prev) => !prev);\n  }, []);\n\n  const handleKeyDown = React.useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        handleToggle();\n      } else if (e.key === \"ArrowRight\" && isCollapsed) {\n        e.preventDefault();\n        setIsCollapsed(false);\n      } else if (e.key === \"ArrowLeft\" && !isCollapsed) {\n        e.preventDefault();\n        setIsCollapsed(true);\n      }\n    },\n    [handleToggle, isCollapsed],\n  );\n\n  const handleCopyPath = React.useCallback(async () => {\n    await navigator.clipboard.writeText(path);\n    setCopied(true);\n    setTimeout(() => setCopied(false), 1500);\n  }, [path]);\n\n  if (!isVisible && searchQuery) return null;\n\n  const renderValue = () => {\n    if (type === \"null\") {\n      return <span className=\"text-muted-foreground italic\">null</span>;\n    }\n    if (type === \"boolean\") {\n      return <span className=\"text-amber-500\">{String(value)}</span>;\n    }\n    if (type === \"number\") {\n      return <span className=\"text-blue-500\">{value as number}</span>;\n    }\n    if (type === \"string\") {\n      return (\n        <span className=\"text-green-600 dark:text-green-400\">\n          \"{value as string}\"\n        </span>\n      );\n    }\n    return null;\n  };\n\n  if (!isExpandable) {\n    return (\n      <div className=\"group flex items-center gap-1 py-0.5\" role=\"treeitem\">\n        {keyName !== undefined && (\n          <>\n            <span className=\"text-foreground\">{keyName}</span>\n            <span className=\"text-muted-foreground\">:</span>\n          </>\n        )}\n        {renderValue()}\n        {copyPath && (\n          <button\n            type=\"button\"\n            onClick={handleCopyPath}\n            aria-label={`Copy path ${path}`}\n            className=\"ml-1 opacity-0 group-hover:opacity-100 text-xs text-muted-foreground hover:text-foreground transition-opacity\"\n          >\n            {copied ? \"✓\" : \"⎘\"}\n          </button>\n        )}\n      </div>\n    );\n  }\n\n  if (depth >= maxDepth) {\n    return (\n      <div className=\"flex items-center gap-1 py-0.5\">\n        {keyName !== undefined && (\n          <>\n            <span className=\"text-foreground\">{keyName}</span>\n            <span className=\"text-muted-foreground\">:</span>\n          </>\n        )}\n        <span className=\"text-muted-foreground italic\">\n          {getPreview(value)}\n        </span>\n      </div>\n    );\n  }\n\n  const entries =\n    type === \"array\"\n      ? (value as JsonValue[]).map(\n          (v, i) => [String(i), v] as [string, JsonValue],\n        )\n      : Object.entries(value as Record<string, JsonValue>);\n\n  const brackets = type === \"array\" ? [\"[\", \"]\"] : [\"{\", \"}\"];\n\n  return (\n    <div className=\"py-0.5\" role=\"treeitem\" aria-expanded={!isCollapsed}>\n      <div className=\"group flex items-center gap-1\">\n        <button\n          type=\"button\"\n          onClick={handleToggle}\n          onKeyDown={handleKeyDown}\n          aria-label={isCollapsed ? \"Expand\" : \"Collapse\"}\n          className=\"w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground\"\n        >\n          {isCollapsed ? \"▶\" : \"▼\"}\n        </button>\n        {keyName !== undefined && (\n          <>\n            <span className=\"text-foreground\">{keyName}</span>\n            <span className=\"text-muted-foreground\">:</span>\n          </>\n        )}\n        <span className=\"text-muted-foreground\">\n          {brackets[0]}\n          {isCollapsed && (\n            <>\n              <span className=\"italic text-xs mx-1\">{getPreview(value)}</span>\n              {brackets[1]}\n            </>\n          )}\n        </span>\n        {copyPath && (\n          <button\n            type=\"button\"\n            onClick={handleCopyPath}\n            aria-label={`Copy path ${path}`}\n            className=\"ml-1 opacity-0 group-hover:opacity-100 text-xs text-muted-foreground hover:text-foreground transition-opacity\"\n          >\n            {copied ? \"✓\" : \"⎘\"}\n          </button>\n        )}\n      </div>\n      {!isCollapsed && (\n        <div className=\"ml-4 border-l border-border pl-2\" role=\"group\">\n          {entries.map(([k, v]) => (\n            <JsonNode\n              key={k}\n              keyName={k}\n              value={v}\n              depth={depth + 1}\n              path={type === \"array\" ? `${path}[${k}]` : `${path}.${k}`}\n              defaultCollapsed={defaultCollapsed}\n              maxDepth={maxDepth}\n              copyPath={copyPath}\n              searchQuery={searchQuery}\n            />\n          ))}\n          <div className=\"text-muted-foreground\">{brackets[1]}</div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function JsonViewer({\n  data,\n  collapsed = false,\n  searchable = false,\n  copyPath = true,\n  maxDepth = 10,\n  className,\n}: JsonViewerProps) {\n  const [searchQuery, setSearchQuery] = React.useState(\"\");\n\n  return (\n    <div\n      data-slot=\"json-viewer\"\n      role=\"tree\"\n      aria-label=\"JSON data\"\n      className={cn(\"font-mono text-sm\", className)}\n    >\n      {searchable && (\n        <div className=\"mb-2\">\n          <input\n            type=\"text\"\n            value={searchQuery}\n            onChange={(e) => setSearchQuery(e.target.value)}\n            placeholder=\"Search...\"\n            className=\"w-full px-2 py-1 text-sm border border-border rounded bg-background\"\n          />\n        </div>\n      )}\n      <div className=\"overflow-auto\">\n        <JsonNode\n          value={data}\n          depth={0}\n          path=\"$\"\n          defaultCollapsed={collapsed}\n          maxDepth={maxDepth}\n          copyPath={copyPath}\n          searchQuery={searchQuery}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport type { JsonViewerProps, JsonValue };\n",
      "type": "registry:component"
    }
  ],
  "docs": "Collapsible tree view with type-based coloring, search filter, and copy JSONPath on hover.",
  "categories": [
    "devtools"
  ],
  "type": "registry:ui"
}