Component
JSON viewer
A collapsible tree viewer for JSON with search and copy.
- #arc
- #component
- #json
- #tree
- #code
- #search
- #developer
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/json-viewer.jsonFiles
components/arc/json-viewer/json-viewer.module.csscomponents/arc/json-viewer/json-viewer.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
Registry items it pulls in
Source
View as Markdownjson-viewer.module.cssjson-viewer.module.cssjson-viewer.module.csscss
.root { --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; --indent: 16px; display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
/* Toolbar: search with a match counter, then expand and collapse all. */
.toolbar { display: flex; align-items: center; gap: 2px; padding: 6px; border-bottom: 1px solid var(--border-subtle); }
/* Focus fills the search field with the accent, like Input; the glass, text, counter and its buttons switch to the accent foreground. */
.search { position: relative; display: flex; box-sizing: border-box; min-width: 0; flex: 1; align-items: center; gap: 4px; min-height: 34px; padding: 0 3px 0 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); cursor: text; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .search:hover:not(:has(.input:focus-visible)) { border-color: var(--foreground); } }
.search:has(.input:focus-visible) { border-color: var(--accent); background: var(--accent); }
.search:has(.input:focus-visible) .searchIcon, .search:has(.input:focus-visible) .tool { color: var(--accent-foreground); }
.search:has(.input:focus-visible) .count { color: var(--accent-foreground-muted); }
.search:has(.input:focus-visible) .input { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.search:has(.input:focus-visible) .input::placeholder { color: var(--accent-foreground-muted); }
.search:has(.input:focus-visible) .tool:active:not(:disabled) { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); }
.searchIcon { flex: none; color: var(--text-muted); }
.input { min-width: 0; flex: 1; height: 32px; padding: 0 4px; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); outline: none; }
.input::placeholder { color: var(--text-muted); }
.input::-webkit-search-cancel-button { display: none; }
.matches { display: inline-flex; flex: none; align-items: center; gap: 0; }
.count { min-width: 3.5ch; padding-inline: 4px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; text-align: right; }
.grow { flex: 1; }
.divider { width: 1px; height: 18px; margin-inline: 4px; background: var(--border-subtle); }
.tool { display: inline-grid; width: 34px; height: 34px; flex: none; place-items: center; padding: 0; border: 0; border-radius: 14px; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
.tool:disabled { cursor: default; opacity: .4; }
.tool:active:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }
.search .tool { width: 26px; height: 26px; border-radius: 8px; }
.search .tool:active:not(:disabled) { background: var(--surface-muted); }
/* Tree. Rows are one fixed height, so scrolling can aim at where a row will settle. */
.tree { position: relative; max-height: var(--json-max-height, 420px); padding: 6px; overflow: auto; overflow-anchor: none; overscroll-behavior: contain; font-family: var(--mono); font-size: 13px; font-variant-ligatures: none; letter-spacing: 0; scrollbar-width: thin; }
.row { position: relative; border-radius: 10px; cursor: default; outline: none; -webkit-tap-highlight-color: transparent; }
.highlight { position: absolute; inset: 1px 0; border-radius: 9px; background: var(--surface-muted); }
.line { position: relative; display: flex; height: 30px; min-width: 0; align-items: center; gap: 6px; padding-right: 6px; padding-left: calc(4px + var(--level, 0) * var(--indent)); white-space: nowrap; }
.row[data-kind="node"] .line { cursor: pointer; }
.chevron { display: inline-grid; width: 14px; height: 14px; flex: none; place-items: center; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }
.chevron[data-open] { transform: rotate(90deg); }
.row[data-active] .chevron[data-branch] { color: var(--foreground); }
.key { flex: none; max-width: 45%; overflow: hidden; color: var(--foreground); text-overflow: ellipsis; }
.index { flex: none; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.colon { flex: none; margin-left: -5px; color: var(--text-muted); }
.summary { display: inline-flex; min-width: 0; align-items: baseline; gap: 8px; overflow: hidden; }
.preview { min-width: 0; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; }
.countLabel { flex: none; color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
/* Type colors lean on the semantic roles, so both themes stay legible. */
.value { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.value[data-type="string"] { color: var(--success); }
.value[data-type="number"] { color: var(--accent); font-variant-numeric: tabular-nums; }
.value[data-type="boolean"] { color: var(--warning); }
.value[data-type="null"], .value[data-type="other"] { color: var(--text-muted); }
.mark { border-radius: 3px; background: color-mix(in oklab, var(--warning) 22%, transparent); color: inherit; box-shadow: 0 0 0 1px color-mix(in oklab, var(--warning) 22%, transparent); transition: background-color var(--duration-fast) var(--ease-standard); }
.mark[data-current] { background: color-mix(in oklab, var(--warning) 48%, transparent); }
.more { display: inline-flex; align-items: baseline; gap: 10px; padding-left: 20px; font-family: var(--font-body); font-size: var(--text-sm); }
.moreLabel { color: var(--foreground); font-weight: 500; }
.row[data-kind="more"] { cursor: pointer; }
.muted { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
/* Copy actions appear at the row's end over a soft fade, on hover or on the current row. */
/* Inset like the highlight, with its right corners, so the fade never shows a square edge past the row's rounded end. */
.actions { position: absolute; top: 1px; right: 0; bottom: 1px; display: flex; align-items: center; gap: 0; padding-right: 1px; padding-left: 24px; border-radius: 0 9px 9px 0; background: linear-gradient(to right, transparent, var(--surface) 22px); opacity: 0; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }
.row[data-active] .actions { background: linear-gradient(to right, transparent, var(--surface-muted) 22px); }
.action { position: relative; display: inline-grid; width: 26px; height: 26px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.action[data-state="done"] { color: var(--success); }
.action[data-state="error"] { color: var(--danger); }
.action:active { background: color-mix(in oklab, var(--foreground) 8%, transparent); }
.actionGlyph { display: grid; place-items: center; }
.row[data-active] .actions, .row:focus-within .actions { opacity: 1; pointer-events: auto; }
/* The current path, always readable in full through its copy action. */
.pathBar { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-top: 1px solid var(--border-subtle); }
.path { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); font-family: var(--mono); font-size: var(--text-xs); letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.pathType { flex: none; color: var(--text-muted); font-size: var(--text-xs); }
.pathCopy { width: 30px; height: 30px; border-radius: 10px; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (hover: hover) and (pointer: fine) {
.row:hover .actions { opacity: 1; pointer-events: auto; }
/* Same inset and corner as the current-row highlight, so hover and selection share one shape. */
.row:not([data-active]):hover { --row-hover: color-mix(in oklab, var(--foreground) 3.5%, var(--surface)); }
.row:not([data-active]):hover::before { content: ""; position: absolute; inset: 1px 0; border-radius: 9px; background: var(--row-hover); pointer-events: none; }
.row:not([data-active]):hover .actions { background: linear-gradient(to right, transparent, var(--row-hover) 22px); }
.row:hover .chevron[data-branch] { color: var(--foreground); }
.tool:hover:not(:disabled), .action:hover { background: var(--surface-muted); color: var(--foreground); }
.search .tool:hover:not(:disabled) { background: var(--surface-muted); }
.search:has(.input:focus-visible) .tool:hover:not(:disabled) { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); }
.action[data-state="done"]:hover { color: var(--success); }
}
@media (max-width: 420px) {
.tree { --indent: 12px; }
.key { max-width: 40%; }
.pathType { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.chevron, .actions, .tool, .action, .mark { transition: none; }
}
json-viewer.tsxjson-viewer.tsxjson-viewer.tsxtsx
"use client";
import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
import { AnimatePresence, LayoutGroup, animate, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, Transition } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { UnfoldLessIcon, UnfoldMoreIcon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./json-viewer.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
export type JsonValueType = "object" | "array" | "string" | "number" | "boolean" | "null" | "other";
export interface JsonViewerCopyDetail {
kind: "value" | "path";
path: string;
text: string;
}
/**
* A collapsible tree for JSON. Values are colored by type, branches unfold row by row, search highlights every match and
* opens the branches that hold them, long arrays and objects load in pages, and each row can copy its value or path.
* The tree follows the WAI-ARIA tree pattern: arrows move and open, Home and End jump, Enter toggles, and the modifier
* key with C copies the focused value.
*/
export interface JsonViewerProps {
data: unknown;
/** Name of the root in paths, as in `root.users[0].name`. Defaults to "root". */
rootName?: string;
/** Levels open on first render when `defaultExpanded` is not set. Defaults to 1, which opens the root. */
defaultExpandDepth?: number;
/** Paths of open branches. */
expanded?: string[];
defaultExpanded?: string[];
onExpandedChange?: (paths: string[]) => void;
/** Show the search field. Defaults to true. */
searchable?: boolean;
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
/** Children shown per page in a long array or object. Defaults to 50. */
pageSize?: number;
/** Show copy value and copy path actions. Defaults to true. */
copyable?: boolean;
onCopy?: (detail: JsonViewerCopyDetail) => void;
/** Called when a row becomes the current one. */
onSelect?: (detail: { path: string; value: unknown; type: JsonValueType }) => void;
/** Show the path of the current row under the tree. Defaults to true. */
showPath?: boolean;
/** Height of the scrolling tree area. Defaults to 420px. */
maxHeight?: number | string;
/** Accessible name of the tree. */
label?: string;
className?: string;
}
type Row = {
id: string;
kind: "node" | "more";
level: number;
name: string | number | null;
value: unknown;
type: JsonValueType;
count: number;
open: boolean;
parent: string | null;
posinset: number;
setsize: number;
/** For "more" rows: how many children are still hidden. */
hidden?: number;
};
const ROW = 30;
const IDENT = /^[A-Za-z_$][\w$]*$/;
function typeOf(value: unknown): JsonValueType {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
switch (typeof value) {
case "object": return "object";
case "string": return "string";
case "number": case "bigint": return "number";
case "boolean": return "boolean";
default: return "other";
}
}
const isBranch = (type: JsonValueType) => type === "object" || type === "array";
function entries(value: unknown): [string | number, unknown][] {
if (Array.isArray(value)) return value.map((item, index) => [index, item]);
if (value && typeof value === "object") return Object.entries(value as Record<string, unknown>);
return [];
}
const childPath = (parent: string, key: string | number) => typeof key === "number" ? `${parent}[${key}]` : IDENT.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
const within = (ancestor: string, id: string) => id === ancestor || id.startsWith(`${ancestor}.`) || id.startsWith(`${ancestor}[`);
function primitiveText(value: unknown, type: JsonValueType) {
if (type === "string") return value as string;
if (type === "null") return "null";
return String(value);
}
function copyText(value: unknown, type: JsonValueType) {
if (type === "string") return value as string;
if (isBranch(type)) return JSON.stringify(value, null, 2);
return primitiveText(value, type);
}
const countLabel = (row: { type: JsonValueType; count: number }) => `${row.count}개 ${row.type === "array" ? "항목" : "키"}`;
const typeLabel: Record<JsonValueType, string> = { object: "객체", array: "배열", string: "문자열", number: "숫자", boolean: "참거짓", null: "비어 있음", other: "기타" };
/** Every container path, for expand all. */
function allBranches(data: unknown, rootName: string) {
const out: string[] = [];
const visit = (value: unknown, id: string) => {
if (!isBranch(typeOf(value))) return;
out.push(id);
for (const [key, child] of entries(value)) visit(child, childPath(id, key));
};
visit(data, rootName);
return out;
}
function branchesToDepth(data: unknown, rootName: string, depth: number) {
const out: string[] = [];
const visit = (value: unknown, id: string, level: number) => {
if (level >= depth || !isBranch(typeOf(value))) return;
out.push(id);
for (const [key, child] of entries(value)) visit(child, childPath(id, key), level + 1);
};
visit(data, rootName, 0);
return out;
}
type SearchResult = { matches: string[]; ancestors: Set<string>; needed: Map<string, number> };
/** Finds keys and primitive values that contain the query, the branches that hold them, and how far each page must reach. */
function searchJson(data: unknown, rootName: string, needle: string): SearchResult {
const matches: string[] = [];
const ancestors = new Set<string>();
const needed = new Map<string, number>();
const frames: [string, number][] = [];
const visit = (value: unknown, id: string, name: string | number | null) => {
if (matches.length >= 2000) return;
const type = typeOf(value);
const keyHit = typeof name === "string" && name.toLowerCase().includes(needle);
const valueHit = !isBranch(type) && primitiveText(value, type).toLowerCase().includes(needle);
if (keyHit || valueHit) {
matches.push(id);
for (const [frame, index] of frames) {
ancestors.add(frame);
needed.set(frame, Math.max(needed.get(frame) ?? 0, index + 1));
}
}
if (!isBranch(type)) return;
entries(value).forEach(([key, child], index) => {
frames.push([id, index]);
visit(child, childPath(id, key), key);
frames.pop();
});
};
visit(data, rootName, null);
return { matches, ancestors, needed };
}
function Highlight({ text, needle, current }: { text: string; needle: string; current: boolean }) {
if (!needle) return <>{text}</>;
const lower = text.toLowerCase();
const parts: ReactNode[] = [];
let from = 0, at = lower.indexOf(needle);
while (at !== -1) {
if (at > from) parts.push(text.slice(from, at));
parts.push(<mark key={at} className={styles.mark} data-current={current || undefined}>{text.slice(at, at + needle.length)}</mark>);
from = at + needle.length;
at = lower.indexOf(needle, from);
}
if (from < text.length) parts.push(text.slice(from));
return <>{parts}</>;
}
type CopyState = "idle" | "done" | "error";
/** Icon-only copy action. The glyph swaps in place, so the button never changes width. */
function CopyButton({ label, icon, onCopy, reduced, className, focusable = false }: { label: string; icon: ReactNode; onCopy: () => Promise<void>; reduced: boolean; className?: string; focusable?: boolean }) {
const [state, setState] = useState<CopyState>("idle");
const timer = useRef(0);
useEffect(() => () => window.clearTimeout(timer.current), []);
const run = async () => {
window.clearTimeout(timer.current);
try { await onCopy(); setState("done"); } catch { setState("error"); }
timer.current = window.setTimeout(() => setState("idle"), 1400);
};
const glyph = state === "done" ? <UiIcon iconRole="check" size={14} strokeWidth={2} aria-hidden="true" /> : state === "error" ? <UiIcon iconRole="close" size={14} strokeWidth={2} aria-hidden="true" /> : icon;
return <button type="button" tabIndex={focusable ? undefined : -1} className={[styles.action, className].filter(Boolean).join(" ")} data-state={state} aria-label={state === "done" ? "복사했어요" : state === "error" ? "복사하지 못했어요" : label} title={label}
onClick={event => { event.stopPropagation(); void run(); }}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={state} className={styles.actionGlyph} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }}
transition={reduced ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant }, filter: { duration: motionTokens.duration.instant } }}>{glyph}</motion.span>
</AnimatePresence>
</button>;
}
async function writeClipboard(text: string) {
if (!navigator.clipboard?.writeText) throw new Error("클립보드를 쓸 수 없어요");
await navigator.clipboard.writeText(text);
}
/* Scroll, row heights and the current-row highlight share one critically damped spring, so a highlight that moves while
* the tree scrolls travels straight to its row instead of overshooting the viewport. */
const scrollSpring: Transition = motionTokens.spring.smooth;
export const JsonViewer = forwardRef<HTMLDivElement, JsonViewerProps>(function JsonViewer({
data, rootName = "root", defaultExpandDepth = 1, expanded: expandedProp, defaultExpanded, onExpandedChange,
searchable = true, query: queryProp, defaultQuery = "", onQueryChange, pageSize = 50, copyable = true, onCopy, onSelect,
showPath = true, maxHeight = 420, label = "JSON 데이터", className,
}, ref) {
const reduced = !!useReducedMotion();
const uid = useId();
const treeRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const rowRefs = useRef(new Map<string, HTMLDivElement>());
/* Open branches. */
const [expandedInternal, setExpandedInternal] = useState<string[]>(() => defaultExpanded ?? branchesToDepth(data, rootName, defaultExpandDepth));
const expandedList = expandedProp ?? expandedInternal;
const base = useMemo(() => new Set(expandedList), [expandedList]);
const setExpanded = useCallback((next: Set<string>) => {
const list = [...next];
if (expandedProp === undefined) setExpandedInternal(list);
onExpandedChange?.(list);
}, [expandedProp, onExpandedChange]);
/* Search. Branches holding a match open on their own; closing one while searching is remembered until the query changes. */
const [queryInternal, setQueryInternal] = useState(defaultQuery);
const query = queryProp ?? queryInternal;
const needle = query.trim().toLowerCase();
const setQuery = (next: string) => {
if (queryProp === undefined) setQueryInternal(next);
onQueryChange?.(next);
};
const search = useMemo<SearchResult | null>(() => needle ? searchJson(data, rootName, needle) : null, [data, needle, rootName]);
const [closedWhileSearching, setClosedWhileSearching] = useState<{ needle: string; ids: Set<string> }>({ needle: "", ids: new Set() });
const closed = closedWhileSearching.needle === needle ? closedWhileSearching.ids : null;
const [matchIndex, setMatchIndex] = useState(0);
const matchCount = search?.matches.length ?? 0;
const matchSet = useMemo(() => new Set(search?.matches ?? []), [search]);
const currentMatch = search && matchCount ? search.matches[Math.min(matchIndex, matchCount - 1)] : null;
const isOpen = useCallback((id: string) => {
if (search && search.ancestors.has(id)) return !closed?.has(id);
return base.has(id);
}, [base, closed, search]);
/* Pages for long branches. */
const [pages, setPages] = useState<Record<string, number>>({});
const limitOf = useCallback((id: string) => Math.max((pages[id] ?? 1) * pageSize, search?.needed.get(id) ?? 0), [pageSize, pages, search]);
/* The visible rows, flat. Each row unfolds its own height, so nested branches, pages, and expand all share one motion. */
const rows = useMemo(() => {
const out: Row[] = [];
const visit = (value: unknown, id: string, name: string | number | null, level: number, parent: string | null, posinset: number, setsize: number) => {
const type = typeOf(value);
const list = isBranch(type) ? entries(value) : [];
const open = isBranch(type) && isOpen(id);
out.push({ id, kind: "node", level, name, value, type, count: list.length, open, parent, posinset, setsize });
if (!open) return;
const limit = limitOf(id);
const shown = list.slice(0, limit);
const more = list.length - shown.length;
const size = shown.length + (more > 0 ? 1 : 0);
shown.forEach(([key, child], index) => visit(child, childPath(id, key), key, level + 1, id, index + 1, size));
if (more > 0) out.push({ id: `${id}::more`, kind: "more", level: level + 1, name: null, value: null, type: "other", count: 0, open: false, parent: id, posinset: size, setsize: size, hidden: more });
};
visit(data, rootName, null, 1, null, 1, 1);
return out;
}, [data, isOpen, limitOf, rootName]);
/* The current row. When it is folded away, the nearest visible branch that holds it takes over. */
const [activeRaw, setActiveRaw] = useState(rootName);
const active = useMemo(() => {
if (rows.some(row => row.id === activeRaw)) return activeRaw;
let best = rootName;
for (const row of rows) if (row.kind === "node" && within(row.id, activeRaw) && row.id.length > best.length) best = row.id;
return best;
}, [activeRaw, rootName, rows]);
const activeIndex = Math.max(0, rows.findIndex(row => row.id === active));
/* 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. */
const folded = active !== activeRaw;
const activeRow = rows[activeIndex];
const onSelectRef = useRef(onSelect);
useEffect(() => { onSelectRef.current = onSelect; }, [onSelect]);
const lastReported = useRef<string | null>(null);
useEffect(() => {
if (!activeRow || activeRow.kind !== "node" || lastReported.current === activeRow.id) return;
lastReported.current = activeRow.id;
onSelectRef.current?.({ path: activeRow.id, value: activeRow.value, type: activeRow.type });
}, [activeRow]);
/* Scrolling keeps the current row in view, springing to where it will sit once rows finish unfolding. */
const scrollFlight = useRef<AnimationPlaybackControls | null>(null);
const reveal = useCallback((index: number, center: boolean) => {
const tree = treeRef.current;
if (!tree) return;
const top = index * ROW, bottom = top + ROW, view = tree.clientHeight, now = tree.scrollTop;
let target = now;
if (center) target = top - view / 2 + ROW / 2;
else if (top < now + 4) target = top - 4;
else if (bottom > now + view - 4) target = bottom - view + 4;
target = Math.max(0, target);
if (Math.abs(target - now) < 1) return;
scrollFlight.current?.stop();
if (reduced) { tree.scrollTop = target; return; }
scrollFlight.current = animate(now, target, { ...scrollSpring, onUpdate: value => { tree.scrollTop = value; } });
}, [reduced]);
useEffect(() => () => scrollFlight.current?.stop(), []);
const pendingFocus = useRef(false);
const pendingReveal = useRef<"nearest" | "center" | null>(null);
/* Bumped on every navigation, so moving to the row that is already current still focuses and reveals it. */
const [navigation, setNavigation] = useState(0);
useEffect(() => {
if (pendingFocus.current) {
pendingFocus.current = false;
rowRefs.current.get(active)?.focus({ preventScroll: true });
}
if (pendingReveal.current) {
reveal(activeIndex, pendingReveal.current === "center");
pendingReveal.current = null;
}
}, [active, activeIndex, reveal, navigation]);
const moveTo = (id: string, focus = true, how: "nearest" | "center" = "nearest") => {
pendingFocus.current = focus;
pendingReveal.current = how;
setActiveRaw(id);
setNavigation(count => count + 1);
};
const toggle = (id: string, next?: boolean) => {
const open = isOpen(id);
const want = next ?? !open;
if (want === open) return;
playUiSound("tick");
if (search?.ancestors.has(id)) {
const ids = new Set(closed ?? []);
if (want) ids.delete(id); else ids.add(id);
setClosedWhileSearching({ needle, ids });
if (want && !base.has(id)) setExpanded(new Set(base).add(id));
return;
}
const nextSet = new Set(base);
if (want) nextSet.add(id); else nextSet.delete(id);
setExpanded(nextSet);
};
const expandAll = () => {
playUiSound("tick");
setExpanded(new Set(allBranches(data, rootName)));
setClosedWhileSearching({ needle, ids: new Set() });
// Branches above the current row unfold and push it down; follow it to where it settles.
moveTo(active, false, "nearest");
};
const collapseAll = () => {
playUiSound("tick");
const rootOpen = isBranch(typeOf(data)) ? [rootName] : [];
setExpanded(new Set(rootOpen));
setClosedWhileSearching({ needle, ids: new Set([...(search?.ancestors ?? [])].filter(id => id !== rootName)) });
setPages({});
moveTo(rootName, false, "nearest");
};
const showMore = (parent: string) => setPages(current => ({ ...current, [parent]: Math.ceil(limitOf(parent) / pageSize) + 1 }));
/* Copy. */
const [announcement, setAnnouncement] = useState("");
const copy = async (row: Row, kind: "value" | "path") => {
const text = kind === "path" ? row.id : copyText(row.value, row.type);
try {
await writeClipboard(text);
setAnnouncement(`${row.id} ${kind === "path" ? "경로를" : "값을"} 복사했어요`);
onCopy?.({ kind, path: row.id, text });
} catch (error) {
setAnnouncement("복사하지 못했어요");
throw error;
}
};
/* Search navigation. */
const stepMatch = (step: number) => {
if (!search || !matchCount) return;
const next = ((Math.min(matchIndex, matchCount - 1) + step) % matchCount + matchCount) % matchCount;
setMatchIndex(next);
moveTo(search.matches[next]!, false, "center");
};
const onQuery = (next: string) => {
setQuery(next);
setMatchIndex(0);
const trimmed = next.trim().toLowerCase();
if (trimmed) {
const first = searchJson(data, rootName, trimmed).matches[0];
if (first) moveTo(first, false, "center");
} else if (needle) {
// Clearing folds the branches search opened. Aim the scroll at where the current row settles, so it stays in view.
moveTo(activeRaw, false, "nearest");
}
};
const onSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter") { event.preventDefault(); stepMatch(event.shiftKey ? -1 : 1); }
else if (event.key === "Escape" && query) { event.preventDefault(); event.stopPropagation(); onQuery(""); }
else if (event.key === "ArrowDown" && !event.altKey) { event.preventDefault(); moveTo(active, true); }
};
const onTreeKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const row = rows[activeIndex];
if (!row || event.altKey) return;
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === "c") {
if (row.kind === "node" && !window.getSelection()?.toString()) { event.preventDefault(); void copy(row, event.shiftKey ? "path" : "value").catch(() => {}); }
return;
}
if (mod) return;
const go = (index: number) => { event.preventDefault(); const target = rows[Math.max(0, Math.min(rows.length - 1, index))]; if (target) moveTo(target.id); };
switch (event.key) {
case "ArrowDown": go(activeIndex + 1); break;
case "ArrowUp": go(activeIndex - 1); break;
case "Home": go(0); break;
case "End": go(rows.length - 1); break;
case "ArrowRight":
event.preventDefault();
if (row.kind === "node" && isBranch(row.type)) { if (!row.open) toggle(row.id, true); else if (row.count) go(activeIndex + 1); }
break;
case "ArrowLeft":
event.preventDefault();
if (row.kind === "node" && row.open) toggle(row.id, false);
else if (row.parent) moveTo(row.parent);
break;
case "Enter": case " ":
event.preventDefault();
if (row.kind === "more") showMore(row.parent!);
else if (isBranch(row.type)) toggle(row.id);
break;
case "/":
if (searchable) { event.preventDefault(); searchRef.current?.focus(); searchRef.current?.select(); }
break;
default:
}
};
const heightStyle = { "--json-max-height": typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight } as CSSProperties;
const bulk = rows.length > 400;
const rowTransition: Transition = reduced || bulk ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const describe = (row: Row) => {
const name = row.name === null ? rootName : String(row.name);
if (row.kind === "more") return `${Math.min(pageSize, row.hidden ?? 0)}개 더 보기, ${row.hidden}개 숨김`;
return isBranch(row.type) ? `${name}, ${typeLabel[row.type]}, ${countLabel(row)}` : `${name}: ${primitiveText(row.value, row.type)}`;
};
return <div ref={ref} className={[styles.root, className].filter(Boolean).join(" ")} style={heightStyle}>
{searchable || rows.length > 1 ? <div className={styles.toolbar}>
{searchable ? <label className={styles.search}>
<UiIcon iconRole="search" size={15} strokeWidth={1.75} aria-hidden="true" className={styles.searchIcon} />
<input ref={searchRef} type="search" className={styles.input} value={query} placeholder="키와 값 검색하기" aria-label="JSON 검색하기" aria-controls={`${uid}-tree`}
onChange={event => onQuery(event.target.value)} onKeyDown={onSearchKeyDown} spellCheck={false} autoComplete="off" />
<AnimatePresence initial={false}>
{needle ? <motion.span key="matches" className={styles.matches} initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} animate={{ opacity: 1, x: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} transition={{ duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }}>
<span className={styles.count} aria-live="polite">{matchCount ? `${Math.min(matchIndex, matchCount - 1) + 1}/${matchCount}` : "0/0"}</span>
<button type="button" className={styles.tool} aria-label="이전 결과" disabled={!matchCount} onClick={() => stepMatch(-1)}><UiIcon iconRole="chevron-up" size={15} strokeWidth={1.75} aria-hidden="true" /></button>
<button type="button" className={styles.tool} aria-label="다음 결과" disabled={!matchCount} onClick={() => stepMatch(1)}><UiIcon iconRole="chevron-down" size={15} strokeWidth={1.75} aria-hidden="true" /></button>
</motion.span> : null}
</AnimatePresence>
</label> : <span className={styles.grow} />}
<span className={styles.divider} aria-hidden="true" />
<button type="button" className={styles.tool} aria-label="모두 펼치기" title="모두 펼치기" onClick={expandAll}><HugeiconsIcon icon={UnfoldMoreIcon} size={15} strokeWidth={1.75} aria-hidden="true" /></button>
<button type="button" className={styles.tool} aria-label="모두 접기" title="모두 접기" onClick={collapseAll}><HugeiconsIcon icon={UnfoldLessIcon} size={15} strokeWidth={1.75} aria-hidden="true" /></button>
</div> : null}
<LayoutGroup id={uid}>
<motion.div ref={treeRef} layoutScroll id={`${uid}-tree`} role="tree" aria-label={label} className={styles.tree} onKeyDown={onTreeKeyDown}>
<AnimatePresence initial={false}>
{rows.map(row => {
const isActive = row.id === active;
const branch = isBranch(row.type);
const matched = !!search && row.kind === "node" && matchSet.has(row.id);
const isCurrentMatch = row.id === currentMatch;
const mark = matched ? needle : "";
return <motion.div key={row.id} ref={(node: HTMLDivElement | null) => { if (node) rowRefs.current.set(row.id, node); else rowRefs.current.delete(row.id); }}
role="treeitem" tabIndex={isActive ? 0 : -1} aria-level={row.level} aria-posinset={row.posinset} aria-setsize={row.setsize}
aria-expanded={row.kind === "node" && branch ? row.open : undefined} aria-selected={isActive} aria-label={describe(row)}
className={styles.row} data-kind={row.kind} data-active={isActive || undefined} style={{ "--level": row.level - 1 } as CSSProperties}
initial={{ height: 0, opacity: 0, overflow: "hidden" }} animate={{ height: ROW, opacity: 1, transitionEnd: { overflow: "visible" } }} exit={{ height: 0, opacity: 0, overflow: "hidden" }} transition={rowTransition}
onFocus={event => { if (event.target === event.currentTarget && row.id !== active) setActiveRaw(row.id); }}
onClick={() => {
if (row.kind === "more") { showMore(row.parent!); moveTo(row.id); return; }
if (branch) toggle(row.id);
moveTo(row.id);
}}>
{isActive ? <motion.span layoutId="json-active" className={styles.highlight} transition={reduced || folded ? { duration: 0 } : scrollSpring} aria-hidden="true" /> : null}
<span className={styles.line}>
{row.kind === "more" ? <span className={styles.more}>
<span className={styles.moreLabel}>{Math.min(pageSize, row.hidden ?? 0)}개 더 보기</span>
<span className={styles.muted}>{row.hidden}개 숨김</span>
</span> : <>
<span className={styles.chevron} data-open={row.open || undefined} data-branch={branch || undefined} aria-hidden="true">{branch ? <UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} /> : null}</span>
<span className={typeof row.name === "number" ? styles.index : styles.key}>
{row.name === null ? rootName : typeof row.name === "number" ? row.name : <Highlight text={row.name} needle={mark} current={isCurrentMatch} />}
</span>
<span className={styles.colon} aria-hidden="true">{row.name === null && branch ? "" : ":"}</span>
{branch
? <span className={styles.summary}>
{!row.open ? <span className={styles.preview}>{row.type === "array" ? "[…]" : `{ ${entries(row.value).slice(0, 3).map(([key]) => key).join(", ")}${row.count > 3 ? ", …" : ""} }`}</span> : null}
<span className={styles.countLabel}>{countLabel(row)}</span>
</span>
: <span className={styles.value} data-type={row.type} title={row.type === "string" && (row.value as string).length > 40 ? row.value as string : undefined}>
{row.type === "string" ? <>"<Highlight text={row.value as string} needle={mark} current={isCurrentMatch} />"</> : <Highlight text={primitiveText(row.value, row.type)} needle={mark} current={isCurrentMatch} />}
</span>}
{copyable ? <span className={styles.actions}>
<CopyButton label="값 복사하기" icon={<UiIcon iconRole="copy" size={14} strokeWidth={1.75} aria-hidden="true" />} reduced={reduced} onCopy={() => copy(row, "value")} />
<CopyButton label="경로 복사하기" icon={<UiIcon iconRole="link" size={14} strokeWidth={1.75} aria-hidden="true" />} reduced={reduced} onCopy={() => copy(row, "path")} />
</span> : null}
</>}
</span>
</motion.div>;
})}
</AnimatePresence>
</motion.div>
</LayoutGroup>
{showPath && activeRow ? <div className={styles.pathBar}>
<code className={styles.path} title={activeRow.kind === "node" ? activeRow.id : activeRow.parent ?? ""}>{activeRow.kind === "node" ? activeRow.id : activeRow.parent}</code>
<span className={styles.pathType}>{activeRow.kind === "node" ? isBranch(activeRow.type) ? `${typeLabel[activeRow.type]}, ${countLabel(activeRow)}` : typeLabel[activeRow.type] : "더 보기"}</span>
{copyable && activeRow.kind === "node" ? <CopyButton key={activeRow.id} focusable className={styles.pathCopy} label="경로 복사하기" icon={<UiIcon iconRole="copy" size={14} strokeWidth={1.75} aria-hidden="true" />} reduced={reduced} onCopy={() => copy(activeRow, "path")} /> : null}
</div> : null}
<span className={styles.srOnly} role="status">{announcement}</span>
</div>;
});
export default JsonViewer;