{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"json-viewer","type":"registry:ui","title":"JSON Viewer","description":"JSON to read and explore: folding objects and arrays, colored values, copy.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/copy-button.json"],"files":[{"path":"registry/dashral/json-viewer.tsx","type":"registry:file","target":"components/dashral/json-viewer.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRightIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { CopyButton } from \"./copy-button\"\n\ntype JsonViewerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** What to show: any value JSON can hold (an object, an array, a string…), or a JSON string, which is parsed. */\n  data: unknown\n  /** A name for the root, shown as its key: \"payload\", \"response\". */\n  name?: string\n  /** How many levels open at first: 1 opens the root, 0 starts it folded. */\n  defaultExpandDepth?: number\n  /** Past this many items in one array or object, a \"Show N more\" button. */\n  pageSize?: number\n  /** The copy button in the corner, copying the whole value as indented JSON. On by default. */\n  copyable?: boolean\n  /** Scrolls past this height, in pixels or any CSS length. */\n  maxHeight?: number | string\n}\n\ntype Kind = \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\"\n\nconst kindOf = (value: unknown): Kind =>\n  value === null || value === undefined ? \"null\" : Array.isArray(value) ? \"array\" : typeof value === \"object\" ? \"object\" : (typeof value as Kind)\n\n// Each kind of value in its color, all from the theme. Every class is written out in full so Tailwind finds it.\nconst valueColors: Record<Exclude<Kind, \"object\" | \"array\">, string> = {\n  string: \"text-success-text\",\n  number: \"text-info-text\",\n  boolean: \"text-highlight-text\",\n  null: \"text-muted-foreground italic\",\n}\n\ntype NodeProps = {\n  name?: string | number\n  value: unknown\n  depth: number\n  expandDepth: number\n  pageSize: number\n  last: boolean\n}\n\n/** A key (quoted for an object's, plain for an array's index), then a colon. */\nfunction Key({ name }: { name?: string | number }) {\n  if (name === undefined) return null\n  return (\n    <>\n      <span className={typeof name === \"number\" ? \"text-muted-foreground\" : \"text-foreground\"}>{typeof name === \"number\" ? name : JSON.stringify(name)}</span>\n      <span className=\"text-muted-foreground\">: </span>\n    </>\n  )\n}\n\nconst comma = <span className=\"text-muted-foreground\">,</span>\n\nfunction JsonNode({ name, value, depth, expandDepth, pageSize, last }: NodeProps) {\n  const { t } = useLocale()\n  const kind = kindOf(value)\n  const [open, setOpen] = React.useState(depth < expandDepth)\n  // The children are only built the first time a node opens, so a large payload costs nothing until it's explored;\n  // then they stay, so folding it again can animate.\n  const [built, setBuilt] = React.useState(open)\n  const [shown, setShown] = React.useState(pageSize)\n\n  if (kind !== \"object\" && kind !== \"array\") {\n    const text = kind === \"null\" ? \"null\" : kind === \"string\" ? JSON.stringify(value) : String(value)\n    return (\n      <div className=\"ps-5 break-all\">\n        <Key name={name} />\n        <span className={valueColors[kind]}>{text}</span>\n        {!last && comma}\n      </div>\n    )\n  }\n\n  const entries: [string | number, unknown][] = kind === \"array\" ? (value as unknown[]).map((v, i) => [i, v]) : Object.entries(value as object)\n  const [openBrace, closeBrace] = kind === \"array\" ? [\"[\", \"]\"] : [\"{\", \"}\"]\n  const count = t(kind === \"array\" ? \"jsonItems\" : \"jsonKeys\", { count: entries.length })\n\n  if (entries.length === 0) {\n    return (\n      <div className=\"ps-5\">\n        <Key name={name} />\n        <span className=\"text-muted-foreground\">\n          {openBrace}\n          {closeBrace}\n        </span>\n        {!last && comma}\n      </div>\n    )\n  }\n\n  return (\n    <div>\n      <button\n        type=\"button\"\n        aria-expanded={open}\n        onClick={() => {\n          setBuilt(true)\n          setOpen(!open)\n        }}\n        className=\"group/json flex w-full items-start rounded-sm text-start outline-none hover:bg-foreground/5 focus-visible:ring-2 focus-visible:ring-ring/50\"\n      >\n        <span className=\"grid h-6 w-5 shrink-0 place-items-center\">\n          <ChevronRightIcon\n            aria-hidden\n            className={cn(\n              \"size-3.5 text-muted-foreground transition-transform duration-(--duration-standard) ease-spring motion-reduce:transition-none\",\n              open && \"rotate-90\"\n            )}\n          />\n        </span>\n        <span className=\"min-w-0\">\n          <Key name={name} />\n          <span className=\"text-muted-foreground\">{openBrace}</span>\n          {/* Folded: a preview of what's inside, fading in as it closes. */}\n          {!open && (\n            <span className=\"animate-in text-muted-foreground duration-(--duration-standard) ease-standard fade-in-0 motion-reduce:animate-none\">\n              <span className=\"mx-1\">…</span>\n              {closeBrace}\n              {!last && \",\"}\n              <span className=\"ms-2 font-sans text-xs\">{count}</span>\n            </span>\n          )}\n        </span>\n      </button>\n      {/* The children grow open and fold shut by their height (grid rows from 0fr to 1fr), fading as they go. */}\n      <div\n        className={cn(\n          \"grid transition-[grid-template-rows,opacity] duration-(--duration-standard) ease-standard motion-reduce:transition-none\",\n          open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\"\n        )}\n        inert={!open}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          {built && (\n            <>\n              <div className=\"ms-2 border-s ps-2\">\n                {entries.slice(0, shown).map(([key, child], i) => (\n                  <JsonNode\n                    key={key}\n                    name={kind === \"array\" ? i : key}\n                    value={child}\n                    depth={depth + 1}\n                    expandDepth={expandDepth}\n                    pageSize={pageSize}\n                    last={i === Math.min(shown, entries.length) - 1 && shown >= entries.length}\n                  />\n                ))}\n                {shown < entries.length && (\n                  <button\n                    type=\"button\"\n                    onClick={() => setShown((n) => n + pageSize)}\n                    className=\"ms-5 rounded-sm font-sans text-xs text-primary-text outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50\"\n                  >\n                    {t(\"showMore\", { count: Math.min(pageSize, entries.length - shown) })}\n                  </button>\n                )}\n              </div>\n              <div className=\"ps-5 text-muted-foreground\">\n                {closeBrace}\n                {!last && \",\"}\n              </div>\n            </>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n\n/**\n * JSON to read and explore, in one tag: `<JsonViewer data={event.payload} />`. Objects and arrays fold and unfold\n * (with their size shown while folded), each kind of value has its color, long arrays come in pages, and the whole\n * thing copies as indented JSON. For a webhook's body, an API response, a log entry, an audit record.\n */\nfunction JsonViewer({ data, name, defaultExpandDepth = 1, pageSize = 100, copyable = true, maxHeight, className, style, ...props }: JsonViewerProps) {\n  const value = React.useMemo(() => {\n    if (typeof data !== \"string\") return data\n    try {\n      return JSON.parse(data) as unknown\n    } catch {\n      return data\n    }\n  }, [data])\n  const text = React.useMemo(() => JSON.stringify(value, null, 2) ?? \"\", [value])\n\n  return (\n    <div\n      data-slot=\"json-viewer\"\n      dir=\"ltr\"\n      className={cn(\"relative min-w-0 overflow-auto rounded-lg border bg-muted/40 py-2 pe-3 ps-1 font-mono text-[13px] leading-6\", className)}\n      style={{ maxHeight: typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight, ...style }}\n      {...props}\n    >\n      {copyable && (\n        <span className=\"sticky top-0 z-10 float-end -me-1.5 -mt-0.5 rounded-md bg-muted/80 backdrop-blur-sm\">\n          <CopyButton value={text} />\n        </span>\n      )}\n      <JsonNode name={name} value={value} depth={0} expandDepth={defaultExpandDepth} pageSize={pageSize} last />\n    </div>\n  )\n}\n\nexport { JsonViewer, type JsonViewerProps }\n"}],"cssVars":{"theme":{"color-primary-text":"var(--primary-text, var(--primary))","color-info":"var(--info)","color-info-foreground":"var(--info-foreground)","color-info-text":"var(--info-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","color-highlight":"var(--highlight)","color-highlight-foreground":"var(--highlight-foreground)","color-highlight-text":"var(--highlight-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"info":"oklch(0.5 0.134 242.749)","info-foreground":"oklch(0.985 0 0)","info-text":"oklch(0.443 0.11 240.79)","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","highlight":"#b8008c","highlight-foreground":"oklch(0.985 0 0)","highlight-text":"#a3007b","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"info":"oklch(0.685 0.169 237.323)","info-foreground":"oklch(0.2 0 0)","info-text":"oklch(0.746 0.16 232.661)","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)","highlight":"#ff2ecf","highlight-foreground":"oklch(0.2 0 0)","highlight-text":"#ff75df"}}}