Skip to content

Commit ced769d

Browse files
committed
fix(text-input): correct MD3 states and accessibility
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.
1 parent 84529e0 commit ced769d

10 files changed

Lines changed: 930 additions & 209 deletions

File tree

‎docs/6.x/docs/guides/migration.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,20 @@ import { TextInput, type TextInputProps } from 'react-native-paper';
257257
/>
258258
```
259259

260+
`TextInput.Icon` is decorative when no press handlers are provided. Decorative icons are
261+
hidden from assistive technology and do not create a keyboard focus stop.
262+
Icons with `onPress`, `onLongPress`, `onPressIn`, or `onPressOut` require an accessible name:
263+
264+
```tsx
265+
<TextInput
266+
label="Search"
267+
startAccessory={(props) => <TextInput.Icon {...props} icon="magnify" />}
268+
endAccessory={(props) => (
269+
<TextInput.Icon {...props} icon="close" aria-label="Clear search" onPress={() => setValue('')} />
270+
)}
271+
/>
272+
```
273+
260274
#### Label and supporting text
261275

262276
- **`label: React.Element | string`** → **`string`**
@@ -284,6 +298,26 @@ import { TextInput, type TextInputProps } from 'react-native-paper';
284298
/>
285299
```
286300

301+
Supporting text and the character counter describe the field without becoming
302+
part of its accessible name. On web, their generated `nativeID` values are
303+
referenced by the input's `aria-describedby`. Additional IDs passed through
304+
`aria-describedby` are preserved. On Android and iOS, these descriptions are
305+
included in `accessibilityHint`, alongside any hint you provide, because React
306+
Native does not support native described-by relationships.
307+
308+
Error supporting text uses `role="alert"`. Android uses an assertive live region;
309+
iOS announces changed error messages through `AccessibilityInfo`.
310+
Custom input renderers should forward the accessibility props they receive.
311+
Explicit `aria-invalid` values are preserved; when omitted, validity is derived
312+
from `error` and the character counter.
313+
Empty fields remain visible to native accessibility before focus and after
314+
clearing. The field content no longer fades with the floating label.
315+
316+
The filled resting indicator now uses `onSurfaceVariant` and changes to
317+
`onSurface` on hover. An invalid filled field uses `error` at rest and
318+
`onErrorContainer` on hover. The focused indicator continues to use `primary` (or
319+
`error` for an invalid field). Outlined fields continue to use `outline` at rest.
320+
287321
#### Removed props
288322

289323
No direct `TextInput` equivalents for:

‎example/src/Examples/TextInputExample.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,12 @@ const TextInputDemo = ({ variant }: TextInputDemoProps) => {
8181
);
8282

8383
const trailingIcon = (props: TextInputAccessoryProps) => (
84-
<TextInput.Icon {...props} icon="close" onPress={() => setValue('')} />
84+
<TextInput.Icon
85+
{...props}
86+
icon="close"
87+
aria-label="Clear text"
88+
onPress={() => setValue('')}
89+
/>
8590
);
8691

8792
const inputColor = theme.colors.onSurfaceVariant;
@@ -100,8 +105,8 @@ const TextInputDemo = ({ variant }: TextInputDemoProps) => {
100105
{ label: 'Error', key: 'error' },
101106
{ label: 'Disabled', key: 'disabled' },
102107
{ label: 'Readonly', key: 'readOnly' },
103-
{ label: 'Leading icon', key: 'leadingIcon' },
104-
{ label: 'Trailing icon', key: 'trailingIcon' },
108+
{ label: 'Decorative leading icon', key: 'leadingIcon' },
109+
{ label: 'Clear text action', key: 'trailingIcon' },
105110
{ label: 'Counter', key: 'counter' },
106111
{ label: 'Prefix', key: 'showPrefix' },
107112
{ label: 'Suffix', key: 'showSuffix' },
@@ -123,7 +128,11 @@ const TextInputDemo = ({ variant }: TextInputDemoProps) => {
123128
variant={variant}
124129
label={modifiers.label || undefined}
125130
placeholder={modifiers.placeholder || undefined}
126-
supportingText={modifiers.helperText || undefined}
131+
supportingText={
132+
controls.error
133+
? 'Please check the entered text'
134+
: modifiers.helperText || undefined
135+
}
127136
error={controls.error}
128137
disabled={controls.disabled}
129138
editable={!controls.readOnly}

‎src/components/TextInput/TextInput.tsx‎

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import {
3+
Platform,
34
Pressable,
45
Text,
56
TextInput as NativeTextInput,
@@ -28,7 +29,6 @@ import type { InternalTheme, ThemeProp } from '../../theme/types';
2829
export type TextInputAnimationState = {
2930
animatedLabelWrapperStyle: StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
3031
animatedLabelTextStyle: StyleProp<AnimatedStyle<StyleProp<TextStyle>>>;
31-
animatedContainerStyle: StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
3232
animatedActiveOutlineStyle?: StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
3333
};
3434

@@ -57,13 +57,18 @@ export type TextInputColors = {
5757
};
5858

5959
export type GetAccessibilityDataReturn = {
60-
input: AccessibilityProps & { 'aria-invalid'?: boolean };
61-
supportingText: AccessibilityProps;
62-
counter: AccessibilityProps;
60+
input: AccessibilityProps & {
61+
'aria-invalid'?: TextInputProps['aria-invalid'];
62+
'aria-describedby'?: string;
63+
};
64+
label: { nativeID: string };
65+
supportingText: AccessibilityProps & { nativeID: string };
66+
counter: AccessibilityProps & { nativeID: string };
6367
};
6468

6569
export type GetAccessibilityDataProps = {
6670
data: TextInputProps;
71+
id: string;
6772
inputLength: number;
6873
hasError: boolean;
6974
hasCounter: boolean;
@@ -76,6 +81,7 @@ export type TextInputSharedApi = {
7681
input: React.RefObject<NativeTextInput | null>;
7782
theme: InternalTheme;
7883
isFocused: boolean;
84+
isHovered?: boolean;
7985
isRTL: boolean;
8086
isDisabled: boolean;
8187
hasAccessory: boolean;
@@ -147,7 +153,6 @@ export type TextInputHookReturn = SharedTextInputStyleData & {
147153
animatedActiveOutlineStyles:
148154
| StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>
149155
| undefined;
150-
animatedContainerStyle: StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
151156
animatedLabelWrapperStyles: StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
152157
containerStyles: StyleProp<ViewStyle>;
153158
fieldStyles: StyleProp<ViewStyle>;
@@ -167,12 +172,16 @@ export type TextInputHookReturn = SharedTextInputStyleData & {
167172
onFocus: (e: FocusEvent) => void;
168173
onBlur: (e: BlurEvent) => void;
169174
focusInput: () => void;
175+
onHoverIn: () => void;
176+
onHoverOut: () => void;
170177
};
171178

172179
export type TextInputRenderProps = React.ComponentPropsWithoutRef<
173180
typeof NativeTextInput
174181
> & {
175182
ref?: React.RefObject<NativeTextInput | null>;
183+
'aria-describedby'?: string;
184+
'aria-invalid'?: TextInputProps['aria-invalid'];
176185
};
177186

178187
export type TextInputHandles = Pick<
@@ -181,6 +190,17 @@ export type TextInputHandles = Pick<
181190
>;
182191

183192
export type TextInputProps = NativeTextInputProps & {
193+
/**
194+
* Overrides the field's invalid state on web, including grammar or spelling
195+
* errors. Defaults to the error state or an exceeded character counter.
196+
*/
197+
'aria-invalid'?: React.AriaAttributes['aria-invalid'];
198+
/**
199+
* Space-separated IDs of additional descriptions on web. The IDs of rendered
200+
* supporting text and the character counter are appended automatically.
201+
* On Android and iOS, provide external descriptions with `accessibilityHint`.
202+
*/
203+
'aria-describedby'?: string;
184204
/**
185205
* Imperative handle exposing a subset of native `TextInput` methods
186206
* with side-effect handling (e.g. `clear()` syncs internal state and animations).
@@ -204,11 +224,15 @@ export type TextInputProps = NativeTextInputProps & {
204224
label?: string;
205225
/**
206226
* Supporting text to display below the input (Material Design 3).
227+
* Associated with the field through `aria-describedby` on web and included
228+
* in the native accessibility hint. When `error` is true, it is announced
229+
* as an alert. It does not become part of the field's accessible name.
207230
*/
208231
supportingText?: string;
209232
/**
210233
* When `true`, displays a character counter below the input on the trailing
211234
* side, showing `currentLength/maxLength`. Requires `maxLength` to be set.
235+
* Associated with the field alongside supporting text.
212236
*/
213237
counter?: boolean;
214238
/**
@@ -324,7 +348,6 @@ function TextInput({
324348
animatedActiveOutlineStyles,
325349
animatedLabelWrapperStyles,
326350
animatedLabelTextStyles,
327-
animatedContainerStyle,
328351
containerStyles,
329352
inputStyles,
330353
prefixStyles,
@@ -343,6 +366,8 @@ function TextInput({
343366
onChangeText,
344367
onFocus,
345368
onBlur,
369+
onHoverIn,
370+
onHoverOut,
346371
} = useTextInput({
347372
ref,
348373
error,
@@ -360,8 +385,25 @@ function TextInput({
360385
});
361386

362387
return (
363-
<Pressable onPress={focusInput} accessible={false} role="none">
364-
<View style={fieldStyles}>
388+
<Pressable
389+
onPress={focusInput}
390+
onHoverIn={Platform.OS === 'web' ? undefined : onHoverIn}
391+
onHoverOut={Platform.OS === 'web' ? undefined : onHoverOut}
392+
accessible={false}
393+
role="none"
394+
>
395+
<View
396+
style={fieldStyles}
397+
// Nested web Pressables contain hover events; track the whole field.
398+
onPointerEnter={
399+
Platform.OS === 'web'
400+
? (event) => {
401+
if (event.nativeEvent.pointerType !== 'touch') onHoverIn();
402+
}
403+
: undefined
404+
}
405+
onPointerLeave={Platform.OS === 'web' ? onHoverOut : undefined}
406+
>
365407
{/* Disabled tint overlay — filled variant only. A childless
366408
absolutely-positioned View whose translucent fill is applied via the
367409
`opacity` style, so it never affects label/input rendering and works
@@ -383,7 +425,10 @@ function TextInput({
383425

384426
{!!label && (
385427
<Animated.View aria-hidden style={animatedLabelWrapperStyles}>
386-
<Animated.Text style={animatedLabelTextStyles}>
428+
<Animated.Text
429+
{...accessibilityProps.label}
430+
style={animatedLabelTextStyles}
431+
>
387432
{label}
388433
</Animated.Text>
389434
</Animated.View>
@@ -398,17 +443,19 @@ function TextInput({
398443
})
399444
: null}
400445

401-
<Animated.View style={[containerStyles, animatedContainerStyle]}>
446+
{/* Keep the field visible to native accessibility even before focus. */}
447+
<View style={containerStyles}>
402448
{hasPrefix && <Text style={prefixStyles}>{prefix}</Text>}
403449

404450
{render({
405451
ref: input,
406452
selectionColor,
407453
cursorColor,
408454
placeholderTextColor,
409-
...accessibilityProps.input,
410455
...rest,
456+
...accessibilityProps.input,
411457
editable: isEditable,
458+
readOnly: disabled ? true : rest.readOnly,
412459
placeholder,
413460
style: inputStyles,
414461
onChangeText,
@@ -417,7 +464,7 @@ function TextInput({
417464
})}
418465

419466
{hasSuffix && <Text style={suffixStyles}>{suffix}</Text>}
420-
</Animated.View>
467+
</View>
421468

422469
{renderTrailingAccessory ? (
423470
renderTrailingAccessory({
@@ -434,6 +481,7 @@ function TextInput({
434481
<View style={styles.addendum}>
435482
{!!supportingText && (
436483
<Text
484+
key={hasError ? 'error' : 'supporting'}
437485
{...accessibilityProps.supportingText}
438486
style={supportingTextStyles}
439487
>

0 commit comments

Comments
 (0)