any nested data as a tree you read: objects, JSON, XML, searched and copied
npx shadcn add @ag/data-viewAlready installed? Preview an update
npx shadcn add @ag/data-view --dry-runYour installed source stays unchanged until you update it. How updates work
inspectis nowdata-view, a noun for what it is: data, viewed as a tree or as text. Migration: install@ag/data-viewand deleteinspect.tsx;InspectisDataViewer(notDataView, which is JavaScript's own),InspectPropsDataViewerProps,InspectNodeDataNode,InspectKindDataKind,inspectValuedataOf,inspectXmldataOfXml, and the slotsinspect-*aredata-view-*. "inspect", "json viewer" and "object inspector" still find it.variant="show"presents data to be read (a format on a landing page, a response in a doc): keys and values only, with no toolbar, no size column and no row actions. The rows are text: a drag selects across keys, values and rows as it does in any text. A link value selects like the rest and opens with ⌘-click (Ctrl elsewhere); its glyph opens it with a plain press. Branches still open. The default,"explore", is the tool as before.itemNamenames an array's item beside its index (0 githubfor{ platform: "github", … }), so a list of records reads as what it holds and not as 0, 1, 2. The search finds an item by its name too. Undefined keeps the index alone.- How deep the tree is open is one
stepper, ‹ none · 1 · 2 · all ›, where a row of loose buttons stood. It says how deep the tree IS open, so a branch closed by hand moves it. - A date's "how long ago" is
relative-time's, so the server's words and the reader's agree (it was its own clock, and the page re-rendered on load); XML, read with the browser's own parser, waits for the page to be live rather than showing "cannot read it" first. - The tree's rows are text in the default variant too: a drag selects keys and values and from row to row, and a press on a value is the reader's (select it, ⌘-click a link). A row still opens from its key, its chevron or its keys.
also called json viewer, object inspector, inspect, json tree
shelf controls
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { DataViewer } from "@/registry/base-nova/ui/data-view"
// #region record
const TAG = {
path: "animals/birds/gull",
color: "#e86f9c",
icon: "CircleDot",
yes: ["gull", "a seagull in flight, wings spread"],
no: [],
auto: true,
cut: 0.5,
fit: null,
answers: [
{
key: "a1b2c3d4e5f60718",
from: 3725,
to: 5325,
answer: "yes",
at: "2026-10-09T14:42:22.728Z",
},
{
key: "9f8e7d6c5b",
from: 10625,
to: 11875,
answer: "no",
at: "2026-10-08T09:10:00.000Z",
},
],
source: "https://example.com/tags/animals",
created: new Date("2026-10-01T12:00:00Z"),
counts: new Map([
["files", 11],
["moments", 23],
]),
}
function Record() {
return (
<DataViewer
value={TAG}
name="tag"
depth={2}
className="h-96 w-full max-w-[34rem]"
annotate={(n) =>
typeof n.key === "string" &&
(n.key === "from" || n.key === "to") &&
typeof n.value === "number"
? `${(n.value / 1000).toFixed(2)} s`
: null
}
/>
)
}
// #endregion
// #region show
// The same record presented to be read: keys and values, nothing else.
function Shown() {
return (
<DataViewer
value={TAG}
name="tag"
depth={2}
variant="show"
className="h-96 w-full max-w-[34rem]"
/>
)
}
// #endregion
// #region rows
const ROWS = Array.from({ length: 240 }, (_, i) => ({
id: i + 1,
name: `${["clip", "take", "scene", "frame"][i % 4] ?? ""}${i + 1}`,
duration_ms: 1000 + ((i * 7919) % 60000),
vertical: i % 5 === 0,
added: new Date(Date.UTC(2026, 9, 1 + (i % 9))).toISOString(),
}))
function Rows() {
return (
<DataViewer
value={ROWS}
name="rows"
// Each row named by what it is, beside its index: "0 clip1".
itemName={(n) => (n.value as (typeof ROWS)[number]).name}
className="h-96 w-[34rem]"
/>
)
}
// #endregion
// #region xml
const XML = `<library version="2">
<file id="a1" kind="video"><name>beach.mp4</name><tag>sea</tag><tag>sunset</tag></file>
<file id="a2" kind="video"><name>kitchen.mov</name><tag>a kitchen</tag></file>
</library>`
function Xml() {
const [text, setText] = React.useState(XML)
return (
<div className="flex w-[34rem] flex-col gap-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
className="h-28 resize-none rounded-md bg-foreground/5 p-2 font-mono text-[11px] outline-none"
/>
<DataViewer source={text} depth={3} className="h-72" />
</div>
)
}
// #endregion
export default function DataViewerDemo() {
return (
<>
<Sample
name="record"
glance="large"
title="a record, every field readable"
description="a tag as the lab keeps it: strings, numbers, a colour with its swatch, a link, dates with how long ago, a Map. annotate adds what only the consumer knows (from/to in seconds). search finds keys and values, fuzzily."
>
<Record />
</Sample>
<Sample
name="show"
title="shown, to be read"
description="variant show: the data presented, not the tool. no toolbar, no sizes, no row actions; branches still open."
with="record"
>
<Shown />
</Sample>
<Sample
name="rows"
title="a list of records, as a table too"
description="240 rows that share their keys, like an SQL result: each named by its name beside its index (itemName), the tree cuts them into ranges of 100, the table lays them out."
>
<Rows />
</Sample>
<Sample
name="xml"
title="text, read as JSON or XML"
description="edit it: attributes as @name, repeated elements numbered, an element holding only text as that text. a parse error says why."
>
<Xml />
</Sample>
</>
)
}the source, every file an install writes
"use client"
import { Braces, Check, Copy, Link2, Table2, TextQuote } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { fuzzy } from "@/registry/base-nova/lib/fuzzy"
import { RelativeTime } from "@/registry/base-nova/ui/relative-time"
import { Highlight, Search } from "@/registry/base-nova/ui/search"
import { Stepper } from "@/registry/base-nova/ui/stepper"
import { Tree, TreeAction, type TreeHandle } from "@/registry/base-nova/ui/tree"
import "./edge-fade"
// DATA VIEW: any nested data as a tree you can read. Feed it a value (an object, an
// array, a Map, a Set, a Date, anything JSON could hold and more) or a text to parse
// (`source`, JSON or XML), and every field is a row: its key, its value drawn by kind
// (strings quoted and green, numbers blue, true and false amber, null faint, a colour
// with its swatch, a link with its arrow, a date with how long ago), a branch closed
// to a preview of what is inside ({ id, name, … } or [3, 7, 9, …]) and its size. On
// the tree component, so it has the tree's keys (arrows walk and open, type to jump)
// and its rendering of only the rows in view, for data of any size.
//
// Around it: a fuzzy search over every key and every value (matches and the way to
// them open, the letters lit), open all, open to a depth, close all, the whole as
// formatted text, and a table for a list of records (rows that share their keys, as
// an SQL result, a GraphQL list, a CSV). Each row copies its value or its path
// ($.tags["a/b"].yes[0], or /root/item[2]/@id for XML). A consumer explains what the
// data means where only it knows (`annotate`: "1.2 s" beside a value in ms).
//
// A branch with more than 100 children is cut into ranges of 100 ([0 … 99]), as a
// browser's devtools do, so a list of 50,000 opens at once.
/** What a value is, as drawn. */
export type DataKind =
| "object"
| "array"
| "map"
| "set"
| "range"
| "element"
| "string"
| "number"
| "bigint"
| "boolean"
| "null"
| "undefined"
| "date"
| "function"
| "symbol"
export interface DataNode {
/** its path, unique: `$.a[0]`, `/root/b[2]/@id` */
id: string
/** its name in its parent: a key, an index, a tag, or null for the root */
key: string | number | null
kind: DataKind
/** the value itself (a leaf's; a branch keeps its whole) */
value: unknown
/** how many inside, for a branch */
size?: number
children?: DataNode[]
}
export interface DataViewerProps {
/** the data */
value?: unknown
/** or a text holding it */
source?: string
/** how to read `source`; auto tries JSON, then XML */
format?: "json" | "xml" | "auto"
/** the root's name */
name?: string
/** open this many levels to begin with */
depth?: number
/** a word about a value only the consumer can give ("1.2 s", "a tag's colour") */
annotate?: (node: DataNode) => React.ReactNode
/** what an array's item is, named beside its index (a record's own name or kind:
* "github" for `{ platform: "github", … }`), so a list reads as what it holds
* and not as 0, 1, 2; found by the search too. Undefined keeps the index alone. */
itemName?: (node: DataNode) => string | undefined
/** the search and the buttons above the tree (explore only) */
toolbar?: boolean
/** `explore` (default): a tool, searched, opened by levels, each row's size and
* its copies. `show`: data presented to be read (a format on a landing page, a
* response in a doc), the keys and values alone: no toolbar, no sizes, no row
* actions, branches still opening. */
variant?: "explore" | "show"
className?: string
}
// ── the model: a value to nodes.
const IDENT = /^[A-Za-z_$][\w$]*$/
const keyPath = (parent: string, key: string) =>
IDENT.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`
const CHUNK = 100
function kindOf(v: unknown): DataKind {
if (v === null) return "null"
if (Array.isArray(v)) return "array"
if (v instanceof Map) return "map"
if (v instanceof Set) return "set"
if (v instanceof Date) return "date"
const t = typeof v
if (t === "object") return "object"
return t as DataKind
}
/** Children over 100 in ranges of 100, each a branch of its own. */
function chunked(id: string, kids: DataNode[]): DataNode[] {
if (kids.length <= CHUNK) return kids
const out: DataNode[] = []
for (let i = 0; i < kids.length; i += CHUNK) {
const part = kids.slice(i, i + CHUNK)
out.push({
id: `${id}#${i}`,
key: `${i} … ${i + part.length - 1}`,
kind: "range",
value: null,
size: part.length,
children: part,
})
}
return out
}
/** A value as nodes. A value met again inside itself (a cycle) is named, not walked;
* one shared by two branches is drawn in both. */
export function dataOf(value: unknown, root = "$"): DataNode {
const above = new Set<object>()
const build = (
v: unknown,
id: string,
key: string | number | null,
): DataNode => {
const kind = kindOf(v)
if (typeof v === "object" && v !== null && above.has(v))
return { id, key, kind: "symbol", value: "[circular]" }
const node = (entries: [string | number, unknown, string][]): DataNode => {
above.add(v as object)
const kids = entries.map(([k, x, p]) => build(x, p, k))
above.delete(v as object)
return {
id,
key,
kind,
value: v,
size: kids.length,
children: chunked(id, kids),
}
}
if (kind === "array")
return node((v as unknown[]).map((x, i) => [i, x, `${id}[${i}]`]))
if (kind === "object")
return node(
Object.entries(v as object).map(([k, x]) => [k, x, keyPath(id, k)]),
)
if (kind === "map")
return node(
[...(v as Map<unknown, unknown>)].map(([k, x], i) => [
String(k),
x,
`${id}.get(${JSON.stringify(String(k))})#${i}`,
]),
)
if (kind === "set")
return node([...(v as Set<unknown>)].map((x, i) => [i, x, `${id}[${i}]`]))
return { id, key, kind, value: v }
}
return build(value, root, null)
}
/** An XML document as nodes: an element's attributes (@name), its elements, its text
* (#text), in order. */
export function dataOfXml(doc: Document): DataNode {
const build = (el: Element, id: string): DataNode => {
const kids: DataNode[] = []
for (const a of [...el.attributes])
kids.push({
id: `${id}/@${a.name}`,
key: `@${a.name}`,
kind: "string",
value: a.value,
})
const count = new Map<string, number>()
for (const c of [...el.childNodes]) {
if (c.nodeType === 1) {
const e = c as Element
const n = (count.get(e.tagName) ?? 0) + 1
count.set(e.tagName, n)
kids.push(build(e, `${id}/${e.tagName}[${n}]`))
} else if (
(c.nodeType === 3 || c.nodeType === 4) &&
c.textContent?.trim()
)
kids.push({
id: `${id}/#text${kids.length}`,
key: "#text",
kind: "string",
value: c.textContent.trim(),
})
}
// An element holding only its text is that text, as a leaf.
if (kids.length === 1 && kids[0]?.key === "#text")
return { id, key: el.tagName, kind: "string", value: kids[0].value }
return {
id,
key: el.tagName,
kind: "element",
value: el,
size: kids.length,
children: chunked(id, kids),
}
}
const root = doc.documentElement
return build(root, `/${root.tagName}`)
}
/** A branch with something inside: an empty `[]` or `{}` has nothing to open, so it
* never counts as a level closed. */
const opens = (n: DataNode) => (n.children?.length ?? 0) > 0
/** Nothing to subscribe to: the server's snapshot and the browser's differ once. */
const subscribeNothing = () => () => {}
/** A text read as JSON or XML, or why it could not be. */
export function parseSource(
source: string,
format: "json" | "xml" | "auto" = "auto",
): { node: DataNode } | { error: string } {
const text = source.trim()
const json = () => ({ node: dataOf(JSON.parse(text)) })
const xml = () => {
const doc = new DOMParser().parseFromString(text, "application/xml")
const bad = doc.querySelector("parsererror")
if (bad) throw new Error(bad.textContent?.split("\n")[0] ?? "not XML")
return { node: dataOfXml(doc) }
}
try {
if (format === "json") return json()
if (format === "xml") return xml()
return text.startsWith("<") ? xml() : json()
} catch (e) {
return { error: (e as Error).message }
}
}
// ── drawing a value.
const COLOUR =
/^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(.+\))$/i
const URL_LIKE = /^(https?:\/\/|\/)[^\s]*$/
const ISO_DATE =
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/
const TONE: Partial<Record<DataKind, string>> = {
string: "text-emerald-300/90",
number: "text-sky-300",
bigint: "text-sky-300",
boolean: "text-amber-300",
null: "italic text-foreground/40",
undefined: "italic text-foreground/40",
date: "text-violet-300",
function: "text-foreground/50",
symbol: "text-foreground/50",
}
/** A leaf's value as text, as searched and copied. */
function leafText(n: DataNode): string {
const v = n.value
if (n.kind === "string") return v as string
if (n.kind === "date") return (v as Date).toISOString()
if (n.kind === "bigint") return `${String(v)}n`
if (n.kind === "function")
return `ƒ ${(v as { name?: string }).name || "anonymous"}()`
return String(v)
}
/** What a closed branch holds, in a few words. */
function preview(n: DataNode): string {
const kids = (n.kind === "range" ? n.children : undefined) ?? n.children ?? []
if (n.kind === "array" || n.kind === "set" || n.kind === "range") {
const items = (n.kind === "range" ? kids : kids)
.slice(0, 6)
.map((k) =>
k.children ? (k.kind === "array" ? `[${k.size}]` : "{…}") : short(k),
)
return `[${items.join(", ")}${(n.size ?? 0) > 6 ? ", …" : ""}]`
}
const keys = kids.slice(0, 5).map((k) => String(k.key))
return `{ ${keys.join(", ")}${(n.size ?? 0) > 5 ? ", …" : ""} }`
}
function short(n: DataNode): string {
const t = leafText(n)
if (n.kind === "string")
return JSON.stringify(t.length > 16 ? `${t.slice(0, 15)}…` : t)
return t
}
/** A press on a value that is the reader's, not the row's: selecting its text, or
* following its link, never selecting or opening the row. */
const READ_HERE = {
onPointerDown: (e: React.PointerEvent) => e.stopPropagation(),
onClick: (e: React.MouseEvent) => e.stopPropagation(),
}
function Value({
node,
query,
read = false,
}: {
node: DataNode
query: string
/** Shown to be read (`variant="show"`): the text selects as any text does,
* and a link is the words themselves, opened by a press (a new tab). */
read?: boolean
}) {
if (node.children) return null
const text = leafText(node)
const shown =
node.kind === "string" && text.length > 160
? `${text.slice(0, 160)}…`
: text
const colour = node.kind === "string" && COLOUR.test(text)
const link = node.kind === "string" && URL_LIKE.test(text) && text.length > 1
const when =
node.kind === "date"
? (node.value as Date).getTime()
: node.kind === "string" && ISO_DATE.test(text)
? Date.parse(text)
: null
return (
<span
className={cn(
"inline-flex min-w-0 items-center gap-1.5",
TONE[node.kind],
)}
>
{colour && (
<span
className="size-2.5 shrink-0 rounded-[3px] ring-1 ring-white/15"
style={{ background: text }}
/>
)}
{read && link ? (
// Text first, as in an editor: it selects by a drag or a double press
// (an <a> would start dragging the link instead), and ⌘-press (Ctrl
// elsewhere) opens it, the convention for a link inside text; the glyph
// after it opens it with a plain press.
// biome-ignore lint/a11y/noStaticElementInteractions: the ⌘-press is a pointer shortcut; the link glyph after it is the focusable way to open it
// biome-ignore lint/a11y/useKeyWithClickEvents: as above, the glyph is the keyboard's
<span
{...READ_HERE}
onClick={(e) => {
e.stopPropagation()
if (e.metaKey || e.ctrlKey)
window.open(text, "_blank", "noreferrer")
}}
className="min-w-0 cursor-text select-text truncate"
title={`${text}\n⌘-click to open`}
>
“<Highlight text={shown} query={query} />”
</span>
) : (
<span
{...(read ? READ_HERE : {})}
className={cn("min-w-0 truncate", read && "cursor-text select-text")}
title={text.length > 160 ? text : undefined}
>
{node.kind === "string" ? "“" : ""}
<Highlight text={shown} query={query} />
{node.kind === "string" ? "”" : ""}
</span>
)}
{link && (
<a
href={text}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="shrink-0 text-foreground/40 hover:text-foreground"
aria-label="open the link"
>
<Link2 className="size-3" />
</a>
)}
{when !== null && !Number.isNaN(when) && (
// The registry's relative time: the server's words and the reader's
// agree, then it ticks.
<RelativeTime
at={when}
className="shrink-0 text-[10px] text-foreground/35"
/>
)}
</span>
)
}
// ── the table, for a list of records.
/** The columns a list of records shares, when it is one: two or more objects whose
* keys mostly agree. */
function columnsOf(n: DataNode): string[] | null {
if (n.kind !== "array" || (n.size ?? 0) < 2) return null
const rows = n.value as unknown[]
if (!rows.every((r) => kindOf(r) === "object")) return null
const counts = new Map<string, number>()
for (const r of rows)
for (const k of Object.keys(r as object))
counts.set(k, (counts.get(k) ?? 0) + 1)
const cols = [...counts]
.filter(([, c]) => c >= rows.length / 2)
.map(([k]) => k)
return cols.length ? cols : null
}
function Table({ node, query }: { node: DataNode; query: string }) {
const cols = columnsOf(node) as string[]
const rows = node.value as Record<string, unknown>[]
return (
// The frame stays whole; the table scrolls inside it both ways, its cut edges
// fading where there is more, below the pinned header row (scrims'
// `ag-edge-fade-xy`, the head its cover).
<div className="flex min-h-0 flex-1 rounded-md ring-1 ring-foreground/10">
<div className="ag-edge-fade-xy min-h-0 flex-1 [--edge-cover:var(--ag-data-view-head)] [--ag-data-view-head:1.5rem]">
<table className="w-max min-w-full border-collapse font-mono text-[11px]">
<thead className="sticky top-0 h-(--ag-data-view-head) bg-[oklch(0.2_0.005_270)]">
<tr>
<th className="px-2 py-1 text-left font-normal text-foreground/35">
#
</th>
{cols.map((c) => (
<th
key={c}
className="px-2 py-1 text-left font-normal text-foreground/55"
>
<Highlight text={c} query={query} />
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr
// biome-ignore lint/suspicious/noArrayIndexKey: rows are positional
key={i}
className="border-foreground/5 border-t hover:bg-foreground/4"
>
<td className="px-2 py-0.5 text-foreground/30 tabular-nums">
{i}
</td>
{cols.map((c) => {
const cell = dataOf(r[c], `$[${i}]`)
return (
<td key={c} className="max-w-72 px-2 py-0.5">
{cell.children ? (
<span className="text-foreground/45">
{cell.kind === "array"
? `[${cell.size}]`
: preview(cell)}
</span>
) : (
<Value node={cell} query={query} />
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
/** A row's size at its end: a branch's kind and count, a string's length. */
function sizeOf(n: DataNode): string | undefined {
if (n.children) {
const mark =
n.kind === "element"
? "<>"
: n.kind === "range"
? ""
: n.kind === "array" || n.kind === "set"
? "[]"
: "{}"
return `${mark} ${n.size}`
}
return n.kind === "string" ? `${(n.value as string).length}` : undefined
}
// ── the whole.
function walk(
n: DataNode,
depth: number,
out: (n: DataNode, depth: number) => void,
) {
out(n, depth)
for (const k of n.children ?? []) walk(k, depth + 1, out)
}
/** A node's value as JSON, as copied. */
function asJson(n: DataNode): string {
if (n.kind === "element")
return new XMLSerializer().serializeToString(n.value as Element)
if (n.kind === "range")
return JSON.stringify(
(n.children ?? []).map((k) => k.value),
null,
2,
)
if (n.kind === "string") return n.value as string
try {
return JSON.stringify(
n.value,
(_, v) =>
v instanceof Map
? Object.fromEntries(v)
: v instanceof Set
? [...v]
: typeof v === "bigint"
? `${v}n`
: v,
2,
)
} catch {
return leafText(n)
}
}
function CopyButton({ label, text }: { label: string; text: () => string }) {
const [done, setDone] = React.useState(false)
return (
<TreeAction
label={label}
onClick={() => {
void navigator.clipboard.writeText(text()).then(() => {
setDone(true)
window.setTimeout(() => setDone(false), 900)
})
}}
>
{done ? <Check /> : label === "copy path" ? <Braces /> : <Copy />}
</TreeAction>
)
}
const TOOL =
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-[11px] text-foreground/55 hover:bg-foreground/6 hover:text-foreground aria-pressed:bg-foreground/10 aria-pressed:text-foreground [&_svg]:size-3.5"
export function DataViewer({
value,
source,
format = "auto",
name,
depth = 1,
annotate,
itemName,
toolbar: wantsToolbar = true,
variant = "explore",
className,
}: DataViewerProps) {
const shown = variant === "show"
const toolbar = wantsToolbar && !shown
// XML is read with the browser's DOMParser, which the server has not: there it
// is pending (an empty frame), read once the page is live, so the two agree.
const browser = React.useSyncExternalStore(
subscribeNothing,
() => true,
() => false,
)
const xml =
source !== undefined &&
(format === "xml" || (format === "auto" && source.trim().startsWith("<")))
const parsed = React.useMemo(
() =>
source !== undefined
? xml && !browser
? { pending: true as const }
: parseSource(source, format)
: { node: dataOf(value) },
[value, source, format, xml, browser],
)
const root = "node" in parsed ? parsed.node : null
const [query, setQuery] = React.useState("")
const [mode, setMode] = React.useState<"tree" | "text" | "table">("tree")
const tree = React.useRef<TreeHandle>(null)
const table = root ? columnsOf(root) : null
const [open, setOpen] = React.useState<string[]>([])
// Opened to the depth asked, again whenever the data takes another shape (other
// branches), never when the same data comes again as a new object: a page that polls
// closed every branch the reader had opened, and the page jumped under its scroll.
const shape = React.useMemo(() => {
const ids: string[] = []
if (root)
walk(root, 0, (n) => {
if (n.children) ids.push(n.id)
})
return ids.join("\n")
}, [root])
// biome-ignore lint/correctness/useExhaustiveDependencies: `shape` stands for the root's branches; a new root of the same branches keeps what is open
React.useEffect(() => {
if (!root) return
const ids: string[] = []
walk(root, 0, (n, d) => {
if (n.children && d < depth) ids.push(n.id)
})
setOpen(ids)
}, [shape, depth])
const openTo = (d: number) => {
if (!root) return
const ids: string[] = []
walk(root, 0, (n, at) => {
if (n.children && at < d) ids.push(n.id)
})
setOpen(ids)
}
const deepest = React.useMemo(() => {
let m = 0
if (root)
walk(root, 0, (n, d) => {
if (opens(n)) m = Math.max(m, d + 1)
})
return m
}, [root])
// How deep it IS open, read from the open branches, never a depth once asked:
// the levels above the shallowest closed branch (a level counts once all of it
// is open). A branch closed by hand moves the stepper with it.
const opened = React.useMemo(() => {
const isOpen = new Set(open)
let d = deepest
if (root)
walk(root, 0, (n, at) => {
if (opens(n) && !isOpen.has(n.id)) d = Math.min(d, at)
})
return d
}, [root, open, deepest])
/** A depth in words: none, a number of levels, or all of them. */
const levels = (d: number) =>
d === 0 ? "none" : d >= deepest ? "all" : String(d)
if ("pending" in parsed)
return (
<div
data-slot="data-view"
aria-busy
className={cn("flex min-h-0 flex-col gap-2", className)}
/>
)
if (!root)
return (
<div
data-slot="data-view"
className={cn(
"rounded-md bg-red-500/8 p-3 font-mono text-[12px] text-red-300",
className,
)}
>
cannot read it: {"error" in parsed ? parsed.error : ""}
</div>
)
const label = (n: DataNode) =>
n.key === null
? (name ?? (n.kind === "element" ? String(n.id.slice(1)) : n.kind))
: String(n.key)
/** An array item's name, when the consumer gives one. */
const named = (n: DataNode) =>
typeof n.key === "number" ? itemName?.(n) : undefined
return (
<div
data-slot="data-view"
className={cn("flex min-h-0 flex-col gap-2", className)}
>
{toolbar && (
<div className="flex flex-wrap items-center gap-1">
<Search
value={query}
onChange={setQuery}
label="search keys and values"
placeholder="search keys and values"
className="min-w-40 flex-1"
/>
<button
type="button"
aria-pressed={mode === "tree"}
className={TOOL}
onClick={() => setMode("tree")}
>
tree
</button>
{table && (
<button
type="button"
aria-pressed={mode === "table"}
className={TOOL}
onClick={() => setMode("table")}
>
<Table2 /> table
</button>
)}
<button
type="button"
aria-pressed={mode === "text"}
className={TOOL}
onClick={() => setMode("text")}
>
<TextQuote /> text
</button>
{mode === "tree" && (
// How deep it is open, stepped level by level from none to all
// (stepper): ‹ › a level a press, the arrows, Home and End.
<span className="flex items-center gap-1.5 pl-1 font-mono text-[10px] text-foreground/40">
open
<Stepper
label="levels open"
size="sm"
value={opened}
options={Array.from({ length: deepest + 1 }, (_, d) => d)}
onValueChange={openTo}
format={levels}
className="font-mono text-[11px] text-foreground/70"
/>
</span>
)}
</div>
)}
{mode === "text" ? (
// The frame stays whole; the text scrolls inside it both ways, its cut
// edges fading where there is more (scrims' `ag-edge-fade-xy`).
<div className="flex min-h-0 flex-1 rounded-md bg-foreground/4">
<pre className="ag-edge-fade-xy min-h-0 flex-1 p-3 font-mono text-[11px] text-foreground/80 leading-5">
{asJson(root)}
</pre>
</div>
) : mode === "table" && table ? (
<Table node={root} query={query} />
) : (
<Tree<DataNode>
ref={tree}
aria-label={name ?? "data"}
items={[root]}
label={label}
expanded={open}
onExpandedChange={setOpen}
filter={query}
match={(n, q) =>
fuzzy(q, String(n.key ?? "")) !== null ||
fuzzy(q, named(n) ?? "") !== null ||
(!n.children && fuzzy(q, leafText(n)) !== null)
}
guides
density="compact"
className={cn(
"min-h-0 flex-1 font-mono text-[12px]",
// The rows are text: a drag selects across keys and values and from
// row to row, as it does in any text (Adrian, 2026-10-14: "i cant
// select text here", on the tree variant).
"[&_[role=treeitem]]:select-text",
)}
content={(n, s) => (
<span className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
"shrink-0",
typeof n.key === "number" || n.kind === "range"
? "text-foreground/40"
: "text-foreground/85",
)}
>
<Highlight text={label(n)} query={query} />
</span>
{named(n) && (
<span className="shrink-0 text-foreground/85">
<Highlight text={named(n) ?? ""} query={query} />
</span>
)}
{n.key !== null && (
<span className="shrink-0 text-foreground/30">
{n.children ? "" : ":"}
</span>
)}
{n.children ? (
!s.expanded && (
<span className="min-w-0 truncate text-foreground/35">
{preview(n)}
</span>
)
) : (
<Value node={n} query={query} read />
)}
{annotate && (
<span className="shrink-0 text-[10px] text-foreground/40">
{annotate(n)}
</span>
)}
</span>
)}
meta={shown ? undefined : sizeOf}
actions={
shown
? undefined
: (n) => (
<>
<CopyButton label="copy path" text={() => n.id} />
<CopyButton label="copy value" text={() => asJson(n)} />
</>
)
}
/>
)}
</div>
)
}