callstack / callstack/react-native-paper

TouchableRipple exposes non-interactive surfaces as disabled controls

Open
#5,070 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
14.5k
Forks
2.2k
Avg merge
5d 23h
Merged PRs (30d)
12

Description

### Current behaviour

`TouchableRipple` treats "no touch handler was passed" as identical to "this control is disabled":

```ts
// src/components/TouchableRipple/TouchableRipple.native.tsx
const disabled = disabledProp || !hasPassedTouchHandler;
```

That single flag is then handed to `Pressable`, which derives `accessibilityState.disabled` from it. The result is that every non-interactive surface built on `TouchableRipple` is announced to assistive tech as a **disabled control**, even though nothing about it is disabled and it was never meant to be a control.

Nothing looks wrong on screen, which is why this has gone unnoticed. Paper never dimmed these views, so a screenshot is identical either way. The defect is only visible in the accessibility tree.

Measured on the example app, `TextInput` screen, with "Leading icon" enabled. The leading magnifier has no `onPress`; the trailing clear button does:

| platform | probe | decorative magnifier | clear button |
| ---------- | ----------------------- | ------------------------------------------------------ | ----------------------- |
| iOS 18.3 | `AXButton.enabled` | `false` | `true` |
| Android 15 | `uiautomator` `enabled` | `"false"` | `"true"` |
| web | DOM | `` | `` |

The web case is the easiest to confirm and the most severe. A decorative icon becomes a genuinely `disabled` HTML `` with `pointer-events: none`.

VoiceOver and TalkBack both read the disabled bit, so a screen reader user hears "search, dimmed, button" on an element that is purely decorative.

This is not limited to one component. It affects any of the 22 components that render `TouchableRipple` when they are used without a touch handler, including `Chip`, `Button`, `List.Item`, `DataTable.Cell`, `Drawer.Item`, `Menu.Item`, `Card` and `IconButton`. Across Paper's own test suite, 80 rendered elements carry the false `disabled` state.

Some consumers work around it by nulling their own handler to fake a disabled state, which stops working the moment the primitive is corrected. `TextInput.Icon` does exactly this:

```ts
// src/components/TextInput/TextInputIcon.tsx
const onPressHandler = disabled ? undefined : onPress;
```

### Expected behaviour

A component with no touch handler should not be exposed as a control at all: no `accessibilityState.disabled`, no keyboard or D-pad focus, no `` on web.

A component whose caller explicitly passed `disabled` should still be announced as a disabled control, exactly as it is today.

The two cases are different and should not share one flag.

### How to reproduce?

1. Run the example app: `cd example && yarn start`
2. Open the **TextInput** screen and turn on the "Leading icon" toggle. That renders a `TextInput.Icon` with no `onPress`, next to a clear button that has one.
3. Inspect the accessibility tree:
- **iOS**: Xcode > Open Developer Tool > Accessibility Inspector, target the simulator, click the magnifier. Traits read `Button, Not Enabled`.
- **Android**: `adb shell uiautomator dump` and read the node with `resource-id="icon-button"`. It reports `enabled="false"`.
- **Web**: `yarn start --web`, then inspect the magnifier in devtools. It is ``.
4. With VoiceOver or TalkBack on, move to the magnifier. It is announced as disabled.

The same thing is visible anywhere a `TouchableRipple`-based component is rendered without a handler, for example `Plain` or a `List.Item` with no `onPress`.

### Preview

Not applicable. There is no visual difference to screenshot, which is the point: Paper applies no dimming to these views on native, and `styles.disabled` on web is only `cursor: auto`. The defect lives entirely in the accessibility tree, so the tables above are the evidence.

### What have you tried so far?

- Confirmed the mechanism in React Native's source. `Pressable.js:235-236` merges the `disabled` prop into `accessibilityState` and it always wins over `aria-disabled`/`accessibilityState`, so passing `undefined` is the only way to leave the key absent.
- Confirmed the blast radius by walking the accessibility tree across component configurations on a real simulator and emulator, plus the DOM on web.
- Looked at the existing narrow fixes. #5011 ("fix: avoid disabled accessibility state for active chips") addresses this same symptom, but only inside `Chip.tsx`, by injecting an empty `onPress` to defeat the primitive. That swaps a false "disabled" for a false "enabled" and leaves the other 21 components untouched. #5002 also modifies `Chip.tsx` in overlapping ways.
- Note for whoever picks this up: two of Paper's own tests currently encode the bug as intended behaviour (`renders disabled button if there is no touch handler passed` and its `Chip` equivalent, both asserting `toBeDisabled()`), so they need splitting into a "no handler" case and a "disabled prop" case rather than updating in place.

### Your Environment

| software | version |
| ------------------ | ---------------------- |
| ios | 18.3 (simulator) |
| android | 15 / API 35 (emulator) |
| react-native | 0.85.3 |
| react-native-paper | 6.0.0-alpha.0 (`main`) |
| node | 24.13.0 |
| npm or yarn | yarn 4.9.1 |
| expo sdk | 56.0.0 |

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.