Skip to content

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

Open
likevy wants to merge 1 commit into
callstack:mainfrom
likevy:fix/textinput-md3-accessibility
Open

fix(text-input): correct MD3 states and accessibility#5111
likevy wants to merge 1 commit into
callstack:mainfrom
likevy:fix/textinput-md3-accessibility

Conversation

@likevy

@likevy likevy commented Sep 8, 2026

Copy link
Copy Markdown

Motivation

TextInput is one of the MD3 reference components. The review found the filled active indicator using the wrong resting colour with no hover state, the field's supporting text, error and counter never programmatically associated with the input, and decorative accessories rendering as focusable buttons.

This worth mentioning:

  • Supporting text is no longer part of the field's name. It was concatenated into aria-label, so a screen reader read label and helper as one blob. It now carries a generated nativeID referenced by aria-describedby on web. React Native 0.85 has no native described-by, so on Android and iOS the same text goes into accessibilityHint, which describes without renaming.
  • Error announcement is per-platform, because live regions are not portable: role="alert", an assertive region on Android, and a one-shot AccessibilityInfo announcement on iOS, which implements neither.
  • An accessory is decorative unless it can be pressed. Breaking. With no press handler it is aria-hidden and unfocusable, so a plain leading icon no longer creates a tab stop that announces "button". With one, it requires an accessible name, enforced by the props type and a __DEV__ warning.
  • Indicator colours now match the tokens: filled onSurfaceVariant at rest, onSurface hovered, primary focused, error invalid, onErrorContainer invalid and hovered. Outlined keeps outline and correctly does not react to hover.

Also fixed: the field container was opacity-bound to the label animation, which faded an empty unfocused field out of the native accessibility tree, and a disabled input is now readOnly so it cannot be operated. aria-invalid and aria-describedby are public props, and an explicit value wins over the derived one.

Indicator values were verified against material-web's generated _md-comp-filled-text-field.scss, since the spec site is JS-rendered.

Related issue

The TextInput review checklist:

  • Filled active-indicator resting color uses outline; MD3 uses onSurfaceVariant.
  • Filled active indicator has no hover state (MD3 hover = on-surface). Also adds the error-hover token (onErrorContainer), missing for the same reason.
  • Helper/error/counter text is not programmatically associated with the field; supporting text is folded into the input aria-label, and the error is aria-live="polite" rather than role="alert". Associate them (aria-describedby / nativeID) and announce errors.
  • Decorative left/right accessories always render a focusable IconButton (role="button") even when non-actionable; render decorative icons non-interactively and require names for actionable ones.

Test plan

yarn lint, yarn typecheck and yarn test pass - 713 tests, 168 snapshots.

35 new tests (TextInput: 62 to 97) in two new suites. TextInputAccessibility.test.tsx covers ID stability as helper text becomes an error, distinct IDs across fields sharing a testID, merged external aria-describedby, web descriptions not duplicated into a hint, the iOS announcement firing once per changed message, the Android live regions, a disabled field staying read-only when readOnly={false}, decorative accessories rendering outside the accessibility tree with no button, named disabled accessories staying inoperable, and refs and layout callbacks surviving the decorative/actionable switch. TextInputStates.test.tsx pins the filled resting and hover colours without disturbing the focused or disabled indicator, and pins that outlined ignores hover.

Manual, on the TextInput example screen, for both variants:

  1. Toggle Error - supporting text becomes the error and is announced: an alert on web, an assertive region on Android, one spoken announcement on iOS. A changed message announces again; the same message re-rendered does not.
  2. Decorative leading icon - not a tab stop, not announced.
  3. Clear text action - focusable, announced as "Clear text", clears the field. Removing its aria-label logs a dev warning.
  4. Web: hover the filled field - the indicator moves onSurfaceVariant to onSurface; with Error on, error to onErrorContainer. Outlined does not change. Focus overrides hover.
  5. Counter - announced as "Characters entered N of M", and "Character limit exceeded N of M" past the limit; the field reports invalid.
  6. Disabled - cannot be focused or edited, indicator onSurface at 38%.
  7. An empty, unfocused field is still reachable and announced.

Run on Android, iOS and web, plus a screen-reader pass.

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

1 participant