{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "title": "JSON Viewer",
  "description": "A collapsible tree for a JSON payload, navigable from the keyboard, where every row can hand you its path.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/json-viewer/json-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronRight, Clipboard } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type JsonViewerProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  value: unknown\n  rootName?: string\n  defaultExpandedDepth?: number\n  maxStringLength?: number\n  copyable?: boolean\n  label?: string\n}\n\ntype Kind =\n  \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\" | \"other\"\n\ntype Row = {\n  path: string\n  key: string | null\n  value: unknown\n  kind: Kind\n  level: number\n  branch: boolean\n  expanded: boolean\n  index: number | null\n}\n\nfunction kindOf(value: unknown): Kind {\n  if (value === null) return \"null\"\n  if (Array.isArray(value)) return \"array\"\n\n  switch (typeof value) {\n    case \"object\":\n      return \"object\"\n    case \"string\":\n      return \"string\"\n    case \"number\":\n      return \"number\"\n    case \"boolean\":\n      return \"boolean\"\n    default:\n      return \"other\"\n  }\n}\n\nfunction entriesOf(value: unknown): [string, unknown][] {\n  if (Array.isArray(value)) {\n    return value.map((entry, index) => [String(index), entry])\n  }\n\n  return Object.entries(value as Record<string, unknown>)\n}\n\nfunction size(value: unknown) {\n  return Array.isArray(value)\n    ? value.length\n    : Object.keys(value as Record<string, unknown>).length\n}\n\n/** What a collapsed branch says about itself, without unfolding it. */\nfunction summarise(value: unknown, kind: Kind) {\n  const count = size(value)\n  const noun = count === 1 ? \"item\" : \"items\"\n\n  return kind === \"array\" ? `[ ${count} ${noun} ]` : `{ ${count} ${noun} }`\n}\n\n/**\n * A JSON path in the notation somebody would paste back into code, so the copy\n * is worth taking: `data.rows[0].name` rather than a list of segments.\n */\nfunction joinPath(parent: string, key: string, inArray: boolean) {\n  if (inArray) return `${parent}[${key}]`\n  return /^[A-Za-z_$][\\w$]*$/.test(key)\n    ? `${parent}.${key}`\n    : `${parent}[${JSON.stringify(key)}]`\n}\n\nfunction flatten(\n  value: unknown,\n  expanded: Set<string>,\n  rootName: string\n): Row[] {\n  const walk = (\n    node: unknown,\n    key: string | null,\n    path: string,\n    level: number,\n    index: number | null\n  ): Row[] => {\n    const kind = kindOf(node)\n    const branch = (kind === \"object\" || kind === \"array\") && size(node) > 0\n    const open = branch && expanded.has(path)\n    const self: Row = {\n      path,\n      key,\n      value: node,\n      kind,\n      level,\n      branch,\n      expanded: open,\n      index,\n    }\n\n    if (!open) return [self]\n\n    const inArray = kind === \"array\"\n\n    return [\n      self,\n      ...entriesOf(node).flatMap(([childKey, child], at) =>\n        walk(\n          child,\n          childKey,\n          joinPath(path, childKey, inArray),\n          level + 1,\n          inArray ? at : null\n        )\n      ),\n    ]\n  }\n\n  return walk(value, null, rootName, 1, null)\n}\n\n/** Every path down to the requested depth, so the first view is not one line. */\nfunction pathsToDepth(value: unknown, rootName: string, depth: number) {\n  const open = new Set<string>()\n\n  const walk = (node: unknown, path: string, level: number) => {\n    const kind = kindOf(node)\n    if (kind !== \"object\" && kind !== \"array\") return\n    if (size(node) === 0 || level > depth) return\n\n    open.add(path)\n\n    const inArray = kind === \"array\"\n    for (const [key, child] of entriesOf(node)) {\n      walk(child, joinPath(path, key, inArray), level + 1)\n    }\n  }\n\n  walk(value, rootName, 1)\n\n  return open\n}\n\nconst VALUE_CLASS: Record<Kind, string> = {\n  string: \"text-emerald-600 dark:text-emerald-400\",\n  number: \"text-sky-600 dark:text-sky-400\",\n  boolean: \"text-violet-600 dark:text-violet-400\",\n  null: \"text-muted-foreground\",\n  other: \"text-muted-foreground\",\n  object: \"text-muted-foreground\",\n  array: \"text-muted-foreground\",\n}\n\nexport function JsonViewer({\n  value,\n  rootName = \"root\",\n  defaultExpandedDepth = 1,\n  maxStringLength = 120,\n  copyable = true,\n  label = \"JSON\",\n  className,\n  ...rootProps\n}: JsonViewerProps) {\n  const [expanded, setExpanded] = React.useState<Set<string>>(() =>\n    pathsToDepth(value, rootName, defaultExpandedDepth)\n  )\n  const [copied, setCopied] = React.useState<string | null>(null)\n  // The row the tree would return to. A tree is one tab stop, so something has\n  // to hold which row that is, and it is also what aria-selected reports.\n  const [activePath, setActivePath] = React.useState<string | null>(null)\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  React.useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current)\n    },\n    []\n  )\n\n  const rows = React.useMemo(\n    () => flatten(value, expanded, rootName),\n    [value, expanded, rootName]\n  )\n\n  // Collapsing a branch can take the active row with it, so fall back to the\n  // first row rather than leaving the tree with no way in.\n  const active =\n    activePath && rows.some((row) => row.path === activePath)\n      ? activePath\n      : rows[0]?.path\n\n  const toggle = (path: string, open?: boolean) => {\n    setExpanded((current) => {\n      const shouldOpen = open ?? !current.has(path)\n      if (shouldOpen === current.has(path)) return current\n\n      const next = new Set(current)\n      if (shouldOpen) next.add(path)\n      else next.delete(path)\n      return next\n    })\n  }\n\n  const focusRow = (index: number) => {\n    const target = rows[index]\n    if (!target) return\n\n    setActivePath(target.path)\n    containerRef.current\n      ?.querySelector<HTMLElement>(`[data-path=\"${CSS.escape(target.path)}\"]`)\n      ?.focus()\n  }\n\n  async function copyValue(row: Row) {\n    try {\n      await navigator.clipboard.writeText(\n        typeof row.value === \"string\"\n          ? row.value\n          : JSON.stringify(row.value, null, 2)\n      )\n      setCopied(row.path)\n    } catch {\n      // Denied permission, an insecure context, or a sandboxed frame.\n      setCopied(null)\n    }\n\n    if (timer.current) clearTimeout(timer.current)\n    timer.current = setTimeout(() => setCopied(null), 1400)\n  }\n\n  const onKeyDown = (\n    event: React.KeyboardEvent<HTMLDivElement>,\n    row: Row,\n    index: number\n  ) => {\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        focusRow(Math.min(index + 1, rows.length - 1))\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        focusRow(Math.max(index - 1, 0))\n        break\n      case \"ArrowRight\":\n        event.preventDefault()\n        if (row.branch && !row.expanded) toggle(row.path, true)\n        else if (row.branch) focusRow(index + 1)\n        break\n      case \"ArrowLeft\": {\n        event.preventDefault()\n        if (row.branch && row.expanded) {\n          toggle(row.path, false)\n          break\n        }\n        const back = rows\n          .slice(0, index)\n          .reverse()\n          .findIndex((entry) => entry.level < row.level)\n        if (back >= 0) focusRow(index - 1 - back)\n        break\n      }\n      case \"Home\":\n        event.preventDefault()\n        focusRow(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        focusRow(rows.length - 1)\n        break\n      case \"Enter\":\n      case \" \":\n        if (!row.branch) break\n        event.preventDefault()\n        toggle(row.path)\n        break\n      default:\n        break\n    }\n  }\n\n  return (\n    <div\n      data-slot=\"json-viewer\"\n      ref={containerRef}\n      className={cn(\n        \"border-border bg-muted/40 min-w-0 overflow-hidden rounded-[calc(var(--radius)+0.15rem)] border font-mono text-xs\",\n        className\n      )}\n      {...rootProps}\n    >\n      <div\n        role=\"tree\"\n        aria-label={label}\n        className=\"max-h-96 overflow-auto py-1\"\n      >\n        {rows.map((row, index) => {\n          const preview =\n            row.kind === \"string\"\n              ? JSON.stringify(row.value as string).length > maxStringLength\n                ? `${JSON.stringify(row.value as string).slice(0, maxStringLength)}…\"`\n                : JSON.stringify(row.value as string)\n              : row.branch || row.kind === \"object\" || row.kind === \"array\"\n                ? summarise(row.value, row.kind)\n                : String(row.value)\n\n          return (\n            <div\n              key={row.path}\n              data-path={row.path}\n              role=\"treeitem\"\n              aria-level={row.level}\n              aria-expanded={row.branch ? row.expanded : undefined}\n              aria-selected={row.path === active}\n              tabIndex={row.path === active ? 0 : -1}\n              onFocus={() => setActivePath(row.path)}\n              onKeyDown={(event) => onKeyDown(event, row, index)}\n              onClick={() => row.branch && toggle(row.path)}\n              style={{ paddingInlineStart: `${row.level * 0.85}rem` }}\n              className={cn(\n                \"group hover:bg-muted/70 focus-visible:ring-ring flex min-h-7 items-center gap-1.5 pr-2 focus-visible:ring-2 focus-visible:outline-none\",\n                row.branch && \"cursor-pointer\"\n              )}\n            >\n              <ChevronRight\n                aria-hidden=\"true\"\n                size={13}\n                className={cn(\n                  \"text-muted-foreground shrink-0 transition-transform motion-reduce:transition-none\",\n                  !row.branch && \"invisible\",\n                  row.expanded && \"rotate-90\"\n                )}\n              />\n\n              {row.key !== null ? (\n                <span className=\"text-foreground shrink-0\">\n                  {row.index === null ? `${row.key}:` : `${row.index}:`}\n                </span>\n              ) : (\n                <span className=\"text-muted-foreground shrink-0\">\n                  {rootName}\n                </span>\n              )}\n\n              <span className={cn(\"truncate\", VALUE_CLASS[row.kind])}>\n                {preview}\n              </span>\n\n              {copyable ? (\n                <button\n                  type=\"button\"\n                  aria-label={\n                    copied === row.path ? \"Copied\" : `Copy ${row.path}`\n                  }\n                  onClick={(event) => {\n                    event.stopPropagation()\n                    void copyValue(row)\n                  }}\n                  className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto hidden size-6 shrink-0 items-center justify-center rounded group-focus-within:flex group-hover:flex focus-visible:ring-2 focus-visible:outline-none\"\n                >\n                  {copied === row.path ? (\n                    <Check aria-hidden=\"true\" size={12} />\n                  ) : (\n                    <Clipboard aria-hidden=\"true\" size={12} />\n                  )}\n                </button>\n              ) : null}\n            </div>\n          )\n        })}\n      </div>\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {copied ? `${copied} copied to clipboard.` : \"\"}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "document",
    "viewer",
    "tree",
    "data",
    "ai"
  ],
  "type": "registry:ui"
}