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/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/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx new file mode 100644 index 000000000..bd54403d1 --- /dev/null +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -0,0 +1,165 @@ +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 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 deeply nested elements with accessibilityElementsHidden prop', () => { + const view = render( + + + + + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(true); +}); + +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', () => { + 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 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(); + expect(isInaccessible(view.getByTestId('subject'))).toBe(false); +}); + +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('is not triggered for child of element with accessibilityViewIsModal prop', () => { + const view = render( + + + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(false); +}); + +test('is not triggered for descendent of element with accessibilityViewIsModal prop', () => { + const view = render( + + + + + + + + + + ); + expect(isInaccessible(view.getByTestId('subject'))).toBe(false); +}); diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx new file mode 100644 index 000000000..2b1b5c619 --- /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 composite 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'), + ]); +}); diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts new file mode 100644 index 000000000..a4dc1885f --- /dev/null +++ b/src/helpers/accessiblity.ts @@ -0,0 +1,51 @@ +import { StyleSheet } from 'react-native'; +import { ReactTestInstance } from 'react-test-renderer'; +import { getHostSiblings } from './component-tree'; + +export function isInaccessible(element: ReactTestInstance | null): boolean { + if (element == null) { + return true; + } + + let current: ReactTestInstance | null = element; + while (current) { + if (isSubtreeInaccessible(current)) { + return true; + } + + current = current.parent; + } + + return false; +} + +function isSubtreeInaccessible(element: ReactTestInstance | null): boolean { + if (element == null) { + return true; + } + + // iOS: accessibilityElementsHidden + // See: https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios + if (element.props.accessibilityElementsHidden) { + return true; + } + + // Android: importantForAccessibility + // See: https://reactnative.dev/docs/accessibility#importantforaccessibility-android + if (element.props.importantForAccessibility === 'no-hide-descendants') { + return true; + } + + // Note that `opacity: 0` is not threated as inassessible on iOS + 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(element); + if (hostSiblings.some((sibling) => sibling.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..340472d72 --- /dev/null +++ b/src/helpers/component-tree.ts @@ -0,0 +1,89 @@ +import { ReactTestInstance } from 'react-test-renderer'; + +/** + * Checks if the given element is a host element. + * @param element The element to check. + */ +export function isHostElement(element?: ReactTestInstance | null): boolean { + return typeof element?.type === 'string'; +} + +/** + * Returns first host ancestor for given element. + * @param element The element start traversing from. + */ +export function getHostParent( + element: ReactTestInstance | null +): ReactTestInstance | null { + if (element == null) { + return null; + } + + let current = element.parent; + while (current) { + if (isHostElement(current)) { + return current; + } + + current = current.parent; + } + + return null; +} + +/** + * Returns host children for given element. + * @param element The element start traversing from. + */ +export function getHostChildren( + element: ReactTestInstance | null +): ReactTestInstance[] { + if (element == null) { + return []; + } + + const hostChildren: ReactTestInstance[] = []; + + element.children.forEach((child) => { + if (typeof child !== 'object') { + return; + } + + if (isHostElement(child)) { + hostChildren.push(child); + } else { + hostChildren.push(...getHostChildren(child)); + } + }); + + return hostChildren; +} + +/** + * Return the array of host elements that represent the passed element. + * + * @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( + element: ReactTestInstance | null +): ReactTestInstance[] { + return typeof element?.type === 'string' + ? [element] + : getHostChildren(element); +} + +/** + * Returns host siblings for given element. + * @param element The element start traversing from. + */ +export function getHostSiblings( + element: ReactTestInstance | null +): ReactTestInstance[] { + const hostParent = getHostParent(element); + const hostSelves = getHostSelves(element); + return getHostChildren(hostParent).filter( + (sibling) => !hostSelves.includes(sibling) + ); +} 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 }; diff --git a/website/docs/API.md b/website/docs/API.md index f297591fe..78ce0af3d 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,29 @@ it('should use context value', () => { // ... }); ``` + +## Accessibility + +### `isInaccessible` + +```ts +function isInaccessible( + element: ReactTestInstance | null +): boolean {} +``` + +Checks if given element is hidden from assistive technology, e.g. screen readers. + +:::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}`, `accessiblityRole="none"`, or `importantForAccessibility="no"` props does not cause the element to become inaccessible.