Repository navigation
Conversation
| } | ||
|
|
||
| return outline; | ||
| return colors.outline; |
There was a problem hiding this comment.
Should the outline change on hover?
|
Checks are failing |
There was a problem hiding this comment.
🟡 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
disabledprop is destructured above but is not forwarded toIconButton;aria-disabledalone 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; passdisabledthrough 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.
| 'aria-valuenow': isCounterReached ? inputLength : undefined, | ||
| 'aria-disabled': isDisabled, | ||
| 'aria-invalid': isInvalid, | ||
| 'aria-label': explicitLabel ?? label, |
| 'aria-label': explicitLabel ?? label, | ||
| 'aria-labelledby': labelledBy, | ||
| 'aria-describedby': describedBy, | ||
| 'aria-disabled': props['aria-disabled'] ?? isDisabled, |
32523ce to
a796f25
Compare
There was a problem hiding this comment.
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)
| const hint = | ||
| Platform.OS === 'web' | ||
| ? props.accessibilityHint | ||
| : [props.accessibilityHint, supportingText, counterAccessibilityLabel] | ||
| .filter(Boolean) |
There was a problem hiding this comment.
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)
| // 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]); |
There was a problem hiding this comment.
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:
| // 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)
| const iconRef = React.useRef<View>(null); | ||
| React.useImperativeHandle<View | null, View | null>( | ||
| rest.ref, | ||
| () => iconRef.current, | ||
| [] | ||
| ); |
There was a problem hiding this comment.
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:
| 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; |
There was a problem hiding this comment.
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)
| 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( |
There was a problem hiding this comment.
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)
|
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 Accessibility tree from agent-device on this PR, rest state: On main the 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 (via Claude, verify if it makes sense) |
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.
a796f25 to
1cc6527
Compare
|
Found potential problems with the pull request:
|

Motivation
MD3 and a11y fixes. Breaking:
TextInput.Iconwithout a press handler is decorative and hidden; with one it needsaria-labeloraccessibilityLabel. Helper text is not in the field's name. Newaria-invalidandaria-describedbyprops (explicit wins). Disabled text uses one 38% opacity. Hover is web-only.Related issue
MD3 review.
Screenshots / Videos
Test plan
TextInput example:
onSurfaceVariant, outlinedoutline->onSurface; with Errorerror->onErrorContainer. Focus wins.