diff --git a/src/components/Json/Json.module.css b/src/components/Json/Json.module.css index 1646e5c..524284c 100644 --- a/src/components/Json/Json.module.css +++ b/src/components/Json/Json.module.css @@ -25,19 +25,26 @@ .clickable { cursor: pointer; -} -.drill { - color: #556; - font-size: 10pt; - margin-left: -12px; - margin-right: 4px; - padding: 2px 0; - user-select: none; -} -.clickable:hover .drill { - color: #778; + /* drill */ + &::before { + content: '▶'; + color: #556; + font-size: 10pt; + margin-left: -12px; + margin-right: 4px; + padding: 2px 0; + user-select: none; + } + &:hover::before { + color: #778; + } + &[aria-expanded="true"]::before { + content: '▼'; + } } + + .key { color: #aad; font-weight: bold; diff --git a/src/components/Json/Json.test.tsx b/src/components/Json/Json.test.tsx index 3bde428..e7a1c37 100644 --- a/src/components/Json/Json.test.tsx +++ b/src/components/Json/Json.test.tsx @@ -27,9 +27,8 @@ describe('Json Component', () => { [1, 'foo', null], Array.from({ length: 101 }, (_, i) => i), ])('collapses any array', (array) => { - const { queryByText } = render() - expect(queryByText('▶')).toBeTruthy() - expect(queryByText('▼')).toBeNull() + const { getByRole } = render() + expect(getByRole('treeitem').ariaExpanded).toBe('false') }) it.for([ @@ -40,13 +39,12 @@ describe('Json Component', () => { ])('shows short arrays of primitive items, without trailing comment about length', (array) => { const { queryByText } = render() expect(queryByText('...')).toBeNull() - expect(queryByText('length')).toBeNull() + expect(queryByText(/length/)).toBeNull() }) it.for([ - // [1, 'foo', [1, 2, 3]], // TODO(SL): this one does not collapses, what to do? The text is wrong Array.from({ length: 101 }, (_, i) => i), - ])('hides long arrays, and non-primitive items, with trailing comment about length', (array) => { + ])('hides long arrays with trailing comment about length', (array) => { const { getByText } = render() getByText('...') getByText(/length/) @@ -66,20 +64,39 @@ describe('Json Component', () => { getByText('"42"') }) + it.for([ + [1, 'foo', [1, 2, 3]], + [1, 'foo', { nested: true }], + ])('expands short arrays with non-primitive values', (arr) => { + const { getAllByRole } = render() + const treeItems = getAllByRole('treeitem') + expect(treeItems.length).toBe(2) + expect(treeItems[0]?.getAttribute('aria-expanded')).toBe('true') // the root + expect(treeItems[1]?.getAttribute('aria-expanded')).toBe('false') // the non-primitive value (object/array) + }) + it.for([ { obj: [314, null] }, { obj: { nested: true } }, ])('expands short objects with non-primitive values', (obj) => { - const { getByText } = render() - getByText('▼') + const { getAllByRole } = render() + const treeItems = getAllByRole('treeitem') + expect(treeItems.length).toBe(2) + expect(treeItems[0]?.getAttribute('aria-expanded')).toBe('true') // the root + expect(treeItems[1]?.getAttribute('aria-expanded')).toBe('false') // the non-primitive value (object/array) }) it.for([ { obj: [314, null] }, { obj: { nested: true } }, ])('hides the content and append number of entries when objects with non-primitive values are collapsed', (obj) => { - const { getByText } = render() - fireEvent.click(getByText('▼')) + const { getAllByRole, getByText } = render() + const root = getAllByRole('treeitem')[0] + if (!root) { /* type assertion, getAllByRole would already have thrown */ + throw new Error('No root element found') + } + fireEvent.click(root) + expect(root.getAttribute('aria-expanded')).toBe('false') getByText('...') getByText(/entries/) }) @@ -90,9 +107,8 @@ describe('Json Component', () => { { a: 1, b: true, c: null, d: undefined }, Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])), ])('collapses long objects, or objects with only primitive values (included empty object)', (obj) => { - const { queryByText } = render() - expect(queryByText('▶')).toBeTruthy() - expect(queryByText('▼')).toBeNull() + const { getByRole } = render() + expect(getByRole('treeitem').getAttribute('aria-expanded')).toBe('false') }) it.for([ @@ -105,21 +121,23 @@ describe('Json Component', () => { it('toggles array collapse state', () => { const longArray = Array.from({ length: 101 }, (_, i) => i) - const { getByText, queryByText } = render() + const { getByRole, getByText, queryByText } = render() + const treeItem = getByRole('treeitem') getByText('...') - fireEvent.click(getByText('▶')) + fireEvent.click(treeItem) expect(queryByText('...')).toBeNull() - fireEvent.click(getByText('▼')) + fireEvent.click(treeItem) getByText('...') }) it('toggles object collapse state', () => { const longObject = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`key${i}`, { nested: true }])) - const { getByText, queryByText } = render() + const { getByRole, getByText, queryByText } = render() + const treeItem = getByRole('treeitem') // only one treeitem because the inner objects are collapsed and not represented as treeitems getByText('...') - fireEvent.click(getByText('▶')) + fireEvent.click(treeItem) expect(queryByText('...')).toBeNull() - fireEvent.click(getByText('▼')) + fireEvent.click(treeItem) getByText('...') }) }) diff --git a/src/components/Json/Json.tsx b/src/components/Json/Json.tsx index 20c9657..31f98c6 100644 --- a/src/components/Json/Json.tsx +++ b/src/components/Json/Json.tsx @@ -14,7 +14,7 @@ interface JsonProps { * JSON viewer component with collapsible objects and arrays. */ export default function Json({ json, label, className }: JsonProps): ReactNode { - return
+ return
} @@ -89,19 +89,17 @@ function JsonArray({ array, label }: { array: unknown[], label?: string }): Reac const [collapsed, setCollapsed] = useState(shouldObjectCollapse(array)) const key = label ? {label}: : '' if (collapsed) { - return
{ setCollapsed(false) }}> - {'\u25B6'} + return } return <> -
{ setCollapsed(true) }}> - {'\u25BC'} +
{ setCollapsed(true) }}> {key} {'['}
-
    +
      {array.map((item, index) =>
    • {}
    • )}
    {']'}
    @@ -154,19 +152,17 @@ function JsonObject({ obj, label }: { obj: object, label?: string }): ReactNode const [collapsed, setCollapsed] = useState(shouldObjectCollapse(obj)) const key = label ? {label}: : '' if (collapsed) { - return
    { setCollapsed(false) }}> - {'\u25B6'} + return } return <> -
    { setCollapsed(true) }}> - {'\u25BC'} +
    { setCollapsed(true) }}> {key} {'{'}
    -
      +
        {Object.entries(obj).map(([key, value]) =>
      • diff --git a/src/components/JsonView/JsonView.test.tsx b/src/components/JsonView/JsonView.test.tsx index ae1cf91..2993d8e 100644 --- a/src/components/JsonView/JsonView.test.tsx +++ b/src/components/JsonView/JsonView.test.tsx @@ -13,7 +13,7 @@ globalThis.fetch = vi.fn() describe('JsonView Component', () => { const encoder = new TextEncoder() - it('renders json content as nested lists (if not collapsed)', async () => { + it('renders json content as nested tree items (if not collapsed)', async () => { const text = '{"key":["value"]}' const body = encoder.encode(text).buffer const source: FileSource = { @@ -29,13 +29,13 @@ describe('JsonView Component', () => { text: () => Promise.resolve(text), } as Response) - const { findByRole, findByText } = render( + const { findAllByRole, findByText } = render( ) expect(fetch).toHaveBeenCalledWith('testKey0', undefined) // Wait for asynchronous JSON loading and parsing - await findByRole('list') + await findAllByRole('treeitem') await findByText('key:') await findByText('"value"') })