callstack / callstack/react-native-paper
TouchableRipple exposes non-interactive surfaces as disabled controls
- 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
Assessment
This issue has not been assessed yet.