diff --git a/src/DataRenderer.test.tsx b/src/DataRenderer.test.tsx index 751a4bb..4b9ad10 100644 --- a/src/DataRenderer.test.tsx +++ b/src/DataRenderer.test.tsx @@ -4,7 +4,7 @@ import { allExpanded, collapseAllNested, defaultStyles } from './index'; import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -const commonProps: JsonRenderProps = { +const commonProps: Omit, 'outerRef'> = { lastElement: false, level: 0, style: { @@ -30,20 +30,32 @@ const commonProps: JsonRenderProps = { field: undefined }; +const WrappedDataRenderer = (testProps: Partial>) => { + const ref = React.useRef(null); + return ( +
+ +
+ ); +}; + const collapseAll = () => false; const testButtonsCollapsed = () => { const buttons = screen.getAllByRole('button', { hidden: true }); - expect(buttons.length).toBe(2); + expect(buttons).toHaveLength(1); expect(buttons[0]).toHaveClass('expand-icon-light'); - expect(buttons[1]).toHaveClass('collapsed-content-light'); + expect(buttons[0]).not.toHaveClass('collapse-icon-light'); + expect(buttons[0]).toHaveAttribute('aria-expanded', 'false'); return buttons; }; const testButtonsExpanded = () => { const buttons = screen.getAllByRole('button', { hidden: true }); - expect(buttons.length).toBe(1); + expect(buttons).toHaveLength(1); expect(buttons[0]).toHaveClass('collapse-icon-light'); + expect(buttons[0]).not.toHaveClass('expand-icon-light'); + expect(buttons[0]).toHaveAttribute('aria-expanded', 'true'); return buttons; }; @@ -53,39 +65,28 @@ const testButtonsIfEmpty = () => { }).toThrow(); }; -const testClickableNodeCollapsed = () => { - const buttons = screen.getAllByRole('button', { hidden: true }); - expect(buttons.length).toBe(4); - expect(buttons[0]).toHaveClass('collapse-icon-light'); - expect(buttons[1]).toHaveClass('expand-icon-light'); - expect(buttons[2]).toHaveClass('clickable-label-light'); - expect(buttons[3]).toHaveClass('collapsed-content-light'); - return buttons; -}; - describe('DataRender', () => { it('should render booleans: true', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('true')).toBeInTheDocument(); }); it('should render booleans: false', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('false')).toBeInTheDocument(); }); it('should render strings', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText(`"string"`)).toBeInTheDocument(); }); it('should render strings without quotes', () => { render( - @@ -97,8 +98,7 @@ describe('DataRender', () => { it('should render strings with quotes if noQuotesForStringValues is undefined', () => { render( - @@ -109,8 +109,7 @@ describe('DataRender', () => { it('should render field names without quotes if quotesForFieldNames is undefined', () => { render( - @@ -120,8 +119,7 @@ describe('DataRender', () => { it('should render field names with quotes if quotesForFieldNames is true', () => { render( - @@ -130,120 +128,116 @@ describe('DataRender', () => { }); it('should render numbers', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('42')).toBeInTheDocument(); }); it('should render bigints', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('42n')).toBeInTheDocument(); }); it('should render dates', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('1970-01-01T00:00:00.000Z')).toBeInTheDocument(); }); it('should render nulls', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('null')).toBeInTheDocument(); }); it('should render undefineds', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('undefined')).toBeInTheDocument(); }); it('should render unknown types', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText(/2020/)).toBeInTheDocument(); }); it('should render object with empty key string', () => { - render(); + render(); expect(screen.getByText(/"":/)).toBeInTheDocument(); expect(screen.getByText(/empty key/)).toBeInTheDocument(); }); it('should render empty objects', () => { - render(); + render(); expect(screen.getByText('{')).toBeInTheDocument(); expect(screen.getByText('}')).toBeInTheDocument(); }); it('should render nested empty objects', () => { - render(); + render(); expect(screen.getByText('nested:')).toBeInTheDocument(); expect(screen.getByText('{')).toBeInTheDocument(); expect(screen.getByText('}')).toBeInTheDocument(); }); it('should not expand empty objects', () => { - render(); + render(); testButtonsIfEmpty(); }); it('should not collapse empty objects', () => { - render(); + render(); testButtonsIfEmpty(); }); it('should render empty arrays', () => { - render(); + render(); expect(screen.getByText('[')).toBeInTheDocument(); expect(screen.getByText(']')).toBeInTheDocument(); }); it('should render nested empty arrays', () => { - render(); + render(); expect(screen.getByText('nested:')).toBeInTheDocument(); expect(screen.getByText('[')).toBeInTheDocument(); expect(screen.getByText(']')).toBeInTheDocument(); }); it('should not expand empty arrays', () => { - render(); + render(); testButtonsIfEmpty(); }); it('should not collapse empty arrays', () => { - render(); + render(); testButtonsIfEmpty(); }); it('should render arrays', () => { - render(); + render(); expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); }); it('should render arrays with key', () => { - render(); + render(); expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); }); it('should render nested objects', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.getByText('123')).toBeInTheDocument(); }); it('should render nested objects collapsed', () => { render( - + ); expect(screen.getByText(/obj/)).toBeInTheDocument(); expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); @@ -252,53 +246,43 @@ describe('DataRender', () => { it('should render nested objects collapsed and expand it once property changed', () => { const { rerender } = render( - + ); expect(screen.getByText(/obj/)).toBeInTheDocument(); expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); expect(screen.queryByText('123')).not.toBeInTheDocument(); - rerender( - - ); + rerender(); expect(screen.getByText(/obj/)).toBeInTheDocument(); expect(screen.queryByText(/test:/)).toBeInTheDocument(); expect(screen.queryByText('123')).toBeInTheDocument(); }); it('should render nested arrays collapsed', () => { - render( - - ); + render(); expect(screen.queryByText(/test:/)).toBeInTheDocument(); expect(screen.queryByText('123')).not.toBeInTheDocument(); }); it('should render nested arrays collapsed and expand it once property changed', () => { const { rerender } = render( - + ); expect(screen.queryByText(/test:/)).toBeInTheDocument(); expect(screen.queryByText('123')).not.toBeInTheDocument(); - rerender( - - ); + rerender(); expect(screen.queryByText(/test:/)).toBeInTheDocument(); expect(screen.queryByText('123')).toBeInTheDocument(); }); it('should render top arrays collapsed', () => { - render(); + render(); expect(screen.queryByText('123')).not.toBeInTheDocument(); }); it('should collapse and expand objects by clicking on icon', () => { - render(); + render(); expect(screen.getByText(/test:/)).toBeInTheDocument(); let buttons = testButtonsExpanded(); fireEvent.click(buttons[0]); @@ -309,9 +293,9 @@ describe('DataRender', () => { }); it('should collapse and expand objects by clicking on node', () => { - render( - @@ -320,9 +304,17 @@ describe('DataRender', () => { // open the 'test' node by clicking the icon expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); expect(screen.queryByText(/child/)).not.toBeInTheDocument(); - const buttons = testButtonsCollapsed(); + let buttons = testButtonsCollapsed(); fireEvent.click(buttons[0]); - testClickableNodeCollapsed(); + + buttons = screen.getAllByRole('button', { hidden: true }); + expect(buttons.length).toBe(2); + expect(buttons[0]).toHaveClass('collapse-icon-light'); + expect(buttons[1]).toHaveClass('expand-icon-light'); + expect(buttons[0].tabIndex).toEqual(0); + expect(buttons[1].tabIndex).toEqual(-1); + expect(container.getElementsByClassName('clickable-label-light')).toHaveLength(1); + expect(container.getElementsByClassName('collapsed-content-light')).toHaveLength(1); expect(screen.getByText(/test:/)).toBeInTheDocument(); expect(screen.queryByText(/child/)).not.toBeInTheDocument(); fireEvent.click(buttons[0]); @@ -331,16 +323,19 @@ describe('DataRender', () => { }); it('should expand objects by clicking on collapsed content', () => { - render(); + const { container } = render( + + ); expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); - const buttons = testButtonsCollapsed(); - fireEvent.click(buttons[1]); + testButtonsCollapsed(); + const collapsedContent = container.getElementsByClassName(commonProps.style.collapsedContent); + fireEvent.click(collapsedContent[0]); testButtonsExpanded(); expect(screen.getByText(/test:/)).toBeInTheDocument(); }); it('should collapse and expand arrays by clicking on icon', () => { - render(); + render(); expect(screen.getByText('1')).toBeInTheDocument(); let buttons = testButtonsExpanded(); fireEvent.click(buttons[0]); @@ -351,55 +346,79 @@ describe('DataRender', () => { }); it('should expand arrays by clicking on collapsed content', () => { - render(); + const { container } = render( + + ); expect(screen.queryByText('1')).not.toBeInTheDocument(); - const buttons = testButtonsCollapsed(); - fireEvent.click(buttons[1]); + testButtonsCollapsed(); + const collapsedContent = container.getElementsByClassName(commonProps.style.collapsedContent); + fireEvent.click(collapsedContent[0]); testButtonsExpanded(); expect(screen.getByText('1')).toBeInTheDocument(); }); - it('should expand objects by pressing Spacebar on icon', () => { - render(); - expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); + it('should expand objects by pressing ArrowRight on icon, collapse objects by pressing ArrowLeft on icon', () => { + render(); + const buttons = testButtonsCollapsed(); - fireEvent.keyDown(buttons[0], { key: ' ', code: 'Space' }); + expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); + + fireEvent.keyDown(buttons[0], { key: 'ArrowRight', code: 'ArrowRight' }); testButtonsExpanded(); expect(screen.getByText(/test:/)).toBeInTheDocument(); + + fireEvent.keyDown(buttons[0], { key: 'ArrowLeft', code: 'ArrowLeft' }); + testButtonsCollapsed(); + expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); }); it('should not expand objects by pressing other keys on icon', () => { - render(); + render(); expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); const buttons = testButtonsCollapsed(); fireEvent.keyDown(buttons[0], { key: 'Enter', code: 'Enter' }); + fireEvent.keyDown(buttons[0], { key: ' ', code: 'Space' }); testButtonsCollapsed(); expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); }); - it('should expand arrays by pressing Spacebar on icon', () => { - render( - - ); + it('should expand arrays by pressing ArrowRight on icon, collapse arrays by pressing ArrowLeft on icon', () => { + render(); + const buttons = testButtonsCollapsed(); expect(screen.queryByText(/test/)).not.toBeInTheDocument(); expect(screen.queryByText(/array/)).not.toBeInTheDocument(); - fireEvent.keyDown(buttons[0], { key: ' ', code: 'Space' }); + + fireEvent.keyDown(buttons[0], { key: 'ArrowRight', code: 'ArrowRight' }); testButtonsExpanded(); expect(screen.getByText(/test/)).toBeInTheDocument(); expect(screen.getByText(/array/)).toBeInTheDocument(); + + fireEvent.keyDown(buttons[0], { key: 'ArrowLeft', code: 'ArrowLeft' }); + testButtonsCollapsed(); + expect(screen.queryByText(/test/)).not.toBeInTheDocument(); + expect(screen.queryByText(/array/)).not.toBeInTheDocument(); }); it('should not expand arrays by pressing other keys on icon', () => { - render( - - ); + render(); const buttons = testButtonsCollapsed(); expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); expect(screen.queryByText(/array/)).not.toBeInTheDocument(); fireEvent.keyDown(buttons[0], { key: 'Enter', code: 'Enter' }); + fireEvent.keyDown(buttons[0], { key: ' ', code: 'Space' }); testButtonsCollapsed(); expect(screen.queryByText(/test:/)).not.toBeInTheDocument(); expect(screen.queryByText(/array/)).not.toBeInTheDocument(); }); + + it('only one item with tabindex=0 if level=0, none if level>0', () => { + const data = { test: [1, 2, 3], test2: [1, 2, 3], test3: { a: 'b', c: { d: '1', a: 2 } } }; + + const { container, rerender } = render(); + expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + + rerender(); + expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(0); + }); }); diff --git a/src/DataRenderer.tsx b/src/DataRenderer.tsx index bfa4cee..19c6455 100644 --- a/src/DataRenderer.tsx +++ b/src/DataRenderer.tsx @@ -21,27 +21,27 @@ export interface StyleProps { quotesForFieldNames?: boolean; } -export interface JsonRenderProps { - field?: string; - value: T; +interface CommonRenderProps { lastElement: boolean; + /** There should only be one node with `level==0`. */ level: number; style: StyleProps; shouldExpandNode: (level: number, value: any, field?: string) => boolean; clickToExpandNode: boolean; + outerRef: React.RefObject; +} + +export interface JsonRenderProps extends CommonRenderProps { + field?: string; + value: T; } -export interface ExpandableRenderProps { +export interface ExpandableRenderProps extends CommonRenderProps { field: string | undefined; value: Array | object; data: Array<[string | undefined, any]>; openBracket: string; closeBracket: string; - lastElement: boolean; - level: number; - style: StyleProps; - shouldExpandNode: (level: number, value: any, field?: string) => boolean; - clickToExpandNode: boolean; } function quoteString(value: string, quoted = false) { @@ -62,12 +62,16 @@ function ExpandableObject({ level, style, shouldExpandNode, - clickToExpandNode + clickToExpandNode, + outerRef }: ExpandableRenderProps) { + // follows tree example for role structure and keypress actions: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-1a/ + const shouldExpandNodeCalledRef = React.useRef(false); const [expanded, toggleExpanded, setExpanded] = useBool(() => shouldExpandNode(level, value, field) ); + const expanderButtonRef = React.useRef(null); React.useEffect(() => { if (!shouldExpandNodeCalledRef.current) { @@ -85,33 +89,68 @@ function ExpandableObject({ const lastIndex = data.length - 1; const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === ' ') { + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + e.preventDefault(); + setExpanded(e.key === 'ArrowRight'); + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); - toggleExpanded(); + const direction = e.key === 'ArrowUp' ? -1 : 1; + + if (!outerRef.current) return; + const buttonElements = outerRef.current.querySelectorAll('[role=button]'); + let currentIndex = -1; + buttonElements.forEach((el, i) => { + if (el.tabIndex === 0) currentIndex = i; + }); + if (currentIndex < 0) return; + + const nextIndex = (currentIndex + direction + buttonElements.length) % buttonElements.length; // auto-wrap + buttonElements[currentIndex].tabIndex = -1; + buttonElements[nextIndex].tabIndex = 0; + buttonElements[nextIndex].focus(); } }; + const onClick = () => { + toggleExpanded(); + + const buttonElement = expanderButtonRef.current; + if (!buttonElement) return; + const prevButtonElement = outerRef.current?.querySelector( + '[role=button][tabindex="0"]' + ); + if (prevButtonElement) { + prevButtonElement.tabIndex = -1; + } + buttonElement.tabIndex = 0; + buttonElement.focus(); + }; + return ( -
+
{(field || field === '') && (clickToExpandNode ? ( - + // don't apply role="button" or tabIndex even though has onClick, because has same + // function as the +/- expander button (so just expose that button to keyboard and a11y tree) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + {quoteString(field, style.quotesForFieldNames)}: ) : ( @@ -120,7 +159,7 @@ function ExpandableObject({ {openBracket} {expanded ? ( -
+
    {data.map((dataElement, index) => ( ))} -
+ ) : ( - + // don't apply role="button" or tabIndex even though has onClick, because has same + // function as the +/- expander button (so just expose that button to keyboard and a11y tree) + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + )} {closeBracket} @@ -163,7 +197,7 @@ export interface EmptyRenderProps { function EmptyObject({ field, openBracket, closeBracket, lastElement, style }: EmptyRenderProps) { return ( -
+
{(field || field === '') && ( {quoteString(field, style.quotesForFieldNames)}: )} @@ -181,7 +215,8 @@ function JsonObject({ lastElement, shouldExpandNode, clickToExpandNode, - level + level, + outerRef }: JsonRenderProps) { if (Object.keys(value).length === 0) { return EmptyObject({ @@ -203,7 +238,8 @@ function JsonObject({ style, shouldExpandNode, clickToExpandNode, - data: Object.keys(value).map((key) => [key, value[key as keyof typeof value]]) + data: Object.keys(value).map((key) => [key, value[key as keyof typeof value]]), + outerRef }); } @@ -214,7 +250,8 @@ function JsonArray({ lastElement, level, shouldExpandNode, - clickToExpandNode + clickToExpandNode, + outerRef }: JsonRenderProps>) { if (value.length === 0) { return EmptyObject({ @@ -236,7 +273,8 @@ function JsonArray({ style, shouldExpandNode, clickToExpandNode, - data: value.map((element) => [undefined, element]) + data: value.map((element) => [undefined, element]), + outerRef }); } @@ -274,7 +312,7 @@ function JsonPrimitiveValue({ } return ( -
+
{(field || field === '') && ( {quoteString(field, style.quotesForFieldNames)}: )} diff --git a/src/hooks.ts b/src/hooks.ts index 4e44802..a0ac35c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -5,9 +5,9 @@ export function useBool( ): [boolean, () => void, (value: boolean) => void] { const [value, setValue] = useState(initialValueCreator()); - const tooggle = () => setValue((currentValue) => !currentValue); + const toggle = () => setValue((currentValue) => !currentValue); - return [value, tooggle, setValue]; + return [value, toggle, setValue]; } let componentId = 1; diff --git a/src/index.test.tsx b/src/index.test.tsx index 3dedf65..7fcc48c 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { JsonView, defaultStyles, allExpanded, collapseAllNested } from '.'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { StyleProps } from './DataRenderer'; describe('JsonView', () => { it('should render object', () => { @@ -17,7 +18,7 @@ describe('JsonView', () => { }); it('should render object with incomplete style object', () => { - render(); + render(); expect(screen.getByText(/test/)).toBeInTheDocument(); expect(screen.getByText('true')).toBeInTheDocument(); }); @@ -44,4 +45,35 @@ describe('JsonView', () => { expect(collapseAllNested(2)).toBeFalsy(); expect(collapseAllNested(3)).toBeFalsy(); }); + + it('should go to next node on ArrowDown, prev node with ArrowUp, tabindex should change', () => { + const { container } = render(); + + expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + const buttons = screen.getAllByRole('button', { hidden: true }); + expect(buttons).toHaveLength(3); + + expect(buttons[0].tabIndex).toEqual(0); + expect(buttons[1].tabIndex).toEqual(-1); + expect(buttons[2].tabIndex).toEqual(-1); + + buttons[0].focus(); + expect(buttons[0]).toHaveFocus(); + + fireEvent.keyDown(buttons[0], { key: 'ArrowDown', code: 'ArrowDown' }); + expect(buttons[0]).not.toHaveFocus(); + expect(buttons[1]).toHaveFocus(); + expect(buttons[2]).not.toHaveFocus(); + expect(buttons[0].tabIndex).toEqual(-1); + expect(buttons[1].tabIndex).toEqual(0); + expect(buttons[2].tabIndex).toEqual(-1); + + fireEvent.keyDown(buttons[1], { key: 'ArrowUp', code: 'ArrowUp' }); + expect(buttons[0]).toHaveFocus(); + expect(buttons[1]).not.toHaveFocus(); + expect(buttons[2]).not.toHaveFocus(); + expect(buttons[0].tabIndex).toEqual(0); + expect(buttons[1].tabIndex).toEqual(-1); + expect(buttons[2].tabIndex).toEqual(-1); + }); }); diff --git a/src/index.tsx b/src/index.tsx index 828d974..9d133fc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import DataRender, { StyleProps } from './DataRenderer'; import styles from './styles.module.css'; -export interface Props { +export interface Props extends React.AriaAttributes { data: Object | Array; style?: StyleProps; shouldExpandNode?: (level: number, value: any, field?: string) => boolean; @@ -54,10 +54,18 @@ export const JsonView = ({ data, style = defaultStyles, shouldExpandNode = allExpanded, - clickToExpandNode = false + clickToExpandNode = false, + ...ariaAttrs }: Props) => { + const outerRef = React.useRef(null); return ( -
+
);