diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx
index ea7b98de9..ce7519186 100644
--- a/src/queries/__tests__/role.test.tsx
+++ b/src/queries/__tests__/role.test.tsx
@@ -1,5 +1,12 @@
import * as React from 'react';
-import { TouchableOpacity, Text } from 'react-native';
+import {
+ TouchableOpacity,
+ TouchableWithoutFeedback,
+ Text,
+ View,
+ Pressable,
+ Button as RNButton,
+} from 'react-native';
import { render } from '../..';
const TEXT_LABEL = 'cool text';
@@ -8,11 +15,11 @@ const TEXT_LABEL = 'cool text';
const NO_MATCHES_TEXT: any = 'not-existent-element';
const getMultipleInstancesFoundMessage = (value: string) => {
- return `Found multiple elements with accessibilityRole: ${value}`;
+ return `Found multiple elements with role: "${value}"`;
};
const getNoInstancesFoundMessage = (value: string) => {
- return `Unable to find an element with accessibilityRole: ${value}`;
+ return `Unable to find an element with role: "${value}"`;
};
const Typography = ({ children, ...rest }: any) => {
@@ -181,3 +188,512 @@ describe('supports name option', () => {
);
});
});
+
+describe('supports accessibility states', () => {
+ describe('disabled', () => {
+ test('returns a disabled element when required', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { disabled: true })).toBeTruthy();
+ expect(queryByRole('button', { disabled: false })).toBe(null);
+ });
+
+ test('returns the correct element when only one matches all the requirements', () => {
+ const { getByRole } = render(
+ <>
+
+ Save
+
+
+ Save
+
+ >
+ );
+
+ expect(
+ getByRole('button', { name: 'Save', disabled: true }).props.testID
+ ).toBe('correct');
+ });
+
+ test('returns an implicitly enabled element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { disabled: false })).toBeTruthy();
+ expect(queryByRole('button', { disabled: true })).toBe(null);
+ });
+
+ test('returns an explicitly enabled element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { disabled: false })).toBeTruthy();
+ expect(queryByRole('button', { disabled: true })).toBe(null);
+ });
+
+ test('does not return disabled elements when querying for non disabled', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('button', { disabled: false })).toBe(null);
+ });
+
+ test('returns elements using the built-in disabled prop', () => {
+ const { getByRole } = render(
+ <>
+
+ Pressable
+
+
+
+
+ TouchableWithoutFeedback
+
+
+ {}} title="RNButton" />
+ >
+ );
+
+ expect(
+ getByRole('button', { name: 'Pressable', disabled: true })
+ ).toBeTruthy();
+
+ expect(
+ getByRole('button', {
+ name: 'TouchableWithoutFeedback',
+ disabled: true,
+ })
+ ).toBeTruthy();
+
+ expect(
+ getByRole('button', { name: 'RNButton', disabled: true })
+ ).toBeTruthy();
+ });
+ });
+
+ describe('selected', () => {
+ test('returns a selected element when required', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('tab', { selected: true })).toBeTruthy();
+ expect(queryByRole('tab', { selected: false })).toBe(null);
+ });
+
+ test('returns the correct element when only one matches all the requirements', () => {
+ const { getByRole } = render(
+ <>
+
+ Save
+
+
+ Save
+
+ >
+ );
+
+ expect(
+ getByRole('tab', { name: 'Save', selected: true }).props.testID
+ ).toBe('correct');
+ });
+
+ test('returns an implicitly non selected element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('tab', { selected: false })).toBeTruthy();
+ expect(queryByRole('tab', { selected: true })).toBe(null);
+ });
+
+ test('returns an explicitly non selected element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('tab', { selected: false })).toBeTruthy();
+ expect(queryByRole('tab', { selected: true })).toBe(null);
+ });
+
+ test('does not return selected elements when querying for non selected', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('tab', { selected: false })).toBe(null);
+ });
+ });
+
+ describe('checked', () => {
+ test('returns a checked element when required', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('checkbox', { checked: true })).toBeTruthy();
+ expect(queryByRole('checkbox', { checked: false })).toBe(null);
+ expect(queryByRole('checkbox', { checked: 'mixed' })).toBe(null);
+ });
+
+ it('returns `mixed` checkboxes', () => {
+ const { queryByRole, getByRole } = render(
+
+ );
+
+ expect(getByRole('checkbox', { checked: 'mixed' })).toBeTruthy();
+ expect(queryByRole('checkbox', { checked: true })).toBe(null);
+ expect(queryByRole('checkbox', { checked: false })).toBe(null);
+ });
+
+ it('does not return mixed checkboxes when querying for checked: true', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('checkbox', { checked: false })).toBe(null);
+ });
+
+ test('returns the correct element when only one matches all the requirements', () => {
+ const { getByRole } = render(
+ <>
+
+ Save
+
+
+ Save
+
+ >
+ );
+
+ expect(
+ getByRole('checkbox', { name: 'Save', checked: true }).props.testID
+ ).toBe('correct');
+ });
+
+ test('does not return return as non checked an element with checked: undefined', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('checkbox', { checked: false })).toBe(null);
+ });
+
+ test('returns an explicitly non checked element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('checkbox', { checked: false })).toBeTruthy();
+ expect(queryByRole('checkbox', { checked: true })).toBe(null);
+ });
+
+ test('does not return checked elements when querying for non checked', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('checkbox', { checked: false })).toBe(null);
+ });
+
+ test('does not return mixed elements when querying for non checked', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('checkbox', { checked: false })).toBe(null);
+ });
+ });
+
+ describe('busy', () => {
+ test('returns a busy element when required', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { busy: true })).toBeTruthy();
+ expect(queryByRole('button', { busy: false })).toBe(null);
+ });
+
+ test('returns the correct element when only one matches all the requirements', () => {
+ const { getByRole } = render(
+ <>
+
+ Save
+
+
+ Save
+
+ >
+ );
+
+ expect(
+ getByRole('button', { name: 'Save', busy: true }).props.testID
+ ).toBe('correct');
+ });
+
+ test('returns an implicitly non busy element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { busy: false })).toBeTruthy();
+ expect(queryByRole('button', { busy: true })).toBe(null);
+ });
+
+ test('returns an explicitly non busy element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { busy: false })).toBeTruthy();
+ expect(queryByRole('button', { busy: true })).toBe(null);
+ });
+
+ test('does not return busy elements when querying for non busy', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('button', { selected: false })).toBe(null);
+ });
+ });
+
+ describe('expanded', () => {
+ test('returns a expanded element when required', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { expanded: true })).toBeTruthy();
+ expect(queryByRole('button', { expanded: false })).toBe(null);
+ });
+
+ test('returns the correct element when only one matches all the requirements', () => {
+ const { getByRole } = render(
+ <>
+
+ Save
+
+
+ Save
+
+ >
+ );
+
+ expect(
+ getByRole('button', { name: 'Save', expanded: true }).props.testID
+ ).toBe('correct');
+ });
+
+ test('does not return return as non expanded an element with expanded: undefined', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('button', { expanded: false })).toBe(null);
+ });
+
+ test('returns an explicitly non expanded element', () => {
+ const { getByRole, queryByRole } = render(
+
+ );
+
+ expect(getByRole('button', { expanded: false })).toBeTruthy();
+ expect(queryByRole('button', { expanded: true })).toBe(null);
+ });
+
+ test('does not return expanded elements when querying for non expanded', () => {
+ const { queryByRole } = render(
+
+ );
+
+ expect(queryByRole('button', { expanded: false })).toBe(null);
+ });
+ });
+
+ test('ignores non queried accessibilityState', () => {
+ const { getByRole, queryByRole } = render(
+
+ Save
+
+ );
+
+ expect(
+ getByRole('button', {
+ name: 'Save',
+ disabled: true,
+ })
+ ).toBeTruthy();
+ expect(
+ queryByRole('button', {
+ name: 'Save',
+ disabled: false,
+ })
+ ).toBe(null);
+ });
+
+ test('matches an element combining all the options', () => {
+ const { getByRole } = render(
+
+ Save
+
+ );
+
+ expect(
+ getByRole('button', {
+ name: 'Save',
+ disabled: true,
+ selected: true,
+ checked: true,
+ busy: true,
+ expanded: true,
+ })
+ ).toBeTruthy();
+ });
+});
+
+describe('error messages', () => {
+ test('gives a descriptive error message when querying with a role', () => {
+ const { getByRole } = render();
+
+ expect(() => getByRole('button')).toThrowErrorMatchingInlineSnapshot(
+ `"Unable to find an element with role: "button""`
+ );
+ });
+
+ test('gives a descriptive error message when querying with a role and a name', () => {
+ const { getByRole } = render();
+
+ expect(() =>
+ getByRole('button', { name: 'Save' })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Unable to find an element with role: "button", name: "Save""`
+ );
+ });
+
+ test('gives a descriptive error message when querying with a role, a name and accessibility state', () => {
+ const { getByRole } = render();
+
+ expect(() =>
+ getByRole('button', { name: 'Save', disabled: true })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Unable to find an element with role: "button", name: "Save", disabled state: true"`
+ );
+ });
+
+ test('gives a descriptive error message when querying with a role, a name and several accessibility state', () => {
+ const { getByRole } = render();
+
+ expect(() =>
+ getByRole('button', { name: 'Save', disabled: true, selected: true })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Unable to find an element with role: "button", name: "Save", disabled state: true, selected state: true"`
+ );
+ });
+
+ test('gives a descriptive error message when querying with a role and an accessibility state', () => {
+ const { getByRole } = render();
+
+ expect(() =>
+ getByRole('button', { disabled: true })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Unable to find an element with role: "button", disabled state: true"`
+ );
+ });
+});
diff --git a/src/queries/makeQueries.ts b/src/queries/makeQueries.ts
index 68fff5299..5e02bcb88 100644
--- a/src/queries/makeQueries.ts
+++ b/src/queries/makeQueries.ts
@@ -48,15 +48,15 @@ export type UnboundQueries = {
export function makeQueries(
queryAllByQuery: UnboundQuery>,
- getMissingError: (predicate: Predicate) => string,
- getMultipleError: (predicate: Predicate) => string
+ getMissingError: (predicate: Predicate, options?: Options) => string,
+ getMultipleError: (predicate: Predicate, options?: Options) => string
): UnboundQueries {
function getAllByQuery(instance: ReactTestInstance) {
return function getAllFn(predicate: Predicate, options?: Options) {
const results = queryAllByQuery(instance)(predicate, options);
if (results.length === 0) {
- throw new ErrorWithStack(getMissingError(predicate), getAllFn);
+ throw new ErrorWithStack(getMissingError(predicate, options), getAllFn);
}
return results;
@@ -68,7 +68,10 @@ export function makeQueries(
const results = queryAllByQuery(instance)(predicate, options);
if (results.length > 1) {
- throw new ErrorWithStack(getMultipleError(predicate), singleQueryFn);
+ throw new ErrorWithStack(
+ getMultipleError(predicate, options),
+ singleQueryFn
+ );
}
if (results.length === 0) {
@@ -84,11 +87,11 @@ export function makeQueries(
const results = queryAllByQuery(instance)(predicate, options);
if (results.length > 1) {
- throw new ErrorWithStack(getMultipleError(predicate), getFn);
+ throw new ErrorWithStack(getMultipleError(predicate, options), getFn);
}
if (results.length === 0) {
- throw new ErrorWithStack(getMissingError(predicate), getFn);
+ throw new ErrorWithStack(getMissingError(predicate, options), getFn);
}
return results[0];
diff --git a/src/queries/role.ts b/src/queries/role.ts
index 02d3aa223..cb77a22aa 100644
--- a/src/queries/role.ts
+++ b/src/queries/role.ts
@@ -1,3 +1,4 @@
+import { type AccessibilityState } from 'react-native';
import type { ReactTestInstance } from 'react-test-renderer';
import { matchStringProp } from '../helpers/matchers/matchStringProp';
import { TextMatch } from '../matches';
@@ -14,7 +15,9 @@ import type {
type ByRoleOptions = {
name?: TextMatch;
-};
+} & AccessibilityState;
+
+type AccessibilityStateKey = keyof AccessibilityState;
const matchAccessibleNameIfNeeded = (
node: ReactTestInstance,
@@ -28,22 +31,85 @@ 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',
+];
+
const queryAllByRole = (
instance: ReactTestInstance
): ((role: TextMatch, options?: ByRoleOptions) => Array) =>
function queryAllByRoleFn(role, options) {
return instance.findAll(
(node) =>
+ // run the cheapest checks first, and early exit too avoid unneeded computations
+
typeof node.type === 'string' &&
matchStringProp(node.props.accessibilityRole, role) &&
+ matchAccessibleStateIfNeeded(node, options) &&
matchAccessibleNameIfNeeded(node, options?.name)
);
};
-const getMultipleError = (role: TextMatch) =>
- `Found multiple elements with accessibilityRole: ${String(role)} `;
-const getMissingError = (role: TextMatch) =>
- `Unable to find an element with accessibilityRole: ${String(role)}`;
+const buildErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => {
+ const errors = [`role: "${String(role)}"`];
+
+ if (options.name) {
+ 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]}`
+ );
+ }
+ });
+ }
+
+ 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)}`;
const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries(
queryAllByRole,
diff --git a/typings/index.flow.js b/typings/index.flow.js
index 2c82adfdc..2c8c35710 100644
--- a/typings/index.flow.js
+++ b/typings/index.flow.js
@@ -203,9 +203,10 @@ interface UnsafeByPropsQueries {
| [];
}
-interface ByRoleOptions {
- name?: string;
-}
+type ByRoleOptions = {
+ ...A11yState,
+ name?: string,
+};
interface A11yAPI {
// Label
diff --git a/website/docs/Queries.md b/website/docs/Queries.md
index 215f5d606..cc9c4b2a6 100644
--- a/website/docs/Queries.md
+++ b/website/docs/Queries.md
@@ -238,6 +238,11 @@ getByRole(
role: TextMatch,
option?: {
name?: TextMatch
+ disabled?: boolean,
+ selected?: boolean,
+ checked?: boolean | 'mixed',
+ busy?: boolean,
+ expanded?: boolean,
}
): ReactTestInstance;
```
@@ -255,6 +260,16 @@ const element = screen.getByRole('button');
`name`: Finds an element with given `accessibilityRole` and an accessible name (equivalent to `byText` or `byLabelText` query).
+`disabled`: You can filter elements by their disabled state. The possible values are `true` or `false`. Querying `disabled: false` will also match elements with `disabled: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `disabled` state.
+
+`selected`: You can filter elements by their selected state. The possible values are `true` or `false`. Querying `selected: false` will also match elements with `selected: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `selected` state.
+
+`checked`: You can filter elements by their checked state. The possible values are `true`, `false`, or `"mixed"`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `checked` state.
+
+`busy`: You can filter elements by their busy state. The possible values are `true` or `false`. Querying `busy: false` will also match elements with `busy: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `busy` state.
+
+`expanded`: You can filter elements by their expanded state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `expanded` state.
+
### `ByA11yState`, `ByAccessibilityState`
> getByA11yState, getAllByA11yState, queryByA11yState, queryAllByA11yState, findByA11yState, findAllByA11yState