Skip to content

fix: don't expose handler-less TouchableRipple as a disabled control - #5071

Open
lukemorawski wants to merge 11 commits into
callstack:mainfrom
lukemorawski:fix/touchable-ripple-non-interactive-semantics
Open

lukemorawski wants to merge 11 commits into
callstack:mainfrom
lukemorawski:fix/touchable-ripple-non-interactive-semantics

Conversation

@lukemorawski

@lukemorawski lukemorawski commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

TouchableRipple did disabled = disabledProp || !hasPassedTouchHandler, so anything without a press handler was announced as a disabled control. Display-only stuff (a decorative TextInput.Icon, a read only Checkbox in List.Item, a Chip or Button with no handler) all showed up as disabled buttons.

Split into two flags:

const isControl = hasPassedTouchHandler || Boolean(disabledProp);
const isInteractive = hasPassedTouchHandler && !disabledProp;

Non-controls render a plain View instead of a Pressable:

  • not disabled, no aria-disabled
  • activatable roles (button, imagebutton, link, menuitem, tab) become none, the element and its label stay
  • state roles like checkbox are kept, a read only checkbox is still a checked checkbox
  • focusable defaults to false, otherwise on web it's a tab stop, RNW turns role="button" into a real <button>
  • function children / style get an idle state (pressed: false)
  • togglebutton counts as activatable too

disabled still gives a disabled control, same as before.

Why drop the Pressable instead of just passing disabled through: disabled also gates responder claiming (Pressability, onStartShouldSetResponder: () => !disabled). Fixing only the announcement turns claiming on, and handler-less ripples start swallowing taps meant for the parent. TextInput wraps its icons in a Pressable that focuses the input, so that's where it shows first.

Trade-off: having a handler or not now renders a different element, so onPress={busy ? undefined : fn} remounts the children (focus, state). Toggle with disabled instead, that keeps the same element. Documented on onPress and in the migration guide, and pinned by a test.

Also in here:

  • DataTable.Title had the same bug outside TouchableRipple, disabled={!onPress} on its own Pressable. Same fix.
  • RadioButton.Android / RadioButton.IOS dropped their handler when disabled instead of passing disabled, so a disabled radio would have turned into a non-control. Now forwards disabled like Checkbox.
  • React.Children.only gone from TouchableRipple, it was left over from when it wrapped TouchableNativeFeedback / TouchableHighlight. Without it the plain View and the Pressable accept the same children.
  • Migration guide entry: "TouchableRipple without a press handler".
  • TextInput.Icon faked disabled by nulling its own handler, which only worked because of this bug. Now forwards disabled when it has a handler, so the clear button on a disabled field is still a disabled button. IconButton dims it itself, so the field wrapper dim is cancelled for that icon, otherwise it's 0.38 * 0.38. A caller's own opacity on such an icon in a disabled field is overridden by that.

Known, open: on a disabled field with error the clear icon goes onSurface (IconButton's disabled colour) and the decorative one stays red (TextInput's getIconColor is error first). See the review thread, it's tied to #5111.

Related issue

Closes #5070

Supersedes #5011, which fixes the same symptom in Chip.tsx only by injecting an empty onPress. That swaps a false "disabled" for a false "enabled" and leaves the other consumers broken.

Test plan

Lint, typecheck (incl. legacy RN types) and tests pass.

Tests assert public output only: role, name, toBeDisabled, and a press on a handler-less ripple reaching its parent Pressable. The new tests were mutation checked (each fails with its fix reverted or broken). Tests on main that pinned the old behaviour are updated: handler-less Checkbox reported as disabled (#5104), handler-less Button reported as disabled, and the #5046 render prop tests that used testOnly_pressed without a handler.

On device against main, example app. iOS sim (iPhone 16 Pro), Android emu (API 36), Chrome.

TextInput, "Leading icon" on. Magnifier has no handler, clear button does.

main this PR
magnifier disabled button (iOS, Android), <button disabled> (web) plain element, not disabled
tap on magnifier focuses field iOS, Android yes, web no yes on all three
clear button works works
clear button, disabled field disabled button disabled button
both icons, disabled field 0.38 opacity 0.38 opacity

Android keeps the magnifier View in the a11y tree after the Animated.View -> View change, it's not flattened away.

DataTable, unsortable titles ("Calories per piece", "Fat (g)"): disabled on iOS and Android and aria-disabled on web on main, not disabled with this PR. Sortable title still sorts. Handler-less DataTable.Row on iOS also stops being enabled=false.

List.Item with a read only <Checkbox status="checked" />: disabled on main, checked and not disabled with this PR, all three.

Tap on the magnifier, and DataTable titles (iOS, Android):

ios-T1-branch.mp4
and-T1-branch.mp4
ios-T4-branch.mp4
and-T4-branch.mp4

Disabled field with error (Android, iOS, web):

Image Image Image

Comment thread src/components/IconButton/utils.ts Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

The same issue shows on all three platforms. Steps: open the example app, go to TextInput, then turn on "Error", "Disabled", "Leading icon" and "Trailing icon".

What happens: the leading magnifier has no onPress and keeps the error color. The trailing clear button has onPress, so it now gets disabled and IconButton paints it onSurface. The two icons in the same field have different colors.

What should happen: both icons in a disabled field use the same color, as they did before this PR. (via Claude, verify if it makes sense)

iOS

iOS, dark theme, disabled field with error: magnifier is red, clear icon is grey

The magnifier is red and the clear icon is grey. (via Claude, verify if it makes sense)

Android

Android, light theme, disabled field with error: magnifier is red, clear icon is grey

The magnifier is red and the clear icon is grey. (via Claude, verify if it makes sense)

Web

Web, light theme, disabled field with error: magnifier is red, clear icon is grey

The magnifier is rgb(179, 38, 30) and the clear icon is rgb(29, 27, 32) at the same 0.38 opacity. (via Claude, verify if it makes sense)

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Claude

  1. The branch conflicts with main in TouchableRipple.tsx, TouchableRipple.native.tsx, TextInputIcon.tsx and most snapshots. Two new tests depend on things main has since removed. #5099 dropped testID="touchable-ripple-underlay", so after the rebase the two "does not show press feedback" tests pass without checking anything, because queryByTestId never finds that ID. #5078 replaced IconButton's Surface, so the decorative-container-outer-layer and clear-container-outer-layer IDs in "dims a disabled TextInput.Icon exactly once" no longer exist.
  2. The PR description still says IconButton.getIconColor was reordered so an explicit color wins. That change was reverted, and the new IconButton test asserts the opposite. Please update the description so the squash commit does not describe a change that is not in it.
  3. DataTable.Title has the same bug outside TouchableRipple: DataTableTitle.tsx sets disabled={!onPress} on its Pressable. On web a title without onSortPress renders as <button disabled> ("Calories per piece" and "Fat (g)" in the DataTable example). (via Claude, verify if it makes sense)

Comment on lines +100 to +114
<Animated.View
{...rest}
ref={ref}
// Pressable defaults this to true, so keep it to preserve any role and
// state the caller set, e.g. a read only checked CheckboxItem.
accessible={rest.accessible !== false}
// Drop the claim, keep the element so its label is still announced.
role={claimsActivatable ? 'none' : rest.role}
// A consumer role of button makes react-native-web render a real
// <button>, which is tabbable by default. Nothing to activate here.
focusable={rest.focusable ?? false}
style={containerStyle}
>
{React.Children.only(children)}
</Animated.View>

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch adds two things the project avoids in new code:

  • React Native's Animated.View. Nothing animates here, and main moved to Reanimated in refactor: migrate components to Reanimated #5015. A plain View type-checks here, so the Animated import can go too.
  • React.Children.only. The plain view can render children directly. The Children API breaks with fragments and wrappers and hides the data flow.
Suggested change
<Animated.View
{...rest}
ref={ref}
// Pressable defaults this to true, so keep it to preserve any role and
// state the caller set, e.g. a read only checked CheckboxItem.
accessible={rest.accessible !== false}
// Drop the claim, keep the element so its label is still announced.
role={claimsActivatable ? 'none' : rest.role}
// A consumer role of button makes react-native-web render a real
// <button>, which is tabbable by default. Nothing to activate here.
focusable={rest.focusable ?? false}
style={containerStyle}
>
{React.Children.only(children)}
</Animated.View>
<View
{...rest}
ref={ref}
// Pressable defaults this to true, so keep it to preserve any role and
// state the caller set, e.g. a read only checked CheckboxItem.
accessible={rest.accessible !== false}
// Drop the claim, keep the element so its label is still announced.
role={claimsActivatable ? 'none' : rest.role}
// A consumer role of button makes react-native-web render a real
// <button>, which is tabbable by default. Nothing to activate here.
focusable={rest.focusable ?? false}
style={containerStyle}
>
{children}
</View>

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, it's a plain View now and renders children as they are. if children or style is a function it gets called with { pressed: false, hovered: false, focused: false }.
I also took React.Children.only out of the Pressable path. otherwise a ripple with 2 children and no onPress renders fine, and then crashes when onPress gets added. it was only there from back when this wrapped TouchableNativeFeedback / TouchableHighlight, nothing needs a single child anymore.

Comment on lines +294 to +315
<Animated.View
{...rest}
ref={ref}
// Pressable defaults this to true, so keep it to preserve any role and
// state the caller set, e.g. a read only checked CheckboxItem.
accessible={rest.accessible !== false}
// Drop the claim, keep the element so its label is still announced.
role={claimsActivatable ? 'none' : rest.role}
// A consumer role of button makes react-native-web render a real
// <button>, which is tabbable by default. Nothing to activate here.
focusable={rest.focusable ?? false}
style={[
styles.touchable,
borderless && styles.borderless,
styles.disabled,
typeof style === 'function' ? style(state) : style,
]}
>
{React.Children.only(
typeof children === 'function' ? children(state) : children
)}
</Animated.View>

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as in the native file. Use a plain View instead of React Native's Animated.View (nothing animates and main uses Reanimated), and render the children without React.Children.only.

Suggested change
<Animated.View
{...rest}
ref={ref}
// Pressable defaults this to true, so keep it to preserve any role and
// state the caller set, e.g. a read only checked CheckboxItem.
accessible={rest.accessible !== false}
// Drop the claim, keep the element so its label is still announced.
role={claimsActivatable ? 'none' : rest.role}
// A consumer role of button makes react-native-web render a real
// <button>, which is tabbable by default. Nothing to activate here.
focusable={rest.focusable ?? false}
style={[
styles.touchable,
borderless && styles.borderless,
styles.disabled,
typeof style === 'function' ? style(state) : style,
]}
>
{React.Children.only(
typeof children === 'function' ? children(state) : children
)}
</Animated.View>
<View
{...rest}
ref={ref}
// Pressable defaults this to true, so keep it to preserve any role and
// state the caller set, e.g. a read only checked CheckboxItem.
accessible={rest.accessible !== false}
// Drop the claim, keep the element so its label is still announced.
role={claimsActivatable ? 'none' : rest.role}
// A consumer role of button makes react-native-web render a real
// <button>, which is tabbable by default. Nothing to activate here.
focusable={rest.focusable ?? false}
style={[
styles.touchable,
borderless && styles.borderless,
styles.disabled,
typeof style === 'function' ? style(state) : style,
]}
>
{typeof children === 'function' ? children(state) : children}
</View>

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, done

// one. A decorative icon keeps it, nothing else dims it.
isInteractive && disabled ? styles.notDimmed : null,
]}
disabled={isInteractive ? disabled : undefined}

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a field is disabled and has an error, the two icons now get different colors. The decorative icon keeps getIconColor's result (the error color). The interactive icon gets disabled, and IconButton replaces its color with onSurface. Before this PR both icons used the same color. The same split happens with an explicit iconColor.

iOS, disabled field with error
Android, disabled field with error
Web, disabled field with error

On web the magnifier is rgb(179, 38, 30) and the clear icon is rgb(29, 27, 32), both at 0.38 opacity.

Pick one rule for both icons. For example, give the decorative icon the same disabled color in getIconColor when isDisabled is true, or keep the interactive icon on color and stop IconButton from overriding it. (via Claude, verify if it makes sense)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, you're right, and it's caused by this PR. on main TextInput.Icon never passed disabled to IconButton, so both icons were red. now the clear button really is a disabled button, and IconButton colours it onSurface.
I don't want to fix it by not passing disabled again, because then the clear button on a disabled field isn't a button at all, which is the opposite of what this PR is for.

so the real question is what wins in a field that is disabled and has an error:

once #5111 is in, both icons are onSurface with the default colours. with a custom iconColor they still split though, because getIconColor checks iconColor first and IconButton checks disabled first. Checkbox avoids that by putting disabled before the custom colour too, so getIconColor would need the same to fully fix it.
your second option (keep the clear icon on color and stop IconButton overriding it) means changing IconButton's colour order for every disabled IconButton with iconColor, which is what got pushed back on earlier in this PR, so I'd rather not.
@Doberjohn should #5111 go first, or should I change just getIconColor here (disabled before the custom colour and before error)? it's the same function #5111 touches so the conflict would be easy. what do you prefer?

one more thing for whichever of the two lands second: #5111 disables TextInput.Icon with onPress={disabled ? undefined : onPress} + aria-disabled. that only works because of the bug this PR fixes. with this PR an icon without a handler isn't a button anymore, so the clear button on a disabled field would end up as role="none" with aria-disabled. it has to pass disabled like this PR does.
#5111 also hides decorative icons completely (aria-hidden, pointerEvents="none"), while this PR keeps them with their label. I'm fine either way for TextInput.Icon, we just need to pick one.

Comment on lines +205 to +208
<View
testID={`${testID}-icon-opacity`}
style={{ opacity: iconOpacity }}
>

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a derived testID to a public component only so a test can read an internal opacity. main removed derived testIDs in #5099 and the default testID in #5088, so after the rebase this renders undefined-icon-opacity when no testID is passed. The "dims a disabled TextInput.Icon exactly once" test also asserts internal styles instead of public output. Please drop the testID and cover the dim-once case with a snapshot of the disabled field, which the suite already does for other states.

Suggested change
<View
testID={`${testID}-icon-opacity`}
style={{ opacity: iconOpacity }}
>
<View style={{ opacity: iconOpacity }}>

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed it. the "dims once" case is now a snapshot of a disabled field with both icons in it.

Comment on lines +56 to +60
expect(plain).not.toBeDisabled();
// no Pressable underneath, so nothing claims the touch. A responder here
// would swallow taps meant for whatever wraps this, e.g. TextInput's
// press to focus.
expect(plain).not.toHaveProp('onStartShouldSetResponder');

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions check internal props (onStartShouldSetResponder, focusable). The regression the PR description names, a handler-less ripple swallowing the tap meant for its parent, can be tested through public behavior. This passes on this branch and fails when the ripple claims the touch (for example with only onPressIn set):

it('lets a press reach the parent when no touch handler is passed', async () => {
  const onPress = jest.fn<(e: GestureResponderEvent) => void>();

  await render(
    <Pressable onPress={onPress}>
      <TouchableRipple>
        <Text>Icon</Text>
      </TouchableRipple>
    </Pressable>
  );

  await userEvent.press(screen.getByText('Icon'));

  expect(onPress).toHaveBeenCalledTimes(1);
});

(via Claude, verify if it makes sense)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added yours, thanks. it fails if the ripple grabs the touch.
the rest of this file and TouchableRippleWeb.test.tsx now check role, name and toBeDisabled instead of internal props. the two "does not show press feedback" tests didn't check anything anymore after #5099 removed the testID, so I replaced them.

@Doberjohn Doberjohn modified the milestones: 6.0.0, 6.0.0-beta.0 Nov 12, 2026
@lukemorawski
lukemorawski force-pushed the fix/touchable-ripple-non-interactive-semantics branch from 1d11c75 to ad9433c Compare November 12, 2026 09:43
@github-actions

Copy link
Copy Markdown

Found potential problems with the pull request:

  • The description is too long. Please keep it under 1000 characters.

@Stoneski93
Stoneski93 self-requested a review November 12, 2026 14:23
@lukemorawski

Copy link
Copy Markdown
Contributor Author
  1. rebased. rewrote the tests that used the removed testIDs. I also had to change a few tests on main that expected the old behaviour: Checkbox "is reported as disabled rather than an enabled no-op without a press handler" (fix(checkbox)!: align state layer, touch target and focus ring with Material 3 #5104), Button "renders disabled button if there is no touch handler passed", and the fix(touchable-ripple): support function children and style on native #5046 render prop tests that used testOnly_pressed without a handler.
  2. updated the description
  3. good catch, fixed here. DataTable.Title without onPress is now a plain View too, same as TouchableRipple, so it doesn't take the touch either. on web it used to get aria-disabled="true", that's gone now.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TouchableRipple exposes non-interactive surfaces as disabled controls

5 participants