Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion docs/api/jest-matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
6 changes: 3 additions & 3 deletions docs/api/user-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down Expand Up @@ -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**:

Expand Down Expand Up @@ -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**:

Expand Down
4 changes: 2 additions & 2 deletions docs/guides/llm-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}` |
Expand Down
9 changes: 9 additions & 0 deletions src/events/__tests__/fire-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TextInput testID="input" readOnly onChangeText={onChangeText} />);
const input = screen.getByTestId('input');
await fireEvent.changeText(input, 'new text');
expect(onChangeText).not.toHaveBeenCalled();
expect(nativeState.valueForInstance.get(input)).toBeUndefined();
});
});

describe('fireEvent.scroll', () => {
Expand Down
39 changes: 39 additions & 0 deletions src/helpers/__tests__/accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,34 @@ describe('computeAriaDisabled', () => {
expect(computeAriaDisabled(screen.getByTestId('editable-false'))).toBe(true);
});

test('supports TextInput with "readOnly" prop', async () => {
await render(
<View>
<TextInput testID="read-only" readOnly />
<TextInput testID="read-only-false" readOnly={false} />
<TextInput testID="read-only-false-non-editable" readOnly={false} editable={false} />
</View>,
);

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(
<View>
<Switch testID="default" />
<Switch testID="disabled" disabled />
<Switch testID="disabled-false" disabled={false} />
</View>,
);

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(
<View>
Expand All @@ -569,12 +597,23 @@ describe('computeAriaDisabled', () => {
<Text>Default Text</Text>
<Text disabled>Disabled Text</Text>
<Text aria-disabled>ARIA Disabled Text</Text>
<Text accessibilityState={{ disabled: true }}>State Disabled Text</Text>
<Text disabled={false} aria-disabled>
Disabled False ARIA Disabled Text
</Text>
<Text disabled={false} accessibilityState={{ disabled: true }}>
Disabled False State Disabled Text
</Text>
</View>,
);

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);
});
});

Expand Down
17 changes: 17 additions & 0 deletions src/helpers/__tests__/text-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<View>
<TextInput testID="read-only" readOnly />
<TextInput testID="read-only-false" readOnly={false} />
<TextInput testID="read-only-false-non-editable" readOnly={false} editable={false} />
<TextInput testID="read-only-editable" readOnly editable />
</View>,
);

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);
});
11 changes: 10 additions & 1 deletion src/helpers/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
12 changes: 11 additions & 1 deletion src/helpers/text-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
78 changes: 62 additions & 16 deletions src/matchers/__tests__/to-be-disabled.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import {
Button,
Pressable,
Switch,
Text,
TextInput,
TouchableHighlight,
Expand All @@ -18,7 +19,7 @@ test('toBeDisabled()/toBeEnabled() supports basic case', async () => {
<View>
<View testID="disabled-parent" aria-disabled>
<View>
<View testID="disabled-child" />
<View testID="child-of-disabled" />
</View>
</View>
<View>
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
<Text>
Button
</Text>"
`);

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:
<Text>
Button
</Text>"
Expand All @@ -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.
<Component disabled testID="subject">
<Text>Button</Text>
{/* `TouchableWithoutFeedback` and `TouchableNativeFeedback` render their child as the host. */}
<View>
<Text>Button</Text>
</View>
</Component>,
);

Expand All @@ -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();
},
);

Expand Down Expand Up @@ -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(
<View>
<TextInput testID="read-only" readOnly />
<TextInput testID="read-only-false" readOnly={false} />
</View>,
);

expect(screen.getByTestId('read-only')).toBeDisabled();
expect(screen.getByTestId('read-only-false')).toBeEnabled();
});

test('toBeDisabled()/toBeEnabled() supports "disabled" prop on Switch', async () => {
await render(
<View>
<Switch testID="enabled" />
<Switch testID="disabled" disabled />
</View>,
);

expect(screen.getByTestId('enabled')).toBeEnabled();
expect(screen.getByTestId('disabled')).toBeDisabled();
});

test('toBeDisabled()/toBeEnabled() does not inherit disabled state from ancestors', async () => {
await render(
<View>
<View aria-disabled>
<Pressable testID="in-disabled-view" />
</View>
<Pressable disabled>
<Pressable testID="in-disabled-pressable" />
</Pressable>
</View>,
);

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(
<View>
Expand Down
13 changes: 2 additions & 11 deletions src/matchers/to-be-disabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
}
Loading
Loading