{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"json-inspector","type":"registry:component","title":"JSON inspector","description":"Read, search and quote a webhook payload or an API response, or see exactly what changed between two versions of a record.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/json-inspector.tsx","type":"registry:component","content":"// Hairline UI · json-inspector · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { buildTree, diffTree, flatten, formatPath, parseError, searchTree, type JsonKey, type JsonNode } from \"./json-tree\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * JSON INSPECTOR: a payload you can read, search and quote\n *\n *   fold      objects and arrays open and close by height, the\n *             chevron turns; closed ones show what's inside\n *             ({ id, amount, … } or 12 items)\n *   search    every key and value that matches; the branches\n *             above them unfold, the count rolls, Enter steps\n *             from match to match and scrolls to it\n *   path      the row you're on is written out below, morphing\n *             letter by letter as you move (data.lines[0].id),\n *             ready to copy along with its value\n *   read      the row you're on shows its whole string, and\n *             timestamps say when they are\n *   diff      give it the value before: added, removed and\n *             changed keys are marked, old values sit struck\n *             through beside the new, and what didn't change\n *             folds into \"12 unchanged\"\n *   broken    text that isn't JSON points at the line and column\n *\n * It's a tree for the keyboard: arrows move and open, Home and\n * End jump, / searches, ⌘C copies the value you're on.\n * ───────────────────────────────────────────────────────── */\n\nexport interface JsonInspectorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  /** A value to show. */\n  data?: unknown;\n  /** Or JSON text; if it doesn't parse, the error is shown where it is. */\n  text?: string;\n  /** The value before: shows how it became `data`. */\n  compare?: unknown;\n  /** In a diff, match array items by this key (\"id\") instead of by position. */\n  arrayKey?: string;\n  /** Levels open at first. */\n  expandDepth?: number;\n  /** Shown at the top left, e.g. the event name. */\n  title?: React.ReactNode;\n  /** Height of the scrolling body in px. */\n  maxHeight?: number;\n  /** How paths are written when copied. */\n  pathStyle?: \"js\" | \"jq\";\n  /** Accessible name of the tree. */\n  label?: string;\n  /** Start with this search, e.g. from a \"find in payload\" link. */\n  defaultQuery?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst CHUNK = 100;\nconst INDENT = 14;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n// GitHub's palette for light and dark, as in Code block.\nconst TONE = {\n  string: \"text-[#0a3069] dark:text-[#a5d6ff]\",\n  number: \"text-[#0550ae] dark:text-[#79c0ff]\",\n  boolean: \"text-[#cf222e] dark:text-[#ff7b72]\",\n  null: \"italic text-muted-foreground\",\n};\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst isContainer = (n: JsonNode) => n.children !== undefined;\nconst removedIndex = (k: JsonKey) => (typeof k === \"string\" && /^\\d+ \\(removed\\)$/.test(k) ? parseInt(k) : k);\n\nfunction valueAt(value: unknown, path: JsonKey[]) {\n  let v = value;\n  for (const k of path) {\n    if (v === null || typeof v !== \"object\") return undefined;\n    v = (v as Record<string | number, unknown>)[removedIndex(k)];\n  }\n  return v;\n}\n\nconst TIME_KEY = /(^|_)(at|created|updated|date|time|timestamp|period_start|period_end|expires|due)($|_)/i;\n/** When a value is a moment: an ISO string, or Unix seconds under a key that sounds like a time. */\nfunction when(node: JsonNode): string | null {\n  const v = node.value;\n  let date: Date | null = null;\n  if (typeof v === \"string\" && /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}/.test(v)) date = new Date(v);\n  else if (typeof v === \"number\" && typeof node.key === \"string\" && TIME_KEY.test(node.key) && v > 9e8 && v < 4e9) date = new Date(v * 1000);\n  else if (typeof v === \"number\" && typeof node.key === \"string\" && TIME_KEY.test(node.key) && v > 9e11 && v < 4e12) date = new Date(v);\n  if (!date || isNaN(date.getTime())) return null;\n  return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" }).format(date);\n}\n\nfunction Marked({ text, query, current }: { text: string; query: string; current: boolean }) {\n  const q = query.trim().toLowerCase();\n  if (!q) return <>{text}</>;\n  const lower = text.toLowerCase();\n  const parts: React.ReactNode[] = [];\n  let at = 0;\n  for (let i = lower.indexOf(q); i !== -1; i = lower.indexOf(q, i + q.length)) {\n    if (i > at) parts.push(text.slice(at, i));\n    parts.push(\n      <mark key={i} className={`rounded-[3px] text-inherit ${current ? \"bg-primary/30 shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,transparent)]\" : \"bg-primary/15\"}`}>\n        {text.slice(i, i + q.length)}\n      </mark>,\n    );\n    at = i + q.length;\n  }\n  if (at < text.length) parts.push(text.slice(at));\n  return <>{parts}</>;\n}\n\nfunction Value({ node, query, current, before }: { node: JsonNode; query: string; current: boolean; before?: boolean }) {\n  const v = before ? node.before : node.value;\n  const kind = before ? (node.beforeKind ?? node.kind) : node.kind;\n  if (kind === \"object\" || kind === \"array\") return <span className=\"text-muted-foreground\">{String(v)}</span>;\n  if (kind === \"string\" && !before) return <span className={TONE.string}>“<Marked text={String(v)} query={query} current={current} />”</span>;\n  if (kind === \"string\") return <span className={TONE.string}>“{String(v)}”</span>;\n  const tone = kind === \"null\" ? TONE.null : kind === \"boolean\" ? TONE.boolean : TONE.number;\n  return <span className={tone}>{before ? String(v) : <Marked text={String(v)} query={query} current={current} />}</span>;\n}\n\nfunction preview(node: JsonNode) {\n  if (node.kind === \"array\") return `${node.size} ${node.size === 1 ? \"item\" : \"items\"}`;\n  const keys = (node.children ?? []).slice(0, 3).map((c) => String(c.key));\n  return `{ ${keys.join(\", \")}${(node.size ?? 0) > 3 ? \", …\" : \"\"} }`;\n}\n\nfunction Chevron({ open, reduced }: { open: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3 text-muted-foreground\" style={{ transform: open ? \"rotate(90deg)\" : \"none\", transition: reduced ? \"none\" : `transform 260ms ${EASE}` }}>\n      <path d=\"M6.25 4.5 9.75 8l-3.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/** A block that opens and closes by height; its children stay mounted once shown, so closing animates too. */\nfunction Fold({ open, reduced, children, id }: { open: boolean; reduced: boolean; children: React.ReactNode; id?: string }) {\n  return (\n    <div\n      id={id}\n      role=\"group\"\n      className=\"grid\"\n      style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 280ms ${EASE}, opacity ${open ? 240 : 140}ms ${EASE}` }}\n      inert={!open || undefined}\n    >\n      <div className=\"min-h-0 overflow-hidden\">{children}</div>\n    </div>\n  );\n}\n\nexport function JsonInspector({\n  data,\n  text,\n  compare,\n  arrayKey,\n  expandDepth = 2,\n  title,\n  maxHeight = 360,\n  pathStyle = \"js\",\n  label = \"JSON\",\n  defaultQuery = \"\",\n  className = \"\",\n  ...props\n}: JsonInspectorProps) {\n  const uid = React.useId();\n  const reduced = useReducedMotion();\n  const diff = compare !== undefined;\n\n  // The value: given, or parsed from text.\n  const parsed = React.useMemo(() => {\n    if (text === undefined) return { ok: true as const, value: data };\n    try {\n      return { ok: true as const, value: JSON.parse(text) as unknown };\n    } catch (err) {\n      return { ok: false as const, error: parseError(text, err) };\n    }\n  }, [text, data]);\n  const value = parsed.ok ? parsed.value : undefined;\n  const root = React.useMemo(() => (diff ? diffTree(compare, value, { arrayKey }) : buildTree(value)), [diff, compare, value, arrayKey]);\n  const { all, parent } = React.useMemo(() => flatten(root), [root]);\n  const byId = React.useMemo(() => new Map(all.map((n) => [n.id, n])), [all]);\n\n  const initialOpen = React.useCallback(() => {\n    const open = new Set<string>();\n    for (const n of all) if (isContainer(n) && (diff ? n.change === \"changed\" && n.children?.length : n.depth < expandDepth)) open.add(n.id);\n    open.add(root.id);\n    return open;\n  }, [all, diff, expandDepth, root.id]);\n\n  const [expanded, setExpanded] = React.useState(initialOpen);\n  const [seenRoot, setSeenRoot] = React.useState(root);\n  const [focusId, setFocusId] = React.useState(root.id);\n  const [query, setQuery] = React.useState(defaultQuery);\n  const [typed, setTyped] = React.useState(defaultQuery);\n  const [matchIndex, setMatchIndex] = React.useState(0);\n  const [onlyChanges, setOnlyChanges] = React.useState(true);\n  const [showSame, setShowSame] = React.useState<Set<string>>(() => new Set());\n  const [chunks, setChunks] = React.useState<Map<string, number>>(() => new Map());\n  const [copied, setCopied] = React.useState<{ path: ActionStatus; value: ActionStatus }>({ path: \"idle\", value: \"idle\" });\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [keyed, setKeyed] = React.useState(false);\n  const mountedRef = React.useRef<Set<string>>(new Set());\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const searchRef = React.useRef<HTMLInputElement>(null);\n  const rowRefs = React.useRef(new Map<string, HTMLDivElement>());\n\n  // New data: start again from its own first view.\n  if (root !== seenRoot) {\n    setSeenRoot(root);\n    setExpanded(initialOpen());\n    setFocusId(root.id);\n    setShowSame(new Set());\n    setChunks(new Map());\n  }\n\n  // Typing settles before it searches.\n  React.useEffect(() => {\n    const t = window.setTimeout(() => setQuery(typed), 120);\n    return () => window.clearTimeout(t);\n  }, [typed]);\n\n  const matches = React.useMemo(() => searchTree(all, query), [all, query]);\n  const current = matches.length ? matches[Math.min(matchIndex, matches.length - 1)] : null;\n  // The branches above every match open while you search.\n  const forced = React.useMemo(() => {\n    const out = new Set<string>();\n    for (const id of matches) for (let p = parent.get(id); p; p = parent.get(p)) out.add(p);\n    return out;\n  }, [matches, parent]);\n  const isOpen = (id: string) => expanded.has(id) || forced.has(id);\n\n  // A match that sits past a \"show more\" or among hidden unchanged keys is shown too.\n  const matchSet = React.useMemo(() => new Set(matches), [matches]);\n  const hiddenSame = (n: JsonNode, p: JsonNode) => diff && onlyChanges && n.change === \"same\" && !showSame.has(p.id) && !forced.has(n.id) && !matchSet.has(n.id);\n  const shownChildren = (n: JsonNode) => {\n    const kids = (n.children ?? []).filter((c) => !hiddenSame(c, n));\n    const limit = chunks.get(n.id) ?? CHUNK;\n    const needed = kids.findIndex((c, i) => i >= limit && (c.id === current || forced.has(c.id)));\n    return kids.slice(0, Math.max(limit, needed + 1));\n  };\n\n  /** Where a row's key starts, so brackets and \"show more\" line up with it. */\n  const keyStart = (depth: number) => 6 + depth * INDENT + (diff ? 12 : 0) + 18;\n\n  // Rows in the order the keyboard walks them.\n  const order = React.useMemo(() => {\n    const out: string[] = [];\n    const walk = (n: JsonNode) => {\n      out.push(n.id);\n      if (isContainer(n) && isOpen(n.id)) shownChildren(n).forEach(walk);\n    };\n    walk(root);\n    return out;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [root, expanded, forced, onlyChanges, showSame, chunks, current]);\n\n  const toggle = (id: string, open?: boolean) => {\n    setExpanded((s) => {\n      const next = new Set(s);\n      if (open ?? !next.has(id)) next.add(id);\n      else next.delete(id);\n      return next;\n    });\n  };\n\n  const move = (id: string, scroll = true) => {\n    setFocusId(id);\n    const row = rowRefs.current.get(id);\n    row?.focus({ preventScroll: !scroll });\n  };\n\n  // Stepping through matches puts you on each one and brings it into view.\n  React.useEffect(() => {\n    if (!current) return;\n    setFocusId(current);\n    const frame = requestAnimationFrame(() => {\n      const row = rowRefs.current.get(current);\n      const scroller = scrollerRef.current;\n      if (!row || !scroller) return;\n      const top = row.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;\n      if (top < scroller.scrollTop + 8 || top + row.offsetHeight > scroller.scrollTop + scroller.clientHeight - 8)\n        scroller.scrollTo({ top: Math.max(0, top - scroller.clientHeight / 3), behavior: reduced ? \"auto\" : \"smooth\" });\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [current, reduced]);\n\n  React.useEffect(() => {\n    if (!query) return;\n    const t = window.setTimeout(() => setAnnounce(matches.length ? `${matches.length} ${matches.length === 1 ? \"match\" : \"matches\"}` : \"No matches\"), 300);\n    return () => window.clearTimeout(t);\n  }, [matches.length, query]);\n\n  const step = (dir: 1 | -1) => {\n    if (!matches.length) return;\n    setMatchIndex((i) => (Math.min(i, matches.length - 1) + dir + matches.length) % matches.length);\n  };\n\n  const focused = byId.get(focusId) ?? root;\n  const focusPath = formatPath(focused.path, pathStyle);\n  const focusValue = () => {\n    const from = focused.change === \"removed\" ? compare : value;\n    const v = focused.path.length ? valueAt(from, focused.path) : from;\n    return typeof v === \"string\" ? v : JSON.stringify(v, null, 2);\n  };\n\n  const copy = async (what: \"path\" | \"value\") => {\n    try {\n      await navigator.clipboard.writeText(what === \"path\" ? focusPath : (focusValue() ?? \"\"));\n      setCopied((c) => ({ ...c, [what]: \"success\" }));\n      setAnnounce(what === \"path\" ? `Copied ${focusPath}` : \"Copied the value\");\n    } catch {\n      setCopied((c) => ({ ...c, [what]: \"error\" }));\n    }\n  };\n\n  const onTreeKey = (e: React.KeyboardEvent) => {\n    setKeyed(true);\n    const at = order.indexOf(focusId);\n    const n = focused;\n    if (e.key === \"ArrowDown\" && at < order.length - 1) move(order[at + 1]);\n    else if (e.key === \"ArrowUp\" && at > 0) move(order[at - 1]);\n    else if (e.key === \"Home\") move(order[0]);\n    else if (e.key === \"End\") move(order[order.length - 1]);\n    else if (e.key === \"ArrowRight\" && isContainer(n)) {\n      if (!isOpen(n.id)) toggle(n.id, true);\n      else if (order[at + 1] && parent.get(order[at + 1]) === n.id) move(order[at + 1]);\n    } else if (e.key === \"ArrowLeft\") {\n      if (isContainer(n) && isOpen(n.id) && !forced.has(n.id)) toggle(n.id, false);\n      else if (parent.get(n.id)) move(parent.get(n.id)!);\n    } else if ((e.key === \"Enter\" || e.key === \" \") && isContainer(n)) toggle(n.id);\n    else if (e.key === \"/\" ) {\n      searchRef.current?.focus();\n      searchRef.current?.select();\n    } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"c\" && !window.getSelection()?.toString()) {\n      void copy(\"value\");\n    } else return;\n    e.preventDefault();\n  };\n\n  const allOpen = all.every((n) => !isContainer(n) || expanded.has(n.id));\n  const counts = root.counts ?? { added: 0, removed: 0, changed: 0 };\n\n  /* ── Rows ── */\n\n  const renderNode = (n: JsonNode, p: JsonNode | null, index: number, siblings: number): React.ReactNode => {\n    const container = isContainer(n);\n    const open = container && isOpen(n.id);\n    if (open) mountedRef.current.add(n.id);\n    const isFocus = n.id === focusId;\n    const isMatch = n.id === current;\n    const keyText = n.key === null ? null : String(removedIndex(n.key));\n    const kids = container && (open || mountedRef.current.has(n.id)) ? shownChildren(n) : [];\n    const sameHidden = container && diff && onlyChanges && !showSame.has(n.id) ? (n.children ?? []).filter((c) => hiddenSame(c, n)).length : 0;\n    const total = container ? (n.children ?? []).length - sameHidden : 0;\n    const limit = chunks.get(n.id) ?? CHUNK;\n    const moment = isFocus && !container ? when(n) : null;\n    const removed = n.change === \"removed\";\n    const sign = !diff ? null : n.change === \"added\" ? \"+\" : removed ? \"−\" : n.change === \"changed\" && !container ? \"~\" : \"\";\n    const tint = n.change === \"added\" ? \"bg-emerald-500/[0.07]\" : removed ? \"bg-red-500/[0.07]\" : \"\";\n    return (\n      <React.Fragment key={n.id}>\n        <div\n          ref={(el) => {\n            if (el) rowRefs.current.set(n.id, el);\n            else rowRefs.current.delete(n.id);\n          }}\n          role=\"treeitem\"\n          data-id={n.id}\n          aria-level={n.depth + 1}\n          aria-setsize={siblings}\n          aria-posinset={index + 1}\n          aria-expanded={container ? open : undefined}\n          aria-selected={isFocus}\n          aria-owns={container && open ? `${uid}-g${n.id}` : undefined}\n          aria-label={`${keyText ?? label}${container ? `, ${n.kind === \"array\" ? `${n.size} items` : `${n.size} keys`}` : `: ${String(n.value)}`}${removed ? \", removed\" : n.change === \"added\" ? \", added\" : !container && n.change === \"changed\" ? `, was ${String(n.before)}` : \"\"}`}\n          tabIndex={isFocus ? 0 : -1}\n          onClick={() => {\n            setKeyed(false);\n            setFocusId(n.id);\n            if (container) toggle(n.id);\n          }}\n          onFocus={() => setFocusId(n.id)}\n          className={`relative flex min-h-[24px] cursor-pointer items-start rounded-[7px] pr-2 font-mono text-[12px] leading-[24px] outline-none transition-colors duration-150 ${tint} ${\n            isFocus ? (keyed ? \"bg-accent shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--ring)_45%,transparent)]\" : \"bg-accent\") : \"hover:bg-accent/60\"\n          }`}\n          style={{ paddingLeft: 6 + n.depth * INDENT }}\n        >\n          {/* Hairline guides for each level above this row. */}\n          {Array.from({ length: n.depth }, (_, d) => (\n            <span key={d} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 w-px bg-border\" style={{ left: 6 + d * INDENT + (diff ? 12 : 0) + 7 }} />\n          ))}\n          {diff && (\n            <span aria-hidden=\"true\" className={`w-3 shrink-0 text-center ${n.change === \"added\" ? \"text-emerald-600 dark:text-emerald-400\" : removed ? \"text-red-600 dark:text-red-400\" : \"text-amber-600 dark:text-amber-400\"}`}>\n              {sign}\n            </span>\n          )}\n          <span aria-hidden=\"true\" className=\"flex h-6 w-3.5 shrink-0 items-center justify-center\">\n            {container && <Chevron open={open} reduced={reduced} />}\n          </span>\n          <span className={`flex min-w-0 flex-1 gap-x-1.5 pl-1 ${isFocus && !container ? \"flex-wrap\" : \"\"}`}>\n            {keyText !== null && (\n              <span className={`shrink-0 ${typeof removedIndex(n.key!) === \"number\" ? \"text-muted-foreground\" : \"text-foreground\"} ${removed ? \"line-through decoration-red-500/50\" : \"\"}`}>\n                {typeof n.key === \"string\" && !/^\\d+ \\(removed\\)$/.test(n.key) ? <Marked text={keyText} query={query} current={isMatch} /> : keyText}\n                <span className=\"text-muted-foreground\">:</span>\n              </span>\n            )}\n            {container ? (\n              <span className=\"min-w-0 truncate text-muted-foreground\">\n                {open ? (n.kind === \"array\" ? \"[\" : \"{\") : preview(n)}\n                {diff && !open && n.change === \"changed\" && (\n                  <span className=\"ml-1.5 text-[11px]\">\n                    {n.counts && [n.counts.added ? `+${n.counts.added}` : \"\", n.counts.removed ? `−${n.counts.removed}` : \"\", n.counts.changed ? `~${n.counts.changed}` : \"\"].filter(Boolean).join(\" \")}\n                  </span>\n                )}\n              </span>\n            ) : (\n              <span className={`min-w-0 ${isFocus ? \"whitespace-pre-wrap break-all\" : \"truncate\"} ${removed ? \"line-through decoration-red-500/50\" : \"\"}`}>\n                {n.change === \"changed\" && (\n                  <>\n                    <span className=\"line-through decoration-red-500/60 opacity-70\">\n                      <Value node={n} query={query} current={isMatch} before />\n                    </span>\n                    <span className=\"px-1.5 text-muted-foreground\">→</span>\n                  </>\n                )}\n                <Value node={n} query={query} current={isMatch} />\n              </span>\n            )}\n            {moment && <span className=\"shrink-0 font-sans text-[11px] text-muted-foreground animate-[ui-fade-in_300ms_ease-out_both]\">{moment}</span>}\n          </span>\n        </div>\n        {container && (\n          <Fold open={open} reduced={reduced} id={`${uid}-g${n.id}`}>\n            {kids.map((c, i) => renderNode(c, n, i, kids.length))}\n            {kids.length < total && (\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                onClick={() => setChunks((m) => new Map(m).set(n.id, limit + CHUNK))}\n                className=\"flex h-6 w-full cursor-pointer items-center rounded-[7px] text-left font-sans text-[11.5px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground\"\n                style={{ paddingLeft: keyStart(n.depth + 1) }}\n              >\n                Show {Math.min(CHUNK, total - kids.length)} more · {(total - kids.length).toLocaleString()} left\n              </button>\n            )}\n            {sameHidden > 0 && (\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                onClick={() => setShowSame((s) => new Set(s).add(n.id))}\n                className=\"flex h-6 w-full cursor-pointer items-center rounded-[7px] text-left font-sans text-[11.5px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground\"\n                style={{ paddingLeft: keyStart(n.depth + 1) }}\n              >\n                {sameHidden} unchanged\n              </button>\n            )}\n            <div aria-hidden=\"true\" className=\"font-mono text-[12px] leading-[22px] text-muted-foreground\" style={{ paddingLeft: keyStart(n.depth) }}>\n              {n.kind === \"array\" ? \"]\" : \"}\"}\n            </div>\n          </Fold>\n        )}\n      </React.Fragment>\n    );\n  };\n\n  /* ── Parse error ── */\n\n  if (!parsed.ok) {\n    const { message, line, column } = parsed.error;\n    const lines = (text ?? \"\").split(\"\\n\");\n    const from = Math.max(1, line - 2);\n    const to = Math.min(lines.length, line + 2);\n    return (\n      <div className={`overflow-hidden rounded-[18px] border border-border bg-background text-foreground ${className}`} {...props}>\n        <div className=\"flex items-center gap-2 border-b border-border px-3.5 py-2.5 text-[12.5px] text-red-600 dark:text-red-400\" role=\"alert\">\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0\">\n            <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n            <path d=\"M8 4.75v3.75\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n            <circle cx=\"8\" cy=\"11\" r=\"1\" fill=\"currentColor\" />\n          </svg>\n          <span className=\"min-w-0\">\n            Not valid JSON: {message}. Line {line}, column {column}.\n          </span>\n        </div>\n        <pre className=\"overflow-x-auto py-2 font-mono text-[12px] leading-[22px]\">\n          {lines.slice(from - 1, to).map((l, i) => {\n            const n = from + i;\n            const bad = n === line;\n            return (\n              <React.Fragment key={n}>\n                <div className={`flex ${bad ? \"bg-red-500/[0.07]\" : \"\"}`}>\n                  <span className=\"w-10 shrink-0 select-none pr-3 text-right text-muted-foreground\">{n}</span>\n                  <span className=\"whitespace-pre pr-4\">{l || \" \"}</span>\n                </div>\n                {bad && (\n                  <div className=\"flex text-red-600 dark:text-red-400\" aria-hidden=\"true\">\n                    <span className=\"w-10 shrink-0\" />\n                    <span className=\"whitespace-pre\">{\" \".repeat(Math.max(0, column - 1))}^</span>\n                  </div>\n                )}\n              </React.Fragment>\n            );\n          })}\n        </pre>\n      </div>\n    );\n  }\n\n  /* ── The inspector ── */\n\n  return (\n    <div className={`@container overflow-hidden rounded-[18px] border border-border bg-background text-foreground ${className}`} {...props}>\n      <div className=\"flex flex-wrap items-center gap-x-2 gap-y-2 border-b border-border px-3 py-2\">\n        {/* The title keeps room to be read; the search wraps onto its own line before it truncates. */}\n        {title && <div className=\"min-w-[min(100%,13rem)] flex-1 truncate text-[12.5px] font-medium\">{title}</div>}\n        {diff && (\n          <div className=\"flex shrink-0 items-center gap-2 text-[11.5px] tabular-nums\">\n            {counts.changed > 0 && <span className=\"text-amber-700 dark:text-amber-400\">{counts.changed} changed</span>}\n            {counts.added > 0 && <span className=\"text-emerald-700 dark:text-emerald-400\">{counts.added} added</span>}\n            {counts.removed > 0 && <span className=\"text-red-600 dark:text-red-400\">{counts.removed} removed</span>}\n            {!counts.changed && !counts.added && !counts.removed && <span className=\"text-muted-foreground\">No changes</span>}\n          </div>\n        )}\n        <div className=\"ml-auto flex min-w-0 items-center gap-1 @max-[460px]:w-full\">\n          <label className=\"relative flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-full px-2.5 shadow-[inset_0_0_0_1px_var(--border)] transition-shadow focus-within:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--ring)_55%,var(--border))] @min-[460px]:w-[190px] @min-[460px]:flex-none\">\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\">\n              <circle cx=\"7\" cy=\"7\" r=\"4.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n              <path d=\"m10.25 10.25 3 3\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n            </svg>\n            <input\n              ref={searchRef}\n              value={typed}\n              onChange={(e) => {\n                setTyped(e.target.value);\n                setMatchIndex(0);\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  e.preventDefault();\n                  step(e.shiftKey ? -1 : 1);\n                } else if (e.key === \"ArrowDown\") {\n                  e.preventDefault();\n                  step(1);\n                } else if (e.key === \"ArrowUp\") {\n                  e.preventDefault();\n                  step(-1);\n                } else if (e.key === \"Escape\" && typed) {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setTyped(\"\");\n                }\n              }}\n              placeholder=\"Search keys and values\"\n              aria-label={`Search ${label}`}\n              aria-controls={`${uid}-tree`}\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n            />\n            {/* The count and the arrows open in once there's something to step through. */}\n            <span\n              className=\"grid shrink-0\"\n              style={{ gridTemplateColumns: query ? \"1fr\" : \"0fr\", opacity: query ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 300ms ${EASE}, opacity 200ms ${EASE}` }}\n            >\n              <span className=\"flex min-w-0 items-center overflow-hidden [clip-path:inset(-4px)]\">\n                <span className={`whitespace-nowrap pl-1 text-[11.5px] tabular-nums ${matches.length ? \"text-muted-foreground\" : \"text-red-600 dark:text-red-400\"}`}>\n                  {matches.length ? (\n                    <>\n                      <NumberRoll value={Math.min(matchIndex, matches.length - 1) + 1} duration={500} />/<NumberRoll value={matches.length} duration={500} />\n                    </>\n                  ) : (\n                    \"0\"\n                  )}\n                </span>\n                <button type=\"button\" tabIndex={query ? 0 : -1} aria-label=\"Previous match\" onClick={() => step(-1)} className={`ml-1 flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}>\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3\" aria-hidden=\"true\">\n                    <path d=\"M4.5 9.75 8 6.25l3.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </svg>\n                </button>\n                <button type=\"button\" tabIndex={query ? 0 : -1} aria-label=\"Next match\" onClick={() => step(1)} className={`flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}>\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3\" aria-hidden=\"true\">\n                    <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </svg>\n                </button>\n              </span>\n            </span>\n          </label>\n          {diff ? (\n            <button\n              type=\"button\"\n              aria-pressed={!onlyChanges}\n              onClick={() => setOnlyChanges((v) => !v)}\n              className={`h-7 shrink-0 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n            >\n              <TextMorph>{onlyChanges ? \"Show all\" : \"Only changes\"}</TextMorph>\n            </button>\n          ) : (\n            <button\n              type=\"button\"\n              aria-label={allOpen ? \"Collapse all\" : \"Expand all\"}\n              onClick={() => {\n                if (allOpen) setExpanded(new Set([root.id]));\n                else setExpanded(new Set(all.filter((n) => isContainer(n)).slice(0, 3000).map((n) => n.id)));\n                setFocusId(root.id);\n              }}\n              className={`flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n            >\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\" aria-hidden=\"true\">\n                <path\n                  d=\"M5 6.5 8 3.5l3 3\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.5\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  style={{ transform: allOpen ? \"translateY(2.5px) rotate(180deg)\" : \"none\", transformOrigin: \"8px 5px\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}\n                />\n                <path\n                  d=\"M5 9.5 8 12.5l3-3\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.5\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  style={{ transform: allOpen ? \"translateY(-2.5px) rotate(180deg)\" : \"none\", transformOrigin: \"8px 11px\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}\n                />\n              </svg>\n            </button>\n          )}\n        </div>\n      </div>\n\n      <div ref={scrollerRef} className=\"overflow-auto overscroll-contain p-1.5\" style={{ maxHeight }}>\n        <div id={`${uid}-tree`} role=\"tree\" aria-label={label} onKeyDown={onTreeKey} className=\"min-w-0\">\n          {renderNode(root, null, 0, 1)}\n        </div>\n      </div>\n\n      <div className=\"flex h-10 items-center gap-1 border-t border-border pl-3.5 pr-1.5\">\n        <code className=\"flex min-w-0 flex-1 justify-end overflow-hidden whitespace-nowrap font-mono text-[11.5px] text-muted-foreground [mask-image:linear-gradient(to_right,transparent,black_16px)]\">\n          <span className=\"ml-auto shrink-0\">\n            <TextMorph duration={360}>{focusPath}</TextMorph>\n          </span>\n        </code>\n        <StatusButton variant=\"ghost\" size=\"sm\" status={copied.path} labels={{ idle: \"Copy path\", success: \"Copied\", error: \"Couldn’t copy\" }} onClick={() => void copy(\"path\")} onReset={() => setCopied((c) => ({ ...c, path: \"idle\" }))} resetAfter={1400} />\n        <StatusButton variant=\"ghost\" size=\"sm\" status={copied.value} labels={{ idle: \"Copy value\", success: \"Copied\", error: \"Couldn’t copy\" }} onClick={() => void copy(\"value\")} onReset={() => setCopied((c) => ({ ...c, value: \"idle\" }))} resetAfter={1400} />\n      </div>\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/json-tree.ts","type":"registry:component","content":"// Hairline UI · json-inspector · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * JSON TREE: the model under the JSON inspector\n *\n *   build    any value into nodes with stable ids (their paths)\n *   diff     two values into one tree: each node same, added,\n *            removed or changed, with counts on the way up;\n *            arrays of records can be matched by a key (\"id\")\n *   paths    data.items[2].id, or .data.items[2].id for jq\n *   search   keys and values that contain the query\n *   errors   where JSON.parse gave up, as a line and column\n * ───────────────────────────────────────────────────────── */\n\nexport type JsonKind = \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\";\nexport type JsonChange = \"same\" | \"added\" | \"removed\" | \"changed\";\nexport type JsonKey = string | number;\n\nexport interface JsonNode {\n  /** The path as a string, stable across renders. */\n  id: string;\n  key: JsonKey | null;\n  path: JsonKey[];\n  depth: number;\n  kind: JsonKind;\n  /** Primitives only. */\n  value?: string | number | boolean | null;\n  children?: JsonNode[];\n  /** Keys or items in a container. */\n  size?: number;\n  /** Diff only. */\n  change?: JsonChange;\n  /** The old value of a changed primitive (or a summary of an old container). */\n  before?: string | number | boolean | null;\n  beforeKind?: JsonKind;\n  /** Changes at or below this node. */\n  counts?: { added: number; removed: number; changed: number };\n}\n\nconst kindOf = (v: unknown): JsonKind =>\n  v === null || v === undefined ? \"null\" : Array.isArray(v) ? \"array\" : typeof v === \"object\" ? \"object\" : typeof v === \"number\" || typeof v === \"bigint\" ? \"number\" : typeof v === \"boolean\" ? \"boolean\" : \"string\";\n\nconst prim = (v: unknown): string | number | boolean | null => {\n  if (v === null || v === undefined) return null;\n  if (typeof v === \"number\" || typeof v === \"boolean\" || typeof v === \"string\") return v;\n  if (typeof v === \"bigint\") return Number(v);\n  return String(v);\n};\n\nexport const idOf = (path: JsonKey[]) => (path.length ? path.map((k) => (typeof k === \"number\" ? `[${k}]` : `.${k.replace(/[.[\\]\\\\]/g, \"\\\\$&\")}`)).join(\"\") : \"$\");\n\nconst entries = (v: unknown): [JsonKey, unknown][] => (Array.isArray(v) ? v.map((x, i) => [i, x] as [JsonKey, unknown]) : Object.entries(v as Record<string, unknown>));\n\n/** Any value as a tree. Circular references become the string \"[circular]\". */\nexport function buildTree(value: unknown, path: JsonKey[] = [], seen = new WeakSet<object>()): JsonNode {\n  const kind = kindOf(value);\n  const node: JsonNode = { id: idOf(path), key: path.length ? path[path.length - 1] : null, path, depth: path.length, kind };\n  if (kind === \"object\" || kind === \"array\") {\n    if (seen.has(value as object)) return { ...node, kind: \"string\", value: \"[circular]\" };\n    seen.add(value as object);\n    node.children = entries(value).map(([k, v]) => buildTree(v, [...path, k], seen));\n    node.size = node.children.length;\n    seen.delete(value as object);\n  } else node.value = prim(value);\n  return node;\n}\n\nconst mark = (node: JsonNode, change: JsonChange): JsonNode => {\n  const children = node.children?.map((c) => mark(c, change));\n  const one = { added: change === \"added\" ? 1 : 0, removed: change === \"removed\" ? 1 : 0, changed: 0 };\n  return { ...node, children, change, counts: one };\n};\n\nconst same = (a: unknown, b: unknown) => a === b || (Number.isNaN(a) && Number.isNaN(b));\n\n/** One tree that shows how `before` became `after`. */\nexport function diffTree(before: unknown, after: unknown, { arrayKey, path = [] }: { arrayKey?: string; path?: JsonKey[] } = {}): JsonNode {\n  const ka = kindOf(before);\n  const kb = kindOf(after);\n  const base: JsonNode = { id: idOf(path), key: path.length ? path[path.length - 1] : null, path, depth: path.length, kind: kb };\n  const containers = (k: JsonKind) => k === \"object\" || k === \"array\";\n  if (ka !== kb || !containers(kb)) {\n    if (!containers(ka) && !containers(kb) && same(before, after)) return { ...base, value: prim(after), change: \"same\", counts: { added: 0, removed: 0, changed: 0 } };\n    // A changed primitive (or a container that became something else): old value shown before the new.\n    const fresh = buildTree(after, path);\n    return {\n      ...fresh,\n      change: \"changed\",\n      before: containers(ka) ? (ka === \"array\" ? `[${(before as unknown[]).length} items]` : `{${Object.keys(before as object).length} keys}`) : prim(before),\n      beforeKind: ka,\n      counts: { added: 0, removed: 0, changed: 1 },\n    };\n  }\n  const children: JsonNode[] = [];\n  if (kb === \"array\" && arrayKey && [...(before as unknown[]), ...(after as unknown[])].every((x) => x && typeof x === \"object\" && arrayKey in (x as object))) {\n    // Records matched by their key, so an item inserted at the top doesn't make every row \"changed\".\n    const keyOf = (x: unknown) => String((x as Record<string, unknown>)[arrayKey]);\n    const old = new Map((before as unknown[]).map((x) => [keyOf(x), x]));\n    const kept = new Set<string>();\n    (after as unknown[]).forEach((x, i) => {\n      const k = keyOf(x);\n      kept.add(k);\n      children.push(old.has(k) ? diffTree(old.get(k), x, { arrayKey, path: [...path, i] }) : mark(buildTree(x, [...path, i]), \"added\"));\n    });\n    (before as unknown[]).forEach((x, i) => {\n      if (!kept.has(keyOf(x))) children.push(mark(buildTree(x, [...path, `${i} (removed)`]), \"removed\"));\n    });\n  } else {\n    const a = new Map(entries(before));\n    const b = entries(after);\n    for (const [k, v] of b) children.push(a.has(k) ? diffTree(a.get(k), v, { arrayKey, path: [...path, k] }) : mark(buildTree(v, [...path, k]), \"added\"));\n    const inAfter = new Set(b.map(([k]) => k));\n    for (const [k, v] of a) if (!inAfter.has(k)) children.push(mark(buildTree(v, [...path, k]), \"removed\"));\n  }\n  const counts = { added: 0, removed: 0, changed: 0 };\n  for (const c of children) {\n    // A whole added or removed subtree counts once.\n    if (c.change === \"added\") counts.added++;\n    else if (c.change === \"removed\") counts.removed++;\n    else if (c.counts) {\n      counts.added += c.counts.added;\n      counts.removed += c.counts.removed;\n      counts.changed += c.counts.changed;\n    }\n  }\n  const changed = counts.added + counts.removed + counts.changed > 0;\n  return { ...base, children, size: children.length, change: changed ? \"changed\" : \"same\", counts };\n}\n\n/** Every node, depth first, with a map from each id to its parent's. */\nexport function flatten(root: JsonNode) {\n  const all: JsonNode[] = [];\n  const parent = new Map<string, string>();\n  const walk = (n: JsonNode) => {\n    all.push(n);\n    n.children?.forEach((c) => {\n      parent.set(c.id, n.id);\n      walk(c);\n    });\n  };\n  walk(root);\n  return { all, parent };\n}\n\nconst IDENT = /^[A-Za-z_$][\\w$]*$/;\n/** The path to copy: data.items[2].id, or .data.items[2].id for jq. */\nexport function formatPath(path: JsonKey[], style: \"js\" | \"jq\" = \"js\") {\n  let out = \"\";\n  for (const k of path) {\n    if (typeof k === \"number\") out += `[${k}]`;\n    else if (/^\\d+ \\(removed\\)$/.test(k)) out += `[${parseInt(k)}]`;\n    else if (IDENT.test(k)) out += style === \"jq\" || out ? `.${k}` : k;\n    else out += style === \"jq\" ? `.${JSON.stringify(k)}` : `[${JSON.stringify(k)}]`;\n  }\n  return style === \"jq\" ? out || \".\" : out || \"(root)\";\n}\n\n/** Ids of nodes whose key or value contains the query, in document order. */\nexport function searchTree(all: JsonNode[], query: string) {\n  const q = query.trim().toLowerCase();\n  if (!q) return [];\n  return all\n    .filter((n) => (n.key !== null && typeof n.key === \"string\" && n.key.toLowerCase().includes(q)) || (n.children === undefined && String(n.value).toLowerCase().includes(q)))\n    .map((n) => n.id);\n}\n\n/** The offset where text stops being JSON, or -1. Browsers word (and place) their errors differently, so we find it ourselves. */\nexport function locateError(text: string): number {\n  let i = 0;\n  const fail = (): never => {\n    throw i;\n  };\n  const ws = () => {\n    while (i < text.length && (text[i] === \" \" || text[i] === \"\\n\" || text[i] === \"\\r\" || text[i] === \"\\t\")) i++;\n  };\n  const string = () => {\n    i++;\n    while (i < text.length) {\n      const c = text[i];\n      if (c === '\"') return void i++;\n      if (c === \"\\\\\") i += 2;\n      else if (c < \" \") fail();\n      else i++;\n    }\n    fail();\n  };\n  const value = (): void => {\n    ws();\n    const c = text[i];\n    if (c === \"{\") {\n      i++;\n      ws();\n      if (text[i] === \"}\") return void i++;\n      for (;;) {\n        ws();\n        if (text[i] !== '\"') fail();\n        string();\n        ws();\n        if (text[i] !== \":\") fail();\n        i++;\n        value();\n        ws();\n        if (text[i] === \",\") i++;\n        else if (text[i] === \"}\") return void i++;\n        else fail();\n      }\n    }\n    if (c === \"[\") {\n      i++;\n      ws();\n      if (text[i] === \"]\") return void i++;\n      for (;;) {\n        value();\n        ws();\n        if (text[i] === \",\") i++;\n        else if (text[i] === \"]\") return void i++;\n        else fail();\n      }\n    }\n    if (c === '\"') return string();\n    const num = /-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?/y;\n    num.lastIndex = i;\n    if (num.test(text)) return void (i = num.lastIndex);\n    for (const w of [\"true\", \"false\", \"null\"]) if (text.startsWith(w, i)) return void (i += w.length);\n    fail();\n  };\n  try {\n    value();\n    ws();\n    if (i < text.length) fail();\n    return -1;\n  } catch (at) {\n    return typeof at === \"number\" ? at : -1;\n  }\n}\n\n/** Why and where JSON.parse gave up, as a 1-based line and column. */\nexport function parseError(text: string, error: unknown): { message: string; line: number; column: number } {\n  const raw = error instanceof Error ? error.message : String(error);\n  let pos = locateError(text);\n  if (pos < 0) {\n    const lc = raw.match(/line (\\d+) column (\\d+)/);\n    if (lc) return { message: tidyMessage(raw), line: Number(lc[1]), column: Number(lc[2]) };\n    const at = raw.match(/position (\\d+)/);\n    pos = at ? Number(at[1]) : 0;\n  }\n  const before = text.slice(0, pos).split(\"\\n\");\n  return { message: tidyMessage(raw), line: before.length, column: before[before.length - 1].length + 1 };\n}\n\nconst tidyMessage = (raw: string) =>\n  raw\n    .replace(/^JSON\\.parse: /, \"\")\n    .replace(/^JSON Parse error: /, \"\")\n    .replace(/ in JSON at position \\d+[\\s\\S]*$/, \"\")\n    .replace(/, \"[\\s\\S]*\" is not valid JSON$/, \"\")\n    .replace(/ at line \\d+ column \\d+ of the JSON data$/, \"\")\n    .replace(/^SyntaxError: /, \"\");\n"}],"css":{"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/json-inspector. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}