mirror of
https://github.com/callstack/react-native-testing-library.git
synced 2026-09-18 23:09:04 +08:00
feat: isInaccessible API form RTL/DTL (#1128)
This commit is contained in:
committed by
GitHub
parent
807898e792
commit
fa21fc52d4
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user