{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-explorer",
  "type": "registry:component",
  "title": "JSON Explorer",
  "description": "A JSON tree you can actually read: types in colour, key and item counts, inline previews of folded branches, copy-any-path, and a search that opens the tree down to every match and steps through them.",
  "categories": [
    "component",
    "Dev Tools"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/input-group.json",
    "https://coss.com/ui/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/devl/json-explorer.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  CheckIcon,\n  ChevronDownIcon,\n  ChevronRightIcon,\n  ChevronsDownUpIcon,\n  ChevronsUpDownIcon,\n  ChevronUpIcon,\n  CopyIcon,\n  SearchIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from \"react\";\nimport { InputGroup, InputGroupAddon, InputGroupInput } from \"@/components/ui/input-group\";\nimport { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nexport type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\n\nexport interface JsonExplorerProps {\n  data: JsonValue;\n  rootName?: string;\n  defaultDepth?: number;\n  defaultExpanded?: string[];\n  title?: string;\n  onCopyPath?: (path: string) => void;\n  className?: string;\n}\n\ntype Kind = \"object\" | \"array\" | \"string\" | \"number\" | \"boolean\" | \"null\";\n\ninterface Node {\n  id: string;\n  key: string | number | null;\n  path: string;\n  depth: number;\n  kind: Kind;\n  value: JsonValue;\n  parent: string | null;\n  children: Node[];\n}\n\nconst ROW = 28;\nconst INDENT = 16;\n\nconst kindOf = (v: JsonValue): Kind =>\n  v === null ? \"null\" : Array.isArray(v) ? \"array\" : typeof v === \"object\" ? \"object\" : (typeof v as Kind);\n\nconst ident = /^[A-Za-z_$][\\w$]*$/;\n\nfunction build(value: JsonValue, rootName: string): Node {\n  const make = (v: JsonValue, key: string | number | null, path: string, depth: number, parent: string | null): Node => {\n    const kind = kindOf(v);\n    const node: Node = { id: path, key, path, depth, kind, value: v, parent, children: [] };\n    if (kind === \"array\") {\n      node.children = (v as JsonValue[]).map((c, i) => make(c, i, `${path}[${i}]`, depth + 1, path));\n    } else if (kind === \"object\") {\n      node.children = Object.entries(v as Record<string, JsonValue>).map(([k, c]) =>\n        make(c, k, ident.test(k) ? `${path}.${k}` : `${path}[${JSON.stringify(k)}]`, depth + 1, path),\n      );\n    }\n    return node;\n  };\n  return make(value, null, rootName, 0, null);\n}\n\nfunction walk(node: Node, fn: (n: Node) => void) {\n  fn(node);\n  for (const c of node.children) walk(c, fn);\n}\n\nconst isContainer = (n: Node) => n.kind === \"object\" || n.kind === \"array\";\n\nconst tone: Record<Kind, string> = {\n  string: \"text-emerald-700 dark:text-emerald-400\",\n  number: \"text-amber-700 dark:text-amber-300\",\n  boolean: \"text-violet-700 dark:text-violet-400\",\n  null: \"text-rose-700 dark:text-rose-400 italic\",\n  object: \"text-muted-foreground\",\n  array: \"text-muted-foreground\",\n};\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  if (!query) return <>{text}</>;\n  const out: ReactNode[] = [];\n  const lower = text.toLowerCase();\n  let at = 0;\n  let hit = lower.indexOf(query, at);\n  while (hit !== -1) {\n    if (hit > at) out.push(text.slice(at, hit));\n    out.push(\n      <mark className=\"rounded-[calc(var(--radius)*0.3)] bg-warning/30 text-inherit dark:bg-warning/35\" key={hit}>\n        {text.slice(hit, hit + query.length)}\n      </mark>,\n    );\n    at = hit + query.length;\n    hit = lower.indexOf(query, at);\n  }\n  if (at < text.length) out.push(text.slice(at));\n  return <>{out}</>;\n}\n\nfunction primitiveText(n: Node) {\n  return n.kind === \"string\" ? JSON.stringify(n.value) : String(n.value);\n}\n\nfunction preview(n: Node) {\n  if (n.kind === \"array\") {\n    const items = n.children.slice(0, 3).map((c) => (isContainer(c) ? (c.kind === \"array\" ? \"[…]\" : \"{…}\") : primitiveText(c)));\n    return `[${items.join(\", \")}${n.children.length > 3 ? \", …\" : \"\"}]`;\n  }\n  const keys = n.children.slice(0, 3).map((c) => String(c.key));\n  return `{${keys.join(\", \")}${n.children.length > 3 ? \", …\" : \"\"}}`;\n}\n\nfunction useCopied() {\n  const [copied, setCopied] = useState<string | null>(null);\n  useEffect(() => {\n    if (!copied) return;\n    const t = setTimeout(() => setCopied(null), 1400);\n    return () => clearTimeout(t);\n  }, [copied]);\n  const copy = async (text: string) => {\n    try {\n      await navigator.clipboard.writeText(text);\n    } catch {}\n    setCopied(text);\n  };\n  return [copied, copy] as const;\n}\n\nfunction SwapIcon({ done }: { done: boolean }) {\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        animate={{ opacity: 1, scale: 1 }}\n        className={cn(\"grid place-items-center\", done && \"text-success-foreground\")}\n        exit={{ opacity: 0, scale: 0.6 }}\n        initial={{ opacity: 0, scale: 0.6 }}\n        key={done ? \"y\" : \"n\"}\n        transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}\n      >\n        {done ? <CheckIcon className=\"size-3.5\" /> : <CopyIcon className=\"size-3.5\" />}\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\nconst iconBtn =\n  \"grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground outline-none transition-[color,background-color,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] disabled:pointer-events-none disabled:opacity-40 [@media(hover:hover)]:hover:bg-accent [@media(hover:hover)]:hover:text-foreground\";\n\nexport function JsonExplorer({\n  data,\n  rootName = \"data\",\n  defaultDepth = 1,\n  defaultExpanded = [],\n  title,\n  onCopyPath,\n  className,\n}: JsonExplorerProps) {\n  const reduce = useReducedMotion();\n  const root = useMemo(() => build(data, rootName), [data, rootName]);\n  const index = useMemo(() => {\n    const map = new Map<string, Node>();\n    walk(root, (n) => map.set(n.id, n));\n    return map;\n  }, [root]);\n\n  const [expanded, setExpanded] = useState(() => {\n    const set = new Set<string>(defaultExpanded);\n    walk(root, (n) => isContainer(n) && n.depth < defaultDepth && set.add(n.id));\n    return set;\n  });\n  const [selected, setSelected] = useState<string>(root.id);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const [copied, copy] = useCopied();\n  const saved = useRef<Set<string> | null>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const rowRefs = useRef(new Map<string, HTMLDivElement>());\n  const q = query.trim().toLowerCase();\n\n  const matches = useMemo(() => {\n    if (!q) return [] as string[];\n    const out: string[] = [];\n    walk(root, (n) => {\n      if (n === root) return;\n      const keyHit = n.key !== null && String(n.key).toLowerCase().includes(q);\n      const valueHit = !isContainer(n) && primitiveText(n).toLowerCase().includes(q);\n      if (keyHit || valueHit) out.push(n.id);\n    });\n    return out;\n  }, [q, root]);\n  const matchSet = useMemo(() => new Set(matches), [matches]);\n  const activeId = matches[Math.min(active, matches.length - 1)];\n\n  useEffect(() => {\n    if (!q) {\n      if (saved.current) {\n        setExpanded(saved.current);\n        saved.current = null;\n      }\n      return;\n    }\n    setExpanded((prev) => {\n      if (!saved.current) saved.current = prev;\n      const next = new Set<string>([root.id]);\n      for (const id of matches) {\n        let p = index.get(id)?.parent;\n        while (p) {\n          next.add(p);\n          p = index.get(p)?.parent ?? null;\n        }\n      }\n      return next;\n    });\n    setActive(0);\n  }, [q, matches, index, root.id]);\n\n  const rows = useMemo(() => {\n    const out: Node[] = [];\n    const visit = (n: Node) => {\n      out.push(n);\n      if (isContainer(n) && expanded.has(n.id)) n.children.forEach(visit);\n    };\n    visit(root);\n    return out;\n  }, [root, expanded]);\n\n  const scrollTo = (id: string) => {\n    const list = listRef.current;\n    const i = rows.findIndex((r) => r.id === id);\n    if (!list || i < 0) return;\n    const top = i * ROW;\n    if (top < list.scrollTop) list.scrollTop = top - ROW;\n    else if (top + ROW > list.scrollTop + list.clientHeight) list.scrollTop = top + 2 * ROW - list.clientHeight;\n  };\n\n  useEffect(() => {\n    if (activeId) {\n      setSelected(activeId);\n      requestAnimationFrame(() => scrollTo(activeId));\n    }\n  }, [activeId, rows.length]);\n\n  const toggle = (id: string, open?: boolean) =>\n    setExpanded((prev) => {\n      const next = new Set(prev);\n      if (open ?? !next.has(id)) next.add(id);\n      else next.delete(id);\n      return next;\n    });\n\n  const focusRow = (id: string) => {\n    setSelected(id);\n    scrollTo(id);\n    requestAnimationFrame(() => rowRefs.current.get(id)?.focus());\n  };\n\n  const copyPath = (path: string) => {\n    copy(path);\n    onCopyPath?.(path);\n  };\n\n  const onKey = (e: KeyboardEvent, n: Node) => {\n    const i = rows.findIndex((r) => r.id === n.id);\n    if (e.key === \"ArrowDown\" && rows[i + 1]) focusRow(rows[i + 1].id);\n    else if (e.key === \"ArrowUp\" && rows[i - 1]) focusRow(rows[i - 1].id);\n    else if (e.key === \"ArrowRight\" && isContainer(n)) {\n      if (!expanded.has(n.id)) toggle(n.id, true);\n      else if (n.children[0]) focusRow(n.children[0].id);\n    } else if (e.key === \"ArrowLeft\") {\n      if (isContainer(n) && expanded.has(n.id)) toggle(n.id, false);\n      else if (n.parent) focusRow(n.parent);\n    } else if ((e.key === \"Enter\" || e.key === \" \") && isContainer(n)) toggle(n.id);\n    else if (e.key === \"c\" && (e.metaKey || e.ctrlKey) && !window.getSelection()?.toString()) copyPath(n.path);\n    else return;\n    e.preventDefault();\n  };\n\n  const step = (dir: 1 | -1) => {\n    if (!matches.length) return;\n    setActive((a) => (a + dir + matches.length) % matches.length);\n  };\n\n  const allIds = useMemo(() => {\n    const ids: string[] = [];\n    walk(root, (n) => isContainer(n) && ids.push(n.id));\n    return ids;\n  }, [root]);\n  const allOpen = allIds.every((id) => expanded.has(id));\n  const sel = index.get(selected) ?? root;\n  const count = root.children.length;\n\n  return (\n    <TooltipProvider delay={500}>\n      <div className={cn(\"flex w-full min-w-0 max-w-[460px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\", className)}>\n        <div className=\"flex items-center gap-1 border-b p-2\">\n          <InputGroup className=\"h-8 min-w-0 flex-1\">\n            <InputGroupAddon>\n              <SearchIcon />\n            </InputGroupAddon>\n            <InputGroupInput\n              aria-label=\"Search keys and values\"\n              onChange={(e) => setQuery(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" || e.key === \"ArrowDown\") {\n                  e.preventDefault();\n                  step(e.shiftKey ? -1 : 1);\n                } else if (e.key === \"ArrowUp\") {\n                  e.preventDefault();\n                  step(-1);\n                } else if (e.key === \"Escape\") setQuery(\"\");\n              }}\n              placeholder={title ? `Search ${title}` : \"Search keys and values\"}\n              size=\"sm\"\n              value={query}\n            />\n            {q && (\n              <InputGroupAddon align=\"inline-end\">\n                <span\n                  aria-live=\"polite\"\n                  className={cn(\"font-mono text-xs tabular-nums\", matches.length ? \"text-muted-foreground\" : \"text-destructive-foreground\")}\n                >\n                  {matches.length ? `${Math.min(active, matches.length - 1) + 1}/${matches.length}` : \"0/0\"}\n                </span>\n                <button aria-label=\"Clear search\" className={cn(iconBtn, \"size-6 rounded-md\")} onClick={() => setQuery(\"\")} type=\"button\">\n                  <XIcon className=\"size-3.5\" />\n                </button>\n              </InputGroupAddon>\n            )}\n          </InputGroup>\n          <div className=\"flex items-center\">\n            <button aria-label=\"Previous match\" className={iconBtn} disabled={!matches.length} onClick={() => step(-1)} type=\"button\">\n              <ChevronUpIcon className=\"size-4\" />\n            </button>\n            <button aria-label=\"Next match\" className={iconBtn} disabled={!matches.length} onClick={() => step(1)} type=\"button\">\n              <ChevronDownIcon className=\"size-4\" />\n            </button>\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <button\n                    aria-label={allOpen ? \"Collapse all\" : \"Expand all\"}\n                    className={iconBtn}\n                    onClick={() => setExpanded(allOpen ? new Set([root.id]) : new Set(allIds))}\n                    type=\"button\"\n                  />\n                }\n              >\n                {allOpen ? <ChevronsDownUpIcon className=\"size-4\" /> : <ChevronsUpDownIcon className=\"size-4\" />}\n              </TooltipTrigger>\n              <TooltipPopup>{allOpen ? \"Collapse all\" : \"Expand all\"}</TooltipPopup>\n            </Tooltip>\n          </div>\n        </div>\n\n        <div\n          aria-label={title ?? \"JSON\"}\n          className=\"relative h-[336px] overflow-auto py-1.5 font-mono text-[12.5px] [scrollbar-width:thin]\"\n          ref={listRef}\n          role=\"tree\"\n        >\n          {!(q && !matches.length) && rows.map((n) => {\n            const open = expanded.has(n.id);\n            const container = isContainer(n);\n            const isSel = n.id === selected;\n            const isActive = n.id === activeId;\n            const hit = matchSet.has(n.id);\n            const isRoot = n === root;\n            return (\n              <motion.div\n                animate={{ opacity: q && !hit && !container ? 0.55 : 1 }}\n                aria-expanded={container ? open : undefined}\n                aria-level={n.depth + 1}\n                aria-selected={isSel}\n                className={cn(\n                  \"group/row relative flex h-7 cursor-default items-center gap-1 pe-1.5 outline-none\",\n                  isSel ? \"bg-accent\" : \"[@media(hover:hover)]:hover:bg-accent/50\",\n                  isActive && \"bg-warning/10 shadow-[inset_2px_0_0_var(--color-warning)]\",\n                  \"focus-visible:shadow-[inset_0_0_0_2px_var(--color-ring)]\",\n                )}\n                initial={false}\n                key={n.id}\n                onClick={() => {\n                  setSelected(n.id);\n                  if (container) toggle(n.id);\n                }}\n                onKeyDown={(e) => onKey(e, n)}\n                ref={(el: HTMLDivElement | null) => {\n                  if (el) rowRefs.current.set(n.id, el);\n                  else rowRefs.current.delete(n.id);\n                }}\n                role=\"treeitem\"\n                style={{ paddingInlineStart: 8 + n.depth * INDENT }}\n                tabIndex={isSel ? 0 : -1}\n                transition={{ duration: 0.15 }}\n              >\n                {Array.from({ length: n.depth }, (_, d) => (\n                  <span aria-hidden className=\"absolute inset-y-0 w-px bg-border\" key={d} style={{ left: 8 + d * INDENT + 7 }} />\n                ))}\n                <span className=\"grid size-4 shrink-0 place-items-center text-muted-foreground\">\n                  {container && (\n                    <motion.span\n                      animate={{ rotate: open ? 90 : 0 }}\n                      className=\"grid place-items-center\"\n                      transition={reduce ? { duration: 0 } : { duration: 0.15, ease: [0.23, 1, 0.32, 1] }}\n                    >\n                      <ChevronRightIcon className=\"size-3.5\" />\n                    </motion.span>\n                  )}\n                </span>\n                {isRoot ? (\n                  <span className=\"shrink-0 text-foreground\">{rootName}</span>\n                ) : (\n                  <span className=\"shrink-0\">\n                    <span className={typeof n.key === \"number\" ? \"text-muted-foreground\" : \"text-sky-700 dark:text-sky-300\"}>\n                      <Highlight query={q} text={String(n.key)} />\n                    </span>\n                    <span className=\"text-muted-foreground\">:</span>\n                  </span>\n                )}\n                {container ? (\n                  <span className=\"flex min-w-0 flex-1 items-center gap-1.5\">\n                    {!open && <span className=\"truncate text-muted-foreground\">{preview(n)}</span>}\n                    <span className=\"shrink-0 rounded-full bg-muted px-1.5 font-sans text-[10.5px] text-muted-foreground tabular-nums leading-4 dark:bg-background\">\n                      {n.children.length} {n.kind === \"array\" ? (n.children.length === 1 ? \"item\" : \"items\") : n.children.length === 1 ? \"key\" : \"keys\"}\n                    </span>\n                  </span>\n                ) : (\n                  <span className={cn(\"min-w-0 flex-1 truncate\", tone[n.kind])}>\n                    <Highlight query={q} text={primitiveText(n)} />\n                  </span>\n                )}\n                <button\n                  aria-label={`Copy path ${n.path}`}\n                  className={cn(\n                    iconBtn,\n                    \"size-6 rounded-md opacity-0 focus-visible:opacity-100 group-focus-within/row:opacity-100 [@media(hover:hover)]:group-hover/row:opacity-100\",\n                    (isSel || copied === n.path) && \"opacity-100\",\n                  )}\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    copyPath(n.path);\n                  }}\n                  tabIndex={-1}\n                  type=\"button\"\n                >\n                  <SwapIcon done={copied === n.path} />\n                </button>\n              </motion.div>\n            );\n          })}\n          {q && !matches.length && (\n            <div className=\"absolute inset-x-0 top-28 px-6 text-center font-sans\">\n              <p className=\"font-medium text-sm\">Nothing matches “{query.trim()}”</p>\n              <p className=\"mt-1 text-muted-foreground text-xs\">Keys and values are searched, case-insensitive.</p>\n            </div>\n          )}\n        </div>\n\n        <div className=\"flex h-10 items-center gap-2 border-t ps-3 pe-1.5\">\n          <span\n            className={cn(\n              \"shrink-0 rounded-md px-1.5 font-medium text-[10.5px] uppercase leading-5 tracking-wide\",\n              sel.kind === \"string\" && \"bg-emerald-500/10 text-emerald-700 dark:text-emerald-400\",\n              sel.kind === \"number\" && \"bg-amber-500/10 text-amber-700 dark:text-amber-300\",\n              sel.kind === \"boolean\" && \"bg-violet-500/10 text-violet-700 dark:text-violet-400\",\n              sel.kind === \"null\" && \"bg-rose-500/10 text-rose-700 dark:text-rose-400\",\n              isContainer(sel) && \"bg-muted text-muted-foreground\",\n            )}\n          >\n            {sel.kind}\n          </span>\n          <code className=\"min-w-0 flex-1 truncate font-mono text-xs\" title={sel.path}>\n            {sel === root ? (\n              <span className=\"text-muted-foreground\">\n                {rootName} · {count} {root.kind === \"array\" ? \"items\" : \"keys\"}\n              </span>\n            ) : (\n              sel.path\n            )}\n          </code>\n          <button\n            className=\"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2 text-muted-foreground text-xs outline-none transition-[color,background-color,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] [@media(hover:hover)]:hover:bg-accent [@media(hover:hover)]:hover:text-foreground\"\n            onClick={() => copyPath(sel.path)}\n            type=\"button\"\n          >\n            <SwapIcon done={copied === sel.path} />\n            <span className=\"w-16 text-start\">{copied === sel.path ? \"Copied\" : \"Copy path\"}</span>\n          </button>\n        </div>\n      </div>\n    </TooltipProvider>\n  );\n}\n"
    }
  ]
}