| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643 |
- import clsx from 'clsx'
- import { useEffect, useId, useRef, useState } from 'react'
- import type {
- KeyboardEvent as ReactKeyboardEvent,
- MouseEvent as ReactMouseEvent,
- ReactNode,
- UIEvent as ReactUIEvent,
- } from 'react'
- import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
- import { Menu } from './Menu.tsx'
- import type { MenuEntry } from './Menu.tsx'
- import css from './JsonTree.module.css'
- const OBJECT_PREVIEW_LIMIT = 4
- const ARRAY_PREVIEW_LIMIT = 5
- const PREVIEW_DEPTH_LIMIT = 2
- /**
- * Display copy for the tree's copy affordance; the owner passes localized
- * labels (this package is cordis-free, so copy arrives via props).
- */
- export interface JsonTreeLabels {
- /** Menu item: copy the raw primitive value. */
- copyValue: string
- /** Menu item: copy the value as compact JSON (primitive rows). */
- copyJson: string
- /** Menu item: copy the property path. */
- copyPath: string
- /** Menu item: copy the value as pretty-printed JSON. */
- copyPrettyJson: string
- /** Menu item: copy the value as compact JSON (object rows). */
- copyCompactJson: string
- /** Copy-button state label after a successful copy. */
- copied: string
- /** Copy-button state label after a failed copy. */
- copyFailed: string
- /** Expander aria label while expanded. */
- collapseNode: string
- /** Expander aria label while collapsed. */
- expandNode: string
- /** Copy-button tooltip, given the current action label. */
- copyButtonTitle: (action: string) => string
- }
- function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
- return [
- { id: 'value', label: labels.copyValue },
- { id: 'json', label: labels.copyJson },
- { id: 'path', label: labels.copyPath },
- ]
- }
- function objectCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
- return [
- { id: 'prettyJson', label: labels.copyPrettyJson },
- { id: 'json', label: labels.copyCompactJson },
- { id: 'path', label: labels.copyPath },
- ]
- }
- type JsonPath = readonly (number | string)[]
- interface RowTarget {
- path: JsonPath
- value: unknown
- }
- interface CopyTarget extends RowTarget {
- left: number
- side: 'bottom' | 'top'
- top: number
- }
- function isExpandableValue(value: unknown): value is object | unknown[] {
- return typeof value === 'object' && value !== null && !(value instanceof Date)
- }
- function entriesOf(value: object | unknown[]): readonly (readonly [string, unknown])[] {
- if (Array.isArray(value)) {
- return value.map((item, index) => [String(index), item] as const)
- }
- return Object.keys(value).map(key => [
- key,
- (value as Record<string, unknown>)[key],
- ] as const)
- }
- function bracketOf(value: object | unknown[]): readonly [string, string] {
- return Array.isArray(value) ? ['[', ']'] : ['{', '}']
- }
- function previewPrimitive(value: unknown): ReactNode {
- if (value === null) return <span className={css.keywordValue}>null</span>
- if (typeof value === 'string') {
- return <span className={css.stringValue}>{JSON.stringify(value)}</span>
- }
- if (typeof value === 'number') {
- return <span className={css.numberValue}>{String(value)}</span>
- }
- if (typeof value === 'boolean') {
- return <span className={css.keywordValue}>{String(value)}</span>
- }
- if (typeof value === 'bigint') {
- return <span className={css.otherValue}>{value.toString()}</span>
- }
- if (typeof value === 'undefined') {
- return <span className={css.otherValue}>undefined</span>
- }
- if (typeof value === 'symbol') {
- return <span className={css.otherValue}>{value.description ?? 'Symbol'}</span>
- }
- if (typeof value === 'function') {
- return <span className={css.otherValue}>{value.name || 'Function'}</span>
- }
- return null
- }
- function previewValue(value: unknown, depth: number): ReactNode {
- if (!isExpandableValue(value)) return previewPrimitive(value)
- const array = Array.isArray(value)
- const entries = entriesOf(value)
- const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
- const visible = entries.slice(0, limit)
- const [open, close] = bracketOf(value)
- return (
- <>
- <span className={css.punctuation}>{open}</span>
- {depth >= PREVIEW_DEPTH_LIMIT
- ? <span className={css.previewEllipsis}>…</span>
- : visible.map(([key, item], index) => (
- <span key={key}>
- {index > 0 && <span className={css.punctuation}>, </span>}
- {!array && (
- <>
- <span className={css.previewProperty}>{key}</span>
- <span className={css.punctuation}>: </span>
- </>
- )}
- {previewValue(item, depth + 1)}
- </span>
- ))}
- {depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
- <span className={css.previewEllipsis}>, …</span>
- )}
- <span className={css.punctuation}>{close}</span>
- </>
- )
- }
- function primitiveValue(value: unknown): ReactNode {
- if (value === null) return <span className={css.keywordValue}>null</span>
- if (typeof value === 'string') {
- return <span className={css.stringValue}>{JSON.stringify(value)}</span>
- }
- if (typeof value === 'boolean') {
- return <span className={css.keywordValue}>{String(value)}</span>
- }
- if (typeof value === 'number') {
- return <span className={css.numberValue}>{String(value)}</span>
- }
- if (typeof value === 'bigint') {
- return <span className={css.numberValue}>{`${value.toString()}n`}</span>
- }
- if (value instanceof Date) {
- return <span className={css.otherValue}>{value.toISOString()}</span>
- }
- if (typeof value === 'function') {
- return <span className={css.otherValue}>function() {'{ }'}</span>
- }
- if (typeof value === 'undefined') {
- return <span className={css.otherValue}>undefined</span>
- }
- return <span className={css.otherValue}>{(value as symbol).toString()}</span>
- }
- function fieldText(field: string): string {
- return field === '' ? '""' : field
- }
- function pathId(path: JsonPath): string {
- return path.map(part => (
- typeof part === 'number' ? `n${String(part)}` : `s${String(part.length)}:${part}`
- )).join('/')
- }
- function claimFocus(button: HTMLElement): void {
- button.focus()
- }
- function moveFocus(button: HTMLElement, direction: -1 | 1): void {
- const tree = button.closest<HTMLElement>('[role="tree"]')
- /* v8 ignore next -- JsonTree attaches expander handlers only beneath its owning role=tree. */
- if (tree === null) return
- const expanders = Array.from(tree.querySelectorAll<HTMLElement>('[data-json-expander]'))
- const current = expanders.indexOf(button)
- /* v8 ignore next -- the current expander is a member of the queried non-empty set. */
- if (current < 0 || expanders.length === 0) return
- const next = (current + direction + expanders.length) % expanders.length
- const nextExpander = expanders[next]
- /* v8 ignore next -- modulo over the non-empty expander set always resolves a member. */
- if (nextExpander !== undefined) claimFocus(nextExpander)
- }
- function NodeField({
- field,
- expandable,
- onToggle,
- }: {
- field: string | undefined
- expandable: boolean
- onToggle: () => void
- }) {
- if (field === undefined) return null
- return (
- <span
- className={clsx(css.label, expandable && css.clickableLabel)}
- onClick={expandable ? onToggle : undefined}
- >
- {fieldText(field)}:
- </span>
- )
- }
- interface JsonTreeNodeProps {
- field?: string
- initialExpanded: boolean
- labels: JsonTreeLabels
- lastElement: boolean
- onClaimTabStop: (id: string) => void
- onRowHover: (row: HTMLElement, target: RowTarget) => void
- path: JsonPath
- tabStopId: string | null
- value: unknown
- }
- function JsonTreeNode({
- field,
- initialExpanded,
- labels,
- lastElement,
- onClaimTabStop,
- onRowHover,
- path,
- tabStopId,
- value,
- }: JsonTreeNodeProps) {
- const contentsId = useId()
- const expanderRef = useRef<HTMLSpanElement>(null)
- const [expanded, setExpanded] = useState(initialExpanded)
- const nodeId = pathId(path)
- const container = isExpandableValue(value)
- const entries = container ? entriesOf(value) : []
- const expandable = entries.length > 0
- const toggle = () => {
- setExpanded(current => !current)
- claimFocus(expanderRef.current as HTMLSpanElement)
- }
- const onExpanderKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
- if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
- event.preventDefault()
- setExpanded(event.key === 'ArrowRight')
- return
- }
- if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
- event.preventDefault()
- moveFocus(event.currentTarget, event.key === 'ArrowUp' ? -1 : 1)
- }
- }
- const row = (children: ReactNode, ariaExpanded?: boolean) => (
- <div
- className={css.row}
- role="treeitem"
- aria-expanded={ariaExpanded}
- onMouseOver={(event) => {
- event.stopPropagation()
- onRowHover(event.currentTarget, { path, value })
- }}
- >
- {children}
- </div>
- )
- if (!container) {
- return row((
- <>
- <NodeField field={field} expandable={false} onToggle={toggle} />
- {primitiveValue(value)}
- {!lastElement && <span className={css.punctuation}>,</span>}
- </>
- ))
- }
- const [open, close] = bracketOf(value)
- if (!expandable) {
- return row((
- <>
- <NodeField field={field} expandable={false} onToggle={toggle} />
- <span className={css.punctuation}>{open}</span>
- <span className={css.punctuation}>{close}</span>
- {!lastElement && <span className={css.punctuation}>,</span>}
- </>
- ))
- }
- return row((
- <>
- <span
- ref={expanderRef}
- className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
- data-json-expander
- role="button"
- aria-label={expanded ? labels.collapseNode : labels.expandNode}
- aria-expanded={expanded}
- aria-controls={expanded ? contentsId : undefined}
- tabIndex={tabStopId === nodeId ? 0 : -1}
- onFocus={() => { onClaimTabStop(nodeId) }}
- onClick={toggle}
- onKeyDown={onExpanderKeyDown}
- />
- <NodeField field={field} expandable onToggle={toggle} />
- <span className={css.preview}>{previewValue(value, 0)}</span>
- {!lastElement && <span className={css.punctuation}>,</span>}
- {expanded && (
- <ul id={contentsId} role="group" className={css.children}>
- {entries.map(([key, item], index) => (
- <JsonTreeNode
- key={key}
- field={key}
- value={item}
- path={[...path, Array.isArray(value) ? index : key]}
- labels={labels}
- lastElement={index === entries.length - 1}
- initialExpanded={false}
- tabStopId={tabStopId}
- onClaimTabStop={onClaimTabStop}
- onRowHover={onRowHover}
- />
- ))}
- </ul>
- )}
- </>
- ), expanded)
- }
- function formattedPath(path: JsonPath): string {
- return path.reduce<string>((result, part) => {
- if (typeof part === 'number') return `${result}[${String(part)}]`
- return /^[A-Za-z_$][\w$]*$/.test(part)
- ? `${result}.${part}`
- : `${result}[${JSON.stringify(part)}]`
- }, '$')
- }
- function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
- if (mode === 'path') return formattedPath(target.path)
- if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
- if (mode === 'json') return JSON.stringify(target.value)
- if (typeof target.value === 'string') return target.value
- if (typeof target.value === 'undefined') return 'undefined'
- if (typeof target.value === 'bigint') return target.value.toString()
- if (typeof target.value === 'symbol') return target.value.description ?? 'Symbol'
- if (typeof target.value === 'function') return target.value.name || 'Function'
- return JSON.stringify(target.value)
- }
- /** Props for the read-only, token-themed JSON tree. */
- export interface JsonTreeProps {
- /** Parsed JSON object or array. */
- data: object | unknown[]
- /** Accessible label for the tree. */
- label: string
- /** Optional positioning class owned by the caller. */
- className?: string | undefined
- /** Whether JSON rows expose copy actions. */
- copyable?: boolean
- /** Whether the top-level object or array is always expanded. */
- expandTopLevel?: boolean
- /** Localized display copy supplied by the owning render site. */
- labels: JsonTreeLabels
- }
- /**
- * Render parsed JSON as a compact, keyboard-accessible inspector tree.
- * @param props - Parsed data, accessible label, and display options.
- * @returns A read-only JSON tree with an optionally fixed-open top level.
- */
- export function JsonTree({
- data,
- label,
- className,
- copyable = true,
- expandTopLevel = true,
- labels,
- }: JsonTreeProps) {
- const rootEntries = entriesOf(data)
- const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
- isExpandableValue(value) && entriesOf(value).length > 0
- ))
- const firstExpandableEntry = rootEntries[firstExpandableIndex]
- const initialTabStopId = expandTopLevel
- ? firstExpandableEntry === undefined
- ? null
- : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
- : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
- const rootRef = useRef<HTMLDivElement>(null)
- const activeRowRef = useRef<HTMLElement>()
- const copyButtonRef = useRef<HTMLButtonElement>(null)
- const copyMenuOpenRef = useRef(false)
- const resetTimer = useRef<ReturnType<typeof setTimeout>>()
- const [copyTarget, setCopyTarget] = useState<CopyTarget>()
- const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
- const [copyMenuOpen, setCopyMenuOpen] = useState(false)
- const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
- const setActiveRow = (row: HTMLElement | undefined) => {
- activeRowRef.current?.removeAttribute('data-json-copy-active')
- activeRowRef.current = row
- row?.setAttribute('data-json-copy-active', '')
- }
- const clearCopyTarget = () => {
- setActiveRow(undefined)
- setCopyTarget(undefined)
- setCopyState('idle')
- copyMenuOpenRef.current = false
- setCopyMenuOpen(false)
- }
- const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
- const root = rootRef.current
- /* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
- if (root === null) throw new Error('JsonTree root is not mounted')
- const rootRect = root.getBoundingClientRect()
- const rowRect = row.getBoundingClientRect()
- return {
- left: rootRect.left + root.clientWidth - 26,
- side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
- top: rowRect.top,
- }
- }
- const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
- const position = copyPosition(row)
- setCopyTarget({ ...target, ...position })
- }
- const repositionCopyButton = (row: HTMLElement) => {
- const position = copyPosition(row)
- setCopyTarget((current) => {
- /* v8 ignore next -- an active row and its copy target are installed together. */
- if (current === undefined) return current
- return { ...current, ...position }
- })
- }
- useEffect(() => () => {
- if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
- activeRowRef.current?.removeAttribute('data-json-copy-active')
- }, [])
- useEffect(() => {
- activeRowRef.current?.removeAttribute('data-json-copy-active')
- activeRowRef.current = undefined
- copyMenuOpenRef.current = false
- setCopyTarget(undefined)
- setCopyState('idle')
- setCopyMenuOpen(false)
- setTabStopId(initialTabStopId)
- }, [data, expandTopLevel, initialTabStopId])
- useEffect(() => {
- const reposition = () => {
- const row = activeRowRef.current
- if (row !== undefined) repositionCopyButton(row)
- }
- window.addEventListener('scroll', reposition, true)
- window.addEventListener('resize', reposition)
- return () => {
- window.removeEventListener('scroll', reposition, true)
- window.removeEventListener('resize', reposition)
- }
- }, [])
- const handleRowHover = (row: HTMLElement, target: RowTarget) => {
- if (!copyable || copyMenuOpenRef.current) return
- if (activeRowRef.current === row) return
- setActiveRow(row)
- setCopyState('idle')
- copyMenuOpenRef.current = false
- setCopyMenuOpen(false)
- positionCopyButton(row, target)
- }
- const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
- if (!copyable || copyMenuOpenRef.current) return
- /* v8 ignore next -- browser mouse events delivered through React target an Element. */
- if (!(event.target instanceof Element)) return
- if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
- }
- const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
- const row = activeRowRef.current
- if (row !== undefined) repositionCopyButton(row)
- }
- const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
- /* v8 ignore next -- copy controls only render while their target exists. */
- if (copyTarget === undefined) return
- try {
- await navigator.clipboard.writeText(copyText(copyTarget, mode))
- setCopyState('copied')
- } catch {
- setCopyState('failed')
- }
- if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
- resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
- }
- const [rootOpen, rootClose] = bracketOf(data)
- const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
- const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
- const copyTitle = copyState === 'copied'
- ? labels.copied
- : copyState === 'failed'
- ? labels.copyFailed
- : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue
- return (
- <div
- ref={rootRef}
- className={clsx(css.root, className)}
- onMouseOver={handleRootMouseOver}
- onMouseLeave={() => {
- if (!copyMenuOpenRef.current) clearCopyTarget()
- }}
- onScroll={handleScroll}
- >
- {expandTopLevel
- ? (
- <div className={css.expandedTopLevel}>
- <div
- className={clsx(css.row, css.topLevelBracket)}
- data-json-root-row
- onMouseOver={(event) => {
- event.stopPropagation()
- handleRowHover(event.currentTarget, { path: [], value: data })
- }}
- >
- <span className={css.punctuation}>{rootOpen}</span>
- </div>
- <div
- aria-label={label}
- className={clsx(css.container, css.expandedTopLevelContainer)}
- role="tree"
- >
- {rootEntries.map(([key, value], index) => (
- <JsonTreeNode
- key={key}
- field={key}
- value={value}
- path={[Array.isArray(data) ? index : key]}
- labels={labels}
- lastElement={index === rootEntries.length - 1}
- initialExpanded={false}
- tabStopId={tabStopId}
- onClaimTabStop={setTabStopId}
- onRowHover={handleRowHover}
- />
- ))}
- </div>
- <div className={clsx(css.row, css.topLevelBracket)}>
- <span className={css.punctuation}>{rootClose}</span>
- </div>
- </div>
- )
- : (
- <div aria-label={label} className={css.container} role="tree">
- <JsonTreeNode
- value={data}
- path={[]}
- labels={labels}
- lastElement
- initialExpanded
- tabStopId={tabStopId}
- onClaimTabStop={setTabStopId}
- onRowHover={handleRowHover}
- />
- </div>
- )}
- {copyTarget !== undefined && (
- <span
- className={css.copyAnchor}
- style={{ left: copyTarget.left, top: copyTarget.top }}
- >
- <Menu
- open={copyMenuOpen}
- compact
- portal
- align="end"
- side={copyTarget.side}
- anchor={(
- <button
- ref={copyButtonRef}
- type="button"
- className={css.copyButton}
- data-json-copy-button
- data-state={copyState}
- aria-label={copyTitle}
- title={labels.copyButtonTitle(copyTitle)}
- onClick={() => void copy(defaultCopyMode)}
- onContextMenu={(event) => {
- event.preventDefault()
- event.stopPropagation()
- copyMenuOpenRef.current = true
- setCopyMenuOpen(true)
- }}
- >
- {copyState === 'copied'
- ? <IconCheckOutline16 size={12} />
- : <IconCopyOutline16 size={12} />}
- </button>
- )}
- items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
- onSelect={(id) => {
- void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
- copyMenuOpenRef.current = false
- setCopyMenuOpen(false)
- }}
- onClose={clearCopyTarget}
- getAnchorRect={() => (
- copyButtonRef.current as HTMLButtonElement
- ).getBoundingClientRect()}
- />
- </span>
- )}
- </div>
- )
- }
|