callstack / callstack/react-native-paper
TouchableRipple exposes non-interactive surfaces as disabled controls
- Langage dominant
- TypeScript
- Étoiles
- 14.5k
- Forks
- 2.2k
- Merge moyen
- 5 j 23 h
- PR mergées (30 j)
- 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 |
Guide de contribution
Ouvrir le guide de contribution
Piste de recherche
Commencez par src/components/TouchableRipple/TouchableRipple.native.tsx et examinez comment le flag disabled partagé est transmis à Pressable, puis examinez src/components/TextInput/TextInputIcon.tsx et les autres utilisations de TouchableRipple. Exécutez les tests existants no-touch-handler et Chip, qui confirment actuellement le bug. C’est terminé lorsque les surfaces sans handler omettent l’état d’accessibilité disabled et le comportement de focus, tandis que les contrôles explicitement désactivés les conservent sur native et web.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Évaluation
- Stack technique
- react-native, typescript
- Domaine
- accessibility, frontend, mobile
- Type d'issue
- Bug
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Activité
- Active
- Clarté
- Clairement spécifiée
- Accessibilité débutants
- 55/100