Skip to content

Commit 0fd683c

Browse files
committed
Json automatically expand root node
1 parent aff8c24 commit 0fd683c

4 files changed

Lines changed: 64 additions & 56 deletions

File tree

package.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,33 +56,33 @@
5656
},
5757
"dependencies": {
5858
"hightable": "0.26.3",
59-
"hyparquet": "1.25.0",
59+
"hyparquet": "1.25.1",
6060
"hyparquet-compressors": "1.1.1",
6161
"icebird": "0.3.1",
62-
"squirreling": "0.9.1"
62+
"squirreling": "0.9.4"
6363
},
6464
"devDependencies": {
65-
"@storybook/react-vite": "10.2.10",
65+
"@storybook/react-vite": "10.2.13",
6666
"@testing-library/react": "16.3.2",
67-
"@types/node": "25.2.3",
67+
"@types/node": "25.3.3",
6868
"@types/react": "19.2.14",
6969
"@types/react-dom": "19.2.3",
7070
"@vitejs/plugin-react": "5.1.4",
7171
"@vitest/coverage-v8": "4.0.18",
7272
"eslint": "9.39.2",
7373
"eslint-plugin-react": "7.37.5",
7474
"eslint-plugin-react-hooks": "7.0.1",
75-
"eslint-plugin-react-refresh": "0.5.0",
76-
"eslint-plugin-storybook": "10.2.10",
77-
"globals": "17.3.0",
75+
"eslint-plugin-react-refresh": "0.5.2",
76+
"eslint-plugin-storybook": "10.2.13",
77+
"globals": "17.4.0",
7878
"jsdom": "28.1.0",
79-
"nodemon": "3.1.11",
79+
"nodemon": "3.1.14",
8080
"npm-run-all": "4.1.5",
8181
"react": "19.2.4",
8282
"react-dom": "19.2.4",
83-
"storybook": "10.2.10",
83+
"storybook": "10.2.13",
8484
"typescript": "5.9.3",
85-
"typescript-eslint": "8.56.0",
85+
"typescript-eslint": "8.56.1",
8686
"vite": "7.3.1",
8787
"vitest": "4.0.18"
8888
},

src/components/Json/Json.stories.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ export const Arrays: Story = {
5353
render,
5454
}
5555

56+
export const TopLevelArray: Story = {
57+
args: {
58+
json: Array.from({ length: 100 }, (_, i) => [i, i + 1, i + 2]),
59+
},
60+
render,
61+
}
62+
5663
export const Objects: Story = {
5764
args: {
5865
json: {
@@ -67,7 +74,6 @@ export const Objects: Story = {
6774
misc3: { k0: 1, k1: 'a', k2: null, k3: undefined },
6875
arrays100: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`k${i}`, [i, i + 1, i + 2]])),
6976
},
70-
label: 'json',
7177
},
7278
render,
7379
}

src/components/Json/Json.test.tsx

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ describe('Json Component', () => {
2121
getByText('"bar"')
2222
})
2323

24-
it.for([
25-
[],
26-
[1, 2, 3],
27-
Array.from({ length: 101 }, (_, i) => i),
28-
])('collapses any array with primitives', (array) => {
29-
const { getByRole } = render(<Json json={array} />)
24+
it('expands root array by default', () => {
25+
const { getByRole } = render(<Json json={[1, 2, 3]} />)
26+
expect(getByRole('treeitem').ariaExpanded).toBe('true')
27+
})
28+
29+
it('collapses array with primitives when expandRoot is false', () => {
30+
const { getByRole } = render(<Json json={[1, 2, 3]} expandRoot={false} />)
3031
expect(getByRole('treeitem').ariaExpanded).toBe('false')
3132
})
3233

@@ -41,10 +42,9 @@ describe('Json Component', () => {
4142
expect(queryByText(/length/)).toBeNull()
4243
})
4344

44-
it.for([
45-
Array.from({ length: 101 }, (_, i) => i),
46-
])('hides long arrays with trailing comment about length', (array) => {
47-
const { getByText } = render(<Json json={array} />)
45+
it('hides long arrays with trailing comment about length when collapsed', () => {
46+
const longArray = Array.from({ length: 101 }, (_, i) => i)
47+
const { getByText } = render(<Json json={longArray} expandRoot={false} />)
4848
getByText('...')
4949
getByText(/length/)
5050
})
@@ -109,20 +109,19 @@ describe('Json Component', () => {
109109
getByText(/entries/)
110110
})
111111

112-
it.for([
113-
{},
114-
{ a: 1, b: 2 },
115-
{ a: 1, b: true, c: null, d: undefined },
116-
Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
117-
])('collapses long objects, or objects with only primitive values (included empty object)', (obj) => {
118-
const { getByRole } = render(<Json json={obj} />)
112+
it('expands root object by default', () => {
113+
const { getByRole } = render(<Json json={{ a: 1, b: 2 }} />)
114+
expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
115+
})
116+
117+
it('collapses objects with only primitive values when expandRoot is false', () => {
118+
const { getByRole } = render(<Json json={{ a: 1, b: 2 }} expandRoot={false} />)
119119
expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('false')
120120
})
121121

122-
it.for([
123-
Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
124-
])('hides the content and append number of entries when objects has many entries', (obj) => {
125-
const { getByText } = render(<Json json={obj} />)
122+
it('hides the content and append number of entries when objects has many entries when collapsed', () => {
123+
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
124+
const { getByText } = render(<Json json={longObject} expandRoot={false} />)
126125
getByText('...')
127126
getByText(/entries/)
128127
})
@@ -131,24 +130,26 @@ describe('Json Component', () => {
131130
const longArray = Array.from({ length: 101 }, (_, i) => i)
132131
const { getByRole, getByText, queryByText } = render(<Json json={longArray} />)
133132
const treeItem = getByRole('treeitem')
134-
getByText('...')
133+
expect(queryByText('...')).toBeNull() // expanded by default
135134
const user = userEvent.setup()
136-
await user.click(treeItem)
137-
expect(queryByText('...')).toBeNull()
138-
await user.click(treeItem)
135+
await user.click(treeItem) // collapse
139136
getByText('...')
137+
await user.click(treeItem) // expand again
138+
expect(queryByText('...')).toBeNull()
140139
})
141140

142141
it('toggles object collapse state', async () => {
143142
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
144-
const { getByRole, getByText, queryByText } = render(<Json json={longObject} />)
145-
const treeItem = getByRole('treeitem') // only one treeitem because the inner objects are collapsed and not represented as treeitems
146-
getByText('...')
147-
const user = userEvent.setup()
148-
await user.click(treeItem)
143+
const { getAllByRole, getByRole, getByText, queryByText } = render(<Json json={longObject} />)
144+
const treeItem = getAllByRole('treeitem')[0] // expanded by default due to expandRoot
145+
if (!treeItem) throw new Error('No root element found')
149146
expect(queryByText('...')).toBeNull()
150-
await user.click(treeItem)
147+
const user = userEvent.setup()
148+
await user.click(treeItem) // collapse
149+
getByRole('treeitem') // now only one treeitem
151150
getByText('...')
151+
await user.click(treeItem) // expand again
152+
expect(queryByText('...')).toBeNull()
152153
})
153154
})
154155

src/components/Json/Json.tsx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,31 @@
11
import { ReactNode, useState } from 'react'
2+
import { cn } from '../../lib'
23
import styles from './Json.module.css'
34
import { isPrimitive, shouldObjectCollapse } from './helpers.js'
4-
import { cn } from '../../lib'
55
import { useWidth } from './useWidth.js'
66

77
interface JsonProps {
88
json: unknown
99
label?: string
1010
className?: string
11+
expandRoot?: boolean // Expand the top-level object/array by default
1112
}
1213

1314
/**
1415
* JSON viewer component with collapsible objects and arrays.
1516
*/
16-
export default function Json({ json, label, className }: JsonProps): ReactNode {
17+
export default function Json({ json, label, className, expandRoot = true }: JsonProps): ReactNode {
1718
return <div className={cn(styles.json, className)} role="tree">
18-
<JsonContent json={json} label={label} />
19+
<JsonContent json={json} label={label} expandRoot={expandRoot} />
1920
</div>
2021
}
2122

22-
function JsonContent({ json, label }: JsonProps): ReactNode {
23+
function JsonContent({ json, label, expandRoot }: JsonProps): ReactNode {
2324
let div
2425
if (Array.isArray(json)) {
25-
div = <JsonArray array={json} label={label} />
26+
div = <JsonArray array={json} label={label} expandRoot={expandRoot} />
2627
} else if (typeof json === 'object' && json !== null) {
27-
div = <JsonObject label={label} obj={json} />
28+
div = <JsonObject label={label} obj={json} expandRoot={expandRoot} />
2829
} else {
2930
// primitive
3031
const key = label ? <span className={styles.key}>{label}: </span> : ''
@@ -85,8 +86,8 @@ function CollapsedArray({ array }: {array: unknown[]}): ReactNode {
8586
)
8687
}
8788

88-
function JsonArray({ array, label }: { array: unknown[], label?: string }): ReactNode {
89-
const [collapsed, setCollapsed] = useState(shouldObjectCollapse(array))
89+
function JsonArray({ array, label, expandRoot }: { array: unknown[], label?: string, expandRoot?: boolean }): ReactNode {
90+
const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(array))
9091
const key = label ? <span className={styles.key}>{label}: </span> : ''
9192
if (collapsed) {
9293
return <div role="treeitem" className={styles.clickable} aria-expanded="false" onClick={() => { setCollapsed(false) }}>
@@ -100,13 +101,13 @@ function JsonArray({ array, label }: { array: unknown[], label?: string }): Reac
100101
<span className={styles.array}>{'['}</span>
101102
</div>
102103
<ul role="group">
103-
{array.map((item, index) => <li key={index}>{<Json json={item} />}</li>)}
104+
{array.map((item, index) => <li key={index}><JsonContent json={item} /></li>)}
104105
</ul>
105106
<div className={styles.array}>{']'}</div>
106107
</>
107108
}
108109

109-
function CollapsedObject({ obj }: {obj: object}): ReactNode {
110+
function CollapsedObject({ obj }: { obj: object }): ReactNode {
110111
const { elementRef, width } = useWidth<HTMLSpanElement>()
111112
const maxCharacterCount = Math.max(20, Math.floor(width / 8))
112113
const separator = ', '
@@ -148,13 +149,13 @@ function CollapsedObject({ obj }: {obj: object}): ReactNode {
148149
)
149150
}
150151

151-
function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode {
152-
const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj))
152+
function JsonObject({ obj, label, expandRoot }: { obj: object, label?: string, expandRoot?: boolean }): ReactNode {
153+
const [collapsed, setCollapsed] = useState(!expandRoot && shouldObjectCollapse(obj))
153154
const key = label ? <span className={styles.key}>{label}: </span> : ''
154155
if (collapsed) {
155156
return <div role="treeitem" className={styles.clickable} aria-expanded="false" onClick={() => { setCollapsed(false) }}>
156157
{key}
157-
<CollapsedObject obj={obj}></CollapsedObject>
158+
<CollapsedObject obj={obj} />
158159
</div>
159160
}
160161
return <>
@@ -165,7 +166,7 @@ function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode
165166
<ul role="group">
166167
{Object.entries(obj).map(([key, value]) =>
167168
<li key={key}>
168-
<Json json={value as unknown} label={key} />
169+
<JsonContent json={value as unknown} label={key} />
169170
</li>
170171
)}
171172
</ul>

0 commit comments

Comments
 (0)