diff --git a/CHANGELOG.md b/CHANGELOG.md
index 959db1570..998e5bdd3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,25 @@
All notable changes to React Native Testing Library will be documented in this file, starting
with v14.
+## Unreleased
+
+### Bug fixes
+
+- `toBeDisabled()` and `toBeEnabled()` incorrectly reported an element as disabled when one of
+ its ancestors was disabled. This is not how React Native works: it doesn't pass the disabled
+ state down to children, so a `Pressable` inside a disabled `View` or `Pressable` still responds
+ to presses and isn't reported as disabled to assistive technologies. The matchers now check
+ only the element's own props, as the `disabled` option of `*ByRole` queries already did.
+ Assert on the element that has the state, e.g. the button instead of the text inside it.
+- A `TextInput` with `readOnly` is treated as non-editable, as React Native does: `fireEvent`
+ and `userEvent` don't change its text, and `toBeDisabled()` and `*ByRole` queries with
+ `disabled: true` match it. `readOnly` wins over `editable`.
+- `toBeDisabled()` and `*ByRole` queries with `disabled: true` match a `Switch` with `disabled` on
+ iOS.
+- `toBeDisabled()` and `*ByRole` queries prefer `disabled` over `aria-disabled` and
+ `accessibilityState.disabled` on `Text`, as React Native does. A `Text` with
+ `disabled={false}` and `aria-disabled` is enabled.
+
## 14.1.0
### Features
diff --git a/docs/api/jest-matchers.md b/docs/api/jest-matchers.md
index a45bf68b6..52596083d 100644
--- a/docs/api/jest-matchers.md
+++ b/docs/api/jest-matchers.md
@@ -92,7 +92,17 @@ expect(element).toBeEnabled();
expect(element).toBeDisabled();
```
-Checks if an element is enabled or disabled from `aria-disabled` or `accessibilityState.disabled` props. An element is disabled if it or any ancestor is disabled.
+Checks if an element is enabled or disabled from `aria-disabled` or `accessibilityState.disabled` props. These host elements are also disabled:
+
+- `TextInput` with `editable={false}` or `readOnly`
+- `Text` with `disabled`
+- `Switch` with `disabled`
+
+As in React Native, an element doesn't inherit the disabled state from its ancestors. Check the element that has the state, e.g. the button, not the text inside it:
+
+```tsx
+expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
+```
> [!NOTE]
> These matchers are opposites. Both are available so you can avoid double negations like `expect(element).not.toBeDisabled()`.
diff --git a/docs/api/user-event.md b/docs/api/user-event.md
index 64d4640b3..b5a5ba6b2 100644
--- a/docs/api/user-event.md
+++ b/docs/api/user-event.md
@@ -111,7 +111,7 @@ This function supports only host `TextInput` elements. Passing other element typ
The sequence of events depends on the `multiline` prop and the passed options.
-Events will not be emitted if the `editable` prop is set to `false`.
+Events will not be emitted if the `editable` prop is set to `false` or the `readOnly` prop is set to `true`.
**Entering the element**:
@@ -159,7 +159,7 @@ This function supports only host `TextInput` elements. Passing other element typ
### Sequence of events
-Events will not be emitted if the `editable` prop is set to `false`.
+Events will not be emitted if the `editable` prop is set to `false` or the `readOnly` prop is set to `true`.
**Entering the element**:
@@ -203,7 +203,7 @@ This function supports only host `TextInput` elements. Passing other element typ
### Sequence of events
-Events will not be emitted if the `editable` prop is set to `false`.
+Events will not be emitted if the `editable` prop is set to `false` or the `readOnly` prop is set to `true`.
**Entering the element**:
diff --git a/docs/guides/llm-guidelines.md b/docs/guides/llm-guidelines.md
index d09155457..f11cf7e46 100644
--- a/docs/guides/llm-guidelines.md
+++ b/docs/guides/llm-guidelines.md
@@ -73,8 +73,8 @@ const { result, rerender, unmount } = await renderHook(() => useMyHook(), option
| `toBeVisible()` | Element is visible (checks style, `aria-hidden`, `accessibilityElementsHidden`, ancestors) |
| `toBeEmptyElement()` | Element has no children or text content |
| `toContainElement(element)` | Element contains another element |
-| `toBeEnabled()` | Element is not disabled (checks `aria-disabled`, `accessibilityState`, ancestors) |
-| `toBeDisabled()` | Element has `aria-disabled` or `accessibilityState={{ disabled: true }}` (checks ancestors) |
+| `toBeEnabled()` | Element is not disabled (checks `aria-disabled`, `accessibilityState`) |
+| `toBeDisabled()` | Element has `aria-disabled` or `accessibilityState={{ disabled: true }}` (not from parents) |
| `toBeBusy()` | Element has `aria-busy` or `accessibilityState={{ busy: true }}` |
| `toBeChecked()` | Element has `aria-checked` or `accessibilityState={{ checked: true }}` |
| `toBePartiallyChecked()` | Element has `aria-checked="mixed"` or `accessibilityState={{ checked: 'mixed' }}` |
diff --git a/src/events/__tests__/fire-event.test.tsx b/src/events/__tests__/fire-event.test.tsx
index e1e950893..67a40615e 100644
--- a/src/events/__tests__/fire-event.test.tsx
+++ b/src/events/__tests__/fire-event.test.tsx
@@ -231,6 +231,15 @@ describe('fireEvent.changeText', () => {
expect(onChangeText).not.toHaveBeenCalled();
expect(nativeState.valueForInstance.get(input)).toBeUndefined();
});
+
+ test('does not fire on read-only TextInput', async () => {
+ const onChangeText = jest.fn();
+ await render();
+ const input = screen.getByTestId('input');
+ await fireEvent.changeText(input, 'new text');
+ expect(onChangeText).not.toHaveBeenCalled();
+ expect(nativeState.valueForInstance.get(input)).toBeUndefined();
+ });
});
describe('fireEvent.scroll', () => {
diff --git a/src/helpers/__tests__/accessibility.test.tsx b/src/helpers/__tests__/accessibility.test.tsx
index db52a43d1..1c0cda21f 100644
--- a/src/helpers/__tests__/accessibility.test.tsx
+++ b/src/helpers/__tests__/accessibility.test.tsx
@@ -543,6 +543,34 @@ describe('computeAriaDisabled', () => {
expect(computeAriaDisabled(screen.getByTestId('editable-false'))).toBe(true);
});
+ test('supports TextInput with "readOnly" prop', async () => {
+ await render(
+
+
+
+
+ ,
+ );
+
+ expect(computeAriaDisabled(screen.getByTestId('read-only'))).toBe(true);
+ expect(computeAriaDisabled(screen.getByTestId('read-only-false'))).toBe(false);
+ expect(computeAriaDisabled(screen.getByTestId('read-only-false-non-editable'))).toBe(false);
+ });
+
+ test('supports Switch', async () => {
+ await render(
+
+
+
+
+ ,
+ );
+
+ expect(computeAriaDisabled(screen.getByTestId('default'))).toBe(false);
+ expect(computeAriaDisabled(screen.getByTestId('disabled'))).toBe(true);
+ expect(computeAriaDisabled(screen.getByTestId('disabled-false'))).toBe(false);
+ });
+
test('supports Button', async () => {
await render(
@@ -569,12 +597,23 @@ describe('computeAriaDisabled', () => {
Default Text
Disabled Text
ARIA Disabled Text
+ State Disabled Text
+
+ Disabled False ARIA Disabled Text
+
+
+ Disabled False State Disabled Text
+
,
);
expect(computeAriaDisabled(screen.getByText('Default Text'))).toBe(false);
expect(computeAriaDisabled(screen.getByText('Disabled Text'))).toBe(true);
expect(computeAriaDisabled(screen.getByText('ARIA Disabled Text'))).toBe(true);
+ expect(computeAriaDisabled(screen.getByText('State Disabled Text'))).toBe(true);
+ // React Native's `Text` prefers `disabled` over `aria-disabled` and `accessibilityState`.
+ expect(computeAriaDisabled(screen.getByText('Disabled False ARIA Disabled Text'))).toBe(false);
+ expect(computeAriaDisabled(screen.getByText('Disabled False State Disabled Text'))).toBe(false);
});
});
diff --git a/src/helpers/__tests__/text-input.test.tsx b/src/helpers/__tests__/text-input.test.tsx
index b3a6f72cf..b40017147 100644
--- a/src/helpers/__tests__/text-input.test.tsx
+++ b/src/helpers/__tests__/text-input.test.tsx
@@ -37,3 +37,20 @@ test('isEditableTextInput basic test', async () => {
expect(isEditableTextInput(screen.getByTestId('non-editable'))).toBe(false);
expect(isEditableTextInput(screen.getByTestId('view'))).toBe(false);
});
+
+test('isEditableTextInput supports readOnly prop', async () => {
+ await render(
+
+
+
+
+
+ ,
+ );
+
+ expect(isEditableTextInput(screen.getByTestId('read-only'))).toBe(false);
+ expect(isEditableTextInput(screen.getByTestId('read-only-false'))).toBe(true);
+ // React Native's `TextInput` prefers `readOnly` over `editable`.
+ expect(isEditableTextInput(screen.getByTestId('read-only-false-non-editable'))).toBe(true);
+ expect(isEditableTextInput(screen.getByTestId('read-only-editable'))).toBe(false);
+});
diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts
index 2638e06cb..a441a99e6 100644
--- a/src/helpers/accessibility.ts
+++ b/src/helpers/accessibility.ts
@@ -230,7 +230,16 @@ export function computeAriaDisabled(instance: TestInstance): boolean {
const { props } = instance;
- if (isHostText(instance) && props.disabled) {
+ // React Native's `Text` prefers `disabled` over `aria-disabled` and `accessibilityState`. The Jest
+ // preset mocks `Text`, so its host element gets these props unchanged.
+ if (isHostText(instance)) {
+ return (
+ (props.disabled ?? props['aria-disabled'] ?? props.accessibilityState?.disabled) === true
+ );
+ }
+
+ // On iOS, `Switch` passes `disabled` to its host element instead of `accessibilityState`.
+ if (isHostSwitch(instance) && props.disabled === true) {
return true;
}
diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts
index d053e0570..01e73db2f 100644
--- a/src/helpers/text-input.ts
+++ b/src/helpers/text-input.ts
@@ -3,8 +3,18 @@ import type { TestInstance } from 'test-renderer';
import { nativeState } from '../events/native-state';
import { isHostTextInput } from './host-component-names';
+/**
+ * Whether the element is a host `TextInput` the user can edit. As in React Native's `TextInput`,
+ * `readOnly` wins over `editable`. The Jest preset mocks `TextInput`, so its host element gets
+ * both props unchanged.
+ */
export function isEditableTextInput(instance: TestInstance) {
- return isHostTextInput(instance) && instance.props.editable !== false;
+ if (!isHostTextInput(instance)) {
+ return false;
+ }
+
+ const { readOnly, editable } = instance.props;
+ return readOnly !== undefined ? !readOnly : editable !== false;
}
export function getTextInputValue(instance: TestInstance) {
diff --git a/src/matchers/__tests__/to-be-disabled.test.tsx b/src/matchers/__tests__/to-be-disabled.test.tsx
index ed3602acc..6aab21195 100644
--- a/src/matchers/__tests__/to-be-disabled.test.tsx
+++ b/src/matchers/__tests__/to-be-disabled.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import {
Button,
Pressable,
+ Switch,
Text,
TextInput,
TouchableHighlight,
@@ -18,7 +19,7 @@ test('toBeDisabled()/toBeEnabled() supports basic case', async () => {
-
+
@@ -31,7 +32,7 @@ test('toBeDisabled()/toBeEnabled() supports basic case', async () => {
);
expect(screen.getByTestId('disabled-parent')).toBeDisabled();
- expect(screen.getByTestId('disabled-child')).toBeDisabled();
+ expect(screen.getByTestId('child-of-disabled')).not.toBeDisabled();
expect(screen.getByTestId('enabled-view')).not.toBeDisabled();
expect(screen.getByTestId('enabled-text')).not.toBeDisabled();
expect(screen.getByTestId('enabled-text-input')).not.toBeDisabled();
@@ -59,7 +60,7 @@ test('toBeDisabled()/toBeEnabled() supports basic case', async () => {
`);
expect(screen.getByTestId('disabled-parent')).not.toBeEnabled();
- expect(screen.getByTestId('disabled-child')).not.toBeEnabled();
+ expect(screen.getByTestId('child-of-disabled')).toBeEnabled();
expect(screen.getByTestId('enabled-view')).toBeEnabled();
expect(screen.getByTestId('enabled-text')).toBeEnabled();
expect(screen.getByTestId('enabled-text-input')).toBeEnabled();
@@ -99,8 +100,8 @@ test('toBeDisabled()/toBeEnabled() supports Pressable with "disabled" prop', asy
expect(pressable).not.toBeEnabled();
const title = screen.getByText('Button');
- expect(title).toBeDisabled();
- expect(title).not.toBeEnabled();
+ expect(title).not.toBeDisabled();
+ expect(title).toBeEnabled();
expect(() => expect(pressable).toBeEnabled()).toThrowErrorMatchingInlineSnapshot(`
"expect(instance).toBeEnabled()
@@ -132,19 +133,19 @@ test('toBeDisabled()/toBeEnabled() supports Pressable with "disabled" prop', asy
/>"
`);
- expect(() => expect(title).toBeEnabled()).toThrowErrorMatchingInlineSnapshot(`
- "expect(instance).toBeEnabled()
+ expect(() => expect(title).toBeDisabled()).toThrowErrorMatchingInlineSnapshot(`
+ "expect(instance).toBeDisabled()
- Received instance is not enabled:
+ Received instance is not disabled:
Button
"
`);
- expect(() => expect(title).not.toBeDisabled()).toThrowErrorMatchingInlineSnapshot(`
- "expect(instance).not.toBeDisabled()
+ expect(() => expect(title).not.toBeEnabled()).toThrowErrorMatchingInlineSnapshot(`
+ "expect(instance).not.toBeEnabled()
- Received instance is disabled:
+ Received instance is enabled:
Button
"
@@ -164,7 +165,10 @@ test.each([
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - JSX element type 'Component' does not have any construct or call signatures.
- Button
+ {/* `TouchableWithoutFeedback` and `TouchableNativeFeedback` render their child as the host. */}
+
+ Button
+
,
);
@@ -173,13 +177,13 @@ test.each([
expect(touchable).not.toBeEnabled();
const title = screen.getByText('Button');
- expect(title).toBeDisabled();
- expect(title).not.toBeEnabled();
+ expect(title).not.toBeDisabled();
+ expect(title).toBeEnabled();
expect(() => expect(touchable).toBeEnabled()).toThrow();
expect(() => expect(touchable).not.toBeDisabled()).toThrow();
- expect(() => expect(title).toBeEnabled()).toThrow();
- expect(() => expect(title).not.toBeDisabled()).toThrow();
+ expect(() => expect(title).toBeDisabled()).toThrow();
+ expect(() => expect(title).not.toBeEnabled()).toThrow();
},
);
@@ -256,6 +260,48 @@ test('toBeDisabled()/toBeEnabled() supports "editable" prop on TextInput', async
expect(screen.getByTestId('disabled')).not.toBeEnabled();
});
+test('toBeDisabled()/toBeEnabled() supports "readOnly" prop on TextInput', async () => {
+ await render(
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('read-only')).toBeDisabled();
+ expect(screen.getByTestId('read-only-false')).toBeEnabled();
+});
+
+test('toBeDisabled()/toBeEnabled() supports "disabled" prop on Switch', async () => {
+ await render(
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('enabled')).toBeEnabled();
+ expect(screen.getByTestId('disabled')).toBeDisabled();
+});
+
+test('toBeDisabled()/toBeEnabled() does not inherit disabled state from ancestors', async () => {
+ await render(
+
+
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('in-disabled-view')).toBeEnabled();
+ expect(screen.getByTestId('in-disabled-view')).not.toBeDisabled();
+ expect(screen.getByTestId('in-disabled-pressable')).toBeEnabled();
+ expect(screen.getByTestId('in-disabled-pressable')).not.toBeDisabled();
+});
+
test('toBeDisabled()/toBeEnabled() supports "disabled" prop on Button', async () => {
await render(
diff --git a/src/matchers/to-be-disabled.ts b/src/matchers/to-be-disabled.ts
index 2efc9951d..6d3452cd4 100644
--- a/src/matchers/to-be-disabled.ts
+++ b/src/matchers/to-be-disabled.ts
@@ -9,7 +9,7 @@ import { checkHostElement } from './utils';
export function toBeDisabled(this: jest.MatcherContext, instance: TestInstance) {
checkHostElement(instance, toBeDisabled, this);
- const isDisabled = computeAriaDisabled(instance) || isAncestorDisabled(instance);
+ const isDisabled = computeAriaDisabled(instance);
return {
pass: isDisabled,
@@ -28,7 +28,7 @@ export function toBeDisabled(this: jest.MatcherContext, instance: TestInstance)
export function toBeEnabled(this: jest.MatcherContext, instance: TestInstance) {
checkHostElement(instance, toBeEnabled, this);
- const isEnabled = !computeAriaDisabled(instance) && !isAncestorDisabled(instance);
+ const isEnabled = !computeAriaDisabled(instance);
return {
pass: isEnabled,
@@ -43,12 +43,3 @@ export function toBeEnabled(this: jest.MatcherContext, instance: TestInstance) {
},
};
}
-
-function isAncestorDisabled(instance: TestInstance): boolean {
- const parent = instance.parent;
- if (parent == null) {
- return false;
- }
-
- return computeAriaDisabled(parent) || isAncestorDisabled(parent);
-}
diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx
index 9ef211dad..c394b4b08 100644
--- a/src/queries/__tests__/role.test.tsx
+++ b/src/queries/__tests__/role.test.tsx
@@ -370,6 +370,28 @@ describe('supports accessibility states', () => {
expect(screen.getByRole('button', { name: 'RNButton', disabled: true })).toBeTruthy();
});
+ test('supports disabled prop on Switch', async () => {
+ await render();
+ expect(screen.getByRole('switch', { disabled: true })).toBeTruthy();
+ expect(screen.queryByRole('switch', { disabled: false })).toBeNull();
+ });
+
+ test('supports readOnly prop on TextInput', async () => {
+ await render();
+ expect(screen.getByRole('searchbox', { disabled: true })).toBeTruthy();
+ expect(screen.queryByRole('searchbox', { disabled: false })).toBeNull();
+ });
+
+ test('does not inherit disabled state from ancestors', async () => {
+ await render(
+
+
+ ,
+ );
+ expect(screen.getByRole('button', { disabled: false })).toBeTruthy();
+ expect(screen.queryByRole('button', { disabled: true })).toBeNull();
+ });
+
test('supports aria-disabled={true} prop', async () => {
await render();
expect(screen.getByRole('button', { disabled: true })).toBeTruthy();
diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx
index 23f0405df..8ecf55905 100644
--- a/src/user-event/type/__tests__/type.test.tsx
+++ b/src/user-event/type/__tests__/type.test.tsx
@@ -183,6 +183,17 @@ describe('type()', () => {
expect(getEventsNames(events)).toEqual([]);
});
+ it('does respect readOnly prop', async () => {
+ const { events } = await renderTextInputWithToolkit({
+ readOnly: true,
+ });
+
+ const user = userEvent.setup();
+ await user.type(screen.getByTestId('input'), 'ab');
+
+ expect(getEventsNames(events)).toEqual([]);
+ });
+
it('supports backspace', async () => {
const { events } = await renderTextInputWithToolkit({
defaultValue: 'xxx',
diff --git a/website/docs/14.x/docs/api/events/user-event.mdx b/website/docs/14.x/docs/api/events/user-event.mdx
index 9246a4c64..bdbc968b0 100644
--- a/website/docs/14.x/docs/api/events/user-event.mdx
+++ b/website/docs/14.x/docs/api/events/user-event.mdx
@@ -112,7 +112,7 @@ This function will add text to the text already present in the text input (as sp
The sequence of events depends on the `multiline` prop and the passed options.
-Events will not be emitted if the `editable` prop is set to `false`.
+Events will not be emitted if the `editable` prop is set to `false` or the `readOnly` prop is set to `true`.
**Entering the element**:
@@ -160,7 +160,7 @@ This function supports only host `TextInput` elements. Passing other element typ
### Sequence of events {#clear-sequence}
-Events will not be emitted if the `editable` prop is set to `false`.
+Events will not be emitted if the `editable` prop is set to `false` or the `readOnly` prop is set to `true`.
**Entering the element**:
@@ -204,7 +204,7 @@ This function supports only host `TextInput` elements. Passing other element typ
### Sequence of events {#paste-sequence}
-Events will not be emitted if the `editable` prop is set to `false`.
+Events will not be emitted if the `editable` prop is set to `false` or the `readOnly` prop is set to `true`.
**Entering the element**:
diff --git a/website/docs/14.x/docs/api/jest-matchers.mdx b/website/docs/14.x/docs/api/jest-matchers.mdx
index a46090781..d322d8b84 100644
--- a/website/docs/14.x/docs/api/jest-matchers.mdx
+++ b/website/docs/14.x/docs/api/jest-matchers.mdx
@@ -92,7 +92,17 @@ expect(element).toBeEnabled();
expect(element).toBeDisabled();
```
-Checks if an element is enabled or disabled from `aria-disabled` or `accessibilityState.disabled` props. An element is disabled if it or any ancestor is disabled.
+Checks if an element is enabled or disabled from `aria-disabled` or `accessibilityState.disabled` props. These host elements are also disabled:
+
+- `TextInput` with `editable={false}` or `readOnly`
+- `Text` with `disabled`
+- `Switch` with `disabled`
+
+As in React Native, an element doesn't inherit the disabled state from its ancestors. Check the element that has the state, e.g. the button, not the text inside it:
+
+```tsx
+expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
+```
:::note
These matchers are opposites. Both are available so you can avoid double negations like `expect(element).not.toBeDisabled()`.
diff --git a/website/docs/14.x/docs/guides/llm-guidelines.mdx b/website/docs/14.x/docs/guides/llm-guidelines.mdx
index d09155457..f11cf7e46 100644
--- a/website/docs/14.x/docs/guides/llm-guidelines.mdx
+++ b/website/docs/14.x/docs/guides/llm-guidelines.mdx
@@ -73,8 +73,8 @@ const { result, rerender, unmount } = await renderHook(() => useMyHook(), option
| `toBeVisible()` | Element is visible (checks style, `aria-hidden`, `accessibilityElementsHidden`, ancestors) |
| `toBeEmptyElement()` | Element has no children or text content |
| `toContainElement(element)` | Element contains another element |
-| `toBeEnabled()` | Element is not disabled (checks `aria-disabled`, `accessibilityState`, ancestors) |
-| `toBeDisabled()` | Element has `aria-disabled` or `accessibilityState={{ disabled: true }}` (checks ancestors) |
+| `toBeEnabled()` | Element is not disabled (checks `aria-disabled`, `accessibilityState`) |
+| `toBeDisabled()` | Element has `aria-disabled` or `accessibilityState={{ disabled: true }}` (not from parents) |
| `toBeBusy()` | Element has `aria-busy` or `accessibilityState={{ busy: true }}` |
| `toBeChecked()` | Element has `aria-checked` or `accessibilityState={{ checked: true }}` |
| `toBePartiallyChecked()` | Element has `aria-checked="mixed"` or `accessibilityState={{ checked: 'mixed' }}` |