Skip to content

Commit 22ec13d

Browse files
refactor: accessibility handling (#1658)
* refactor: improve code structure * refactor: more tweaks
1 parent 4112abc commit 22ec13d

23 files changed

+257
-311
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"cSpell.words": ["Pressable", "RNTL", "Uncapitalize"]
2+
"cSpell.words": ["labelledby", "Pressable", "RNTL", "Uncapitalize", "valuenow", "valuetext"]
33
}

src/helpers/accessibility.ts

Lines changed: 47 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { AccessibilityState, AccessibilityValue, StyleSheet } from 'react-native';
1+
import {
2+
AccessibilityRole,
3+
AccessibilityState,
4+
AccessibilityValue,
5+
Role,
6+
StyleSheet,
7+
} from 'react-native';
28
import { ReactTestInstance } from 'react-test-renderer';
39
import { getHostSiblings, getUnsafeRootElement } from './component-tree';
4-
import { getHostComponentNames, isHostText } from './host-component-names';
10+
import { getHostComponentNames, isHostText, isHostTextInput } from './host-component-names';
511
import { getTextContent } from './text-content';
12+
import { isTextInputEditable } from './text-input';
613

714
type IsInaccessibleOptions = {
815
cache?: WeakMap<ReactTestInstance, boolean>;
@@ -45,7 +52,7 @@ export function isHiddenFromAccessibility(
4552
return false;
4653
}
4754

48-
/** RTL-compatitibility alias for `isHiddenFromAccessibility` */
55+
/** RTL-compatibility alias for `isHiddenFromAccessibility` */
4956
export const isInaccessible = isHiddenFromAccessibility;
5057

5158
function isSubtreeInaccessible(element: ReactTestInstance): boolean {
@@ -78,7 +85,7 @@ function isSubtreeInaccessible(element: ReactTestInstance): boolean {
7885
// iOS: accessibilityViewIsModal or aria-modal
7986
// See: https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios
8087
const hostSiblings = getHostSiblings(element);
81-
if (hostSiblings.some((sibling) => getAccessibilityViewIsModal(sibling))) {
88+
if (hostSiblings.some((sibling) => computeAriaModal(sibling))) {
8289
return true;
8390
}
8491

@@ -115,7 +122,7 @@ export function isAccessibilityElement(element: ReactTestInstance | null): boole
115122
* @param element
116123
* @returns
117124
*/
118-
export function getAccessibilityRole(element: ReactTestInstance) {
125+
export function getRole(element: ReactTestInstance): Role | AccessibilityRole {
119126
const explicitRole = element.props.role ?? element.props.accessibilityRole;
120127
if (explicitRole) {
121128
return explicitRole;
@@ -128,57 +135,55 @@ export function getAccessibilityRole(element: ReactTestInstance) {
128135
return 'none';
129136
}
130137

131-
export function getAccessibilityViewIsModal(element: ReactTestInstance) {
138+
export function computeAriaModal(element: ReactTestInstance): boolean | undefined {
132139
return element.props['aria-modal'] ?? element.props.accessibilityViewIsModal;
133140
}
134141

135-
export function getAccessibilityLabel(element: ReactTestInstance): string | undefined {
142+
export function computeAriaLabel(element: ReactTestInstance): string | undefined {
136143
return element.props['aria-label'] ?? element.props.accessibilityLabel;
137144
}
138145

139-
export function getAccessibilityLabelledBy(element: ReactTestInstance): string | undefined {
146+
export function computeAriaLabelledBy(element: ReactTestInstance): string | undefined {
140147
return element.props['aria-labelledby'] ?? element.props.accessibilityLabelledBy;
141148
}
142149

143-
export function getAccessibilityState(element: ReactTestInstance): AccessibilityState | undefined {
144-
const {
145-
accessibilityState,
146-
'aria-busy': ariaBusy,
147-
'aria-checked': ariaChecked,
148-
'aria-disabled': ariaDisabled,
149-
'aria-expanded': ariaExpanded,
150-
'aria-selected': ariaSelected,
151-
} = element.props;
152-
153-
const hasAnyAccessibilityStateProps =
154-
accessibilityState != null ||
155-
ariaBusy != null ||
156-
ariaChecked != null ||
157-
ariaDisabled != null ||
158-
ariaExpanded != null ||
159-
ariaSelected != null;
150+
// See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#busy-state
151+
export function computeAriaBusy({ props }: ReactTestInstance): boolean {
152+
return props['aria-busy'] ?? props.accessibilityState?.busy ?? false;
153+
}
160154

161-
if (!hasAnyAccessibilityStateProps) {
155+
// See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#checked-state
156+
export function computeAriaChecked(element: ReactTestInstance): AccessibilityState['checked'] {
157+
const role = getRole(element);
158+
if (role !== 'checkbox' && role !== 'radio') {
162159
return undefined;
163160
}
164161

165-
return {
166-
busy: ariaBusy ?? accessibilityState?.busy,
167-
checked: ariaChecked ?? accessibilityState?.checked,
168-
disabled: ariaDisabled ?? accessibilityState?.disabled,
169-
expanded: ariaExpanded ?? accessibilityState?.expanded,
170-
selected: ariaSelected ?? accessibilityState?.selected,
171-
};
162+
const props = element.props;
163+
return props['aria-checked'] ?? props.accessibilityState?.checked;
164+
}
165+
166+
// See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#disabled-state
167+
export function computeAriaDisabled(element: ReactTestInstance): boolean {
168+
if (isHostTextInput(element) && !isTextInputEditable(element)) {
169+
return true;
170+
}
171+
172+
const { props } = element;
173+
return props['aria-disabled'] ?? props.accessibilityState?.disabled ?? false;
174+
}
175+
176+
// See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#expanded-state
177+
export function computeAriaExpanded({ props }: ReactTestInstance): boolean | undefined {
178+
return props['aria-expanded'] ?? props.accessibilityState?.expanded;
172179
}
173180

174-
export function getAccessibilityCheckedState(
175-
element: ReactTestInstance,
176-
): AccessibilityState['checked'] {
177-
const { accessibilityState, 'aria-checked': ariaChecked } = element.props;
178-
return ariaChecked ?? accessibilityState?.checked;
181+
// See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#selected-state
182+
export function computeAriaSelected({ props }: ReactTestInstance): boolean {
183+
return props['aria-selected'] ?? props.accessibilityState?.selected ?? false;
179184
}
180185

181-
export function getAccessibilityValue(element: ReactTestInstance): AccessibilityValue | undefined {
186+
export function computeAriaValue(element: ReactTestInstance): AccessibilityValue {
182187
const {
183188
accessibilityValue,
184189
'aria-valuemax': ariaValueMax,
@@ -187,17 +192,6 @@ export function getAccessibilityValue(element: ReactTestInstance): Accessibility
187192
'aria-valuetext': ariaValueText,
188193
} = element.props;
189194

190-
const hasAnyAccessibilityValueProps =
191-
accessibilityValue != null ||
192-
ariaValueMax != null ||
193-
ariaValueMin != null ||
194-
ariaValueNow != null ||
195-
ariaValueText != null;
196-
197-
if (!hasAnyAccessibilityValueProps) {
198-
return undefined;
199-
}
200-
201195
return {
202196
max: ariaValueMax ?? accessibilityValue?.max,
203197
min: ariaValueMin ?? accessibilityValue?.min,
@@ -206,39 +200,13 @@ export function getAccessibilityValue(element: ReactTestInstance): Accessibility
206200
};
207201
}
208202

209-
export function isElementBusy(element: ReactTestInstance): NonNullable<AccessibilityState['busy']> {
210-
const { accessibilityState, 'aria-busy': ariaBusy } = element.props;
211-
return ariaBusy ?? accessibilityState?.busy ?? false;
212-
}
213-
214-
export function isElementCollapsed(
215-
element: ReactTestInstance,
216-
): NonNullable<AccessibilityState['expanded']> {
217-
const { accessibilityState, 'aria-expanded': ariaExpanded } = element.props;
218-
return (ariaExpanded ?? accessibilityState?.expanded) === false;
219-
}
220-
221-
export function isElementExpanded(
222-
element: ReactTestInstance,
223-
): NonNullable<AccessibilityState['expanded']> {
224-
const { accessibilityState, 'aria-expanded': ariaExpanded } = element.props;
225-
return ariaExpanded ?? accessibilityState?.expanded ?? false;
226-
}
227-
228-
export function isElementSelected(
229-
element: ReactTestInstance,
230-
): NonNullable<AccessibilityState['selected']> {
231-
const { accessibilityState, 'aria-selected': ariaSelected } = element.props;
232-
return ariaSelected ?? accessibilityState?.selected ?? false;
233-
}
234-
235-
export function getAccessibleName(element: ReactTestInstance): string | undefined {
236-
const label = getAccessibilityLabel(element);
203+
export function computeAccessibleName(element: ReactTestInstance): string | undefined {
204+
const label = computeAriaLabel(element);
237205
if (label) {
238206
return label;
239207
}
240208

241-
const labelElementId = getAccessibilityLabelledBy(element);
209+
const labelElementId = computeAriaLabelledBy(element);
242210
if (labelElementId) {
243211
const rootElement = getUnsafeRootElement(element);
244212
const labelElement = rootElement?.findByProps({ nativeID: labelElementId });

src/helpers/host-component-names.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,31 +70,39 @@ function getByTestId(instance: ReactTestInstance, testID: string) {
7070
}
7171

7272
/**
73-
* Checks if the given element is a host Text.
73+
* Checks if the given element is a host Text element.
7474
* @param element The element to check.
7575
*/
7676
export function isHostText(element?: ReactTestInstance | null): element is HostTestInstance {
7777
return element?.type === getHostComponentNames().text;
7878
}
7979

8080
/**
81-
* Checks if the given element is a host TextInput.
81+
* Checks if the given element is a host TextInput element.
8282
* @param element The element to check.
8383
*/
8484
export function isHostTextInput(element?: ReactTestInstance | null): element is HostTestInstance {
8585
return element?.type === getHostComponentNames().textInput;
8686
}
8787

8888
/**
89-
* Checks if the given element is a host ScrollView.
89+
* Checks if the given element is a host Switch element.
90+
* @param element The element to check.
91+
*/
92+
export function isHostSwitch(element?: ReactTestInstance | null): element is HostTestInstance {
93+
return element?.type === getHostComponentNames().switch;
94+
}
95+
96+
/**
97+
* Checks if the given element is a host ScrollView element.
9098
* @param element The element to check.
9199
*/
92100
export function isHostScrollView(element?: ReactTestInstance | null): element is HostTestInstance {
93101
return element?.type === getHostComponentNames().scrollView;
94102
}
95103

96104
/**
97-
* Checks if the given element is a host Modal.
105+
* Checks if the given element is a host Modal element.
98106
* @param element The element to check.
99107
*/
100108
export function isHostModal(element?: ReactTestInstance | null): element is HostTestInstance {
Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { AccessibilityState } from 'react-native';
21
import { ReactTestInstance } from 'react-test-renderer';
3-
import { accessibilityStateKeys, getAccessibilityState } from '../accessibility';
2+
import {
3+
computeAriaBusy,
4+
computeAriaChecked,
5+
computeAriaDisabled,
6+
computeAriaExpanded,
7+
computeAriaSelected,
8+
} from '../accessibility';
49

510
// This type is the same as AccessibilityState from `react-native` package
611
// It is re-declared here due to issues with migration from `@types/react-native` to
@@ -14,32 +19,25 @@ export interface AccessibilityStateMatcher {
1419
expanded?: boolean;
1520
}
1621

17-
/**
18-
* Default accessibility state values based on experiments using accessibility
19-
* inspector/screen reader on iOS and Android.
20-
*
21-
* @see https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State
22-
*/
23-
const defaultState: AccessibilityState = {
24-
disabled: false,
25-
selected: false,
26-
checked: undefined,
27-
busy: false,
28-
expanded: undefined,
29-
};
30-
3122
export function matchAccessibilityState(
3223
node: ReactTestInstance,
3324
matcher: AccessibilityStateMatcher,
3425
) {
35-
const state = getAccessibilityState(node);
36-
return accessibilityStateKeys.every((key) => matchState(matcher, state, key));
37-
}
26+
if (matcher.busy !== undefined && matcher.busy !== computeAriaBusy(node)) {
27+
return false;
28+
}
29+
if (matcher.checked !== undefined && matcher.checked !== computeAriaChecked(node)) {
30+
return false;
31+
}
32+
if (matcher.disabled !== undefined && matcher.disabled !== computeAriaDisabled(node)) {
33+
return false;
34+
}
35+
if (matcher.expanded !== undefined && matcher.expanded !== computeAriaExpanded(node)) {
36+
return false;
37+
}
38+
if (matcher.selected !== undefined && matcher.selected !== computeAriaSelected(node)) {
39+
return false;
40+
}
3841

39-
function matchState(
40-
matcher: AccessibilityStateMatcher,
41-
state: AccessibilityState | undefined,
42-
key: keyof AccessibilityState,
43-
) {
44-
return matcher[key] === undefined || matcher[key] === (state?.[key] ?? defaultState[key]);
42+
return true;
4543
}

src/helpers/matchers/match-accessibility-value.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ReactTestInstance } from 'react-test-renderer';
2-
import { getAccessibilityValue } from '../accessibility';
2+
import { computeAriaValue } from '../accessibility';
33
import { TextMatch } from '../../matches';
44
import { matchStringProp } from './match-string-prop';
55

@@ -14,7 +14,7 @@ export function matchAccessibilityValue(
1414
node: ReactTestInstance,
1515
matcher: AccessibilityValueMatcher,
1616
): boolean {
17-
const value = getAccessibilityValue(node);
17+
const value = computeAriaValue(node);
1818
return (
1919
(matcher.min === undefined || matcher.min === value?.min) &&
2020
(matcher.max === undefined || matcher.max === value?.max) &&

src/helpers/matchers/match-label-text.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ReactTestInstance } from 'react-test-renderer';
22
import { matches, TextMatch, TextMatchOptions } from '../../matches';
3-
import { getAccessibilityLabel, getAccessibilityLabelledBy } from '../accessibility';
3+
import { computeAriaLabel, computeAriaLabelledBy } from '../accessibility';
44
import { findAll } from '../find-all';
55
import { matchTextContent } from './match-text-content';
66

@@ -12,7 +12,7 @@ export function matchLabelText(
1212
) {
1313
return (
1414
matchAccessibilityLabel(element, expectedText, options) ||
15-
matchAccessibilityLabelledBy(root, getAccessibilityLabelledBy(element), expectedText, options)
15+
matchAccessibilityLabelledBy(root, computeAriaLabelledBy(element), expectedText, options)
1616
);
1717
}
1818

@@ -21,7 +21,7 @@ function matchAccessibilityLabel(
2121
extpectedLabel: TextMatch,
2222
options: TextMatchOptions,
2323
) {
24-
return matches(extpectedLabel, getAccessibilityLabel(element), options.normalizer, options.exact);
24+
return matches(extpectedLabel, computeAriaLabel(element), options.normalizer, options.exact);
2525
}
2626

2727
function matchAccessibilityLabelledBy(

src/matchers/__tests__/to-be-checked.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { screen } from '../../screen';
55
import '../extend-expect';
66

77
function renderViewsWithRole(role: AccessibilityRole) {
8-
return render(
8+
render(
99
<>
1010
<View
1111
testID={`${role}-checked`}

0 commit comments

Comments
 (0)