From 00ce2682d13f46a1be8b35c7abc37e8f4eec20a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 19 Oct 2026 11:37:42 +0200 Subject: [PATCH] fix: various disabled calculation edge cases --- CHANGELOG.md | 19 +++++ docs/api/jest-matchers.md | 12 ++- docs/api/user-event.md | 6 +- docs/guides/llm-guidelines.md | 4 +- src/events/__tests__/fire-event.test.tsx | 9 +++ src/helpers/__tests__/accessibility.test.tsx | 39 ++++++++++ src/helpers/__tests__/text-input.test.tsx | 17 ++++ src/helpers/accessibility.ts | 11 ++- src/helpers/text-input.ts | 12 ++- .../__tests__/to-be-disabled.test.tsx | 78 +++++++++++++++---- src/matchers/to-be-disabled.ts | 13 +--- src/queries/__tests__/role.test.tsx | 22 ++++++ src/user-event/type/__tests__/type.test.tsx | 11 +++ .../docs/14.x/docs/api/events/user-event.mdx | 6 +- website/docs/14.x/docs/api/jest-matchers.mdx | 12 ++- .../docs/14.x/docs/guides/llm-guidelines.mdx | 4 +- 16 files changed, 234 insertions(+), 41 deletions(-) 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' }}` |