{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-view",
  "title": "JSON View",
  "author": "Noppakorn Kaewsalabnil (https://www.pungrumpy.com)",
  "description": "A collapsible tree for inspecting a JSON payload, with syntax roles, search highlighting and the full ARIA tree keyboard model.",
  "dependencies": [],
  "files": [
    {
      "path": "components/json-view.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface JsonViewProps {\n  /** The value to render. Pass the object or array itself, never a JSON string. */\n  readonly data: unknown;\n  /** 0 collapses everything, 1 opens the first level. Deeper values open further. */\n  readonly defaultExpandDepth?: number;\n  /** Only while a search is active; `null` the rest of the time. */\n  readonly highlightPattern?: RegExp | null;\n  readonly className?: string;\n}\n\ninterface JsonNode {\n  readonly id: string;\n  readonly label: string | null;\n  readonly value: unknown;\n  readonly depth: number;\n  readonly children: readonly JsonNode[] | null;\n}\n\nconst REGEXP_SPECIAL = /[.*+?^${}()|[\\]\\\\]/gu;\n\n/** Every metacharacter here is a syntax character, so `\\x` stays legal under `u`. */\nconst escapeRegExp = (value: string) => value.replace(REGEXP_SPECIAL, \"\\\\$&\");\n\n/**\n * Builds the pattern `JsonView` highlights with. Terms are matched literally, so\n * a search for `a.b` cannot turn into a wildcard.\n */\nexport const makeJsonViewHighlightPattern = (terms: readonly string[]): RegExp | null => {\n  const cleaned = terms.flatMap((term) => {\n    const trimmed = term.trim();\n\n    return trimmed ? [trimmed] : [];\n  });\n\n  if (cleaned.length === 0) {\n    return null;\n  }\n\n  return new RegExp(cleaned.map(escapeRegExp).join(\"|\"), \"giu\");\n};\n\nconst isContainer = (value: unknown): value is Record<string, unknown> | unknown[] =>\n  typeof value === \"object\" && value !== null;\n\nconst entriesOf = (value: Record<string, unknown> | unknown[]): [string, unknown][] =>\n  Array.isArray(value) ? value.map((item, index) => [String(index), item]) : Object.entries(value);\n\nconst buildNode = (value: unknown, id: string, label: string | null, depth: number): JsonNode => {\n  if (!isContainer(value)) {\n    return { children: null, depth, id, label, value };\n  }\n\n  const entries = entriesOf(value);\n\n  return {\n    // An empty object has nothing to disclose, so it stays a leaf rather than\n    // an expandable node whose group would be empty.\n    children:\n      entries.length === 0\n        ? null\n        : entries.map(([key, child]) => buildNode(child, `${id}.${key}`, key, depth + 1)),\n    depth,\n    id,\n    label,\n    value,\n  };\n};\n\nconst collectExpanded = (node: JsonNode, maxDepth: number, into: Set<string>): Set<string> => {\n  if (node.children && node.depth < maxDepth) {\n    into.add(node.id);\n\n    for (const child of node.children) {\n      collectExpanded(child, maxDepth, into);\n    }\n  }\n\n  return into;\n};\n\nconst flatten = (node: JsonNode, expanded: ReadonlySet<string>, into: JsonNode[]): JsonNode[] => {\n  into.push(node);\n\n  if (node.children && expanded.has(node.id)) {\n    for (const child of node.children) {\n      flatten(child, expanded, into);\n    }\n  }\n\n  return into;\n};\n\nconst bracketsOf = (value: unknown) =>\n  Array.isArray(value) ? ([\"[\", \"]\"] as const) : ([\"{\", \"}\"] as const);\n\nconst toneOf = (value: unknown) => {\n  if (typeof value === \"string\") {\n    return \"text-json-string\";\n  }\n  if (typeof value === \"number\") {\n    return \"text-json-number\";\n  }\n  if (typeof value === \"boolean\") {\n    return \"text-json-boolean\";\n  }\n  return \"text-gray-900\";\n};\n\nconst printValue = (value: unknown) =>\n  typeof value === \"string\" ? JSON.stringify(value) : String(value);\n\nconst countLabel = (count: number) => (count === 1 ? \"1 item\" : `${count} items`);\n\nconst describe = (node: JsonNode) => {\n  const name = node.label ?? \"JSON\";\n\n  if (!node.children) {\n    return isContainer(node.value)\n      ? `${name}: empty ${Array.isArray(node.value) ? \"array\" : \"object\"}`\n      : `${name}: ${printValue(node.value)}`;\n  }\n\n  const kind = Array.isArray(node.value) ? \"array\" : \"object\";\n\n  return `${name}: ${kind}, ${countLabel(node.children.length)}`;\n};\n\n/** Wraps every match in a `<mark>` so a search can point at what it found. */\nconst Highlight = ({\n  text,\n  pattern,\n}: {\n  readonly text: string;\n  readonly pattern?: RegExp | null;\n}) => {\n  if (!pattern) {\n    return text;\n  }\n\n  const global = pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);\n  const parts: React.ReactNode[] = [];\n  let cursor = 0;\n\n  for (const match of text.matchAll(global)) {\n    const at = match.index;\n\n    // A pattern that can match nothing would otherwise loop forever.\n    if (match[0].length === 0) {\n      continue;\n    }\n\n    if (at > cursor) {\n      parts.push(text.slice(cursor, at));\n    }\n\n    parts.push(\n      <mark className=\"rounded-xs bg-json-highlight text-json-highlight-foreground\" key={at}>\n        {match[0]}\n      </mark>,\n    );\n    cursor = at + match[0].length;\n  }\n\n  if (parts.length === 0) {\n    return text;\n  }\n\n  if (cursor < text.length) {\n    parts.push(text.slice(cursor));\n  }\n\n  return parts;\n};\n\nconst Chevron = ({ open }: { readonly open: boolean }) => (\n  <svg\n    aria-hidden=\"true\"\n    className={cn(\n      \"size-4 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none\",\n      open && \"rotate-90\",\n    )}\n    fill=\"none\"\n    viewBox=\"0 0 16 16\"\n  >\n    <path\n      clipRule=\"evenodd\"\n      d=\"M6.75 3.94L7.28 4.47L10.1 7.29C10.49 7.68 10.49 8.32 10.1 8.71L7.28 11.53L6.75 12.06L5.69 11L6.22 10.47L8.69 8L6.22 5.53L5.69 5L6.75 3.94Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n\nexport const JsonView = ({\n  data,\n  defaultExpandDepth = 1,\n  highlightPattern = null,\n  className,\n}: JsonViewProps) => {\n  const root = useMemo(() => buildNode(data, \"$\", null, 0), [data]);\n  const [expanded, setExpanded] = useState(() =>\n    collectExpanded(root, defaultExpandDepth, new Set<string>()),\n  );\n  const [activeId, setActiveId] = useState(root.id);\n  // Built once. `useRef(new Map())` would allocate a Map on every render and\n  // throw it away, since only the first argument is ever kept.\n  const nodesRef = useRef<Map<string, HTMLDivElement> | null>(null);\n  nodesRef.current ??= new Map();\n  const nodes = nodesRef.current;\n\n  const visible = useMemo(() => flatten(root, expanded, []), [root, expanded]);\n  // A collapse can strip the focused node out of the tree; without this the\n  // roving tabindex would land on nothing and the whole tree would leave the\n  // tab order.\n  const active = visible.some((node) => node.id === activeId) ? activeId : root.id;\n\n  const toggle = (node: JsonNode, open?: boolean) => {\n    if (!node.children) {\n      return;\n    }\n\n    setExpanded((current) => {\n      const next = new Set(current);\n      const shouldOpen = open ?? !next.has(node.id);\n\n      if (shouldOpen) {\n        next.add(node.id);\n      } else {\n        next.delete(node.id);\n      }\n\n      return next;\n    });\n  };\n\n  const focus = (id: string) => {\n    setActiveId(id);\n    nodes.get(id)?.focus();\n  };\n\n  // The node comes from the row the key was pressed on, not from state. Reading\n  // `active` here meant the handler bound in the previous render answered with\n  // the previously focused node, so the first arrow after a move was eaten.\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>, node: JsonNode) => {\n    const index = visible.findIndex((entry) => entry.id === node.id);\n\n    if (index === -1) {\n      return;\n    }\n\n    // Every treeitem carries this handler, so an unclaimed keystroke would run\n    // again on each ancestor on the way up. That is harmless for a move, but a\n    // toggle would flip straight back.\n    const claim = () => {\n      event.preventDefault();\n      event.stopPropagation();\n    };\n\n    const step = (to: number) => {\n      const target = visible[to];\n\n      if (target) {\n        claim();\n        focus(target.id);\n      }\n    };\n\n    switch (event.key) {\n      case \"ArrowDown\": {\n        step(index + 1);\n        break;\n      }\n      case \"ArrowUp\": {\n        step(index - 1);\n        break;\n      }\n      case \"ArrowRight\": {\n        claim();\n        // APG: the first press opens the node, the second walks into it, so a\n        // node never expands and loses focus in the same keystroke.\n        if (node.children && !expanded.has(node.id)) {\n          toggle(node, true);\n        } else if (node.children) {\n          step(index + 1);\n        }\n        break;\n      }\n      case \"ArrowLeft\": {\n        claim();\n        if (node.children && expanded.has(node.id)) {\n          toggle(node, false);\n        } else {\n          for (let at = index - 1; at >= 0; at -= 1) {\n            const candidate = visible[at];\n\n            if (candidate && candidate.depth < node.depth) {\n              focus(candidate.id);\n              break;\n            }\n          }\n        }\n        break;\n      }\n      case \"Home\": {\n        step(0);\n        break;\n      }\n      case \"End\": {\n        step(visible.length - 1);\n        break;\n      }\n      case \"Enter\":\n      case \" \": {\n        if (node.children) {\n          claim();\n          toggle(node);\n        }\n        break;\n      }\n      default: {\n        break;\n      }\n    }\n  };\n\n  const renderNode = (node: JsonNode, position: number, size: number) => {\n    const open = Boolean(node.children) && expanded.has(node.id);\n    const [openBracket, closeBracket] = bracketsOf(node.value);\n    const container = isContainer(node.value);\n\n    return (\n      <div\n        aria-expanded={node.children ? open : undefined}\n        aria-label={describe(node)}\n        aria-level={node.depth + 1}\n        aria-posinset={position}\n        aria-setsize={size}\n        // Indent by depth, not by position: `first:` would match the first\n        // child of every group and flatten one row at each level.\n        className={cn(\"group\", node.depth > 0 && \"ps-4\")}\n        key={node.id}\n        onClick={(event) => {\n          // The treeitem box wraps its own subtree, so without this a click on\n          // a child would toggle every ancestor on the way up.\n          event.stopPropagation();\n          toggle(node);\n        }}\n        onFocus={() => setActiveId(node.id)}\n        onKeyDown={(event) => onKeyDown(event, node)}\n        // A second click on a row that opens is a toggle, not a word select.\n        // Drag still selects, and a leaf still selects on double click.\n        onMouseDown={(event) => {\n          event.stopPropagation();\n          if (node.children && event.detail > 1) {\n            event.preventDefault();\n          }\n        }}\n        ref={(element) => {\n          if (element) {\n            nodes.set(node.id, element);\n          } else {\n            nodes.delete(node.id);\n          }\n        }}\n        role=\"treeitem\"\n        tabIndex={node.id === active ? 0 : -1}\n      >\n        <div\n          className={cn(\n            \"flex min-h-6 items-start gap-1 rounded-sm py-0.5\",\n            \"group-focus-visible:outline-2 group-focus-visible:outline-solid group-focus-visible:outline-gray-1000 group-focus-visible:outline-offset-1\",\n            node.children &&\n              \"cursor-pointer touch-manipulation [@media(hover:hover)_and_(pointer:fine)]:hover:bg-gray-100\",\n          )}\n        >\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"flex size-4 shrink-0 translate-y-0.5 items-center justify-center text-gray-700\",\n              !node.children && \"invisible\",\n            )}\n          >\n            <Chevron open={open} />\n          </span>\n          <span className=\"min-w-0 wrap-anywhere\">\n            {node.label === null ? null : (\n              <span className=\"text-json-key\">\n                <Highlight pattern={highlightPattern} text={node.label} />\n                <span className=\"text-gray-1000\">: </span>\n              </span>\n            )}\n            {container ? (\n              <span className=\"text-gray-1000\">\n                {openBracket}\n                {node.children && !open ? <span className=\"text-gray-900\">…</span> : null}\n                {node.children && open ? null : closeBracket}\n              </span>\n            ) : (\n              <span className={toneOf(node.value)}>\n                <Highlight pattern={highlightPattern} text={printValue(node.value)} />\n              </span>\n            )}\n          </span>\n        </div>\n\n        {node.children && open ? (\n          <>\n            {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- a tree's child list is role=\"group\" per the ARIA APG; the suggested tags all carry the wrong semantics here */}\n            <div className=\"m-0 p-0\" role=\"group\">\n              {node.children.map((child, index) =>\n                renderNode(child, index + 1, node.children?.length ?? 0),\n              )}\n            </div>\n            <div aria-hidden=\"true\" className=\"min-h-6 py-0.5 ps-5 text-gray-1000\">\n              {closeBracket}\n            </div>\n          </>\n        ) : null}\n      </div>\n    );\n  };\n\n  return (\n    <div\n      aria-label=\"JSON\"\n      className={cn(\n        \"w-full font-mono text-xs/5 text-gray-1000 [font-variant-ligatures:none]\",\n        className,\n      )}\n      role=\"tree\"\n      // The tree is reached through whichever treeitem currently holds the\n      // roving 0, so the container itself stays out of the tab order.\n      tabIndex={-1}\n    >\n      {renderNode(root, 1, 1)}\n    </div>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "color-json-key": "var(--json-key)",
      "color-json-string": "var(--json-string)",
      "color-json-number": "var(--json-number)",
      "color-json-boolean": "var(--json-boolean)",
      "color-json-highlight": "var(--json-highlight)",
      "color-json-highlight-foreground": "var(--json-highlight-foreground)"
    },
    "light": {
      "json-key": "oklch(0.522 0.212 1.01)",
      "json-string": "oklch(0.5 0.09 181.95)",
      "json-number": "oklch(0.515 0.193 258.23)",
      "json-boolean": "oklch(0.517 0.109 76.46)",
      "json-highlight": "oklch(0.9 0.17 96)",
      "json-highlight-foreground": "oklch(0.205 0 0)"
    },
    "dark": {
      "json-key": "oklch(0.867 0.076 1.01)",
      "json-string": "oklch(0.835 0.15 181.95)",
      "json-number": "oklch(0.857 0.071 258.23)",
      "json-boolean": "oklch(0.86 0.124 76.46)",
      "json-highlight": "oklch(0.9 0.17 96)",
      "json-highlight-foreground": "oklch(0.205 0 0)"
    }
  },
  "type": "registry:ui"
}