{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "type": "registry:ui",
  "title": "JSON viewer",
  "description": "A collapsible JSON tree with search, paging for long arrays, and copy value or path.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/json-viewer/json-viewer.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useReducedMotion } from \"motion/react\";\nimport type { AnimationPlaybackControls, Transition } from \"motion/react\";\nimport {\n  Check,\n  ChevronDown,\n  ChevronRight,\n  ChevronUp,\n  ChevronsDownUp,\n  ChevronsUpDown,\n  Copy,\n  Link2,\n  Search,\n  X,\n} from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type JsonValueType = \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\" | \"other\";\n\nexport interface JsonViewerCopyDetail {\n  kind: \"value\" | \"path\";\n  path: string;\n  text: string;\n}\n\n/**\n * A collapsible tree for JSON. Values are colored by type, branches unfold row by row, search highlights every match and\n * opens the branches that hold them, long arrays and objects load in pages, and each row can copy its value or path.\n * The tree follows the WAI-ARIA tree pattern: arrows move and open, Home and End jump, Enter toggles, and the modifier\n * key with C copies the focused value.\n */\nexport interface JsonViewerProps {\n  data: unknown;\n  /** Name of the root in paths, as in `root.users[0].name`. Defaults to \"root\". */\n  rootName?: string;\n  /** Levels open on first render when `defaultExpanded` is not set. Defaults to 1, which opens the root. */\n  defaultExpandDepth?: number;\n  /** Paths of open branches. */\n  expanded?: string[];\n  defaultExpanded?: string[];\n  onExpandedChange?: (paths: string[]) => void;\n  /** Show the search field. Defaults to true. */\n  searchable?: boolean;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  /** Children shown per page in a long array or object. Defaults to 50. */\n  pageSize?: number;\n  /** Show copy value and copy path actions. Defaults to true. */\n  copyable?: boolean;\n  onCopy?: (detail: JsonViewerCopyDetail) => void;\n  /** Called when a row becomes the current one. */\n  onSelect?: (detail: { path: string; value: unknown; type: JsonValueType }) => void;\n  /** Show the path of the current row under the tree. Defaults to true. */\n  showPath?: boolean;\n  /** Height of the scrolling tree area. Defaults to 420px. */\n  maxHeight?: number | string;\n  /** Accessible name of the tree. */\n  label?: string;\n  className?: string;\n}\n\ntype Row = {\n  id: string;\n  kind: \"node\" | \"more\";\n  level: number;\n  name: string | number | null;\n  value: unknown;\n  type: JsonValueType;\n  count: number;\n  open: boolean;\n  parent: string | null;\n  posinset: number;\n  setsize: number;\n  /** For \"more\" rows: how many children are still hidden. */\n  hidden?: number;\n};\n\nconst ROW = 30;\n/** Type colours lean on the semantic roles, so both themes stay legible. */\nconst valueTone: Record<JsonValueType, string> = {\n  string: \"text-green\",\n  number: \"text-accent tabular-nums\",\n  boolean: \"text-orange\",\n  null: \"text-ink-3\",\n  other: \"text-ink-3\",\n  object: \"\",\n  array: \"\",\n};\nconst IDENT = /^[A-Za-z_$][\\w$]*$/;\n\nfunction typeOf(value: unknown): JsonValueType {\n  if (value === null) return \"null\";\n  if (Array.isArray(value)) return \"array\";\n  switch (typeof value) {\n    case \"object\":\n      return \"object\";\n    case \"string\":\n      return \"string\";\n    case \"number\":\n    case \"bigint\":\n      return \"number\";\n    case \"boolean\":\n      return \"boolean\";\n    default:\n      return \"other\";\n  }\n}\nconst isBranch = (type: JsonValueType) => type === \"object\" || type === \"array\";\nfunction entries(value: unknown): [string | number, unknown][] {\n  if (Array.isArray(value)) return value.map((item, index) => [index, item]);\n  if (value && typeof value === \"object\") return Object.entries(value as Record<string, unknown>);\n  return [];\n}\nconst childPath = (parent: string, key: string | number) =>\n  typeof key === \"number\"\n    ? `${parent}[${key}]`\n    : IDENT.test(key)\n      ? `${parent}.${key}`\n      : `${parent}[${JSON.stringify(key)}]`;\nconst within = (ancestor: string, id: string) =>\n  id === ancestor || id.startsWith(`${ancestor}.`) || id.startsWith(`${ancestor}[`);\n\nfunction primitiveText(value: unknown, type: JsonValueType) {\n  if (type === \"string\") return value as string;\n  if (type === \"null\") return \"null\";\n  return String(value);\n}\nfunction copyText(value: unknown, type: JsonValueType) {\n  if (type === \"string\") return value as string;\n  if (isBranch(type)) return JSON.stringify(value, null, 2);\n  return primitiveText(value, type);\n}\nconst plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;\nconst countLabel = (row: { type: JsonValueType; count: number }) =>\n  row.type === \"array\" ? plural(row.count, \"item\", \"items\") : plural(row.count, \"key\", \"keys\");\n\n/** Every container path, for expand all. */\nfunction allBranches(data: unknown, rootName: string) {\n  const out: string[] = [];\n  const visit = (value: unknown, id: string) => {\n    if (!isBranch(typeOf(value))) return;\n    out.push(id);\n    for (const [key, child] of entries(value)) visit(child, childPath(id, key));\n  };\n  visit(data, rootName);\n  return out;\n}\nfunction branchesToDepth(data: unknown, rootName: string, depth: number) {\n  const out: string[] = [];\n  const visit = (value: unknown, id: string, level: number) => {\n    if (level >= depth || !isBranch(typeOf(value))) return;\n    out.push(id);\n    for (const [key, child] of entries(value)) visit(child, childPath(id, key), level + 1);\n  };\n  visit(data, rootName, 0);\n  return out;\n}\n\ntype SearchResult = { matches: string[]; ancestors: Set<string>; needed: Map<string, number> };\n/** Finds keys and primitive values that contain the query, the branches that hold them, and how far each page must reach. */\nfunction searchJson(data: unknown, rootName: string, needle: string): SearchResult {\n  const matches: string[] = [];\n  const ancestors = new Set<string>();\n  const needed = new Map<string, number>();\n  const frames: [string, number][] = [];\n  const visit = (value: unknown, id: string, name: string | number | null) => {\n    if (matches.length >= 2000) return;\n    const type = typeOf(value);\n    const keyHit = typeof name === \"string\" && name.toLowerCase().includes(needle);\n    const valueHit = !isBranch(type) && primitiveText(value, type).toLowerCase().includes(needle);\n    if (keyHit || valueHit) {\n      matches.push(id);\n      for (const [frame, index] of frames) {\n        ancestors.add(frame);\n        needed.set(frame, Math.max(needed.get(frame) ?? 0, index + 1));\n      }\n    }\n    if (!isBranch(type)) return;\n    entries(value).forEach(([key, child], index) => {\n      frames.push([id, index]);\n      visit(child, childPath(id, key), key);\n      frames.pop();\n    });\n  };\n  visit(data, rootName, null);\n  return { matches, ancestors, needed };\n}\n\nfunction Highlight({ text, needle, current }: { text: string; needle: string; current: boolean }) {\n  if (!needle) return <>{text}</>;\n  const lower = text.toLowerCase();\n  const parts: ReactNode[] = [];\n  let from = 0,\n    at = lower.indexOf(needle);\n  while (at !== -1) {\n    if (at > from) parts.push(text.slice(from, at));\n    parts.push(\n      <mark\n        key={at}\n        className={cn(\n          \"rounded-[3px] text-inherit shadow-[0_0_0_1px_color-mix(in_oklab,var(--orange)_22%,transparent)] transition-[background-color] duration-fast ease-standard motion-reduce:transition-none\",\n          current\n            ? \"bg-[color-mix(in_oklab,var(--orange)_48%,transparent)]\"\n            : \"bg-[color-mix(in_oklab,var(--orange)_22%,transparent)]\",\n        )}\n        data-current={current || undefined}\n      >\n        {text.slice(at, at + needle.length)}\n      </mark>,\n    );\n    from = at + needle.length;\n    at = lower.indexOf(needle, from);\n  }\n  if (from < text.length) parts.push(text.slice(from));\n  return <>{parts}</>;\n}\n\ntype CopyState = \"idle\" | \"done\" | \"error\";\n/** Row and path-bar copy buttons. The settled glyph keeps its colour on hover; a failed one gives way to the hover ink. */\nconst actionClass =\n  \"relative inline-grid size-[26px] cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[color,background-color] duration-fast ease-standard hover:bg-hover hover:text-ink active:bg-[color-mix(in_oklab,var(--ink)_8%,transparent)] motion-reduce:transition-none\";\nconst actionTone: Record<CopyState, string> = { idle: \"\", done: \"text-green hover:text-green\", error: \"text-red\" };\n/** Toolbar buttons: expand and collapse all, and the match steppers inside the search field. */\nconst toolClass =\n  \"inline-grid flex-none cursor-pointer place-items-center border-0 bg-transparent p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[color,background-color,opacity] duration-fast ease-standard disabled:cursor-default disabled:opacity-40 hover:not-disabled:text-ink active:not-disabled:text-ink motion-reduce:transition-none\";\nconst toolbarTool = cn(\n  toolClass,\n  \"size-[34px] rounded-button hover:not-disabled:bg-hover active:not-disabled:bg-hover\",\n);\nconst searchTool = cn(\n  toolClass,\n  \"size-[26px] rounded-button hover:not-disabled:bg-surface active:not-disabled:bg-surface\",\n);\n/** Icon-only copy action. The glyph swaps in place, so the button never changes width. */\nfunction CopyButton({\n  label,\n  icon,\n  onCopy,\n  reduced,\n  className,\n  focusable = false,\n}: {\n  label: string;\n  icon: ReactNode;\n  onCopy: () => Promise<void>;\n  reduced: boolean;\n  className?: string;\n  focusable?: boolean;\n}) {\n  const [state, setState] = useState<CopyState>(\"idle\");\n  const timer = useRef(0);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  const run = async () => {\n    window.clearTimeout(timer.current);\n    try {\n      await onCopy();\n      setState(\"done\");\n    } catch {\n      setState(\"error\");\n    }\n    timer.current = window.setTimeout(() => setState(\"idle\"), 1400);\n  };\n  const glyph =\n    state === \"done\" ? (\n      <Check size={14} strokeWidth={2} aria-hidden=\"true\" />\n    ) : state === \"error\" ? (\n      <X size={14} strokeWidth={2} aria-hidden=\"true\" />\n    ) : (\n      icon\n    );\n  return (\n    <button\n      type=\"button\"\n      tabIndex={focusable ? undefined : -1}\n      className={cn(actionClass, actionTone[state], className)}\n      data-state={state}\n      aria-label={state === \"done\" ? \"Copied\" : state === \"error\" ? \"Copy failed\" : label}\n      title={label}\n      onClick={(event) => {\n        event.stopPropagation();\n        void run();\n      }}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={state}\n          className=\"grid place-items-center\"\n          initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }}\n          animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n          exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }}\n          transition={\n            reduced\n              ? { duration: motionTokens.duration.instant }\n              : {\n                  ...motionTokens.spring.snappy,\n                  opacity: { duration: motionTokens.duration.instant },\n                  filter: { duration: motionTokens.duration.instant },\n                }\n          }\n        >\n          {glyph}\n        </motion.span>\n      </AnimatePresence>\n    </button>\n  );\n}\n\nasync function writeClipboard(text: string) {\n  if (!navigator.clipboard?.writeText) throw new Error(\"Clipboard unavailable\");\n  await navigator.clipboard.writeText(text);\n}\n\n/* Scroll, row heights and the current-row highlight share one critically damped spring, so a highlight that moves while\n * the tree scrolls travels straight to its row instead of overshooting the viewport. */\nconst scrollSpring: Transition = motionTokens.spring.smooth;\n\nexport const JsonViewer = forwardRef<HTMLDivElement, JsonViewerProps>(function JsonViewer(\n  {\n    data,\n    rootName = \"root\",\n    defaultExpandDepth = 1,\n    expanded: expandedProp,\n    defaultExpanded,\n    onExpandedChange,\n    searchable = true,\n    query: queryProp,\n    defaultQuery = \"\",\n    onQueryChange,\n    pageSize = 50,\n    copyable = true,\n    onCopy,\n    onSelect,\n    showPath = true,\n    maxHeight = 420,\n    label = \"JSON\",\n    className,\n  },\n  ref,\n) {\n  const reduced = !!useReducedMotion();\n  const uid = useId();\n  const treeRef = useRef<HTMLDivElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const rowRefs = useRef(new Map<string, HTMLDivElement>());\n\n  /* Open branches. */\n  const [expandedInternal, setExpandedInternal] = useState<string[]>(\n    () => defaultExpanded ?? branchesToDepth(data, rootName, defaultExpandDepth),\n  );\n  const expandedList = expandedProp ?? expandedInternal;\n  const base = useMemo(() => new Set(expandedList), [expandedList]);\n  const setExpanded = useCallback(\n    (next: Set<string>) => {\n      const list = [...next];\n      if (expandedProp === undefined) setExpandedInternal(list);\n      onExpandedChange?.(list);\n    },\n    [expandedProp, onExpandedChange],\n  );\n\n  /* Search. Branches holding a match open on their own; closing one while searching is remembered until the query changes. */\n  const [queryInternal, setQueryInternal] = useState(defaultQuery);\n  const query = queryProp ?? queryInternal;\n  const needle = query.trim().toLowerCase();\n  const setQuery = (next: string) => {\n    if (queryProp === undefined) setQueryInternal(next);\n    onQueryChange?.(next);\n  };\n  const search = useMemo<SearchResult | null>(\n    () => (needle ? searchJson(data, rootName, needle) : null),\n    [data, needle, rootName],\n  );\n  const [closedWhileSearching, setClosedWhileSearching] = useState<{ needle: string; ids: Set<string> }>({\n    needle: \"\",\n    ids: new Set(),\n  });\n  const closed = closedWhileSearching.needle === needle ? closedWhileSearching.ids : null;\n  const [matchIndex, setMatchIndex] = useState(0);\n  const matchCount = search?.matches.length ?? 0;\n  const matchSet = useMemo(() => new Set(search?.matches ?? []), [search]);\n  const currentMatch = search && matchCount ? search.matches[Math.min(matchIndex, matchCount - 1)] : null;\n\n  const isOpen = useCallback(\n    (id: string) => {\n      if (search && search.ancestors.has(id)) return !closed?.has(id);\n      return base.has(id);\n    },\n    [base, closed, search],\n  );\n\n  /* Pages for long branches. */\n  const [pages, setPages] = useState<Record<string, number>>({});\n  const limitOf = useCallback(\n    (id: string) => Math.max((pages[id] ?? 1) * pageSize, search?.needed.get(id) ?? 0),\n    [pageSize, pages, search],\n  );\n\n  /* The visible rows, flat. Each row unfolds its own height, so nested branches, pages, and expand all share one motion. */\n  const rows = useMemo(() => {\n    const out: Row[] = [];\n    const visit = (\n      value: unknown,\n      id: string,\n      name: string | number | null,\n      level: number,\n      parent: string | null,\n      posinset: number,\n      setsize: number,\n    ) => {\n      const type = typeOf(value);\n      const list = isBranch(type) ? entries(value) : [];\n      const open = isBranch(type) && isOpen(id);\n      out.push({ id, kind: \"node\", level, name, value, type, count: list.length, open, parent, posinset, setsize });\n      if (!open) return;\n      const limit = limitOf(id);\n      const shown = list.slice(0, limit);\n      const more = list.length - shown.length;\n      const size = shown.length + (more > 0 ? 1 : 0);\n      shown.forEach(([key, child], index) => visit(child, childPath(id, key), key, level + 1, id, index + 1, size));\n      if (more > 0)\n        out.push({\n          id: `${id}::more`,\n          kind: \"more\",\n          level: level + 1,\n          name: null,\n          value: null,\n          type: \"other\",\n          count: 0,\n          open: false,\n          parent: id,\n          posinset: size,\n          setsize: size,\n          hidden: more,\n        });\n    };\n    visit(data, rootName, null, 1, null, 1, 1);\n    return out;\n  }, [data, isOpen, limitOf, rootName]);\n\n  /* The current row. When it is folded away, the nearest visible branch that holds it takes over. */\n  const [activeRaw, setActiveRaw] = useState(rootName);\n  const active = useMemo(() => {\n    if (rows.some((row) => row.id === activeRaw)) return activeRaw;\n    let best = rootName;\n    for (const row of rows)\n      if (row.kind === \"node\" && within(row.id, activeRaw) && row.id.length > best.length) best = row.id;\n    return best;\n  }, [activeRaw, rootName, rows]);\n  const activeIndex = Math.max(\n    0,\n    rows.findIndex((row) => row.id === active),\n  );\n  /* The current row folded away and a branch above took over. The highlight lands there directly instead of flying from a row that is collapsing out of view. */\n  const folded = active !== activeRaw;\n  const activeRow = rows[activeIndex];\n\n  const onSelectRef = useRef(onSelect);\n  useEffect(() => {\n    onSelectRef.current = onSelect;\n  }, [onSelect]);\n  const lastReported = useRef<string | null>(null);\n  useEffect(() => {\n    if (!activeRow || activeRow.kind !== \"node\" || lastReported.current === activeRow.id) return;\n    lastReported.current = activeRow.id;\n    onSelectRef.current?.({ path: activeRow.id, value: activeRow.value, type: activeRow.type });\n  }, [activeRow]);\n\n  /* Scrolling keeps the current row in view, springing to where it will sit once rows finish unfolding. */\n  const scrollFlight = useRef<AnimationPlaybackControls | null>(null);\n  const reveal = useCallback(\n    (index: number, center: boolean) => {\n      const tree = treeRef.current;\n      if (!tree) return;\n      const top = index * ROW,\n        bottom = top + ROW,\n        view = tree.clientHeight,\n        now = tree.scrollTop;\n      let target = now;\n      if (center) target = top - view / 2 + ROW / 2;\n      else if (top < now + 4) target = top - 4;\n      else if (bottom > now + view - 4) target = bottom - view + 4;\n      target = Math.max(0, target);\n      if (Math.abs(target - now) < 1) return;\n      scrollFlight.current?.stop();\n      if (reduced) {\n        tree.scrollTop = target;\n        return;\n      }\n      scrollFlight.current = animate(now, target, {\n        ...scrollSpring,\n        onUpdate: (value) => {\n          tree.scrollTop = value;\n        },\n      });\n    },\n    [reduced],\n  );\n  useEffect(() => () => scrollFlight.current?.stop(), []);\n\n  const pendingFocus = useRef(false);\n  const pendingReveal = useRef<\"nearest\" | \"center\" | null>(null);\n  /* Bumped on every navigation, so moving to the row that is already current still focuses and reveals it. */\n  const [navigation, setNavigation] = useState(0);\n  useEffect(() => {\n    if (pendingFocus.current) {\n      pendingFocus.current = false;\n      rowRefs.current.get(active)?.focus({ preventScroll: true });\n    }\n    if (pendingReveal.current) {\n      reveal(activeIndex, pendingReveal.current === \"center\");\n      pendingReveal.current = null;\n    }\n  }, [active, activeIndex, reveal, navigation]);\n\n  const moveTo = (id: string, focus = true, how: \"nearest\" | \"center\" = \"nearest\") => {\n    pendingFocus.current = focus;\n    pendingReveal.current = how;\n    setActiveRaw(id);\n    setNavigation((count) => count + 1);\n  };\n\n  const toggle = (id: string, next?: boolean) => {\n    const open = isOpen(id);\n    const want = next ?? !open;\n    if (want === open) return;\n    if (search?.ancestors.has(id)) {\n      const ids = new Set(closed ?? []);\n      if (want) ids.delete(id);\n      else ids.add(id);\n      setClosedWhileSearching({ needle, ids });\n      if (want && !base.has(id)) setExpanded(new Set(base).add(id));\n      return;\n    }\n    const nextSet = new Set(base);\n    if (want) nextSet.add(id);\n    else nextSet.delete(id);\n    setExpanded(nextSet);\n  };\n\n  const expandAll = () => {\n    setExpanded(new Set(allBranches(data, rootName)));\n    setClosedWhileSearching({ needle, ids: new Set() });\n    // Branches above the current row unfold and push it down; follow it to where it settles.\n    moveTo(active, false, \"nearest\");\n  };\n  const collapseAll = () => {\n    const rootOpen = isBranch(typeOf(data)) ? [rootName] : [];\n    setExpanded(new Set(rootOpen));\n    setClosedWhileSearching({ needle, ids: new Set([...(search?.ancestors ?? [])].filter((id) => id !== rootName)) });\n    setPages({});\n    moveTo(rootName, false, \"nearest\");\n  };\n  const showMore = (parent: string) =>\n    setPages((current) => ({ ...current, [parent]: Math.ceil(limitOf(parent) / pageSize) + 1 }));\n\n  /* Copy. */\n  const [announcement, setAnnouncement] = useState(\"\");\n  const copy = async (row: Row, kind: \"value\" | \"path\") => {\n    const text = kind === \"path\" ? row.id : copyText(row.value, row.type);\n    try {\n      await writeClipboard(text);\n      setAnnouncement(`Copied ${kind === \"path\" ? \"path\" : \"value of\"} ${row.id}`);\n      onCopy?.({ kind, path: row.id, text });\n    } catch (error) {\n      setAnnouncement(\"Copy failed\");\n      throw error;\n    }\n  };\n\n  /* Search navigation. */\n  const stepMatch = (step: number) => {\n    if (!search || !matchCount) return;\n    const next = (((Math.min(matchIndex, matchCount - 1) + step) % matchCount) + matchCount) % matchCount;\n    setMatchIndex(next);\n    moveTo(search.matches[next]!, false, \"center\");\n  };\n  const onQuery = (next: string) => {\n    setQuery(next);\n    setMatchIndex(0);\n    const trimmed = next.trim().toLowerCase();\n    if (trimmed) {\n      const first = searchJson(data, rootName, trimmed).matches[0];\n      if (first) moveTo(first, false, \"center\");\n    } else if (needle) {\n      // Clearing folds the branches search opened. Aim the scroll at where the current row settles, so it stays in view.\n      moveTo(activeRaw, false, \"nearest\");\n    }\n  };\n\n  const onSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      stepMatch(event.shiftKey ? -1 : 1);\n    } else if (event.key === \"Escape\" && query) {\n      event.preventDefault();\n      event.stopPropagation();\n      onQuery(\"\");\n    } else if (event.key === \"ArrowDown\" && !event.altKey) {\n      event.preventDefault();\n      moveTo(active, true);\n    }\n  };\n\n  const onTreeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const row = rows[activeIndex];\n    if (!row || event.altKey) return;\n    const mod = event.metaKey || event.ctrlKey;\n    if (mod && event.key.toLowerCase() === \"c\") {\n      if (row.kind === \"node\" && !window.getSelection()?.toString()) {\n        event.preventDefault();\n        void copy(row, event.shiftKey ? \"path\" : \"value\").catch(() => {});\n      }\n      return;\n    }\n    if (mod) return;\n    const go = (index: number) => {\n      event.preventDefault();\n      const target = rows[Math.max(0, Math.min(rows.length - 1, index))];\n      if (target) moveTo(target.id);\n    };\n    switch (event.key) {\n      case \"ArrowDown\":\n        go(activeIndex + 1);\n        break;\n      case \"ArrowUp\":\n        go(activeIndex - 1);\n        break;\n      case \"Home\":\n        go(0);\n        break;\n      case \"End\":\n        go(rows.length - 1);\n        break;\n      case \"ArrowRight\":\n        event.preventDefault();\n        if (row.kind === \"node\" && isBranch(row.type)) {\n          if (!row.open) toggle(row.id, true);\n          else if (row.count) go(activeIndex + 1);\n        }\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        if (row.kind === \"node\" && row.open) toggle(row.id, false);\n        else if (row.parent) moveTo(row.parent);\n        break;\n      case \"Enter\":\n      case \" \":\n        event.preventDefault();\n        if (row.kind === \"more\") showMore(row.parent!);\n        else if (isBranch(row.type)) toggle(row.id);\n        break;\n      case \"/\":\n        if (searchable) {\n          event.preventDefault();\n          searchRef.current?.focus();\n          searchRef.current?.select();\n        }\n        break;\n      default:\n    }\n  };\n\n  const heightStyle = {\n    \"--json-max-height\": typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight,\n  } as CSSProperties;\n  const bulk = rows.length > 400;\n  const rowTransition: Transition =\n    reduced || bulk\n      ? { duration: 0 }\n      : {\n          height: motionTokens.spring.smooth,\n          opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n        };\n\n  const describe = (row: Row) => {\n    const name = row.name === null ? rootName : String(row.name);\n    if (row.kind === \"more\") return `Show ${Math.min(pageSize, row.hidden ?? 0)} more, ${row.hidden} hidden`;\n    return isBranch(row.type)\n      ? `${name}, ${row.type}, ${countLabel(row)}`\n      : `${name}: ${primitiveText(row.value, row.type)}`;\n  };\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"grid min-w-0 grid-cols-[minmax(0,1fr)] overflow-hidden rounded-panel border border-line bg-surface font-body tracking-body text-ink [--indent:16px] [--mono:ui-monospace,'SFMono-Regular',Menlo,Consolas,'Liberation_Mono',monospace]\",\n        className,\n      )}\n      style={heightStyle}\n    >\n      {/* Toolbar: search with a match counter, then expand and collapse all. */}\n      {searchable || rows.length > 1 ? (\n        <div className=\"flex items-center gap-0.5 border-b border-line-soft p-1.5\">\n          {searchable ? (\n            <label className=\"relative flex min-h-[34px] min-w-0 flex-1 cursor-text items-center gap-1 rounded-[14px] bg-hover pr-1 pl-2.5\">\n              <Search size={15} strokeWidth={1.75} aria-hidden=\"true\" className=\"flex-none text-ink-3\" />\n              <input\n                ref={searchRef}\n                type=\"search\"\n                className=\"h-[34px] min-w-0 flex-1 border-0 bg-transparent px-1 text-(length:--text-sm) text-ink outline-none placeholder:text-ink-3 [&::-webkit-search-cancel-button]:hidden\"\n                value={query}\n                placeholder=\"Search keys and values\"\n                aria-label=\"Search JSON\"\n                aria-controls={`${uid}-tree`}\n                onChange={(event) => onQuery(event.target.value)}\n                onKeyDown={onSearchKeyDown}\n                spellCheck={false}\n                autoComplete=\"off\"\n              />\n              <AnimatePresence initial={false}>\n                {needle ? (\n                  <motion.span\n                    key=\"matches\"\n                    className=\"inline-flex flex-none items-center gap-0\"\n                    initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }}\n                    animate={{ opacity: 1, x: 0 }}\n                    exit={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }}\n                    transition={{ duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }}\n                  >\n                    <span\n                      className=\"min-w-[3.5ch] px-1 text-right text-(length:--text-xs) text-ink-3 tabular-nums\"\n                      aria-live=\"polite\"\n                    >\n                      {matchCount ? `${Math.min(matchIndex, matchCount - 1) + 1}/${matchCount}` : \"0/0\"}\n                    </span>\n                    <button\n                      type=\"button\"\n                      className={searchTool}\n                      aria-label=\"Previous match\"\n                      disabled={!matchCount}\n                      onClick={() => stepMatch(-1)}\n                    >\n                      <ChevronUp size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                    </button>\n                    <button\n                      type=\"button\"\n                      className={searchTool}\n                      aria-label=\"Next match\"\n                      disabled={!matchCount}\n                      onClick={() => stepMatch(1)}\n                    >\n                      <ChevronDown size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                    </button>\n                  </motion.span>\n                ) : null}\n              </AnimatePresence>\n            </label>\n          ) : (\n            <span className=\"flex-1\" />\n          )}\n          <span className=\"mx-1 h-[18px] w-px bg-line-soft\" aria-hidden=\"true\" />\n          <button type=\"button\" className={toolbarTool} aria-label=\"Expand all\" title=\"Expand all\" onClick={expandAll}>\n            <ChevronsUpDown size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n          </button>\n          <button\n            type=\"button\"\n            className={toolbarTool}\n            aria-label=\"Collapse all\"\n            title=\"Collapse all\"\n            onClick={collapseAll}\n          >\n            <ChevronsDownUp size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n          </button>\n        </div>\n      ) : null}\n\n      <LayoutGroup id={uid}>\n        {/* Rows are one fixed height, so scrolling can aim at where a row will settle. */}\n        <motion.div\n          ref={treeRef}\n          layoutScroll\n          id={`${uid}-tree`}\n          role=\"tree\"\n          aria-label={label}\n          className=\"relative max-h-[var(--json-max-height,420px)] overflow-auto overscroll-contain p-1.5 text-[13px] tracking-[0] [font-family:var(--mono)] [font-variant-ligatures:none] [overflow-anchor:none] [scrollbar-width:thin] max-[420px]:[--indent:12px]\"\n          onKeyDown={onTreeKeyDown}\n        >\n          <AnimatePresence initial={false}>\n            {rows.map((row) => {\n              const isActive = row.id === active;\n              const branch = isBranch(row.type);\n              const matched = !!search && row.kind === \"node\" && matchSet.has(row.id);\n              const isCurrentMatch = row.id === currentMatch;\n              const mark = matched ? needle : \"\";\n              return (\n                <motion.div\n                  key={row.id}\n                  ref={(node: HTMLDivElement | null) => {\n                    if (node) rowRefs.current.set(row.id, node);\n                    else rowRefs.current.delete(row.id);\n                  }}\n                  role=\"treeitem\"\n                  tabIndex={isActive ? 0 : -1}\n                  aria-level={row.level}\n                  aria-posinset={row.posinset}\n                  aria-setsize={row.setsize}\n                  aria-expanded={row.kind === \"node\" && branch ? row.open : undefined}\n                  aria-selected={isActive}\n                  aria-label={describe(row)}\n                  // Hover takes the current row's inset and corner, so hover and selection share one shape.\n                  className={cn(\n                    \"group/row relative cursor-default rounded-[10px] outline-none [-webkit-tap-highlight-color:transparent]\",\n                    row.kind === \"more\" && \"cursor-pointer\",\n                    !isActive &&\n                      \"hover:[--row-hover:color-mix(in_oklab,var(--ink)_3.5%,var(--surface))] hover:before:pointer-events-none hover:before:absolute hover:before:inset-x-0 hover:before:inset-y-px hover:before:rounded-[9px] hover:before:bg-(--row-hover) hover:before:content-['']\",\n                  )}\n                  data-kind={row.kind}\n                  data-active={isActive || undefined}\n                  style={{ \"--level\": row.level - 1 } as CSSProperties}\n                  initial={{ height: 0, opacity: 0, overflow: \"hidden\" }}\n                  animate={{ height: ROW, opacity: 1, transitionEnd: { overflow: \"visible\" } }}\n                  exit={{ height: 0, opacity: 0, overflow: \"hidden\" }}\n                  transition={rowTransition}\n                  onFocus={(event) => {\n                    if (event.target === event.currentTarget && row.id !== active) setActiveRaw(row.id);\n                  }}\n                  onClick={() => {\n                    if (row.kind === \"more\") {\n                      showMore(row.parent!);\n                      moveTo(row.id);\n                      return;\n                    }\n                    if (branch) toggle(row.id);\n                    moveTo(row.id);\n                  }}\n                >\n                  {isActive ? (\n                    <motion.span\n                      layoutId=\"json-active\"\n                      className=\"absolute inset-x-0 inset-y-px rounded-[9px] bg-hover\"\n                      transition={reduced || folded ? { duration: 0 } : scrollSpring}\n                      aria-hidden=\"true\"\n                    />\n                  ) : null}\n                  <span\n                    className={cn(\n                      \"relative flex h-[30px] min-w-0 items-center gap-1.5 pr-1.5 pl-[calc(4px_+_var(--level,0)_*_var(--indent))] whitespace-nowrap\",\n                      row.kind === \"node\" && \"cursor-pointer\",\n                    )}\n                  >\n                    {row.kind === \"more\" ? (\n                      <span className=\"inline-flex items-baseline gap-2.5 pl-5 font-body text-(length:--text-sm)\">\n                        <span className=\"font-medium text-ink\">Show {Math.min(pageSize, row.hidden ?? 0)} more</span>\n                        <span className=\"text-(length:--text-xs) text-ink-3 tabular-nums\">{row.hidden} hidden</span>\n                      </span>\n                    ) : (\n                      <>\n                        <span\n                          className={cn(\n                            \"inline-grid size-3.5 flex-none place-items-center [transition:transform_var(--duration-spring)_var(--ease-spring),color_var(--duration-fast)_var(--ease-standard)] motion-reduce:transition-none\",\n                            row.open && \"[transform:rotate(90deg)]\",\n                            branch && isActive ? \"text-ink\" : \"text-ink-3\",\n                            branch && \"group-hover/row:text-ink\",\n                          )}\n                          data-open={row.open || undefined}\n                          data-branch={branch || undefined}\n                          aria-hidden=\"true\"\n                        >\n                          {branch ? <ChevronRight size={14} strokeWidth={1.75} /> : null}\n                        </span>\n                        <span\n                          className={\n                            typeof row.name === \"number\"\n                              ? \"flex-none text-ink-3 tabular-nums\"\n                              : \"max-w-[45%] flex-none truncate text-ink max-[420px]:max-w-[40%]\"\n                          }\n                        >\n                          {row.name === null ? (\n                            rootName\n                          ) : typeof row.name === \"number\" ? (\n                            row.name\n                          ) : (\n                            <Highlight text={row.name} needle={mark} current={isCurrentMatch} />\n                          )}\n                        </span>\n                        <span className=\"-ml-[5px] flex-none text-ink-3\" aria-hidden=\"true\">\n                          {row.name === null && branch ? \"\" : \":\"}\n                        </span>\n                        {branch ? (\n                          <span className=\"inline-flex min-w-0 items-baseline gap-2 overflow-hidden\">\n                            {!row.open ? (\n                              <span className=\"min-w-0 truncate text-ink-3\">\n                                {row.type === \"array\"\n                                  ? \"[…]\"\n                                  : `{ ${entries(row.value)\n                                      .slice(0, 3)\n                                      .map(([key]) => key)\n                                      .join(\", \")}${row.count > 3 ? \", …\" : \"\"} }`}\n                              </span>\n                            ) : null}\n                            <span className=\"flex-none font-body text-(length:--text-xs) text-ink-3 tabular-nums\">\n                              {countLabel(row)}\n                            </span>\n                          </span>\n                        ) : (\n                          <span\n                            className={cn(\"min-w-0 truncate\", valueTone[row.type])}\n                            data-type={row.type}\n                            title={\n                              row.type === \"string\" && (row.value as string).length > 40\n                                ? (row.value as string)\n                                : undefined\n                            }\n                          >\n                            {row.type === \"string\" ? (\n                              <>\n                                &quot;\n                                <Highlight text={row.value as string} needle={mark} current={isCurrentMatch} />\n                                &quot;\n                              </>\n                            ) : (\n                              <Highlight\n                                text={primitiveText(row.value, row.type)}\n                                needle={mark}\n                                current={isCurrentMatch}\n                              />\n                            )}\n                          </span>\n                        )}\n                        {copyable ? (\n                          <span\n                            className={cn(\n                              \"pointer-events-none absolute top-px right-0 bottom-px flex items-center gap-0 rounded-[0_9px_9px_0] pr-px pl-6 opacity-0 transition-opacity duration-fast ease-standard group-focus-within/row:pointer-events-auto group-focus-within/row:opacity-100 group-hover/row:pointer-events-auto group-hover/row:opacity-100 motion-reduce:transition-none\",\n                              isActive\n                                ? \"pointer-events-auto bg-[linear-gradient(to_right,transparent,var(--hover)_22px)] opacity-100\"\n                                : \"bg-[linear-gradient(to_right,transparent,var(--surface)_22px)] group-hover/row:bg-[linear-gradient(to_right,transparent,var(--row-hover)_22px)]\",\n                            )}\n                          >\n                            <CopyButton\n                              label=\"Copy value\"\n                              icon={<Copy size={14} strokeWidth={1.75} aria-hidden=\"true\" />}\n                              reduced={reduced}\n                              onCopy={() => copy(row, \"value\")}\n                            />\n                            <CopyButton\n                              label=\"Copy path\"\n                              icon={<Link2 size={14} strokeWidth={1.75} aria-hidden=\"true\" />}\n                              reduced={reduced}\n                              onCopy={() => copy(row, \"path\")}\n                            />\n                          </span>\n                        ) : null}\n                      </>\n                    )}\n                  </span>\n                </motion.div>\n              );\n            })}\n          </AnimatePresence>\n        </motion.div>\n      </LayoutGroup>\n\n      {/* The current path, always readable in full through its copy action. */}\n      {showPath && activeRow ? (\n        <div className=\"flex min-w-0 items-center gap-2.5 border-t border-line-soft py-1.5 pr-1.5 pl-3.5\">\n          <code\n            className=\"min-w-0 flex-1 truncate text-(length:--text-xs) tracking-[0] text-ink-2 [font-family:var(--mono)]\"\n            title={activeRow.kind === \"node\" ? activeRow.id : (activeRow.parent ?? \"\")}\n          >\n            {activeRow.kind === \"node\" ? activeRow.id : activeRow.parent}\n          </code>\n          <span className=\"flex-none text-(length:--text-xs) text-ink-3 max-[420px]:hidden\">\n            {activeRow.kind === \"node\"\n              ? isBranch(activeRow.type)\n                ? `${activeRow.type}, ${countLabel(activeRow)}`\n                : activeRow.type\n              : \"page\"}\n          </span>\n          {copyable && activeRow.kind === \"node\" ? (\n            <CopyButton\n              key={activeRow.id}\n              focusable\n              className=\"size-[30px]\"\n              label=\"Copy path\"\n              icon={<Copy size={14} strokeWidth={1.75} aria-hidden=\"true\" />}\n              reduced={reduced}\n              onCopy={() => copy(activeRow, \"path\")}\n            />\n          ) : null}\n        </div>\n      ) : null}\n      <span className=\"sr-only\" role=\"status\">\n        {announcement}\n      </span>\n    </div>\n  );\n});\n\nexport default JsonViewer;\n",
      "type": "registry:component",
      "target": "@components/iris/json-viewer/json-viewer.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/json-viewer",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "JsonViewer",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://irisui.dev/components/json-viewer"
  }
}
