Skip to content

Commit ee56a1c

Browse files
authored
fix tests + move ▶ to CSS and use aria-expanded (#339)
1 parent a8d88e4 commit ee56a1c

File tree

4 files changed

+65
-44
lines changed

4 files changed

+65
-44
lines changed

src/components/Json/Json.module.css

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,26 @@
2525

2626
.clickable {
2727
cursor: pointer;
28-
}
2928

30-
.drill {
31-
color: #556;
32-
font-size: 10pt;
33-
margin-left: -12px;
34-
margin-right: 4px;
35-
padding: 2px 0;
36-
user-select: none;
37-
}
38-
.clickable:hover .drill {
39-
color: #778;
29+
/* drill */
30+
&::before {
31+
content: '▶';
32+
color: #556;
33+
font-size: 10pt;
34+
margin-left: -12px;
35+
margin-right: 4px;
36+
padding: 2px 0;
37+
user-select: none;
38+
}
39+
&:hover::before {
40+
color: #778;
41+
}
42+
&[aria-expanded="true"]::before {
43+
content: '▼';
44+
}
4045
}
46+
47+
4148
.key {
4249
color: #aad;
4350
font-weight: bold;

src/components/Json/Json.test.tsx

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ describe('Json Component', () => {
2727
[1, 'foo', null],
2828
Array.from({ length: 101 }, (_, i) => i),
2929
])('collapses any array', (array) => {
30-
const { queryByText } = render(<Json json={array} />)
31-
expect(queryByText('▶')).toBeTruthy()
32-
expect(queryByText('▼')).toBeNull()
30+
const { getByRole } = render(<Json json={array} />)
31+
expect(getByRole('treeitem').ariaExpanded).toBe('false')
3332
})
3433

3534
it.for([
@@ -40,13 +39,12 @@ describe('Json Component', () => {
4039
])('shows short arrays of primitive items, without trailing comment about length', (array) => {
4140
const { queryByText } = render(<Json json={array} />)
4241
expect(queryByText('...')).toBeNull()
43-
expect(queryByText('length')).toBeNull()
42+
expect(queryByText(/length/)).toBeNull()
4443
})
4544

4645
it.for([
47-
// [1, 'foo', [1, 2, 3]], // TODO(SL): this one does not collapses, what to do? The text is wrong
4846
Array.from({ length: 101 }, (_, i) => i),
49-
])('hides long arrays, and non-primitive items, with trailing comment about length', (array) => {
47+
])('hides long arrays with trailing comment about length', (array) => {
5048
const { getByText } = render(<Json json={array} />)
5149
getByText('...')
5250
getByText(/length/)
@@ -66,20 +64,39 @@ describe('Json Component', () => {
6664
getByText('"42"')
6765
})
6866

67+
it.for([
68+
[1, 'foo', [1, 2, 3]],
69+
[1, 'foo', { nested: true }],
70+
])('expands short arrays with non-primitive values', (arr) => {
71+
const { getAllByRole } = render(<Json json={arr} />)
72+
const treeItems = getAllByRole('treeitem')
73+
expect(treeItems.length).toBe(2)
74+
expect(treeItems[0]?.getAttribute('aria-expanded')).toBe('true') // the root
75+
expect(treeItems[1]?.getAttribute('aria-expanded')).toBe('false') // the non-primitive value (object/array)
76+
})
77+
6978
it.for([
7079
{ obj: [314, null] },
7180
{ obj: { nested: true } },
7281
])('expands short objects with non-primitive values', (obj) => {
73-
const { getByText } = render(<Json json={obj} />)
74-
getByText('▼')
82+
const { getAllByRole } = render(<Json json={obj} />)
83+
const treeItems = getAllByRole('treeitem')
84+
expect(treeItems.length).toBe(2)
85+
expect(treeItems[0]?.getAttribute('aria-expanded')).toBe('true') // the root
86+
expect(treeItems[1]?.getAttribute('aria-expanded')).toBe('false') // the non-primitive value (object/array)
7587
})
7688

7789
it.for([
7890
{ obj: [314, null] },
7991
{ obj: { nested: true } },
8092
])('hides the content and append number of entries when objects with non-primitive values are collapsed', (obj) => {
81-
const { getByText } = render(<Json json={obj} />)
82-
fireEvent.click(getByText('▼'))
93+
const { getAllByRole, getByText } = render(<Json json={obj} />)
94+
const root = getAllByRole('treeitem')[0]
95+
if (!root) { /* type assertion, getAllByRole would already have thrown */
96+
throw new Error('No root element found')
97+
}
98+
fireEvent.click(root)
99+
expect(root.getAttribute('aria-expanded')).toBe('false')
83100
getByText('...')
84101
getByText(/entries/)
85102
})
@@ -90,9 +107,8 @@ describe('Json Component', () => {
90107
{ a: 1, b: true, c: null, d: undefined },
91108
Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])),
92109
])('collapses long objects, or objects with only primitive values (included empty object)', (obj) => {
93-
const { queryByText } = render(<Json json={obj} />)
94-
expect(queryByText('▶')).toBeTruthy()
95-
expect(queryByText('▼')).toBeNull()
110+
const { getByRole } = render(<Json json={obj} />)
111+
expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('false')
96112
})
97113

98114
it.for([
@@ -105,21 +121,23 @@ describe('Json Component', () => {
105121

106122
it('toggles array collapse state', () => {
107123
const longArray = Array.from({ length: 101 }, (_, i) => i)
108-
const { getByText, queryByText } = render(<Json json={longArray} />)
124+
const { getByRole, getByText, queryByText } = render(<Json json={longArray} />)
125+
const treeItem = getByRole('treeitem')
109126
getByText('...')
110-
fireEvent.click(getByText('▶'))
127+
fireEvent.click(treeItem)
111128
expect(queryByText('...')).toBeNull()
112-
fireEvent.click(getByText('▼'))
129+
fireEvent.click(treeItem)
113130
getByText('...')
114131
})
115132

116133
it('toggles object collapse state', () => {
117134
const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }]))
118-
const { getByText, queryByText } = render(<Json json={longObject} />)
135+
const { getByRole, getByText, queryByText } = render(<Json json={longObject} />)
136+
const treeItem = getByRole('treeitem') // only one treeitem because the inner objects are collapsed and not represented as treeitems
119137
getByText('...')
120-
fireEvent.click(getByText('▶'))
138+
fireEvent.click(treeItem)
121139
expect(queryByText('...')).toBeNull()
122-
fireEvent.click(getByText('▼'))
140+
fireEvent.click(treeItem)
123141
getByText('...')
124142
})
125143
})

src/components/Json/Json.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ interface JsonProps {
1414
* JSON viewer component with collapsible objects and arrays.
1515
*/
1616
export default function Json({ json, label, className }: JsonProps): ReactNode {
17-
return <div className={cn(styles.json, className)}>
17+
return <div className={cn(styles.json, className)} role="tree">
1818
<JsonContent json={json} label={label} />
1919
</div>
2020
}
@@ -89,19 +89,17 @@ function JsonArray({ array, label }: { array: unknown[], label?: string }): Reac
8989
const [collapsed, setCollapsed] = useState(shouldObjectCollapse(array))
9090
const key = label ? <span className={styles.key}>{label}: </span> : ''
9191
if (collapsed) {
92-
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
93-
<span className={styles.drill}>{'\u25B6'}</span>
92+
return <div role="treeitem" className={styles.clickable} aria-expanded="false" onClick={() => { setCollapsed(false) }}>
9493
{key}
9594
<CollapsedArray array={array}></CollapsedArray>
9695
</div>
9796
}
9897
return <>
99-
<div className={styles.clickable} onClick={() => { setCollapsed(true) }}>
100-
<span className={styles.drill}>{'\u25BC'}</span>
98+
<div role="treeitem" className={styles.clickable} aria-expanded="true" onClick={() => { setCollapsed(true) }}>
10199
{key}
102100
<span className={styles.array}>{'['}</span>
103101
</div>
104-
<ul>
102+
<ul role="group">
105103
{array.map((item, index) => <li key={index}>{<Json json={item} />}</li>)}
106104
</ul>
107105
<div className={styles.array}>{']'}</div>
@@ -154,19 +152,17 @@ function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode
154152
const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj))
155153
const key = label ? <span className={styles.key}>{label}: </span> : ''
156154
if (collapsed) {
157-
return <div className={styles.clickable} onClick={() => { setCollapsed(false) }}>
158-
<span className={styles.drill}>{'\u25B6'}</span>
155+
return <div role="treeitem" className={styles.clickable} aria-expanded="false" onClick={() => { setCollapsed(false) }}>
159156
{key}
160157
<CollapsedObject obj={obj}></CollapsedObject>
161158
</div>
162159
}
163160
return <>
164-
<div className={styles.clickable} onClick={() => { setCollapsed(true) }}>
165-
<span className={styles.drill}>{'\u25BC'}</span>
161+
<div role="treeitem" className={styles.clickable} aria-expanded="true" onClick={() => { setCollapsed(true) }}>
166162
{key}
167163
<span className={styles.object}>{'{'}</span>
168164
</div>
169-
<ul>
165+
<ul role="group">
170166
{Object.entries(obj).map(([key, value]) =>
171167
<li key={key}>
172168
<Json json={value as unknown} label={key} />

src/components/JsonView/JsonView.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ globalThis.fetch = vi.fn()
1313
describe('JsonView Component', () => {
1414
const encoder = new TextEncoder()
1515

16-
it('renders json content as nested lists (if not collapsed)', async () => {
16+
it('renders json content as nested tree items (if not collapsed)', async () => {
1717
const text = '{"key":["value"]}'
1818
const body = encoder.encode(text).buffer
1919
const source: FileSource = {
@@ -29,13 +29,13 @@ describe('JsonView Component', () => {
2929
text: () => Promise.resolve(text),
3030
} as Response)
3131

32-
const { findByRole, findByText } = render(
32+
const { findAllByRole, findByText } = render(
3333
<JsonView source={source} setError={console.error} />
3434
)
3535

3636
expect(fetch).toHaveBeenCalledWith('testKey0', undefined)
3737
// Wait for asynchronous JSON loading and parsing
38-
await findByRole('list')
38+
await findAllByRole('treeitem')
3939
await findByText('key:')
4040
await findByText('"value"')
4141
})

0 commit comments

Comments
 (0)