diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts
index a4dc1885f..0cccb7b6b 100644
--- a/src/helpers/accessiblity.ts
+++ b/src/helpers/accessiblity.ts
@@ -1,7 +1,17 @@
-import { StyleSheet } from 'react-native';
+import { AccessibilityState, StyleSheet } from 'react-native';
import { ReactTestInstance } from 'react-test-renderer';
import { getHostSiblings } from './component-tree';
+export type AccessibilityStateKey = keyof AccessibilityState;
+
+export const accessibilityStateKeys: AccessibilityStateKey[] = [
+ 'disabled',
+ 'selected',
+ 'checked',
+ 'busy',
+ 'expanded',
+];
+
export function isInaccessible(element: ReactTestInstance | null): boolean {
if (element == null) {
return true;
diff --git a/src/helpers/matchers/accessibilityState.ts b/src/helpers/matchers/accessibilityState.ts
new file mode 100644
index 000000000..b068353e4
--- /dev/null
+++ b/src/helpers/matchers/accessibilityState.ts
@@ -0,0 +1,36 @@
+import { AccessibilityState } from 'react-native';
+import { ReactTestInstance } from 'react-test-renderer';
+import { accessibilityStateKeys } from '../accessiblity';
+
+/**
+ * Default accessibility state values based on experiments using accessibility
+ * inspector/screen reader on iOS and Android.
+ *
+ * @see https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State
+ */
+const defaultState: AccessibilityState = {
+ disabled: false,
+ selected: false,
+ checked: undefined,
+ busy: false,
+ expanded: undefined,
+};
+
+export function matchAccessibilityState(
+ node: ReactTestInstance,
+ matcher: AccessibilityState
+) {
+ const state = node.props.accessibilityState;
+ return accessibilityStateKeys.every((key) => matchState(state, matcher, key));
+}
+
+function matchState(
+ state: AccessibilityState,
+ matcher: AccessibilityState,
+ key: keyof AccessibilityState
+) {
+ return (
+ matcher[key] === undefined ||
+ matcher[key] === (state?.[key] ?? defaultState[key])
+ );
+}
diff --git a/src/queries/__tests__/a11yState.test.tsx b/src/queries/__tests__/a11yState.test.tsx
index c15c078b4..c97ad70cb 100644
--- a/src/queries/__tests__/a11yState.test.tsx
+++ b/src/queries/__tests__/a11yState.test.tsx
@@ -1,17 +1,9 @@
import * as React from 'react';
-import { TouchableOpacity, Text } from 'react-native';
+import { View, Text, Pressable, TouchableOpacity } from 'react-native';
import { render } from '../..';
const TEXT_LABEL = 'cool text';
-const getMultipleInstancesFoundMessage = (value: string) => {
- return `Found multiple elements with accessibilityState: ${value}`;
-};
-
-const getNoInstancesFoundMessage = (value: string) => {
- return `Unable to find an element with accessibilityState: ${value}`;
-};
-
const Typography = ({ children, ...rest }: any) => {
return {children};
};
@@ -48,15 +40,15 @@ test('getByA11yState, queryByA11yState, findByA11yState', async () => {
});
expect(() => getByA11yState({ disabled: true })).toThrow(
- getNoInstancesFoundMessage('{"disabled":true}')
+ 'Unable to find an element with disabled state: true'
);
expect(queryByA11yState({ disabled: true })).toEqual(null);
expect(() => getByA11yState({ expanded: false })).toThrow(
- getMultipleInstancesFoundMessage('{"expanded":false}')
+ 'Found multiple elements with expanded state: false'
);
expect(() => queryByA11yState({ expanded: false })).toThrow(
- getMultipleInstancesFoundMessage('{"expanded":false}')
+ 'Found multiple elements with expanded state: false'
);
const asyncButton = await findByA11yState({ selected: true });
@@ -65,10 +57,10 @@ test('getByA11yState, queryByA11yState, findByA11yState', async () => {
expanded: false,
});
await expect(findByA11yState({ disabled: true })).rejects.toThrow(
- getNoInstancesFoundMessage('{"disabled":true}')
+ 'Unable to find an element with disabled state: true'
);
await expect(findByA11yState({ expanded: false })).rejects.toThrow(
- getMultipleInstancesFoundMessage('{"expanded":false}')
+ 'Found multiple elements with expanded state: false'
);
});
@@ -81,7 +73,7 @@ test('getAllByA11yState, queryAllByA11yState, findAllByA11yState', async () => {
expect(queryAllByA11yState({ selected: true })).toHaveLength(1);
expect(() => getAllByA11yState({ disabled: true })).toThrow(
- getNoInstancesFoundMessage('{"disabled":true}')
+ 'Unable to find an element with disabled state: true'
);
expect(queryAllByA11yState({ disabled: true })).toEqual([]);
@@ -90,9 +82,147 @@ test('getAllByA11yState, queryAllByA11yState, findAllByA11yState', async () => {
await expect(findAllByA11yState({ selected: true })).resolves.toHaveLength(1);
await expect(findAllByA11yState({ disabled: true })).rejects.toThrow(
- getNoInstancesFoundMessage('{"disabled":true}')
+ 'Unable to find an element with disabled state: true'
);
await expect(findAllByA11yState({ expanded: false })).resolves.toHaveLength(
2
);
});
+
+describe('checked state matching', () => {
+ it('handles true', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ checked: true })).toBeTruthy();
+ expect(view.queryByA11yState({ checked: 'mixed' })).toBeFalsy();
+ expect(view.queryByA11yState({ checked: false })).toBeFalsy();
+ });
+
+ it('handles mixed', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ checked: 'mixed' })).toBeTruthy();
+ expect(view.queryByA11yState({ checked: true })).toBeFalsy();
+ expect(view.queryByA11yState({ checked: false })).toBeFalsy();
+ });
+
+ it('handles false', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ checked: false })).toBeTruthy();
+ expect(view.queryByA11yState({ checked: true })).toBeFalsy();
+ expect(view.queryByA11yState({ checked: 'mixed' })).toBeFalsy();
+ });
+
+ it('handles default', () => {
+ const view = render();
+
+ expect(view.queryByA11yState({ checked: false })).toBeFalsy();
+ expect(view.queryByA11yState({ checked: true })).toBeFalsy();
+ expect(view.queryByA11yState({ checked: 'mixed' })).toBeFalsy();
+ });
+});
+
+describe('expanded state matching', () => {
+ it('handles true', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ expanded: true })).toBeTruthy();
+ expect(view.queryByA11yState({ expanded: false })).toBeFalsy();
+ });
+
+ it('handles false', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ expanded: false })).toBeTruthy();
+ expect(view.queryByA11yState({ expanded: true })).toBeFalsy();
+ });
+
+ it('handles default', () => {
+ const view = render();
+
+ expect(view.queryByA11yState({ expanded: false })).toBeFalsy();
+ expect(view.queryByA11yState({ expanded: true })).toBeFalsy();
+ });
+});
+
+describe('disabled state matching', () => {
+ it('handles true', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ disabled: true })).toBeTruthy();
+ expect(view.queryByA11yState({ disabled: false })).toBeFalsy();
+ });
+
+ it('handles false', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ disabled: false })).toBeTruthy();
+ expect(view.queryByA11yState({ disabled: true })).toBeFalsy();
+ });
+
+ it('handles default', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ disabled: false })).toBeTruthy();
+ expect(view.queryByA11yState({ disabled: true })).toBeFalsy();
+ });
+});
+
+describe('busy state matching', () => {
+ it('handles true', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ busy: true })).toBeTruthy();
+ expect(view.queryByA11yState({ busy: false })).toBeFalsy();
+ });
+
+ it('handles false', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ busy: false })).toBeTruthy();
+ expect(view.queryByA11yState({ busy: true })).toBeFalsy();
+ });
+
+ it('handles default', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ busy: false })).toBeTruthy();
+ expect(view.queryByA11yState({ busy: true })).toBeFalsy();
+ });
+});
+
+describe('selected state matching', () => {
+ it('handles true', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ selected: true })).toBeTruthy();
+ expect(view.queryByA11yState({ selected: false })).toBeFalsy();
+ });
+
+ it('handles false', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ selected: false })).toBeTruthy();
+ expect(view.queryByA11yState({ selected: true })).toBeFalsy();
+ });
+
+ it('handles default', () => {
+ const view = render();
+
+ expect(view.getByA11yState({ selected: false })).toBeTruthy();
+ expect(view.queryByA11yState({ selected: true })).toBeFalsy();
+ });
+});
+
+test('*ByA11yState on Pressable with "disabled" prop', () => {
+ const view = render();
+ expect(view.getByA11yState({ disabled: true })).toBeTruthy();
+ expect(view.queryByA11yState({ disabled: false })).toBeFalsy();
+});
+
+test('*ByA11yState on TouchableOpacity with "disabled" prop', () => {
+ const view = render();
+ expect(view.getByA11yState({ disabled: true })).toBeTruthy();
+ expect(view.queryByA11yState({ disabled: false })).toBeFalsy();
+});
diff --git a/src/queries/a11yState.ts b/src/queries/a11yState.ts
index 4d740367d..7bbb415f7 100644
--- a/src/queries/a11yState.ts
+++ b/src/queries/a11yState.ts
@@ -1,6 +1,7 @@
import type { ReactTestInstance } from 'react-test-renderer';
import type { AccessibilityState } from 'react-native';
-import { matchObjectProp } from '../helpers/matchers/matchObjectProp';
+import { accessibilityStateKeys } from '../helpers/accessiblity';
+import { matchAccessibilityState } from '../helpers/matchers/accessibilityState';
import { makeQueries } from './makeQueries';
import type {
FindAllByQuery,
@@ -13,19 +14,31 @@ import type {
const queryAllByA11yState = (
instance: ReactTestInstance
-): ((state: AccessibilityState) => Array) =>
- function queryAllByA11yStateFn(state) {
+): ((matcher: AccessibilityState) => Array) =>
+ function queryAllByA11yStateFn(matcher) {
return instance.findAll(
(node) =>
- typeof node.type === 'string' &&
- matchObjectProp(node.props.accessibilityState, state)
+ typeof node.type === 'string' && matchAccessibilityState(node, matcher)
);
};
+const buildErrorMessage = (state: AccessibilityState = {}) => {
+ const errors: string[] = [];
+
+ accessibilityStateKeys.forEach((stateKey) => {
+ if (state[stateKey] !== undefined) {
+ errors.push(`${stateKey} state: ${state[stateKey]}`);
+ }
+ });
+
+ return errors.join(', ');
+};
+
const getMultipleError = (state: AccessibilityState) =>
- `Found multiple elements with accessibilityState: ${JSON.stringify(state)}`;
+ `Found multiple elements with ${buildErrorMessage(state)}`;
+
const getMissingError = (state: AccessibilityState) =>
- `Unable to find an element with accessibilityState: ${JSON.stringify(state)}`;
+ `Unable to find an element with ${buildErrorMessage(state)}`;
const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries(
queryAllByA11yState,
diff --git a/src/queries/role.ts b/src/queries/role.ts
index cb77a22aa..00d42b5e0 100644
--- a/src/queries/role.ts
+++ b/src/queries/role.ts
@@ -1,7 +1,9 @@
import { type AccessibilityState } from 'react-native';
import type { ReactTestInstance } from 'react-test-renderer';
+import { accessibilityStateKeys } from '../helpers/accessiblity';
+import { matchAccessibilityState } from '../helpers/matchers/accessibilityState';
import { matchStringProp } from '../helpers/matchers/matchStringProp';
-import { TextMatch } from '../matches';
+import type { TextMatch } from '../matches';
import { getQueriesForElement } from '../within';
import { makeQueries } from './makeQueries';
import type {
@@ -17,8 +19,6 @@ type ByRoleOptions = {
name?: TextMatch;
} & AccessibilityState;
-type AccessibilityStateKey = keyof AccessibilityState;
-
const matchAccessibleNameIfNeeded = (
node: ReactTestInstance,
name?: TextMatch
@@ -31,42 +31,12 @@ const matchAccessibleNameIfNeeded = (
);
};
-// disabled:undefined is equivalent to disabled:false, same for selected. busy not, but it makes
-// sense from a testing/voice-over perspective. checked and expanded do behave differently
-const implicityFalseState: AccessibilityStateKey[] = [
- 'disabled',
- 'selected',
- 'busy',
-];
-
const matchAccessibleStateIfNeeded = (
node: ReactTestInstance,
options?: ByRoleOptions
-) =>
- accessibilityStates.every((accessibilityState) => {
- const queriedState = options?.[accessibilityState];
-
- if (typeof queriedState !== 'undefined') {
- // Some accessibilityState properties have implicit value (when not set)
- const defaultState = implicityFalseState.includes(accessibilityState)
- ? false
- : undefined;
- return (
- queriedState ===
- (node.props.accessibilityState?.[accessibilityState] ?? defaultState)
- );
- } else {
- return true;
- }
- });
-
-const accessibilityStates: AccessibilityStateKey[] = [
- 'disabled',
- 'selected',
- 'checked',
- 'busy',
- 'expanded',
-];
+) => {
+ return options != null ? matchAccessibilityState(node, options) : true;
+};
const queryAllByRole = (
instance: ReactTestInstance
@@ -90,24 +60,18 @@ const buildErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => {
errors.push(`name: "${String(options.name)}"`);
}
- if (
- accessibilityStates.some(
- (accessibilityState) => typeof options[accessibilityState] !== 'undefined'
- )
- ) {
- accessibilityStates.forEach((accessibilityState) => {
- if (options[accessibilityState]) {
- errors.push(
- `${accessibilityState} state: ${options[accessibilityState]}`
- );
- }
- });
- }
+ accessibilityStateKeys.forEach((stateKey) => {
+ if (options[stateKey] !== undefined) {
+ errors.push(`${stateKey} state: ${options[stateKey]}`);
+ }
+ });
return errors.join(', ');
};
+
const getMultipleError = (role: TextMatch, options?: ByRoleOptions) =>
`Found multiple elements with ${buildErrorMessage(role, options)}`;
+
const getMissingError = (role: TextMatch, options?: ByRoleOptions) =>
`Unable to find an element with ${buildErrorMessage(role, options)}`;
diff --git a/website/docs/Queries.md b/website/docs/Queries.md
index cc9c4b2a6..2b39059af 100644
--- a/website/docs/Queries.md
+++ b/website/docs/Queries.md
@@ -23,6 +23,8 @@ title: Queries
- [`ByRole`](#byrole)
- [Options](#options-1)
- [`ByA11yState`, `ByAccessibilityState`](#bya11ystate-byaccessibilitystate)
+ - [Default state for: `disabled`, `selected`, and `busy` keys](#default-state-for-disabled-selected-and-busy-keys)
+ - [Default state for: `checked` and `expanded` keys](#default-state-for-checked-and-expanded-keys)
- [`ByA11Value`, `ByAccessibilityValue`](#bya11value-byaccessibilityvalue)
- [TextMatch](#textmatch)
- [Examples](#examples)
@@ -296,6 +298,30 @@ render();
const element = screen.getByA11yState({ disabled: true });
```
+:::note
+
+#### Default state for: `disabled`, `selected`, and `busy` keys
+
+Passing `false` matcher value will match both elements with explicit `false` state value and without explicit state value.
+
+For instance, `getByA11yState({ disabled: false })` will match elements with following props:
+* `accessibilityState={{ disabled: false, ... }}`
+* no `disabled` key under `accessibilityState` prop, e.g. `accessibilityState={{}}`
+* no `accessibilityState` prop at all
+
+#### Default state for: `checked` and `expanded` keys
+Passing `false` matcher value will only match elements with explicit `false` state value.
+
+For instance, `getByA11yState({ checked: false })` will only match elements with:
+* `accessibilityState={{ checked: false, ... }}`
+
+but will not match elements with following props:
+* no `checked` key under `accessibilityState` prop, e.g. `accessibilityState={{}}`
+* no `accessibilityState` prop at all
+
+The difference in handling default values is made to reflect observed accessibility behaviour on iOS and Android platforms.
+:::
+
### `ByA11Value`, `ByAccessibilityValue`
> getByA11yValue, getAllByA11yValue, queryByA11yValue, queryAllByA11yValue, findByA11yValue, findAllByA11yValue