JsonTree.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. import clsx from 'clsx'
  2. import { useEffect, useId, useRef, useState } from 'react'
  3. import type {
  4. KeyboardEvent as ReactKeyboardEvent,
  5. MouseEvent as ReactMouseEvent,
  6. ReactNode,
  7. UIEvent as ReactUIEvent,
  8. } from 'react'
  9. import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
  10. import { Menu } from './Menu.tsx'
  11. import type { MenuEntry } from './Menu.tsx'
  12. import css from './JsonTree.module.css'
  13. const OBJECT_PREVIEW_LIMIT = 4
  14. const ARRAY_PREVIEW_LIMIT = 5
  15. const PREVIEW_DEPTH_LIMIT = 2
  16. /**
  17. * Display copy for the tree's copy affordance; the owner passes localized
  18. * labels (this package is cordis-free, so copy arrives via props).
  19. */
  20. export interface JsonTreeLabels {
  21. /** Menu item: copy the raw primitive value. */
  22. copyValue: string
  23. /** Menu item: copy the value as compact JSON (primitive rows). */
  24. copyJson: string
  25. /** Menu item: copy the property path. */
  26. copyPath: string
  27. /** Menu item: copy the value as pretty-printed JSON. */
  28. copyPrettyJson: string
  29. /** Menu item: copy the value as compact JSON (object rows). */
  30. copyCompactJson: string
  31. /** Copy-button state label after a successful copy. */
  32. copied: string
  33. /** Copy-button state label after a failed copy. */
  34. copyFailed: string
  35. /** Expander aria label while expanded. */
  36. collapseNode: string
  37. /** Expander aria label while collapsed. */
  38. expandNode: string
  39. /** Copy-button tooltip, given the current action label. */
  40. copyButtonTitle: (action: string) => string
  41. }
  42. function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
  43. return [
  44. { id: 'value', label: labels.copyValue },
  45. { id: 'json', label: labels.copyJson },
  46. { id: 'path', label: labels.copyPath },
  47. ]
  48. }
  49. function objectCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
  50. return [
  51. { id: 'prettyJson', label: labels.copyPrettyJson },
  52. { id: 'json', label: labels.copyCompactJson },
  53. { id: 'path', label: labels.copyPath },
  54. ]
  55. }
  56. type JsonPath = readonly (number | string)[]
  57. interface RowTarget {
  58. path: JsonPath
  59. value: unknown
  60. }
  61. interface CopyTarget extends RowTarget {
  62. left: number
  63. side: 'bottom' | 'top'
  64. top: number
  65. }
  66. function isExpandableValue(value: unknown): value is object | unknown[] {
  67. return typeof value === 'object' && value !== null && !(value instanceof Date)
  68. }
  69. function entriesOf(value: object | unknown[]): readonly (readonly [string, unknown])[] {
  70. if (Array.isArray(value)) {
  71. return value.map((item, index) => [String(index), item] as const)
  72. }
  73. return Object.keys(value).map(key => [
  74. key,
  75. (value as Record<string, unknown>)[key],
  76. ] as const)
  77. }
  78. function bracketOf(value: object | unknown[]): readonly [string, string] {
  79. return Array.isArray(value) ? ['[', ']'] : ['{', '}']
  80. }
  81. function previewPrimitive(value: unknown): ReactNode {
  82. if (value === null) return <span className={css.keywordValue}>null</span>
  83. if (typeof value === 'string') {
  84. return <span className={css.stringValue}>{JSON.stringify(value)}</span>
  85. }
  86. if (typeof value === 'number') {
  87. return <span className={css.numberValue}>{String(value)}</span>
  88. }
  89. if (typeof value === 'boolean') {
  90. return <span className={css.keywordValue}>{String(value)}</span>
  91. }
  92. if (typeof value === 'bigint') {
  93. return <span className={css.otherValue}>{value.toString()}</span>
  94. }
  95. if (typeof value === 'undefined') {
  96. return <span className={css.otherValue}>undefined</span>
  97. }
  98. if (typeof value === 'symbol') {
  99. return <span className={css.otherValue}>{value.description ?? 'Symbol'}</span>
  100. }
  101. if (typeof value === 'function') {
  102. return <span className={css.otherValue}>{value.name || 'Function'}</span>
  103. }
  104. return null
  105. }
  106. function previewValue(value: unknown, depth: number): ReactNode {
  107. if (!isExpandableValue(value)) return previewPrimitive(value)
  108. const array = Array.isArray(value)
  109. const entries = entriesOf(value)
  110. const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
  111. const visible = entries.slice(0, limit)
  112. const [open, close] = bracketOf(value)
  113. return (
  114. <>
  115. <span className={css.punctuation}>{open}</span>
  116. {depth >= PREVIEW_DEPTH_LIMIT
  117. ? <span className={css.previewEllipsis}>…</span>
  118. : visible.map(([key, item], index) => (
  119. <span key={key}>
  120. {index > 0 && <span className={css.punctuation}>, </span>}
  121. {!array && (
  122. <>
  123. <span className={css.previewProperty}>{key}</span>
  124. <span className={css.punctuation}>: </span>
  125. </>
  126. )}
  127. {previewValue(item, depth + 1)}
  128. </span>
  129. ))}
  130. {depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
  131. <span className={css.previewEllipsis}>, …</span>
  132. )}
  133. <span className={css.punctuation}>{close}</span>
  134. </>
  135. )
  136. }
  137. function primitiveValue(value: unknown): ReactNode {
  138. if (value === null) return <span className={css.keywordValue}>null</span>
  139. if (typeof value === 'string') {
  140. return <span className={css.stringValue}>{JSON.stringify(value)}</span>
  141. }
  142. if (typeof value === 'boolean') {
  143. return <span className={css.keywordValue}>{String(value)}</span>
  144. }
  145. if (typeof value === 'number') {
  146. return <span className={css.numberValue}>{String(value)}</span>
  147. }
  148. if (typeof value === 'bigint') {
  149. return <span className={css.numberValue}>{`${value.toString()}n`}</span>
  150. }
  151. if (value instanceof Date) {
  152. return <span className={css.otherValue}>{value.toISOString()}</span>
  153. }
  154. if (typeof value === 'function') {
  155. return <span className={css.otherValue}>function() {'{ }'}</span>
  156. }
  157. if (typeof value === 'undefined') {
  158. return <span className={css.otherValue}>undefined</span>
  159. }
  160. return <span className={css.otherValue}>{(value as symbol).toString()}</span>
  161. }
  162. function fieldText(field: string): string {
  163. return field === '' ? '""' : field
  164. }
  165. function pathId(path: JsonPath): string {
  166. return path.map(part => (
  167. typeof part === 'number' ? `n${String(part)}` : `s${String(part.length)}:${part}`
  168. )).join('/')
  169. }
  170. function claimFocus(button: HTMLElement): void {
  171. button.focus()
  172. }
  173. function moveFocus(button: HTMLElement, direction: -1 | 1): void {
  174. const tree = button.closest<HTMLElement>('[role="tree"]')
  175. /* v8 ignore next -- JsonTree attaches expander handlers only beneath its owning role=tree. */
  176. if (tree === null) return
  177. const expanders = Array.from(tree.querySelectorAll<HTMLElement>('[data-json-expander]'))
  178. const current = expanders.indexOf(button)
  179. /* v8 ignore next -- the current expander is a member of the queried non-empty set. */
  180. if (current < 0 || expanders.length === 0) return
  181. const next = (current + direction + expanders.length) % expanders.length
  182. const nextExpander = expanders[next]
  183. /* v8 ignore next -- modulo over the non-empty expander set always resolves a member. */
  184. if (nextExpander !== undefined) claimFocus(nextExpander)
  185. }
  186. function NodeField({
  187. field,
  188. expandable,
  189. onToggle,
  190. }: {
  191. field: string | undefined
  192. expandable: boolean
  193. onToggle: () => void
  194. }) {
  195. if (field === undefined) return null
  196. return (
  197. <span
  198. className={clsx(css.label, expandable && css.clickableLabel)}
  199. onClick={expandable ? onToggle : undefined}
  200. >
  201. {fieldText(field)}:
  202. </span>
  203. )
  204. }
  205. interface JsonTreeNodeProps {
  206. field?: string
  207. initialExpanded: boolean
  208. labels: JsonTreeLabels
  209. lastElement: boolean
  210. onClaimTabStop: (id: string) => void
  211. onRowHover: (row: HTMLElement, target: RowTarget) => void
  212. path: JsonPath
  213. tabStopId: string | null
  214. value: unknown
  215. }
  216. function JsonTreeNode({
  217. field,
  218. initialExpanded,
  219. labels,
  220. lastElement,
  221. onClaimTabStop,
  222. onRowHover,
  223. path,
  224. tabStopId,
  225. value,
  226. }: JsonTreeNodeProps) {
  227. const contentsId = useId()
  228. const expanderRef = useRef<HTMLSpanElement>(null)
  229. const [expanded, setExpanded] = useState(initialExpanded)
  230. const nodeId = pathId(path)
  231. const container = isExpandableValue(value)
  232. const entries = container ? entriesOf(value) : []
  233. const expandable = entries.length > 0
  234. const toggle = () => {
  235. setExpanded(current => !current)
  236. claimFocus(expanderRef.current as HTMLSpanElement)
  237. }
  238. const onExpanderKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
  239. if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
  240. event.preventDefault()
  241. setExpanded(event.key === 'ArrowRight')
  242. return
  243. }
  244. if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
  245. event.preventDefault()
  246. moveFocus(event.currentTarget, event.key === 'ArrowUp' ? -1 : 1)
  247. }
  248. }
  249. const row = (children: ReactNode, ariaExpanded?: boolean) => (
  250. <div
  251. className={css.row}
  252. role="treeitem"
  253. aria-expanded={ariaExpanded}
  254. onMouseOver={(event) => {
  255. event.stopPropagation()
  256. onRowHover(event.currentTarget, { path, value })
  257. }}
  258. >
  259. {children}
  260. </div>
  261. )
  262. if (!container) {
  263. return row((
  264. <>
  265. <NodeField field={field} expandable={false} onToggle={toggle} />
  266. {primitiveValue(value)}
  267. {!lastElement && <span className={css.punctuation}>,</span>}
  268. </>
  269. ))
  270. }
  271. const [open, close] = bracketOf(value)
  272. if (!expandable) {
  273. return row((
  274. <>
  275. <NodeField field={field} expandable={false} onToggle={toggle} />
  276. <span className={css.punctuation}>{open}</span>
  277. <span className={css.punctuation}>{close}</span>
  278. {!lastElement && <span className={css.punctuation}>,</span>}
  279. </>
  280. ))
  281. }
  282. return row((
  283. <>
  284. <span
  285. ref={expanderRef}
  286. className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
  287. data-json-expander
  288. role="button"
  289. aria-label={expanded ? labels.collapseNode : labels.expandNode}
  290. aria-expanded={expanded}
  291. aria-controls={expanded ? contentsId : undefined}
  292. tabIndex={tabStopId === nodeId ? 0 : -1}
  293. onFocus={() => { onClaimTabStop(nodeId) }}
  294. onClick={toggle}
  295. onKeyDown={onExpanderKeyDown}
  296. />
  297. <NodeField field={field} expandable onToggle={toggle} />
  298. <span className={css.preview}>{previewValue(value, 0)}</span>
  299. {!lastElement && <span className={css.punctuation}>,</span>}
  300. {expanded && (
  301. <ul id={contentsId} role="group" className={css.children}>
  302. {entries.map(([key, item], index) => (
  303. <JsonTreeNode
  304. key={key}
  305. field={key}
  306. value={item}
  307. path={[...path, Array.isArray(value) ? index : key]}
  308. labels={labels}
  309. lastElement={index === entries.length - 1}
  310. initialExpanded={false}
  311. tabStopId={tabStopId}
  312. onClaimTabStop={onClaimTabStop}
  313. onRowHover={onRowHover}
  314. />
  315. ))}
  316. </ul>
  317. )}
  318. </>
  319. ), expanded)
  320. }
  321. function formattedPath(path: JsonPath): string {
  322. return path.reduce<string>((result, part) => {
  323. if (typeof part === 'number') return `${result}[${String(part)}]`
  324. return /^[A-Za-z_$][\w$]*$/.test(part)
  325. ? `${result}.${part}`
  326. : `${result}[${JSON.stringify(part)}]`
  327. }, '$')
  328. }
  329. function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
  330. if (mode === 'path') return formattedPath(target.path)
  331. if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
  332. if (mode === 'json') return JSON.stringify(target.value)
  333. if (typeof target.value === 'string') return target.value
  334. if (typeof target.value === 'undefined') return 'undefined'
  335. if (typeof target.value === 'bigint') return target.value.toString()
  336. if (typeof target.value === 'symbol') return target.value.description ?? 'Symbol'
  337. if (typeof target.value === 'function') return target.value.name || 'Function'
  338. return JSON.stringify(target.value)
  339. }
  340. /** Props for the read-only, token-themed JSON tree. */
  341. export interface JsonTreeProps {
  342. /** Parsed JSON object or array. */
  343. data: object | unknown[]
  344. /** Accessible label for the tree. */
  345. label: string
  346. /** Optional positioning class owned by the caller. */
  347. className?: string | undefined
  348. /** Whether JSON rows expose copy actions. */
  349. copyable?: boolean
  350. /** Whether the top-level object or array is always expanded. */
  351. expandTopLevel?: boolean
  352. /** Localized display copy supplied by the owning render site. */
  353. labels: JsonTreeLabels
  354. }
  355. /**
  356. * Render parsed JSON as a compact, keyboard-accessible inspector tree.
  357. * @param props - Parsed data, accessible label, and display options.
  358. * @returns A read-only JSON tree with an optionally fixed-open top level.
  359. */
  360. export function JsonTree({
  361. data,
  362. label,
  363. className,
  364. copyable = true,
  365. expandTopLevel = true,
  366. labels,
  367. }: JsonTreeProps) {
  368. const rootEntries = entriesOf(data)
  369. const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
  370. isExpandableValue(value) && entriesOf(value).length > 0
  371. ))
  372. const firstExpandableEntry = rootEntries[firstExpandableIndex]
  373. const initialTabStopId = expandTopLevel
  374. ? firstExpandableEntry === undefined
  375. ? null
  376. : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
  377. : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
  378. const rootRef = useRef<HTMLDivElement>(null)
  379. const activeRowRef = useRef<HTMLElement>()
  380. const copyButtonRef = useRef<HTMLButtonElement>(null)
  381. const copyMenuOpenRef = useRef(false)
  382. const resetTimer = useRef<ReturnType<typeof setTimeout>>()
  383. const [copyTarget, setCopyTarget] = useState<CopyTarget>()
  384. const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
  385. const [copyMenuOpen, setCopyMenuOpen] = useState(false)
  386. const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
  387. const setActiveRow = (row: HTMLElement | undefined) => {
  388. activeRowRef.current?.removeAttribute('data-json-copy-active')
  389. activeRowRef.current = row
  390. row?.setAttribute('data-json-copy-active', '')
  391. }
  392. const clearCopyTarget = () => {
  393. setActiveRow(undefined)
  394. setCopyTarget(undefined)
  395. setCopyState('idle')
  396. copyMenuOpenRef.current = false
  397. setCopyMenuOpen(false)
  398. }
  399. const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
  400. const root = rootRef.current
  401. /* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
  402. if (root === null) throw new Error('JsonTree root is not mounted')
  403. const rootRect = root.getBoundingClientRect()
  404. const rowRect = row.getBoundingClientRect()
  405. return {
  406. left: rootRect.left + root.clientWidth - 26,
  407. side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
  408. top: rowRect.top,
  409. }
  410. }
  411. const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
  412. const position = copyPosition(row)
  413. setCopyTarget({ ...target, ...position })
  414. }
  415. const repositionCopyButton = (row: HTMLElement) => {
  416. const position = copyPosition(row)
  417. setCopyTarget((current) => {
  418. /* v8 ignore next -- an active row and its copy target are installed together. */
  419. if (current === undefined) return current
  420. return { ...current, ...position }
  421. })
  422. }
  423. useEffect(() => () => {
  424. if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
  425. activeRowRef.current?.removeAttribute('data-json-copy-active')
  426. }, [])
  427. useEffect(() => {
  428. activeRowRef.current?.removeAttribute('data-json-copy-active')
  429. activeRowRef.current = undefined
  430. copyMenuOpenRef.current = false
  431. setCopyTarget(undefined)
  432. setCopyState('idle')
  433. setCopyMenuOpen(false)
  434. setTabStopId(initialTabStopId)
  435. }, [data, expandTopLevel, initialTabStopId])
  436. useEffect(() => {
  437. const reposition = () => {
  438. const row = activeRowRef.current
  439. if (row !== undefined) repositionCopyButton(row)
  440. }
  441. window.addEventListener('scroll', reposition, true)
  442. window.addEventListener('resize', reposition)
  443. return () => {
  444. window.removeEventListener('scroll', reposition, true)
  445. window.removeEventListener('resize', reposition)
  446. }
  447. }, [])
  448. const handleRowHover = (row: HTMLElement, target: RowTarget) => {
  449. if (!copyable || copyMenuOpenRef.current) return
  450. if (activeRowRef.current === row) return
  451. setActiveRow(row)
  452. setCopyState('idle')
  453. copyMenuOpenRef.current = false
  454. setCopyMenuOpen(false)
  455. positionCopyButton(row, target)
  456. }
  457. const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
  458. if (!copyable || copyMenuOpenRef.current) return
  459. /* v8 ignore next -- browser mouse events delivered through React target an Element. */
  460. if (!(event.target instanceof Element)) return
  461. if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
  462. }
  463. const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
  464. const row = activeRowRef.current
  465. if (row !== undefined) repositionCopyButton(row)
  466. }
  467. const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
  468. /* v8 ignore next -- copy controls only render while their target exists. */
  469. if (copyTarget === undefined) return
  470. try {
  471. await navigator.clipboard.writeText(copyText(copyTarget, mode))
  472. setCopyState('copied')
  473. } catch {
  474. setCopyState('failed')
  475. }
  476. if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
  477. resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
  478. }
  479. const [rootOpen, rootClose] = bracketOf(data)
  480. const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
  481. const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
  482. const copyTitle = copyState === 'copied'
  483. ? labels.copied
  484. : copyState === 'failed'
  485. ? labels.copyFailed
  486. : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue
  487. return (
  488. <div
  489. ref={rootRef}
  490. className={clsx(css.root, className)}
  491. onMouseOver={handleRootMouseOver}
  492. onMouseLeave={() => {
  493. if (!copyMenuOpenRef.current) clearCopyTarget()
  494. }}
  495. onScroll={handleScroll}
  496. >
  497. {expandTopLevel
  498. ? (
  499. <div className={css.expandedTopLevel}>
  500. <div
  501. className={clsx(css.row, css.topLevelBracket)}
  502. data-json-root-row
  503. onMouseOver={(event) => {
  504. event.stopPropagation()
  505. handleRowHover(event.currentTarget, { path: [], value: data })
  506. }}
  507. >
  508. <span className={css.punctuation}>{rootOpen}</span>
  509. </div>
  510. <div
  511. aria-label={label}
  512. className={clsx(css.container, css.expandedTopLevelContainer)}
  513. role="tree"
  514. >
  515. {rootEntries.map(([key, value], index) => (
  516. <JsonTreeNode
  517. key={key}
  518. field={key}
  519. value={value}
  520. path={[Array.isArray(data) ? index : key]}
  521. labels={labels}
  522. lastElement={index === rootEntries.length - 1}
  523. initialExpanded={false}
  524. tabStopId={tabStopId}
  525. onClaimTabStop={setTabStopId}
  526. onRowHover={handleRowHover}
  527. />
  528. ))}
  529. </div>
  530. <div className={clsx(css.row, css.topLevelBracket)}>
  531. <span className={css.punctuation}>{rootClose}</span>
  532. </div>
  533. </div>
  534. )
  535. : (
  536. <div aria-label={label} className={css.container} role="tree">
  537. <JsonTreeNode
  538. value={data}
  539. path={[]}
  540. labels={labels}
  541. lastElement
  542. initialExpanded
  543. tabStopId={tabStopId}
  544. onClaimTabStop={setTabStopId}
  545. onRowHover={handleRowHover}
  546. />
  547. </div>
  548. )}
  549. {copyTarget !== undefined && (
  550. <span
  551. className={css.copyAnchor}
  552. style={{ left: copyTarget.left, top: copyTarget.top }}
  553. >
  554. <Menu
  555. open={copyMenuOpen}
  556. compact
  557. portal
  558. align="end"
  559. side={copyTarget.side}
  560. anchor={(
  561. <button
  562. ref={copyButtonRef}
  563. type="button"
  564. className={css.copyButton}
  565. data-json-copy-button
  566. data-state={copyState}
  567. aria-label={copyTitle}
  568. title={labels.copyButtonTitle(copyTitle)}
  569. onClick={() => void copy(defaultCopyMode)}
  570. onContextMenu={(event) => {
  571. event.preventDefault()
  572. event.stopPropagation()
  573. copyMenuOpenRef.current = true
  574. setCopyMenuOpen(true)
  575. }}
  576. >
  577. {copyState === 'copied'
  578. ? <IconCheckOutline16 size={12} />
  579. : <IconCopyOutline16 size={12} />}
  580. </button>
  581. )}
  582. items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
  583. onSelect={(id) => {
  584. void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
  585. copyMenuOpenRef.current = false
  586. setCopyMenuOpen(false)
  587. }}
  588. onClose={clearCopyTarget}
  589. getAnchorRect={() => (
  590. copyButtonRef.current as HTMLButtonElement
  591. ).getBoundingClientRect()}
  592. />
  593. </span>
  594. )}
  595. </div>
  596. )
  597. }