From 9978bfeb8da126af69fd84798fc1b52dfb835a0e Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 19 Sep 2022 16:03:06 +0200 Subject: [PATCH 01/14] feat: isInaccessible API --- src/helpers/accessiblity.ts | 52 +++++++++++++++++++++++++++++++++++++ src/pure.ts | 2 ++ 2 files changed, 54 insertions(+) create mode 100644 src/helpers/accessiblity.ts diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts new file mode 100644 index 000000000..b08bfda42 --- /dev/null +++ b/src/helpers/accessiblity.ts @@ -0,0 +1,52 @@ +import { StyleSheet } from 'react-native'; +import { ReactTestInstance } from 'react-test-renderer'; + +export function isInaccessible(instance: ReactTestInstance | null): boolean { + if (!instance) { + return true; + } + + // Android: importantForAccessibility + // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android + if (instance.props.importantForAccessibility === 'no') return true; + + let current: ReactTestInstance | null = instance; + while (current) { + if (isSubtreeInaccessible(current)) { + return true; + } + + current = current.parent; + } + + return false; +} + +function isSubtreeInaccessible( + instance: ReactTestInstance | null | undefined +): boolean { + if (!instance) { + return true; + } + + // TODO implement iOS: accessibilityViewIsModal + // The hard part is to implement this to look only for host views + // See: https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios + // if (instance.parent?.children.some((child) => child.accessibilityViewIsModal)) + // return true; + + // iOS: accessibilityElementsHidden + // See: https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios + if (instance.props.accessibilityElementsHidden) return true; + + // Android: importantForAccessibility + // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android + if (instance.props.importantForAccessibility === 'no-hide-descendants') + return true; + + const flatStyle = StyleSheet.flatten(instance.props.style); + if (flatStyle.display === 'none') return true; + if (flatStyle.opacity === 0) return true; + + return false; +} diff --git a/src/pure.ts b/src/pure.ts index c1ad3e00d..c3f448249 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -8,6 +8,7 @@ import { within, getQueriesForElement } from './within'; import { getDefaultNormalizer } from './matches'; import { renderHook } from './renderHook'; import { screen } from './screen'; +import { isInaccessible } from './helpers/accessiblity'; export type { RenderOptions, @@ -26,3 +27,4 @@ export { within, getQueriesForElement }; export { getDefaultNormalizer }; export { renderHook }; export { screen }; +export { isInaccessible }; From 8ac8ac60d6e11990bc2ea9ec10ba92fbcceb2cf6 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 19 Sep 2022 16:15:42 +0200 Subject: [PATCH 02/14] feat: basic implementation & tests --- .eslintrc | 3 +- src/helpers/__tests__/accessiblity.test.tsx | 74 +++++++++++++++++++++ src/helpers/accessiblity.ts | 2 +- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/helpers/__tests__/accessiblity.test.tsx diff --git a/.eslintrc b/.eslintrc index d40d133a2..33ed457bd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,9 +10,10 @@ 2, { "ignore": ["^@theme", "^@docusaurus", "^@generated"] } ], - "react-native-a11y/has-valid-accessibility-ignores-invert-colors": 0, "react-native/no-color-literals": "off", + "react-native/no-inline-styles": "off", "react-native-a11y/has-valid-accessibility-descriptors": "off", + "react-native-a11y/has-valid-accessibility-ignores-invert-colors": 0, "react-native-a11y/has-valid-accessibility-value": "off" } } diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx new file mode 100644 index 000000000..4c815c74f --- /dev/null +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { View, Text, TextInput } from 'react-native'; +import { render, isInaccessible } from '../..'; + +test('returns false for accessible elements', () => { + expect( + isInaccessible(render().getByTestId('subject')) + ).toBe(false); + + expect( + isInaccessible( + render(Hello).getByTestId('subject') + ) + ).toBe(false); + + expect( + isInaccessible( + render().getByTestId('subject') + ) + ).toBe(false); +}); + +test('detects elements with display=none', () => { + const view = render(); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects nested elements with display=none', () => { + const view = render( + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects elements with display=none with complex style', () => { + const view = render( + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects elements with opacity=0', () => { + const view = render(); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects nested elements with opacity=0', () => { + const view = render( + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects elements with opacity=0 with complex styles', () => { + const view = render( + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('is not trigged by opacity > 0', () => { + const view = render(); + expect(isInaccessible(view.getByTestId('subject'))).toBe(false); +}); diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index b08bfda42..4d088b491 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -44,7 +44,7 @@ function isSubtreeInaccessible( if (instance.props.importantForAccessibility === 'no-hide-descendants') return true; - const flatStyle = StyleSheet.flatten(instance.props.style); + const flatStyle = StyleSheet.flatten(instance.props.style) ?? {}; if (flatStyle.display === 'none') return true; if (flatStyle.opacity === 0) return true; From 512d79487e99faed28009ee5b18c65afb7cf6771 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 19 Sep 2022 17:40:54 +0200 Subject: [PATCH 03/14] feature: add more checks & tests --- src/helpers/__tests__/accessiblity.test.tsx | 185 ++++++++++++++++++-- src/helpers/accessiblity.ts | 27 +-- src/helpers/component-tree.ts | 55 ++++++ 3 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 src/helpers/component-tree.ts diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx index 4c815c74f..825a6c332 100644 --- a/src/helpers/__tests__/accessiblity.test.tsx +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { View, Text, TextInput } from 'react-native'; -import { render, isInaccessible } from '../..'; +import { View, Text, TextInput, Pressable, Modal } from 'react-native'; +import { render, fireEvent, isInaccessible } from '../..'; +import { sleep } from '../../__tests__/timerUtils'; test('returns false for accessible elements', () => { expect( @@ -20,55 +21,201 @@ test('returns false for accessible elements', () => { ).toBe(false); }); -test('detects elements with display=none', () => { - const view = render(); +test('detects elements with importantForAccessibility="no" prop', () => { + const view = render(); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects nested elements with display=none', () => { +test('detects elements with importantForAccessibility="no-hide-descendants" prop', () => { const view = render( - + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects nested elements with importantForAccessibility="no-hide-descendants" prop', () => { + const view = render( + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects elements with display=none with complex style', () => { +test('detects elements with accessibilityElementsHidden prop', () => { + const view = render(); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects nested elements with accessibilityElementsHidden prop', () => { const view = render( - + + + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects elements with opacity=0', () => { - const view = render(); +test('detects deeply nested elements with accessibilityElementsHidden prop', () => { + const view = render( + + + + + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects elements with display=none', () => { + const view = render(); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects nested elements with opacity=0', () => { +test('detects nested elements with display=none', () => { const view = render( - + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects elements with opacity=0 with complex styles', () => { +test('detects deeply nested elements with display=none', () => { + const view = render( + + + + + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects elements with display=none with complex style', () => { const view = render( ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('is not trigged by opacity > 0', () => { - const view = render(); +test('is not trigged by opacity = 0', () => { + const view = render(); expect(isInaccessible(view.getByTestId('subject'))).toBe(false); }); + +function ModalContainer() { + const [visible, setVisible] = React.useState(true); + + return ( + + setVisible(false)}> + Hide me + + + + ); +} + +test('detects elements in invisible modal', async () => { + const view = render(); + expect(isInaccessible(view.getByTestId('subject'))).toBe(false); + expect(view.toJSON()).toMatchInlineSnapshot(` + + + + Hide me + + + + + `); + + await sleep(1000); + fireEvent.press(view.getByText('Hide me')); + + expect(view.toJSON()).toMatchInlineSnapshot(` + + `); + expect(isInaccessible(view.queryByTestId('subject'))).toBe(true); + + await sleep(1000); + expect(view.toJSON()).toMatchInlineSnapshot(` + + `); +}); + +// test('detects siblings elements to visible modal', () => { +// const view = render( +// +// +// Modal +// +// +// +// ); +// expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +// }); + +// test('detects descendants of siblings elements to visible modal', () => { +// const view = render( +// +// +// Modal +// +// +// +// +// +// ); +// expect(view.toJSON()).toMatchInlineSnapshot(` +// +// +// +// Modal +// +// +// +// +// +// +// `); + +// expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +// }); diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index 4d088b491..dced1ffc6 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -1,8 +1,9 @@ import { StyleSheet } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; +import { getHostChildren } from './component-tree'; export function isInaccessible(instance: ReactTestInstance | null): boolean { - if (!instance) { + if (instance == null) { return true; } @@ -25,28 +26,32 @@ export function isInaccessible(instance: ReactTestInstance | null): boolean { function isSubtreeInaccessible( instance: ReactTestInstance | null | undefined ): boolean { - if (!instance) { + if (instance == null) { return true; } - // TODO implement iOS: accessibilityViewIsModal - // The hard part is to implement this to look only for host views - // See: https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios - // if (instance.parent?.children.some((child) => child.accessibilityViewIsModal)) - // return true; - // iOS: accessibilityElementsHidden // See: https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios - if (instance.props.accessibilityElementsHidden) return true; + if (instance.props.accessibilityElementsHidden) { + return true; + } // Android: importantForAccessibility // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android - if (instance.props.importantForAccessibility === 'no-hide-descendants') + if (instance.props.importantForAccessibility === 'no-hide-descendants') { return true; + } + // Note that `opacity: 0` is not threated as inassessible on iOS () const flatStyle = StyleSheet.flatten(instance.props.style) ?? {}; if (flatStyle.display === 'none') return true; - if (flatStyle.opacity === 0) return true; + + // iOS: accessibilityViewIsModal + // See: https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios + const hostChildren = getHostChildren(instance); + if (hostChildren.some((child) => child.props.accessibilityViewIsModal)) { + return true; + } return false; } diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts new file mode 100644 index 000000000..a910b66f7 --- /dev/null +++ b/src/helpers/component-tree.ts @@ -0,0 +1,55 @@ +import { ReactTestInstance } from 'react-test-renderer'; + +/** + * Checks if the given instance is a host component. + * @param node The node to check. + */ +export function isHostComponent(node: ReactTestInstance): boolean { + return typeof node.type === 'string'; +} + +/** + * Returns first host ancestor for given node. + * @param node The node start traversing from. + */ +export function getHostParent( + node: ReactTestInstance | null +): ReactTestInstance | null { + if (node == null) { + return null; + } + + if (isHostComponent(node)) { + return node; + } + + return getHostParent(node.parent); +} + +/** + * Returns first host ancestor for given node. + * @param node The node start traversing from. + */ +export function getHostChildren( + node: ReactTestInstance | null +): ReactTestInstance[] { + if (node == null) { + return []; + } + + const hostChildren: ReactTestInstance[] = []; + + node.children.forEach((child) => { + if (typeof child === 'string') { + return; + } + + if (isHostComponent(child)) { + hostChildren.push(child); + } else if (typeof child !== 'string') { + hostChildren.push(...getHostChildren(child)); + } + }); + + return hostChildren; +} From a14fd263a188d30995c9cf43bacd03dcc472abc8 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 20 Sep 2022 15:01:04 +0200 Subject: [PATCH 04/14] feat: support accessibilityViewIsModal prop --- src/helpers/__tests__/accessiblity.test.tsx | 201 ++++++++------------ src/helpers/accessiblity.ts | 28 ++- src/helpers/component-tree.ts | 44 ++++- 3 files changed, 126 insertions(+), 147 deletions(-) diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx index 825a6c332..bf5e73fbf 100644 --- a/src/helpers/__tests__/accessiblity.test.tsx +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { View, Text, TextInput, Pressable, Modal } from 'react-native'; -import { render, fireEvent, isInaccessible } from '../..'; -import { sleep } from '../../__tests__/timerUtils'; +import { View, Text, TextInput } from 'react-native'; +import { render, isInaccessible } from '../..'; test('returns false for accessible elements', () => { expect( @@ -21,49 +20,49 @@ test('returns false for accessible elements', () => { ).toBe(false); }); -test('detects elements with importantForAccessibility="no" prop', () => { - const view = render(); +test('detects elements with accessibilityElementsHidden prop', () => { + const view = render(); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects elements with importantForAccessibility="no-hide-descendants" prop', () => { +test('detects nested elements with accessibilityElementsHidden prop', () => { const view = render( - + + + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects nested elements with importantForAccessibility="no-hide-descendants" prop', () => { +test('detects deeply nested elements with accessibilityElementsHidden prop', () => { const view = render( - - + + + + + + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects elements with accessibilityElementsHidden prop', () => { - const view = render(); +test('detects elements with importantForAccessibility="no" prop', () => { + const view = render(); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects nested elements with accessibilityElementsHidden prop', () => { +test('detects elements with importantForAccessibility="no-hide-descendants" prop', () => { const view = render( - - - + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects deeply nested elements with accessibilityElementsHidden prop', () => { +test('detects nested elements with importantForAccessibility="no-hide-descendants" prop', () => { const view = render( - - - - - - + + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(true); @@ -111,111 +110,61 @@ test('is not trigged by opacity = 0', () => { expect(isInaccessible(view.getByTestId('subject'))).toBe(false); }); -function ModalContainer() { - const [visible, setVisible] = React.useState(true); - - return ( - - setVisible(false)}> - Hide me - +test('detects siblings of element with accessibilityViewIsModal prop', () => { + const view = render( + + - + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('detects deeply nested siblings of element with accessibilityViewIsModal prop', () => { + const view = render( + + + + + + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +test('is not triggered for element with accessibilityViewIsModal prop', () => { + const view = render( + + + ); -} + expect(isInaccessible(view.getByTestId('subject'))).toBe(false); +}); -test('detects elements in invisible modal', async () => { - const view = render(); +test('is not triggered for child of element with accessibilityViewIsModal prop', () => { + const view = render( + + + + + + ); expect(isInaccessible(view.getByTestId('subject'))).toBe(false); - expect(view.toJSON()).toMatchInlineSnapshot(` - - - - Hide me - +}); + +test('is not triggered for descendent of element with accessibilityViewIsModal prop', () => { + const view = render( + + + + + + + - - - `); - - await sleep(1000); - fireEvent.press(view.getByText('Hide me')); - - expect(view.toJSON()).toMatchInlineSnapshot(` - - `); - expect(isInaccessible(view.queryByTestId('subject'))).toBe(true); - - await sleep(1000); - expect(view.toJSON()).toMatchInlineSnapshot(` - - `); -}); - -// test('detects siblings elements to visible modal', () => { -// const view = render( -// -// -// Modal -// -// -// -// ); -// expect(isInaccessible(view.getByTestId('subject'))).toBe(true); -// }); - -// test('detects descendants of siblings elements to visible modal', () => { -// const view = render( -// -// -// Modal -// -// -// -// -// -// ); -// expect(view.toJSON()).toMatchInlineSnapshot(` -// -// -// -// Modal -// -// -// -// -// -// -// `); - -// expect(isInaccessible(view.getByTestId('subject'))).toBe(true); -// }); + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(false); +}); diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index dced1ffc6..7cf025b2f 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -1,17 +1,17 @@ import { StyleSheet } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; -import { getHostChildren } from './component-tree'; +import { getHostSiblings } from './component-tree'; -export function isInaccessible(instance: ReactTestInstance | null): boolean { - if (instance == null) { +export function isInaccessible(node: ReactTestInstance | null): boolean { + if (node == null) { return true; } // Android: importantForAccessibility // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android - if (instance.props.importantForAccessibility === 'no') return true; + if (node.props.importantForAccessibility === 'no') return true; - let current: ReactTestInstance | null = instance; + let current: ReactTestInstance | null = node; while (current) { if (isSubtreeInaccessible(current)) { return true; @@ -23,33 +23,31 @@ export function isInaccessible(instance: ReactTestInstance | null): boolean { return false; } -function isSubtreeInaccessible( - instance: ReactTestInstance | null | undefined -): boolean { - if (instance == null) { +function isSubtreeInaccessible(node: ReactTestInstance | null): boolean { + if (node == null) { return true; } // iOS: accessibilityElementsHidden // See: https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios - if (instance.props.accessibilityElementsHidden) { + if (node.props.accessibilityElementsHidden) { return true; } // Android: importantForAccessibility // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android - if (instance.props.importantForAccessibility === 'no-hide-descendants') { + if (node.props.importantForAccessibility === 'no-hide-descendants') { return true; } - // Note that `opacity: 0` is not threated as inassessible on iOS () - const flatStyle = StyleSheet.flatten(instance.props.style) ?? {}; + // Note that `opacity: 0` is not threated as inassessible on iOS + const flatStyle = StyleSheet.flatten(node.props.style) ?? {}; if (flatStyle.display === 'none') return true; // iOS: accessibilityViewIsModal // See: https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios - const hostChildren = getHostChildren(instance); - if (hostChildren.some((child) => child.props.accessibilityViewIsModal)) { + const hostSiblings = getHostSiblings(node); + if (hostSiblings.some((sibling) => sibling.props.accessibilityViewIsModal)) { return true; } diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index a910b66f7..4d6621836 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -19,15 +19,20 @@ export function getHostParent( return null; } - if (isHostComponent(node)) { - return node; + let current = node.parent; + while (current) { + if (isHostComponent(current)) { + return current; + } + + current = current.parent; } - return getHostParent(node.parent); + return null; } /** - * Returns first host ancestor for given node. + * Returns host children for given node. * @param node The node start traversing from. */ export function getHostChildren( @@ -40,16 +45,43 @@ export function getHostChildren( const hostChildren: ReactTestInstance[] = []; node.children.forEach((child) => { - if (typeof child === 'string') { + if (typeof child !== 'object') { return; } if (isHostComponent(child)) { hostChildren.push(child); - } else if (typeof child !== 'string') { + } else { hostChildren.push(...getHostChildren(child)); } }); return hostChildren; } + +/** + * Return the array of host nodes that represent the passed node. + * + * @param node The node start traversing from. + * @returns If the passed node is a host node, it will return an array containing only that node, + * if the passed node is a composite node, it will return an array containing its host children (zero, one or many). + */ +export function getHostSelves( + node: ReactTestInstance | null +): ReactTestInstance[] { + return typeof node?.type === 'string' ? [node] : getHostChildren(node); +} + +/** + * Returns host siblings for given node. + * @param node The node start traversing from. + */ +export function getHostSiblings( + node: ReactTestInstance | null +): ReactTestInstance[] { + const hostParent = getHostParent(node); + const hostSelves = getHostSelves(node); + return getHostChildren(hostParent).filter( + (sibling) => !hostSelves.includes(sibling) + ); +} From f761d9c46ed73001a274b0c4224d67e60be1d897 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 20 Sep 2022 16:03:03 +0200 Subject: [PATCH 05/14] chore: add component tree tests --- src/helpers/__tests__/component-tree.test.tsx | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/helpers/__tests__/component-tree.test.tsx diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx new file mode 100644 index 000000000..984fb0186 --- /dev/null +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { View, Text, TextInput } from 'react-native'; +import { render } from '../..'; +import { + getHostChildren, + getHostParent, + getHostSelves, + getHostSiblings, +} from '../component-tree'; + +function MultipleHostChildren() { + return ( + <> + + + + + ); +} + +test('returns host parent for host component', () => { + const view = render( + + + + + + + ); + + const hostParent = getHostParent(view.getByTestId('subject')); + expect(hostParent).toBe(view.getByTestId('parent')); + + const hostGrandparent = getHostParent(hostParent); + expect(hostGrandparent).toBe(view.getByTestId('grandparent')); + + expect(getHostParent(hostGrandparent)).toBe(null); +}); + +test('returns host parent for composite component', () => { + const view = render( + + + + + ); + + const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); + const hostParent = getHostParent(compositeComponent); + expect(hostParent).toBe(view.getByTestId('parent')); +}); + +test('returns host children for host component', () => { + const view = render( + + + + + + + ); + + const hostSubject = view.getByTestId('subject'); + expect(getHostChildren(hostSubject)).toEqual([]); + + const hostSibling = view.getByTestId('sibling'); + const hostParent = view.getByTestId('parent'); + expect(getHostChildren(hostParent)).toEqual([hostSubject, hostSibling]); + + const hostGrandparent = view.getByTestId('grandparent'); + expect(getHostChildren(hostGrandparent)).toEqual([hostParent]); +}); + +test('returns host children for composite component', () => { + const view = render( + + + + + + ); + + expect(getHostChildren(view.getByTestId('parent'))).toEqual([ + view.getByTestId('child1'), + view.getByTestId('child2'), + view.getByTestId('child3'), + view.getByTestId('subject'), + view.getByTestId('sibling'), + ]); +}); + +test('returns host selves for host components', () => { + const view = render( + + + + + + + ); + + const hostSubject = view.getByTestId('subject'); + expect(getHostSelves(hostSubject)).toEqual([hostSubject]); + + const hostSibling = view.getByTestId('sibling'); + expect(getHostSelves(hostSibling)).toEqual([hostSibling]); + + const hostParent = view.getByTestId('parent'); + expect(getHostSelves(hostParent)).toEqual([hostParent]); + + const hostGrandparent = view.getByTestId('grandparent'); + expect(getHostSelves(hostGrandparent)).toEqual([hostGrandparent]); +}); + +test('returns host selves for React Native composite components', () => { + const view = render( + + Text + + + ); + + const compositeText = view.getByText('Text'); + const hostText = view.getByTestId('text'); + expect(getHostSelves(compositeText)).toEqual([hostText]); + + const compositeTextInputByValue = view.getByDisplayValue('TextInputValue'); + const compositeTextInputByPlaceholder = view.getByPlaceholderText( + 'TextInputPlaceholder' + ); + const hostTextInput = view.getByTestId('textInput'); + expect(getHostSelves(compositeTextInputByValue)).toEqual([hostTextInput]); + expect(getHostSelves(compositeTextInputByPlaceholder)).toEqual([ + hostTextInput, + ]); +}); + +test('returns host selves for custom composite components', () => { + const view = render( + + + + + ); + + const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); + const hostChild1 = view.getByTestId('child1'); + const hostChild2 = view.getByTestId('child2'); + const hostChild3 = view.getByTestId('child3'); + expect(getHostSelves(compositeComponent)).toEqual([ + hostChild1, + hostChild2, + hostChild3, + ]); +}); + +test('returns host siblings for host component', () => { + const view = render( + + + + + + + + + ); + + const hostSiblings = getHostSiblings(view.getByTestId('subject')); + expect(hostSiblings).toEqual([ + view.getByTestId('siblingBefore'), + view.getByTestId('siblingAfter'), + view.getByTestId('child1'), + view.getByTestId('child2'), + view.getByTestId('child3'), + ]); +}); + +test('returns host siblings for composie component', () => { + const view = render( + + + + + + + + + ); + + const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); + const hostSiblings = getHostSiblings(compositeComponent); + expect(hostSiblings).toEqual([ + view.getByTestId('siblingBefore'), + view.getByTestId('subject'), + view.getByTestId('siblingAfter'), + ]); +}); From 1f32dcecc5bfb86349c482abebf7783c58b4b8db Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 20 Sep 2022 16:03:18 +0200 Subject: [PATCH 06/14] refactor: self code review --- src/helpers/accessiblity.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index 7cf025b2f..5466c7acf 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -2,16 +2,16 @@ import { StyleSheet } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; import { getHostSiblings } from './component-tree'; -export function isInaccessible(node: ReactTestInstance | null): boolean { - if (node == null) { +export function isInaccessible(element: ReactTestInstance | null): boolean { + if (element == null) { return true; } // Android: importantForAccessibility // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android - if (node.props.importantForAccessibility === 'no') return true; + if (element.props.importantForAccessibility === 'no') return true; - let current: ReactTestInstance | null = node; + let current: ReactTestInstance | null = element; while (current) { if (isSubtreeInaccessible(current)) { return true; @@ -23,30 +23,30 @@ export function isInaccessible(node: ReactTestInstance | null): boolean { return false; } -function isSubtreeInaccessible(node: ReactTestInstance | null): boolean { - if (node == null) { +function isSubtreeInaccessible(element: ReactTestInstance | null): boolean { + if (element == null) { return true; } // iOS: accessibilityElementsHidden // See: https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios - if (node.props.accessibilityElementsHidden) { + if (element.props.accessibilityElementsHidden) { return true; } // Android: importantForAccessibility // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android - if (node.props.importantForAccessibility === 'no-hide-descendants') { + if (element.props.importantForAccessibility === 'no-hide-descendants') { return true; } // Note that `opacity: 0` is not threated as inassessible on iOS - const flatStyle = StyleSheet.flatten(node.props.style) ?? {}; + const flatStyle = StyleSheet.flatten(element.props.style) ?? {}; if (flatStyle.display === 'none') return true; // iOS: accessibilityViewIsModal // See: https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios - const hostSiblings = getHostSiblings(node); + const hostSiblings = getHostSiblings(element); if (hostSiblings.some((sibling) => sibling.props.accessibilityViewIsModal)) { return true; } From 80361132ad5ed9d3e06d02be324b3887e9d900d4 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 20 Sep 2022 16:03:29 +0200 Subject: [PATCH 07/14] docs: improve API formatting --- website/docs/API.md | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/website/docs/API.md b/website/docs/API.md index f297591fe..407731ad9 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -41,6 +41,8 @@ title: API - [Examples](#examples) - [With `initialProps`](#with-initialprops) - [With `wrapper`](#with-wrapper) +- [Accessibility](#accessibility) + - [`isInaccessible`](#isinaccessible) This page gathers public API of React Native Testing Library along with usage examples. @@ -225,7 +227,11 @@ Failing to call `cleanup` when you've called `render` could result in a memory l ## `fireEvent` ```ts -fireEvent(element: ReactTestInstance, eventName: string, ...data: Array): void +function fireEvent( + element: ReactTestInstance, + eventName: string, + ...data: Array +): void {} ``` Fires native-like event with data. @@ -485,8 +491,13 @@ If you receive warnings related to `act()` function consult our [Undestanding Ac Defined as: ```jsx -function within(instance: ReactTestInstance): Queries -function getQueriesForElement(instance: ReactTestInstance): Queries +function within( + element: ReactTestInstance +): Queries {} + +function getQueriesForElement( + element: ReactTestInstance +): Queries {} ``` `within` (also available as `getQueriesForElement` alias) performs [queries](./Queries.md) scoped to given element. @@ -669,3 +680,23 @@ it('should use context value', () => { // ... }); ``` + +## Accessibility + +### `isInaccessible` + +```ts +function isInaccessible( + element: ReactTestInstance | null +): boolean {} +``` + +Checks if given element should be excluded from the accessiblity e.g. by screen readers. + +Element is considered inaccessbile when one of the follwing applies: +* element has `display: none` style +* element has [`accessibilityElementsHidden`](https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios) prop set to `true` +* element has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no` or `no-hide-descendants` +* element is a sibling of view with [`accessibilityViewIsModal`](https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios) prop set to `true` +* element has an inaccessbile ancestor element, with exception of being descendant of element with `importantForAccessibility` set to `no`. + From 9972cb4926c951f1e212d864c80d42584ab0e55a Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 20 Sep 2022 16:05:37 +0200 Subject: [PATCH 08/14] fix: typo --- src/helpers/__tests__/component-tree.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index 984fb0186..2b1b5c619 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -180,7 +180,7 @@ test('returns host siblings for host component', () => { ]); }); -test('returns host siblings for composie component', () => { +test('returns host siblings for composite component', () => { const view = render( From a1a3c7bc2295d29c5d2c1c7ded3babf722f4845e Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 20 Sep 2022 16:10:39 +0200 Subject: [PATCH 09/14] refactor: self code review --- src/fireEvent.ts | 5 +--- src/helpers/component-tree.ts | 56 ++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/fireEvent.ts b/src/fireEvent.ts index 2da697383..3cbf18a8c 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -1,13 +1,10 @@ import { ReactTestInstance } from 'react-test-renderer'; import act from './act'; +import { isHostElement } from './helpers/component-tree'; import { filterNodeByType } from './helpers/filterNodeByType'; type EventHandler = (...args: any) => unknown; -const isHostElement = (element?: ReactTestInstance) => { - return typeof element?.type === 'string'; -}; - const isTextInput = (element?: ReactTestInstance) => { if (!element) { return false; diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 4d6621836..340472d72 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -1,27 +1,27 @@ import { ReactTestInstance } from 'react-test-renderer'; /** - * Checks if the given instance is a host component. - * @param node The node to check. + * Checks if the given element is a host element. + * @param element The element to check. */ -export function isHostComponent(node: ReactTestInstance): boolean { - return typeof node.type === 'string'; +export function isHostElement(element?: ReactTestInstance | null): boolean { + return typeof element?.type === 'string'; } /** - * Returns first host ancestor for given node. - * @param node The node start traversing from. + * Returns first host ancestor for given element. + * @param element The element start traversing from. */ export function getHostParent( - node: ReactTestInstance | null + element: ReactTestInstance | null ): ReactTestInstance | null { - if (node == null) { + if (element == null) { return null; } - let current = node.parent; + let current = element.parent; while (current) { - if (isHostComponent(current)) { + if (isHostElement(current)) { return current; } @@ -32,24 +32,24 @@ export function getHostParent( } /** - * Returns host children for given node. - * @param node The node start traversing from. + * Returns host children for given element. + * @param element The element start traversing from. */ export function getHostChildren( - node: ReactTestInstance | null + element: ReactTestInstance | null ): ReactTestInstance[] { - if (node == null) { + if (element == null) { return []; } const hostChildren: ReactTestInstance[] = []; - node.children.forEach((child) => { + element.children.forEach((child) => { if (typeof child !== 'object') { return; } - if (isHostComponent(child)) { + if (isHostElement(child)) { hostChildren.push(child); } else { hostChildren.push(...getHostChildren(child)); @@ -60,27 +60,29 @@ export function getHostChildren( } /** - * Return the array of host nodes that represent the passed node. + * Return the array of host elements that represent the passed element. * - * @param node The node start traversing from. - * @returns If the passed node is a host node, it will return an array containing only that node, - * if the passed node is a composite node, it will return an array containing its host children (zero, one or many). + * @param element The element start traversing from. + * @returns If the passed element is a host element, it will return an array containing only that element, + * if the passed element is a composite element, it will return an array containing its host children (zero, one or many). */ export function getHostSelves( - node: ReactTestInstance | null + element: ReactTestInstance | null ): ReactTestInstance[] { - return typeof node?.type === 'string' ? [node] : getHostChildren(node); + return typeof element?.type === 'string' + ? [element] + : getHostChildren(element); } /** - * Returns host siblings for given node. - * @param node The node start traversing from. + * Returns host siblings for given element. + * @param element The element start traversing from. */ export function getHostSiblings( - node: ReactTestInstance | null + element: ReactTestInstance | null ): ReactTestInstance[] { - const hostParent = getHostParent(node); - const hostSelves = getHostSelves(node); + const hostParent = getHostParent(element); + const hostSelves = getHostSelves(element); return getHostChildren(hostParent).filter( (sibling) => !hostSelves.includes(sibling) ); From 6c33584838904d9320bfaada666b57b50e4249b7 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Thu, 22 Sep 2022 12:04:27 +0200 Subject: [PATCH 10/14] Update website/docs/API.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Augustin Le Fèvre --- website/docs/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/API.md b/website/docs/API.md index 407731ad9..0925430aa 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -693,7 +693,7 @@ function isInaccessible( Checks if given element should be excluded from the accessiblity e.g. by screen readers. -Element is considered inaccessbile when one of the follwing applies: +Element is considered inaccessible when one of the following applies: * element has `display: none` style * element has [`accessibilityElementsHidden`](https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios) prop set to `true` * element has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no` or `no-hide-descendants` From e2c2c8206721775261a3ce7cc8966ae225a63022 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 23 Sep 2022 12:20:16 +0200 Subject: [PATCH 11/14] refactor: remove importantForAccessibility="no" check as incorrect --- src/helpers/__tests__/accessiblity.test.tsx | 5 ----- src/helpers/accessiblity.ts | 4 ---- 2 files changed, 9 deletions(-) diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx index bf5e73fbf..bd54403d1 100644 --- a/src/helpers/__tests__/accessiblity.test.tsx +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -47,11 +47,6 @@ test('detects deeply nested elements with accessibilityElementsHidden prop', () expect(isInaccessible(view.getByTestId('subject'))).toBe(true); }); -test('detects elements with importantForAccessibility="no" prop', () => { - const view = render(); - expect(isInaccessible(view.getByTestId('subject'))).toBe(true); -}); - test('detects elements with importantForAccessibility="no-hide-descendants" prop', () => { const view = render( diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index 5466c7acf..a4dc1885f 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -7,10 +7,6 @@ export function isInaccessible(element: ReactTestInstance | null): boolean { return true; } - // Android: importantForAccessibility - // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android - if (element.props.importantForAccessibility === 'no') return true; - let current: ReactTestInstance | null = element; while (current) { if (isSubtreeInaccessible(current)) { From f1153e0ea8a396b8567aa039b19d08325c7297c8 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 23 Sep 2022 15:14:31 +0200 Subject: [PATCH 12/14] docs: update API doc --- website/docs/API.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/website/docs/API.md b/website/docs/API.md index 0925430aa..a6e47d066 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -691,12 +691,18 @@ function isInaccessible( ): boolean {} ``` -Checks if given element should be excluded from the accessiblity e.g. by screen readers. +Checks if given element is hidden from assistive technology, e.g. screen readers. -Element is considered inaccessible when one of the following applies: -* element has `display: none` style -* element has [`accessibilityElementsHidden`](https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios) prop set to `true` -* element has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no` or `no-hide-descendants` -* element is a sibling of view with [`accessibilityViewIsModal`](https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios) prop set to `true` -* element has an inaccessbile ancestor element, with exception of being descendant of element with `importantForAccessibility` set to `no`. +:::note +Like [`isInaccessible`](https://testing-library.com/docs/dom-testing-library/api-accessibility/#isinaccessible) function from [DOM Testing Library](https://testing-library.com/docs/dom-testing-library/intro) this function considers both accessibility elements and presentational elements (regular `View`s) to be accessible, unless they are hidden in terms of host platform. + +This covers only part of [ARIA notion of Accessiblity Tree](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), as ARIA excludes both hidden and presentational elements from the Accessibility Tree. +::: +For the scope of this function, element is inaccessible when it, or any of its ancestors, meets any of the following conditions: + * it has `display: none` style + * it has [`accessibilityElementsHidden`](https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios) prop set to `true` + * it has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no-hide-descendants` + * it has sibling host element with [`accessibilityViewIsModal`](https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios) prop set to `true` + +Specifying `accessible={false}` or `accessiblityRole="none"` props does not cause the element to be inaccessible. From a6ad21bec99b3a8dddc1f9a010e3853b277da4ac Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 23 Sep 2022 15:16:46 +0200 Subject: [PATCH 13/14] docs: slight tweak --- website/docs/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/API.md b/website/docs/API.md index a6e47d066..b99e72da0 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -705,4 +705,4 @@ For the scope of this function, element is inaccessible when it, or any of its a * it has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no-hide-descendants` * it has sibling host element with [`accessibilityViewIsModal`](https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios) prop set to `true` -Specifying `accessible={false}` or `accessiblityRole="none"` props does not cause the element to be inaccessible. +Specifying `accessible={false}`, `accessiblityRole="none"`, or `importantForAccessibility="no"` props does not cause the element to be inaccessible. From db9109fd1879de9a2a0e23be836984b5c9b0e7b8 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 23 Sep 2022 15:17:29 +0200 Subject: [PATCH 14/14] docs: more tweaks --- website/docs/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/API.md b/website/docs/API.md index b99e72da0..78ce0af3d 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -705,4 +705,4 @@ For the scope of this function, element is inaccessible when it, or any of its a * it has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no-hide-descendants` * it has sibling host element with [`accessibilityViewIsModal`](https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios) prop set to `true` -Specifying `accessible={false}`, `accessiblityRole="none"`, or `importantForAccessibility="no"` props does not cause the element to be inaccessible. +Specifying `accessible={false}`, `accessiblityRole="none"`, or `importantForAccessibility="no"` props does not cause the element to become inaccessible.