fix(client): isolate JSON copy feedback and guard string measurement
This commit is contained in:
1 parent
573b044320
commit
e7fc4e8fdc
5 files changed
+254
-99
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: d783a2d4eaaac887c5357fe30efc5e47249066be
|
||||
README.zh.md: 33dc17db52e7263aa6aab81549a3b95f7dbfc59d
|
||||
README.md: 888662a9e1150b3cf410f2f8202d0f27cba0e961
|
||||
README.zh.md: c1d5a4c45ff364925bb66506c0ea1bf8dae0df5d
|
||||
@@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio
|
||||
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
|
||||
|
||||
|
||||
`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
|
||||
|
||||
### Localizing copy
|
||||
|
||||
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
|
||||
|
||||
@@ -74,6 +74,8 @@ kind: "package-library"
|
||||
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
|
||||
|
||||
|
||||
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
|
||||
|
||||
### 本地化文案
|
||||
|
||||
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import type {
|
||||
CSSProperties,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
@@ -65,6 +65,105 @@ interface RowTarget {
|
||||
value: unknown
|
||||
}
|
||||
|
||||
type CopyMode = 'json' | 'path' | 'prettyJson' | 'value'
|
||||
|
||||
interface CopySnapshot {
|
||||
id: string
|
||||
target: RowTarget
|
||||
state: 'idle' | 'copied' | 'failed'
|
||||
menuOpen: boolean
|
||||
}
|
||||
|
||||
/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */
|
||||
function createCopyStore() {
|
||||
let current: CopySnapshot | undefined
|
||||
const listeners = new Map<string, Set<() => void>>()
|
||||
return {
|
||||
get: () => current,
|
||||
set(next: CopySnapshot | undefined) {
|
||||
const previous = current?.id
|
||||
current = next
|
||||
for (const id of new Set([previous, next?.id])) {
|
||||
if (id === undefined) continue
|
||||
for (const listener of listeners.get(id) ?? []) listener()
|
||||
}
|
||||
},
|
||||
subscribe(id: string, listener: () => void) {
|
||||
let row = listeners.get(id)
|
||||
if (row === undefined) listeners.set(id, row = new Set())
|
||||
row.add(listener)
|
||||
return () => {
|
||||
row.delete(listener)
|
||||
if (row.size === 0) listeners.delete(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: {
|
||||
store: ReturnType<typeof createCopyStore>
|
||||
target: RowTarget
|
||||
persistent: boolean
|
||||
labels: JsonTreeLabels
|
||||
onCopy: (target: RowTarget, mode: CopyMode) => Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const id = pathId(target.path)
|
||||
const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store])
|
||||
const getSnapshot = () => {
|
||||
const current = store.get()
|
||||
return current?.id === id ? current : undefined
|
||||
}
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const state = snapshot?.state ?? 'idle'
|
||||
const object = typeof target.value === 'object' && target.value !== null
|
||||
const copyTitle = state === 'copied'
|
||||
? labels.copied
|
||||
: state === 'failed'
|
||||
? labels.copyFailed
|
||||
: object ? labels.copyPrettyJson : labels.copyValue
|
||||
return (
|
||||
<span className={css.copySlot}>
|
||||
{(persistent || snapshot !== undefined) && (
|
||||
<Menu
|
||||
open={snapshot?.menuOpen === true}
|
||||
compact
|
||||
portal
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={css.actionButton}
|
||||
data-json-copy-button
|
||||
data-state={state}
|
||||
aria-label={copyTitle}
|
||||
title={labels.copyButtonTitle(copyTitle)}
|
||||
onClick={() => void onCopy(target, object ? 'prettyJson' : 'value')}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
store.set({ id, target, state, menuOpen: true })
|
||||
}}
|
||||
>
|
||||
{state === 'copied'
|
||||
? <IconCheckOutline16 size={12} />
|
||||
: <IconCopyOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
|
||||
onSelect={(mode) => {
|
||||
void onCopy(target, mode as CopyMode)
|
||||
}}
|
||||
onClose={onClose}
|
||||
getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function isExpandableValue(value: unknown): value is object | unknown[] {
|
||||
return typeof value === 'object' && value !== null && !(value instanceof Date)
|
||||
}
|
||||
@@ -273,6 +372,8 @@ function JsonString({
|
||||
useLayoutEffect(() => {
|
||||
if (!expanded) return
|
||||
const raw = rawRef.current as HTMLPreElement
|
||||
// Keep raw text within the window and clipping ancestors outside the tree.
|
||||
// Capture scrolling because an ancestor can move the string without resizing it.
|
||||
const clips: HTMLElement[] = []
|
||||
const tree = raw.closest<HTMLElement>(`.${css.root}`) as HTMLElement
|
||||
for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) {
|
||||
@@ -292,13 +393,13 @@ function JsonString({
|
||||
raw.style.maxHeight = `${Math.max(16, available - 4)}px`
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(raw)
|
||||
for (const clip of clips) observer.observe(clip)
|
||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
|
||||
observer?.observe(raw)
|
||||
for (const clip of clips) observer?.observe(clip)
|
||||
window.addEventListener('resize', measure)
|
||||
window.addEventListener('scroll', measure, true)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
observer?.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
window.removeEventListener('scroll', measure, true)
|
||||
}
|
||||
@@ -319,6 +420,7 @@ function JsonString({
|
||||
>
|
||||
{value}
|
||||
</pre>
|
||||
{!lastElement && <span className={css.punctuation}>,</span>}
|
||||
<div className={css.stringActions}>
|
||||
{stringWrapping !== undefined && (
|
||||
<button
|
||||
@@ -539,7 +641,7 @@ function formattedPath(path: JsonPath): string {
|
||||
}, '$')
|
||||
}
|
||||
|
||||
function copyText(target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
|
||||
function copyText(target: RowTarget, mode: CopyMode): 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)
|
||||
@@ -604,12 +706,9 @@ export function JsonTree({
|
||||
: pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
|
||||
: isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
|
||||
const activeRowRef = useRef<HTMLElement>()
|
||||
const copyButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
const copyMenuOpenRef = useRef(false)
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const [copyTarget, setCopyTarget] = useState<RowTarget>()
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
|
||||
const copySequence = useRef(0)
|
||||
const [copyStore] = useState(createCopyStore)
|
||||
const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
|
||||
|
||||
const setActiveRow = (row: HTMLElement | undefined) => {
|
||||
@@ -619,113 +718,65 @@ export function JsonTree({
|
||||
}
|
||||
|
||||
const clearCopyTarget = () => {
|
||||
copySequence.current += 1
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
setActiveRow(undefined)
|
||||
setCopyTarget(undefined)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
copyStore.set(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
copySequence.current += 1
|
||||
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)
|
||||
clearCopyTarget()
|
||||
setTabStopId(initialTabStopId)
|
||||
}, [data, expandTopLevel, initialTabStopId])
|
||||
|
||||
const handleRowHover = (row: HTMLElement, target: RowTarget) => {
|
||||
if (!copyable || copyMenuOpenRef.current) return
|
||||
if (!copyable || copyStore.get()?.menuOpen) return
|
||||
if (activeRowRef.current === row) return
|
||||
setActiveRow(row)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
setCopyTarget(target)
|
||||
copyStore.set({ id: pathId(target.path), target, state: 'idle', menuOpen: false })
|
||||
}
|
||||
|
||||
const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!copyable || copyMenuOpenRef.current) return
|
||||
if (!copyable || copyStore.get()?.menuOpen) 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 copy = async (target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value') => {
|
||||
setCopyTarget(target)
|
||||
const copy = async (target: RowTarget, mode: CopyMode) => {
|
||||
const sequence = ++copySequence.current
|
||||
const snapshot: CopySnapshot = {
|
||||
id: pathId(target.path), target, state: 'idle', menuOpen: false,
|
||||
}
|
||||
copyStore.set(snapshot)
|
||||
let state: CopySnapshot['state']
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText(target, mode))
|
||||
setCopyState('copied')
|
||||
state = 'copied'
|
||||
} catch {
|
||||
setCopyState('failed')
|
||||
state = 'failed'
|
||||
}
|
||||
const current = copyStore.get()
|
||||
if (sequence !== copySequence.current || current?.target !== target) return
|
||||
copyStore.set({ ...current, state })
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
|
||||
resetTimer.current = setTimeout(() => {
|
||||
const current = copyStore.get()
|
||||
if (current?.target === target) copyStore.set({ ...current, state: 'idle' })
|
||||
}, 1_500)
|
||||
}
|
||||
|
||||
const [rootOpen, rootClose] = bracketOf(data)
|
||||
const renderCopy = copyable ? (target: RowTarget, persistent = false) => {
|
||||
const active = copyTarget !== undefined && pathId(copyTarget.path) === pathId(target.path)
|
||||
const state = active ? copyState : 'idle'
|
||||
const object = typeof target.value === 'object' && target.value !== null
|
||||
const copyTitle = state === 'copied'
|
||||
? labels.copied
|
||||
: state === 'failed'
|
||||
? labels.copyFailed
|
||||
: object ? labels.copyPrettyJson : labels.copyValue
|
||||
return (
|
||||
<span className={css.copySlot}>
|
||||
{(persistent || active) && (
|
||||
<Menu
|
||||
open={active && copyMenuOpen}
|
||||
compact
|
||||
portal
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.actionButton}
|
||||
data-json-copy-button
|
||||
data-state={state}
|
||||
aria-label={copyTitle}
|
||||
title={labels.copyButtonTitle(copyTitle)}
|
||||
onClick={() => void copy(target, object ? 'prettyJson' : 'value')}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
copyButtonRef.current = event.currentTarget
|
||||
setCopyTarget(target)
|
||||
copyMenuOpenRef.current = true
|
||||
setCopyMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{state === 'copied'
|
||||
? <IconCheckOutline16 size={12} />
|
||||
: <IconCopyOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
|
||||
onSelect={(id) => {
|
||||
void copy(target, id as 'json' | 'path' | 'prettyJson' | 'value')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}}
|
||||
onClose={clearCopyTarget}
|
||||
getAnchorRect={() => (
|
||||
copyButtonRef.current as HTMLButtonElement
|
||||
).getBoundingClientRect()}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
} : undefined
|
||||
const renderCopy = copyable ? (target: RowTarget, persistent = false) => (
|
||||
<JsonCopyAction store={copyStore} target={target} persistent={persistent} labels={labels}
|
||||
onCopy={copy} onClose={clearCopyTarget} />
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -733,7 +784,7 @@ export function JsonTree({
|
||||
style={{ '--json-tree-collapsed-lines': collapsedStringLines } as CSSProperties}
|
||||
onMouseOver={handleRootMouseOver}
|
||||
onMouseLeave={() => {
|
||||
if (!copyMenuOpenRef.current) clearCopyTarget()
|
||||
if (!copyStore.get()?.menuOpen) clearCopyTarget()
|
||||
}}
|
||||
>
|
||||
{expandTopLevel
|
||||
|
||||
@@ -12,12 +12,12 @@ function JsonTree(props: Omit<ComponentProps<typeof LocalizedJsonTree>, 'label'
|
||||
return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
|
||||
}
|
||||
|
||||
let writeText: ReturnType<typeof vi.fn>
|
||||
let writeText: ReturnType<typeof vi.fn<Clipboard['writeText']>>
|
||||
let originalClipboard: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
writeText = vi.fn().mockResolvedValue(undefined)
|
||||
writeText = vi.fn<Clipboard['writeText']>().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
@@ -33,15 +33,115 @@ afterEach(() => {
|
||||
else Object.defineProperty(navigator, 'clipboard', originalClipboard)
|
||||
})
|
||||
|
||||
function stubStringLayout(scrollHeight = 200): void {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight)
|
||||
const computedStyle = window.getComputedStyle.bind(window)
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
|
||||
const style = computedStyle(element)
|
||||
style.lineHeight = '16px'
|
||||
if (style.paddingBottom === '') style.paddingBottom = '0px'
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
describe('JsonTree', () => {
|
||||
it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200)
|
||||
const computedStyle = window.getComputedStyle.bind(window)
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
|
||||
const style = computedStyle(element)
|
||||
style.lineHeight = '16px'
|
||||
return style
|
||||
it('ignores clipboard settlement after the row changes or the tree unmounts', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending: (() => void)[] = []
|
||||
writeText.mockImplementation(() => new Promise<void>((resolve) => { pending.push(resolve) }))
|
||||
const view = render(<JsonTree data={{ first: 1, second: 2 }} />)
|
||||
const rows = screen.getAllByRole('treeitem')
|
||||
fireEvent.mouseOver(rows[0] as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
|
||||
fireEvent.mouseOver(rows[1] as HTMLElement)
|
||||
await act(async () => { pending[0]!() })
|
||||
expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
|
||||
view.unmount()
|
||||
const timerCount = vi.getTimerCount()
|
||||
await act(async () => { pending[1]!() })
|
||||
expect(vi.getTimerCount()).toBe(timerCount)
|
||||
})
|
||||
|
||||
it('updates copy actions without rereading JSON properties on hover', () => {
|
||||
const readValue = vi.fn(() => 'payload '.repeat(100))
|
||||
const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [
|
||||
`field${index}`,
|
||||
{ get value() { return readValue() } },
|
||||
]))
|
||||
render(<JsonTree data={data} />)
|
||||
const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
|
||||
readValue.mockClear()
|
||||
|
||||
fireEvent.mouseOver(rows[0] as HTMLElement)
|
||||
expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
|
||||
fireEvent.mouseOver(rows[1] as HTMLElement)
|
||||
expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull()
|
||||
expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
|
||||
expect(readValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => {
|
||||
stubStringLayout()
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
let rawTop = 150
|
||||
const view = render(
|
||||
<div style={{ overflowY: 'auto', paddingBottom: '10px' }}>
|
||||
<JsonTree data={{ first: 'raw\ntext', last: 'last\ntext' }} />
|
||||
</div>,
|
||||
)
|
||||
const clip = view.container.firstElementChild as HTMLElement
|
||||
vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2)
|
||||
vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140)
|
||||
})
|
||||
const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
|
||||
fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
|
||||
const raw = rows[0]?.querySelector('pre') as HTMLPreElement
|
||||
expect(raw.textContent).toBe('raw\ntext')
|
||||
expect(raw.style.maxHeight).toBe('78px')
|
||||
expect(raw.nextElementSibling?.textContent).toBe(',')
|
||||
|
||||
rawTop = 80
|
||||
fireEvent.scroll(clip)
|
||||
expect(raw.style.maxHeight).toBe('126px')
|
||||
rawTop = 300
|
||||
fireEvent.resize(window)
|
||||
expect(raw.style.maxHeight).toBe('16px')
|
||||
fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' }))
|
||||
expect(raw.isConnected).toBe(false)
|
||||
rawTop = 100
|
||||
fireEvent.scroll(clip)
|
||||
expect(raw.style.maxHeight).toBe('16px')
|
||||
|
||||
fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
|
||||
expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',')
|
||||
})
|
||||
|
||||
it('shows the string expander only beyond the configured collapsed line count', () => {
|
||||
stubStringLayout(48)
|
||||
let resize: (() => void) | undefined
|
||||
const disconnect = vi.fn()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: () => void) { resize = callback }
|
||||
observe() {}
|
||||
disconnect = disconnect
|
||||
})
|
||||
const data = { text: 'three lines of text' }
|
||||
const view = render(<JsonTree data={data} />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
|
||||
view.rerender(<JsonTree data={data} collapsedStringLines={2} />)
|
||||
expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy()
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16)
|
||||
act(() => { resize?.() })
|
||||
expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
|
||||
view.unmount()
|
||||
expect(disconnect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
|
||||
stubStringLayout()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
|
||||
Reference in new issue
Block a user