# JSON Viewer

A folding tree of JSON, with search and copyable values and paths.

Source: [json-viewer.tsx](https://github.com/jakejarvis/jarvis-ui/blob/main/src/components/ui/json-viewer.tsx)

```tsx
import { JsonViewer } from "@/components/ui/json-viewer";
import { deploymentEvent } from "@/examples/data";

export default function JsonViewerBasic() {
  return (
    <JsonViewer label="deployment.succeeded" data={deploymentEvent} className="max-h-96 max-w-lg" />
  );
}
```

## Installation

Add the registry, once per project:

```bash
npx shadcn@latest registry add @jarvis=https://ui.jarv.is/r/{name}.json
```

Then add the component:

```bash
npx shadcn@latest add @jarvis/json-viewer
```

## Usage

```tsx
import { JsonViewer } from "@/components/ui/json-viewer";

export function WebhookPayload({ payload }: { payload: unknown }) {
  return <JsonViewer label="payload.json" data={payload} className="max-h-96" />;
}
```

Pass `data` any value JSON can hold. A top-level object or array shows its children as the first
level, and `defaultExpandDepth` sets how many levels start open, counted from the first `data`
with any children, so data that arrives after a loading state still opens. To show a JSON string,
parse it first. Values JSON leaves out, such as `undefined`, functions and references back to an
object that holds them, show in a muted color rather than breaking the tree. Dates show as the
strings `JSON.stringify` would write, and like it, a `Map` or `Set` shows as an empty object.

Set a height on `JsonViewer`, such as `max-h-96`, and the tree scrolls inside it. Give it a `label`
for a header with buttons to expand, collapse and copy everything. The label names the tree too, or
pass an `aria-label` to name it instead.

The viewer remembers what's open by path, so a new `data` with the same shape keeps the same rows
open. Pass a `key` that changes with the data, such as a record's id, to start over with each one,
and keep `data` from changing on every render, with `useMemo` or a value from outside the
component, since the viewer reads the whole value whenever it does.

Each row is named by its path, written the way you'd reach it in code, such as
`deployment.regions[0]` or `delivery.headers["content-type"]`, and a key that isn't a plain name,
such as `"content-type"`, shows in quotes. Pointing at a row, or moving to it, shows buttons to copy
its value or its path. A string's value is copied without its quotes, an object's or array's as
formatted JSON, and any other value as it's shown. Clicking a string that's cut off wraps it in
place.

The tree is a single stop in the tab order. Inside it, the up and down arrows move between rows,
right opens an object or array or moves into it, and left closes it or moves to its parent. Home
and End jump to the first and last rows, `*` opens every object and array beside the current one,
and Enter or Space opens or closes one. C copies the current row's value, and P its path.

## Examples

### Searchable

`searchable` adds a field that finds keys and values as you type, and opens whatever holds the
current match, including a row past `pageSize`. The current match's row wraps, so a match far along
a long string stays in view. Enter and Shift+Enter step through the matches, and Escape clears the
search.

```tsx
import { JsonViewer } from "@/components/ui/json-viewer";
import { deploymentEvent } from "@/examples/data";

export default function JsonViewerSearchable() {
  return (
    <JsonViewer
      searchable
      aria-label="Webhook payload"
      data={deploymentEvent}
      defaultExpandDepth={0}
      className="max-h-96 max-w-lg"
    />
  );
}
```

### Expand depth

`defaultExpandDepth` is `1` by default, which opens the top-level objects and arrays. Pass `0` to
start with everything closed, or `Infinity` to open everything.

```tsx
import { JsonViewer } from "@/components/ui/json-viewer";
import { deploymentEvent } from "@/examples/data";

export default function JsonViewerExpandDepth() {
  return (
    <JsonViewer
      label="Fully open"
      data={deploymentEvent}
      defaultExpandDepth={Infinity}
      className="max-h-96 max-w-lg"
    />
  );
}
```

### Controlled

Pass `expanded` and `onExpandedChange` to control which objects and arrays are open, by their paths.

```tsx
import * as React from "react";

import { Button } from "@/components/ui/button";
import { JsonViewer } from "@/components/ui/json-viewer";
import { deploymentEvent } from "@/examples/data";

export default function JsonViewerControlled() {
  const [expanded, setExpanded] = React.useState<string[]>(["deployment"]);

  return (
    <div className="flex w-full max-w-lg flex-col gap-3">
      <div className="flex flex-wrap gap-2">
        <Button size="sm" onClick={() => setExpanded(["deployment", "deployment.commit"])}>
          Show the commit
        </Button>
        <Button size="sm" onClick={() => setExpanded(["delivery", "delivery.headers"])}>
          Show the headers
        </Button>
      </div>
      <JsonViewer
        aria-label="Webhook payload"
        data={deploymentEvent}
        expanded={expanded}
        onExpandedChange={setExpanded}
        className="max-h-96"
      />
      <p className="text-sm text-muted-foreground">
        Open: {expanded.length ? expanded.join(", ") : "nothing"}
      </p>
    </div>
  );
}
```

### Long arrays

An object or array with more children than `pageSize`, 100 by default, shows that many and a row to
show more. Stepping to a search match past them shows enough to reach it.

```tsx
import { JsonViewer } from "@/components/ui/json-viewer";
import { latencies } from "@/examples/data";

export default function JsonViewerLongArrays() {
  return (
    <JsonViewer
      label="latency.json"
      data={{ endpoint: "/api/checkout", unit: "ms", samples: latencies }}
      pageSize={50}
      className="max-h-96 max-w-lg"
    />
  );
}
```

### As raw text

`highlightJson` colors formatted JSON line by line, for Code Block's `highlightedLines`. Pass it the
same string you pass Code Block as `code`.

```tsx
import { CodeBlock } from "@/components/ui/code-block";
import { highlightJson } from "@/components/ui/json-viewer";
import { deploymentEvent } from "@/examples/data";

const code = JSON.stringify(deploymentEvent.deployment.commit, null, 2);

export default function JsonViewerRaw() {
  return (
    <CodeBlock
      label="commit.json"
      language="json"
      code={code}
      highlightedLines={highlightJson(code)}
      lineNumbers
      className="max-w-lg"
    />
  );
}
```

### In a dialog

A dialog that switches between the tree and the raw text.

```tsx
import * as React from "react";

import { Button } from "@/components/ui/button";
import { CodeBlock } from "@/components/ui/code-block";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { highlightJson, JsonViewer } from "@/components/ui/json-viewer";
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
import { deploymentEvent } from "@/examples/data";

const code = JSON.stringify(deploymentEvent, null, 2);

export default function JsonViewerInDialog() {
  const [view, setView] = React.useState("tree");

  return (
    <Dialog>
      <DialogTrigger render={<Button variant="secondary" />}>View payload</DialogTrigger>
      <DialogContent className="sm:max-w-[640px]">
        <DialogHeader>
          <DialogTitle>Webhook payload</DialogTitle>
          <DialogDescription>
            Sent to your endpoint when the deployment succeeded.
          </DialogDescription>
        </DialogHeader>
        <SegmentedControl value={view} onValueChange={setView} aria-label="View">
          <SegmentedControlItem value="tree">Tree</SegmentedControlItem>
          <SegmentedControlItem value="raw">Raw</SegmentedControlItem>
        </SegmentedControl>
        {view === "tree" ? (
          <JsonViewer
            searchable
            aria-label="Webhook payload"
            data={deploymentEvent}
            className="h-96"
          />
        ) : (
          <div className="h-96 overflow-y-auto rounded-md">
            <CodeBlock
              language="json"
              code={code}
              highlightedLines={highlightJson(code)}
              lineNumbers
            />
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}
```

### Colors

Keys and values are colored with the theme's syntax tokens: `syntax-property` for keys,
`syntax-string`, `syntax-number`, and `syntax-constant` for `true`, `false` and `null`. They follow
GitHub's light and dark themes, as the docs' code blocks do, so keys, numbers and constants share a
blue, and quotes and colons tell them apart. Change them in your CSS to recolor every JSON Viewer,
and Code Blocks highlighted with `highlightJson`. See [Theming](https://ui.jarv.is/docs/theming.md).

## Props

`JsonViewer` takes the props of a `<div>`, apart from `children`.

The file also exports `highlightJson(code)`, which takes formatted JSON and returns one element per
line, colored with the syntax tokens, for Code Block's `highlightedLines`.

| Part | Extends |
| --- | --- |
| `JsonViewer` | `<div>` |

The tables leave out `className`, `style` and `render`. Parts built on Base UI take them as its [styling](https://base-ui.com/react/handbook/styling) and [composition](https://base-ui.com/react/handbook/composition) guides describe.

### JsonViewer

| Prop                  | Type                                                | Description                                                                                                                     |
| --------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `aria-label?`         | `string`                                            | Defines a string value that labels the current element. Names the tree, in place of `label`.                                    |
| `data`                | `unknown`                                           | Any value JSON can hold: an object, an array or a primitive. A top-level object or array shows its children as the first level. |
| `label?`              | `React.ReactNode`                                   | Optional label, such as a file name, shown in a header bar with buttons to expand, collapse and copy everything.                |
| `defaultExpandDepth?` | `number`                                            | How many levels of objects and arrays start open, when `expanded` and `defaultExpanded` aren't given. Default: `1`              |
| `expanded?`           | `string[]`                                          | The paths of the open objects and arrays, such as `["user", "user.roles"]`.                                                     |
| `defaultExpanded?`    | `string[]`                                          | The paths of the objects and arrays open at first, when `expanded` isn't controlled.                                            |
| `onExpandedChange?`   | `((expanded: string[]) => void)`                    | Called with the paths of the open objects and arrays when one opens or closes.                                                  |
| `searchable?`         | `boolean`                                           | Shows a field that searches keys and values, and opens whatever holds the current match. Default: `false`                       |
| `guides?`             | `boolean`                                           | Draws a line down each open object's or array's children. Default: `true`                                                       |
| `pageSize?`           | `number`                                            | How many children of a long object or array show at first, and how many more each press of "Show more" adds. Default: `100`     |
| `onCopy?`             | `((text: string, what: "value" \| "path") => void)` | Called with the text copied, and whether it was a value or a path.                                                              |
