feat: isInaccessible API form RTL/DTL (#1128)

This commit is contained in:
Maciej Jastrzebski
2022-09-23 19:33:48 +02:00
committed by GitHub
parent 807898e792
commit fa21fc52d4
8 changed files with 552 additions and 8 deletions
+2 -1
View File
@@ -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"
}
}
+1 -4
View File
@@ -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;
+165
View File
@@ -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(<View testID="subject" />).getByTestId('subject'))
).toBe(false);
expect(
isInaccessible(
render(<Text testID="subject">Hello</Text>).getByTestId('subject')
)
).toBe(false);
expect(
isInaccessible(
render(<TextInput testID="subject" />).getByTestId('subject')
)
).toBe(false);
});
test('detects elements with accessibilityElementsHidden prop', () => {
const view = render(<View testID="subject" accessibilityElementsHidden />);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects nested elements with accessibilityElementsHidden prop', () => {
const view = render(
<View accessibilityElementsHidden>
<View testID="subject" />
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects deeply nested elements with accessibilityElementsHidden prop', () => {
const view = render(
<View accessibilityElementsHidden>
<View>
<View>
<View testID="subject" />
</View>
</View>
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects elements with importantForAccessibility="no-hide-descendants" prop', () => {
const view = render(
<View testID="subject" importantForAccessibility="no-hide-descendants" />
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects nested elements with importantForAccessibility="no-hide-descendants" prop', () => {
const view = render(
<View importantForAccessibility="no-hide-descendants">
<View testID="subject" />
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects elements with display=none', () => {
const view = render(<View testID="subject" style={{ display: 'none' }} />);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects nested elements with display=none', () => {
const view = render(
<View style={{ display: 'none' }}>
<View testID="subject" />
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects deeply nested elements with display=none', () => {
const view = render(
<View style={{ display: 'none' }}>
<View>
<View>
<View testID="subject" />
</View>
</View>
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects elements with display=none with complex style', () => {
const view = render(
<View
testID="subject"
style={[{ display: 'flex' }, [{ display: 'flex' }], { display: 'none' }]}
/>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('is not trigged by opacity = 0', () => {
const view = render(<View testID="subject" style={{ opacity: 0 }} />);
expect(isInaccessible(view.getByTestId('subject'))).toBe(false);
});
test('detects siblings of element with accessibilityViewIsModal prop', () => {
const view = render(
<View>
<View accessibilityViewIsModal />
<View testID="subject" />
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('detects deeply nested siblings of element with accessibilityViewIsModal prop', () => {
const view = render(
<View>
<View accessibilityViewIsModal />
<View>
<View>
<View testID="subject" />
</View>
</View>
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(true);
});
test('is not triggered for element with accessibilityViewIsModal prop', () => {
const view = render(
<View>
<View accessibilityViewIsModal testID="subject" />
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(false);
});
test('is not triggered for child of element with accessibilityViewIsModal prop', () => {
const view = render(
<View>
<View accessibilityViewIsModal>
<View testID="subject" />
</View>
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(false);
});
test('is not triggered for descendent of element with accessibilityViewIsModal prop', () => {
const view = render(
<View>
<View accessibilityViewIsModal>
<View>
<View>
<View testID="subject" />
</View>
</View>
</View>
</View>
);
expect(isInaccessible(view.getByTestId('subject'))).toBe(false);
});
@@ -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 (
<>
<View testID="child1" />
<View testID="child2" />
<View testID="child3" />
</>
);
}
test('returns host parent for host component', () => {
const view = render(
<View testID="grandparent">
<View testID="parent">
<View testID="subject" />
<View testID="sibling" />
</View>
</View>
);
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(
<View testID="parent">
<MultipleHostChildren />
<View testID="subject" />
</View>
);
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(
<View testID="grandparent">
<View testID="parent">
<View testID="subject" />
<View testID="sibling" />
</View>
</View>
);
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(
<View testID="parent">
<MultipleHostChildren />
<View testID="subject" />
<View testID="sibling" />
</View>
);
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(
<View testID="grandparent">
<View testID="parent">
<View testID="subject" />
<View testID="sibling" />
</View>
</View>
);
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(
<View testID="parent">
<Text testID="text">Text</Text>
<TextInput
testID="textInput"
defaultValue="TextInputValue"
placeholder="TextInputPlaceholder"
/>
</View>
);
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(
<View testID="parent">
<MultipleHostChildren />
<View testID="sibling" />
</View>
);
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(
<View testID="grandparent">
<View testID="parent">
<View testID="siblingBefore" />
<View testID="subject" />
<View testID="siblingAfter" />
<MultipleHostChildren />
</View>
</View>
);
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(
<View testID="grandparent">
<View testID="parent">
<View testID="siblingBefore" />
<View testID="subject" />
<View testID="siblingAfter" />
<MultipleHostChildren />
</View>
</View>
);
const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren);
const hostSiblings = getHostSiblings(compositeComponent);
expect(hostSiblings).toEqual([
view.getByTestId('siblingBefore'),
view.getByTestId('subject'),
view.getByTestId('siblingAfter'),
]);
});
+51
View File
@@ -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;
}
+89
View File
@@ -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)
);
}
+2
View File
@@ -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 };
+40 -3
View File
@@ -45,6 +45,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.
@@ -258,7 +260,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<any>): void
function fireEvent(
element: ReactTestInstance,
eventName: string,
...data: Array<any>
): void {}
```
Fires native-like event with data.
@@ -518,8 +524,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.
@@ -702,3 +713,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.