Skip to content

fix(text-input): correct MD3 states and accessibility - #5111

Open
likevy wants to merge 17 commits into
callstack:mainfrom
likevy:fix/textinput-md3-accessibility
Open

likevy wants to merge 17 commits into
callstack:mainfrom
likevy:fix/textinput-md3-accessibility

Conversation

@likevy

@likevy likevy commented Sep 8, 2026 •

Copy link
Copy Markdown

Motivation

MD3 and a11y fixes. Breaking: TextInput.Icon without a press handler is decorative and hidden; with one it needs aria-label or accessibilityLabel. Helper text is not in the field's name. New aria-invalid and aria-describedby props (explicit wins). Disabled text uses one 38% opacity. Hover is web-only.

Related issue

MD3 review.

Screenshots / Videos

Test plan

TextInput example:

  1. Web hover: filled onSurfaceVariant, outlined outline -> onSurface; with Error error -> onErrorContainer. Focus wins.
  2. Enable Decorative leading icon and Clear text action. Tab into the field: icon skipped. Type, Tab: "Clear text" is read, Enter clears.
  3. Error + Counter: read once in the hint on iOS/Android; on web, an alert and the description.

Comment thread src/components/TextInput/utils.ts Outdated
}

return outline;
return colors.outline;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should the outline change on hover?

@JKobrynski

Copy link
Copy Markdown
Collaborator

Checks are failing

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.

🟡 Changes recommended

Three moderate accessibility issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Updates TextInput to align MD3 indicator states and improve cross-platform accessibility.

Changes:

  • Corrects filled-field colors and hover behavior.
  • Adds accessibility relationships and platform-specific announcements.
  • Updates accessory behavior, tests, examples, and migration guidance.
File summaries
File Summary
src/components/TextInput/utils.ts Accessibility data and indicator color logic
src/components/TextInput/TextInputIcon.tsx Decorative/actionable accessory behavior
src/components/TextInput/TextInput.tsx Accessibility props and rendering updates
src/components/TextInput/hooks.ts Hover state and announcements
src/components/__tests__/TextInputStates.test.tsx Indicator state tests
src/components/__tests__/TextInputAccessibility.test.tsx Accessibility behavior tests
src/components/__tests__/TextInput.test.tsx Updated component tests
src/components/__tests__/__snapshots__/TextInput.test.tsx.snap Updated snapshots
example/src/Examples/TextInputExample.tsx Updated accessibility examples
docs/6.x/docs/guides/migration.md Migration guidance
Review details

Suppressed comments (1)

src/components/TextInput/TextInputIcon.tsx:142

  • The disabled prop is destructured above but is not forwarded to IconButton; aria-disabled alone does not activate IconButton's disabled styling (getIconButtonColor) or its disabled prop. A directly used named <TextInput.Icon disabled mode="contained" /> can therefore remain visually enabled even though its handlers are suppressed; pass disabled through as well.
        aria-disabled={disabled}
  • Files reviewed: 10/10 changed files
  • Comments generated: 2
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/components/TextInput/utils.ts Outdated
'aria-valuenow': isCounterReached ? inputLength : undefined,
'aria-disabled': isDisabled,
'aria-invalid': isInvalid,
'aria-label': explicitLabel ?? label,
Comment thread src/components/TextInput/utils.ts Outdated
'aria-label': explicitLabel ?? label,
'aria-labelledby': labelledBy,
'aria-describedby': describedBy,
'aria-disabled': props['aria-disabled'] ?? isDisabled,
@likevy
likevy force-pushed the fix/textinput-md3-accessibility branch from 32523ce to a796f25 Compare September 22, 2026 14:32

@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

The PR description is out of date. It says outlined fields do not react to hover, and test plan step 4 says "Outlined does not change". The second commit makes outlined use onSurface and onErrorContainer on hover. Please update the description and the test plan. (via Claude, verify if it makes sense)

Comment on lines +602 to +606
const hint =
Platform.OS === 'web'
? props.accessibilityHint
: [props.accessibilityHint, supportingText, counterAccessibilityLabel]
.filter(Boolean)

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.

On Android and iOS the supporting text and the counter are now read twice. The input hint contains them, and the Text nodes below the field are still in the accessibility tree. On the iOS simulator the example shows the text field followed by separate static text elements "Supporting text" and "Characters entered 0 of 100". A VoiceOver or TalkBack user hears the hint on the field and then the same text again on the next swipe. Before this PR the helper text was aria-hidden, because it was part of the name.

When the text goes into the native hint, hide the supporting text and counter nodes on native, for example with aria-hidden: Platform.OS !== 'web' in supportingText and counter. A hidden live region does not announce, so the Android error then needs the same AccessibilityInfo.announceForAccessibility path that iOS uses in hooks.ts. (via Claude, verify if it makes sense)

Comment thread src/components/TextInput/hooks.ts Outdated
Comment on lines +438 to +445
// Native iOS does not implement live regions. Announce changed errors once,
// while Android and web use the rendered alert/live region.
const errorMessage = flags.hasError ? props.supportingText : undefined;
useEffect(() => {
if (Platform.OS === 'ios' && errorMessage) {
AccessibilityInfo.announceForAccessibility(errorMessage);
}
}, [errorMessage]);

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 effect also runs on mount, so iOS announces an error that is already present when the field first renders. Web and Android do not do that: an element that has role="alert" or a live region at first render is not announced. For example, a screen that opens with server validation errors on three fields fires three announcements on iOS, and each one cuts off the one before it. Skip the first render and announce only changed messages:

Suggested change
// Native iOS does not implement live regions. Announce changed errors once,
// while Android and web use the rendered alert/live region.
const errorMessage = flags.hasError ? props.supportingText : undefined;
useEffect(() => {
if (Platform.OS === 'ios' && errorMessage) {
AccessibilityInfo.announceForAccessibility(errorMessage);
}
}, [errorMessage]);
// Native iOS does not implement live regions. Announce changed errors once,
// while Android and web use the rendered alert/live region. Skip the first
// render, because an alert or live region present on mount is not announced.
const errorMessage = flags.hasError ? props.supportingText : undefined;
const previousErrorMessage = useRef(errorMessage);
useEffect(() => {
if (
Platform.OS === 'ios' &&
errorMessage &&
errorMessage !== previousErrorMessage.current
) {
AccessibilityInfo.announceForAccessibility(errorMessage);
}
previousErrorMessage.current = errorMessage;
}, [errorMessage]);

A test that renders with error and checks that nothing is announced would cover this. (via Claude, verify if it makes sense)

Comment on lines +102 to +107
const iconRef = React.useRef<View>(null);
React.useImperativeHandle<View | null, View | null>(
rest.ref,
() => iconRef.current,
[]
);

@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.

Optional: this ref indirection does nothing. {...rest} already passes ref to IconButton, and IconButton puts it on the same Animated.View in both decorative and actionable mode. So the ref target does not change when the icon switches mode. Remove these lines and ref={rest.ref ? iconRef : undefined} below:

Suggested change
const iconRef = React.useRef<View>(null);
React.useImperativeHandle<View | null, View | null>(
rest.ref,
() => iconRef.current,
[]
);

(via Claude, verify if it makes sense)

}
if (isFocused) {
return primary;
return isHovered && !isFocused ? colors.onErrorContainer : colors.error;

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.

Optional: in MD3 the error hover state also changes the label text and the trailing icon to onErrorContainer (error-hover-label-text-color and error-hover-trailing-icon-color in material-web). Hover also adds an onSurface state layer at 8% on the filled container. On web with Error on, the indicator now turns dark red on hover while the label and error icon stay error. This can be a follow-up, since the PR scope is the indicator. (via Claude, verify if it makes sense)

Comment on lines +43 to +45
it('uses MD3 filled resting and hover colors without overriding the focused or disabled indicator', async () => {
jest.replaceProperty(Platform, 'OS', 'web');
const { result, rerender } = await renderHook(

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 tests call useTextInput through renderHook and check exact internal style values with StyleSheet.flatten. The project test rules flag assertions on exact internal style values, and new StyleSheet.flatten calls should be avoided. The color table is already covered by the direct getOutlineColor test at the end of this file, which is a pure helper with enough branches to test on its own. Move the remaining state cases (filled and outlined, rest, hover, error and disabled) into getOutlineColor tests and drop the renderHook and StyleSheet.flatten tests, including the disabled opacity test at the top. (via Claude, verify if it makes sense)

@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

Android (Pixel 9 emulator)

The field wrapper becomes a TalkBack stop that repeats the supporting text

Steps: open the TextInput example and look at the filled field with the default "Supporting text". Then turn on Error and Counter.

What happens: the Pressable around the field is clickable, so React Native gives it a content description built from its child text that is not hidden. This PR removes aria-hidden from the helper text, so the wrapper node (it covers the field and the supporting text) now has the description "Supporting text". On main the same node has no description. With Error and Counter on, the wrapper reads "Please check the entered text, Characters entered 0 of 100". The input hint also contains that text. So a TalkBack user gets the same text on the wrapper and again in the hint of the field.

Accessibility tree from agent-device on this PR, rest state:

View "Supporting text"   clickable, bounds of field + supporting text
  TextView "Label"
  EditText
  TextView "Supporting text"

On main the View has no label.

What should happen: the supporting text and the counter are read once. Hiding the supporting text and counter nodes on native, as in the inline comment on utils.ts lines 602 to 606, also removes them from the wrapper description.

Android, Error and Counter on

(via Claude, verify if it makes sense)

@Doberjohn Doberjohn added this to the 6.0.0 milestone Oct 30, 2026
likevy added 17 commits October 30, 2026 12:55
Filled indicator colors now follow the tokens: `onSurfaceVariant` at rest,
`onSurface` on hover, and `onErrorContainer` when an invalid field is hovered.
Outlined fields keep `outline` and ignore hover.

Supporting text, the error and the counter are associated with the field
instead of being folded into its accessible name: generated ids referenced by
`aria-describedby` on web, and `accessibilityHint` on Android and iOS, which
have no described-by relationship. Errors announce through `role="alert"`, an
assertive Android live region, and a one-shot iOS announcement. An explicit
`aria-invalid` or `aria-describedby` is preserved.

An empty unfocused field no longer fades out of the native accessibility tree
with the label animation, and a disabled input is read-only so it cannot be
operated.

BREAKING CHANGE: TextInput.Icon with any press handler requires aria-label
or accessibilityLabel. Icons without press handlers are decorative.
Both decorative and actionable icons render the same IconButton, so the ref already lands on the same view in either mode. The imperative handle only added indirection.
Supporting text and the counter are already part of the input's accessibilityHint on Android and iOS, so their Text nodes were read a second time and Android merged them into the label of the clickable field wrapper. Hide them on native. A hidden live region does not announce, so Android now announces changed errors through AccessibilityInfo like iOS, and web keeps the alert role.
A screen that opens with server validation errors on several fields fired one announcement per field on native, each cutting off the previous one. Announce only errors that change after the first render.
Test the filled and outlined indicator colors for rest, hover, focus, error and disabled states on the pure helper, instead of rendering the hook and flattening its internal styles.
Note that the text below the field is hidden on native because the hint reads it, that Android and iOS announce new or changed errors, and format the accessory example.
When several fields become invalid in one update, each field announced its error and VoiceOver only spoke the last one. Queue the announcements on iOS. Android falls back to the plain announcement.
The indicator already used the disabled color for a disabled invalid field, while the label, supporting text and icons stayed red. Use the disabled colors for all of them, as the MD3 reference field drops error styling while disabled. The error icon stays in place in the disabled color, and the field is still reported as invalid.
The generated label, supporting text and counter IDs are only referenced by aria-labelledby and aria-describedby on web, but they were rendered on every platform. Native snapshots then contained order-dependent useId values, so a snapshot test failed when it ran on its own. Native keeps any aria-describedby passed by the caller.
Assert the accessible name the field gets on web from the caller's labelled-by element, so the test fails when the generated label relationship overrides accessibilityLabelledBy.
An actionable icon with a blank aria-label warns, while a decorative icon and a named actionable icon do not.
Show the TextInput.Icon change with a before and after snippet on a controlled field, scope hover to web and the mount rule for error announcements to native, mention that hints off hides supporting text on native, and drop implementation details and changes that only applied to the 6.0 alpha.
React Native has no native invalid state, so aria-invalid only reaches assistive technology on web. On Android and iOS the error text stays in the hint.
aria-invalid only reaches assistive technology on web, so run the disabled invalid field test there.
Assert the native input has no generated aria-describedby and the supporting text and counter render without a nativeID.
React Native only honors the queue option on iOS, and Android falls back to the plain announcement. Check that both platforms announce each field's error, and check the queue option on iOS.
@likevy
likevy force-pushed the fix/textinput-md3-accessibility branch from a796f25 to 1cc6527 Compare October 30, 2026 14:10
@github-actions

Copy link
Copy Markdown

Found potential problems with the pull request:

  • Screenshot or video evidence is missing. Make sure to include one if it affects the UI.

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.

5 participants