{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "type": "registry:ui",
  "title": "Json viewer",
  "description": "A collapsible JSON tree with syntax colours, item counts, copy value and copy path on hover, search highlighting and full keyboard navigation.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/json-viewer.tsx",
      "type": "registry:ui",
      "target": "components/ui/json-viewer.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { CheckIcon, ChevronRightIcon, CopyIcon, FoldVerticalIcon, Link2Icon, SearchIcon, UnfoldVerticalIcon, XIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/** A collapsible JSON tree with syntax colours, item counts, copy value and copy path on hover, search highlighting and full keyboard navigation. */\ntype JsonViewerProps = {\n  /** Any JSON serialisable value. Defaults to a Lumen invoice API response. */\n  value?: unknown\n  /** Label shown in the header, for example the request that produced the data. Pass an empty string to hide the header. */\n  title?: string\n  /** Key shown on the root row. Empty hides it. */\n  rootName?: string\n  /** Containers at or above this depth start open. The root is depth 0. */\n  defaultExpandDepth?: number\n  /** Extra container paths that start open, in `$.a.b[0]` form. */\n  defaultExpanded?: string[]\n  /** Show the search field and expand or collapse all buttons. */\n  toolbar?: boolean\n  /** Longest string shown inline before it is cut with an ellipsis. */\n  maxStringLength?: number\n  /** Called after a value or path is copied. */\n  onCopy?: (text: string, kind: \"value\" | \"path\", path: string) => void\n  \"aria-label\"?: string\n  className?: string\n}\n\nconst SAMPLE = {\n  id: \"inv_8f3a2c\",\n  object: \"invoice\",\n  number: \"LUM-2026-0147\",\n  status: \"paid\",\n  currency: \"usd\",\n  amount_due: 0,\n  amount_paid: 184000,\n  total: 184000,\n  created: \"2026-10-02T09:14:33Z\",\n  due_date: \"2026-10-16\",\n  paid_at: \"2026-10-09T15:02:11Z\",\n  customer: {\n    id: \"cus_n0rthw1nd\",\n    name: \"Northwind Traders\",\n    email: \"billing@northwind.example\",\n    contact: { name: \"Maya Chen\", role: \"Finance lead\" },\n    address: { line1: \"120 Market St\", city: \"Seattle\", region: \"WA\", postal_code: \"98101\", country: \"US\" },\n    tax_exempt: false,\n  },\n  lines: [\n    { id: \"li_01\", description: \"Lumen Pro, 12 seats\", quantity: 12, unit_amount: 12000, amount: 144000, period: { start: \"2026-10-01\", end: \"2026-10-31\" } },\n    { id: \"li_02\", description: \"Usage: 2.4M API calls\", quantity: 1, unit_amount: 40000, amount: 40000, period: { start: \"2026-09-01\", end: \"2026-09-30\" } },\n  ],\n  payment: { method: \"card\", brand: \"visa\", last4: \"4242\", receipt_url: \"https://lumen.example/r/8f3a2c\" },\n  metadata: { plan: \"pro\", seats: 12, renewal: \"annual\", owner: \"theo.okafor\" },\n  discount: null,\n  livemode: true,\n}\n\ntype Kind = \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\"\n\ntype Row = {\n  path: string\n  key: string | null\n  value: unknown\n  kind: Kind\n  depth: number\n  parent: string | null\n  count: number\n  /** True for items of an array; the key is their index. */\n  index: boolean\n  children: Row[]\n}\n\nconst INDENT = 16\nconst PAD = 8\n\nfunction kindOf(v: unknown): Kind {\n  if (v === null || v === undefined) return \"null\"\n  if (Array.isArray(v)) return \"array\"\n  switch (typeof v) {\n    case \"string\":\n      return \"string\"\n    case \"number\":\n    case \"bigint\":\n      return \"number\"\n    case \"boolean\":\n      return \"boolean\"\n    default:\n      return \"object\"\n  }\n}\n\nfunction build(value: unknown, key: string | null, path: string, depth: number, parent: string | null, index = false): Row {\n  const kind = kindOf(value)\n  let children: Row[] = []\n  if (kind === \"array\") children = (value as unknown[]).map((v, i) => build(v, String(i), `${path}[${i}]`, depth + 1, path, true))\n  else if (kind === \"object\") {\n    const safeKey = (k: string) => (/^[A-Za-z_$][\\w$]*$/.test(k) ? `.${k}` : `[${JSON.stringify(k)}]`)\n    children = Object.entries(value as Record<string, unknown>).map(([k, v]) => build(v, k, `${path}${safeKey(k)}`, depth + 1, path))\n  }\n  return { path, key, value, kind, depth, parent, count: children.length, index, children }\n}\n\nfunction primitiveText(row: Row, max: number): string {\n  if (row.kind === \"string\") {\n    const s = row.value as string\n    return JSON.stringify(s.length > max ? s.slice(0, max) + \"…\" : s)\n  }\n  if (row.kind === \"null\") return \"null\"\n  return String(row.value)\n}\n\nfunction copyText(row: Row): string {\n  if (row.kind === \"string\") return row.value as string\n  if (row.kind === \"null\") return \"null\"\n  if (row.kind === \"object\" || row.kind === \"array\") return JSON.stringify(row.value, null, 2)\n  return String(row.value)\n}\n\nconst VALUE_TONE: Record<Kind, string> = {\n  string: \"text-success\",\n  number: \"text-info\",\n  boolean: \"text-warning\",\n  null: \"text-fg-subtle italic\",\n  object: \"text-fg-subtle\",\n  array: \"text-fg-subtle\",\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  if (!query) return <>{text}</>\n  const i = text.toLowerCase().indexOf(query)\n  if (i < 0) return <>{text}</>\n  return (\n    <>\n      {text.slice(0, i)}\n      <mark className=\"rounded-sm bg-accent-soft text-accent-fg\">{text.slice(i, i + query.length)}</mark>\n      {text.slice(i + query.length)}\n    </>\n  )\n}\n\n/**\n * A JSON tree for API responses and debug panels. Containers fold with a\n * chevron and show their item count while closed, values are coloured by\n * type, every row offers copy value and copy path on hover or focus, the\n * search field keeps only matching branches and highlights the hits, and\n * the whole tree walks with the arrow keys like a native tree view.\n */\nfunction JsonViewer({\n  value = SAMPLE,\n  title = \"GET /v1/invoices/inv_8f3a2c\",\n  rootName = \"\",\n  defaultExpandDepth = 1,\n  defaultExpanded = [\"$.customer\", \"$.lines\", \"$.lines[0]\"],\n  toolbar = true,\n  maxStringLength = 64,\n  onCopy,\n  \"aria-label\": ariaLabel = \"JSON tree\",\n  className,\n}: JsonViewerProps) {\n  const treeId = React.useId()\n  const root = React.useMemo(() => build(value, rootName || null, \"$\", 0, null), [value, rootName])\n  const containers = React.useMemo(() => {\n    const out: Row[] = []\n    const walk = (r: Row) => {\n      if (r.count) out.push(r)\n      r.children.forEach(walk)\n    }\n    walk(root)\n    return out\n  }, [root])\n\n  const [expanded, setExpanded] = React.useState(() => {\n    const s = new Set<string>(defaultExpanded)\n    const walk = (r: Row) => {\n      if (r.count && r.depth <= defaultExpandDepth) s.add(r.path)\n      r.children.forEach(walk)\n    }\n    walk(root)\n    return s\n  })\n  const [query, setQuery] = React.useState(\"\")\n  const q = query.trim().toLowerCase()\n  const [focusPath, setFocusPath] = React.useState<string | null>(null)\n  const [copied, setCopied] = React.useState<string | null>(null)\n  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const refs = React.useRef(new Map<string, HTMLDivElement>())\n  React.useEffect(() => () => {\n    if (copyTimer.current) clearTimeout(copyTimer.current)\n  }, [])\n\n  /* Search: keep rows that match or contain a match; everything kept is open. */\n  const { visible, matches, open } = React.useMemo(() => {\n    const matches = new Set<string>()\n    const keep = new Set<string>()\n    const hit = (r: Row) => {\n      if (!q) return false\n      const keyHit = r.key !== null && r.key.toLowerCase().includes(q)\n      const valHit = r.count === 0 && primitiveText(r, Infinity).toLowerCase().includes(q)\n      return keyHit || valHit\n    }\n    const mark = (r: Row): boolean => {\n      let any = false\n      for (const c of r.children) if (mark(c)) any = true\n      const h = hit(r)\n      if (h) matches.add(r.path)\n      if (h || any) keep.add(r.path)\n      return h || any\n    }\n    if (q) mark(root)\n    const open = q ? keep : expanded\n    const visible: Row[] = []\n    const walk = (r: Row) => {\n      if (q && !keep.has(r.path)) return\n      visible.push(r)\n      if (r.count && open.has(r.path)) r.children.forEach(walk)\n    }\n    walk(root)\n    return { visible, matches, open }\n  }, [root, q, expanded])\n\n  const tabStop = visible.find((r) => r.path === focusPath)?.path ?? visible[0]?.path\n\n  const focus = (path: string | undefined) => {\n    if (!path) return\n    setFocusPath(path)\n    refs.current.get(path)?.focus()\n  }\n  const toggle = (path: string, to?: boolean) => {\n    if (q) return\n    setExpanded((prev) => {\n      const next = new Set(prev)\n      const want = to ?? !next.has(path)\n      if (want) next.add(path)\n      else next.delete(path)\n      return next\n    })\n  }\n  const copy = (row: Row, kind: \"value\" | \"path\") => {\n    const text = kind === \"value\" ? copyText(row) : row.path\n    try {\n      navigator.clipboard?.writeText(text).catch(() => {})\n    } catch {}\n    onCopy?.(text, kind, row.path)\n    setCopied(`${row.path}:${kind}`)\n    if (copyTimer.current) clearTimeout(copyTimer.current)\n    copyTimer.current = setTimeout(() => setCopied(null), 1400)\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget && !(e.target as HTMLElement).matches('[role=\"treeitem\"]')) return\n    const cur = visible.findIndex((r) => r.path === (focusPath ?? tabStop))\n    const row = visible[cur]\n    if (!row) return\n    const key = e.key\n    if (key === \"ArrowDown\") { e.preventDefault(); focus(visible[Math.min(visible.length - 1, cur + 1)]?.path) }\n    else if (key === \"ArrowUp\") { e.preventDefault(); focus(visible[Math.max(0, cur - 1)]?.path) }\n    else if (key === \"Home\") { e.preventDefault(); focus(visible[0]?.path) }\n    else if (key === \"End\") { e.preventDefault(); focus(visible[visible.length - 1]?.path) }\n    else if (key === \"ArrowRight\") {\n      e.preventDefault()\n      if (row.count && !open.has(row.path)) toggle(row.path, true)\n      else if (row.count) focus(visible[cur + 1]?.path)\n    } else if (key === \"ArrowLeft\") {\n      e.preventDefault()\n      if (row.count && open.has(row.path) && !q) toggle(row.path, false)\n      else if (row.parent) focus(row.parent)\n    } else if (key === \"Enter\" || key === \" \") {\n      e.preventDefault()\n      if (row.count) toggle(row.path)\n    }\n  }\n\n  const allOpen = containers.every((c) => expanded.has(c.path))\n\n  return (\n    <div data-slot=\"json-viewer\" className={cn(\"flex w-full max-w-2xl flex-col rounded-xl border border-border bg-surface text-fg shadow-raised\", className)}>\n      {title ? (\n        <div className=\"flex h-10 items-center justify-between gap-2 border-b border-border px-3\">\n          <span className=\"truncate font-mono text-[11px] font-medium tracking-[0.02em] text-fg-muted\">{title}</span>\n          <span className=\"shrink-0 rounded-full bg-sunken px-2 py-0.5 font-mono text-[10px] text-fg-subtle tabular-nums\">\n            {root.count} {root.kind === \"array\" ? (root.count === 1 ? \"item\" : \"items\") : root.count === 1 ? \"key\" : \"keys\"}\n          </span>\n        </div>\n      ) : null}\n      {toolbar ? (\n        <div className=\"flex items-center gap-2 border-b border-border px-2 py-2\">\n          <div className=\"relative min-w-0 flex-1\">\n            <SearchIcon aria-hidden className=\"pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-subtle\" />\n            <input\n              type=\"search\"\n              value={query}\n              placeholder=\"Search keys and values\"\n              aria-label=\"Search keys and values\"\n              aria-controls={treeId}\n              onChange={(e) => setQuery(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\" && query) { e.preventDefault(); setQuery(\"\") }\n                if (e.key === \"ArrowDown\") { e.preventDefault(); focus(visible[0]?.path) }\n              }}\n              className={cn(\n                \"h-8 w-full rounded-lg border border-border bg-bg pr-8 pl-8 text-[0.8125rem] text-fg shadow-xs outline-none placeholder:text-fg-subtle\",\n                \"transition-[border-color,box-shadow] duration-[140ms] ease-hairline [&::-webkit-search-cancel-button]:appearance-none\",\n                \"focus-visible:border-accent focus-visible:shadow-[0_0_0_3px_var(--accent-soft)]\"\n              )}\n            />\n            {query ? (\n              <button\n                type=\"button\"\n                aria-label=\"Clear search\"\n                onClick={() => setQuery(\"\")}\n                className=\"absolute top-1/2 right-1 grid size-6 -translate-y-1/2 place-items-center rounded-md text-fg-subtle outline-none hover:bg-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-accent\"\n              >\n                <XIcon className=\"size-3.5\" />\n              </button>\n            ) : null}\n          </div>\n          <span aria-live=\"polite\" className={cn(\"shrink-0 font-mono text-[11px] tabular-nums\", q ? \"text-fg-muted\" : \"sr-only\")}>\n            {q ? `${matches.size} ${matches.size === 1 ? \"match\" : \"matches\"}` : \"\"}\n          </span>\n          <div className=\"flex shrink-0 items-center gap-0.5\">\n            <button\n              type=\"button\"\n              aria-label=\"Expand all\"\n              title=\"Expand all\"\n              disabled={!!q || allOpen}\n              onClick={() => setExpanded(new Set(containers.map((c) => c.path)))}\n              className=\"grid size-8 place-items-center rounded-lg text-fg-muted outline-none transition-colors duration-[70ms] hover:bg-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-40 disabled:hover:bg-transparent\"\n            >\n              <UnfoldVerticalIcon className=\"size-4\" />\n            </button>\n            <button\n              type=\"button\"\n              aria-label=\"Collapse all\"\n              title=\"Collapse all\"\n              disabled={!!q || expanded.size <= 1}\n              onClick={() => setExpanded(new Set([\"$\"]))}\n              className=\"grid size-8 place-items-center rounded-lg text-fg-muted outline-none transition-colors duration-[70ms] hover:bg-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-40 disabled:hover:bg-transparent\"\n            >\n              <FoldVerticalIcon className=\"size-4\" />\n            </button>\n          </div>\n        </div>\n      ) : null}\n      <div className=\"max-h-96 overflow-auto p-2\">\n        {visible.length ? (\n          <div id={treeId} role=\"tree\" aria-label={ariaLabel} onKeyDown={onKeyDown} className=\"min-w-max outline-none\">\n            {visible.map((row) => {\n              const isOpen = row.count > 0 && open.has(row.path)\n              const focused = row.path === tabStop\n              const bracket = row.kind === \"array\" ? [\"[\", \"]\"] : [\"{\", \"}\"]\n              const noun = row.kind === \"array\" ? (row.count === 1 ? \"item\" : \"items\") : row.count === 1 ? \"key\" : \"keys\"\n              const summary = row.count ? `${row.count} ${noun}` : primitiveText(row, maxStringLength)\n              return (\n                <div\n                  key={row.path}\n                  ref={(el) => {\n                    if (el) refs.current.set(row.path, el)\n                    else refs.current.delete(row.path)\n                  }}\n                  role=\"treeitem\"\n                  aria-level={row.depth + 1}\n                  aria-expanded={row.count ? isOpen : undefined}\n                  aria-selected={focused}\n                  aria-label={`${row.key ?? \"root\"}: ${row.count ? `${row.kind}, ${summary}` : summary}`}\n                  tabIndex={focused ? 0 : -1}\n                  onFocus={(e) => {\n                    if (e.target === e.currentTarget) setFocusPath(row.path)\n                  }}\n                  onClick={() => {\n                    focus(row.path)\n                    if (row.count) toggle(row.path)\n                  }}\n                  data-slot=\"json-viewer-row\"\n                  className={cn(\n                    \"group/row relative flex h-6 items-center gap-1 rounded-md pr-1 font-mono text-[12px] leading-none outline-none select-none\",\n                    \"transition-colors duration-[70ms] ease-hairline hover:bg-sunken focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset\",\n                    row.count ? \"cursor-pointer\" : \"cursor-default\"\n                  )}\n                  style={{ paddingLeft: PAD + row.depth * INDENT }}\n                >\n                  {Array.from({ length: row.depth }, (_, d) => (\n                    <span key={d} aria-hidden className=\"pointer-events-none absolute top-0 bottom-0 w-px bg-border\" style={{ left: PAD + d * INDENT + 7 }} />\n                  ))}\n                  {row.count ? (\n                    <ChevronRightIcon\n                      aria-hidden\n                      className={cn(\"size-3.5 shrink-0 text-fg-subtle transition-transform duration-[140ms] ease-hairline motion-reduce:transition-none\", isOpen && \"rotate-90\")}\n                    />\n                  ) : (\n                    <span aria-hidden className=\"size-3.5 shrink-0\" />\n                  )}\n                  {row.key !== null ? (\n                    <span className={cn(\"shrink-0\", row.index ? \"text-fg-subtle\" : \"text-fg\")}>\n                      <Highlight text={row.key} query={q} />\n                      <span className=\"text-fg-subtle\">:</span>\n                    </span>\n                  ) : null}\n                  {row.count ? (\n                    <span className=\"flex items-center gap-1.5 text-fg-subtle\">\n                      <span>{isOpen ? bracket[0] : `${bracket[0]}…${bracket[1]}`}</span>\n                      {!isOpen ? <span className=\"rounded-full bg-sunken px-1.5 py-px text-[10px] text-fg-muted tabular-nums group-hover/row:bg-border/60\">{summary}</span> : null}\n                    </span>\n                  ) : row.kind === \"object\" || row.kind === \"array\" ? (\n                    <span className=\"text-fg-subtle\">{bracket[0] + bracket[1]}</span>\n                  ) : (\n                    <span className={cn(\"whitespace-pre\", VALUE_TONE[row.kind])} title={row.kind === \"string\" && (row.value as string).length > maxStringLength ? (row.value as string) : undefined}>\n                      <Highlight text={summary} query={q} />\n                    </span>\n                  )}\n                  <span className=\"ml-auto flex shrink-0 items-center gap-0.5 pl-3 opacity-0 transition-opacity duration-[70ms] group-hover/row:opacity-100 group-focus-within/row:opacity-100\">\n                    {([\"value\", \"path\"] as const).map((kind) => {\n                      const done = copied === `${row.path}:${kind}`\n                      const Icon = done ? CheckIcon : kind === \"value\" ? CopyIcon : Link2Icon\n                      return (\n                        <button\n                          key={kind}\n                          type=\"button\"\n                          aria-label={done ? \"Copied\" : kind === \"value\" ? \"Copy value\" : `Copy path ${row.path}`}\n                          title={done ? \"Copied\" : kind === \"value\" ? \"Copy value\" : `Copy path ${row.path}`}\n                          onClick={(e) => {\n                            e.stopPropagation()\n                            copy(row, kind)\n                          }}\n                          onKeyDown={(e) => e.stopPropagation()}\n                          className={cn(\n                            \"grid size-5 place-items-center rounded-md outline-none transition-colors duration-[70ms] hover:bg-border/70 focus-visible:ring-2 focus-visible:ring-accent\",\n                            done ? \"text-success\" : \"text-fg-subtle hover:text-fg\"\n                          )}\n                        >\n                          <Icon className=\"size-3\" />\n                        </button>\n                      )\n                    })}\n                  </span>\n                </div>\n              )\n            })}\n          </div>\n        ) : (\n          <p className=\"px-2 py-6 text-center text-[0.8125rem] text-fg-subtle\">No keys or values match “{query.trim()}”</p>\n        )}\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">{copied ? `Copied ${copied.endsWith(\":path\") ? \"path\" : \"value\"}` : \"\"}</p>\n    </div>\n  )\n}\n\nexport { JsonViewer }\nexport type { JsonViewerProps }\n"
    }
  ]
}
