From 69fb9a723e9511f3074d29741ea6dc0434da419f Mon Sep 17 00:00:00 2001 From: likevy Date: Tue, 8 Sep 2026 09:28:18 +0200 Subject: [PATCH 01/17] 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. --- docs/6.x/docs/guides/migration.md | 34 ++ example/src/Examples/TextInputExample.tsx | 17 +- src/components/TextInput/TextInput.tsx | 72 ++- src/components/TextInput/TextInputIcon.tsx | 62 ++- src/components/TextInput/hooks.ts | 32 +- src/components/TextInput/utils.ts | 133 +++--- src/components/__tests__/TextInput.test.tsx | 57 ++- .../__tests__/TextInputAccessibility.test.tsx | 437 ++++++++++++++++++ .../__tests__/TextInputStates.test.tsx | 91 ++++ .../__snapshots__/TextInput.test.tsx.snap | 204 ++++---- 10 files changed, 930 insertions(+), 209 deletions(-) create mode 100644 src/components/__tests__/TextInputAccessibility.test.tsx create mode 100644 src/components/__tests__/TextInputStates.test.tsx diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 72aee6d00f..ca17c34625 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -355,6 +355,20 @@ import { TextInput, type TextInputProps } from 'react-native-paper'; /> ``` +`TextInput.Icon` is decorative when no press handlers are provided. Decorative icons are +hidden from assistive technology and do not create a keyboard focus stop. +Icons with `onPress`, `onLongPress`, `onPressIn`, or `onPressOut` require an accessible name: + +```tsx + } + endAccessory={(props) => ( + setValue('')} /> + )} +/> +``` + #### Label and supporting text - **`label: React.Element | string`** → **`string`** @@ -382,6 +396,26 @@ import { TextInput, type TextInputProps } from 'react-native-paper'; /> ``` +Supporting text and the character counter describe the field without becoming +part of its accessible name. On web, their generated `nativeID` values are +referenced by the input's `aria-describedby`. Additional IDs passed through +`aria-describedby` are preserved. On Android and iOS, these descriptions are +included in `accessibilityHint`, alongside any hint you provide, because React +Native does not support native described-by relationships. + +Error supporting text uses `role="alert"`. Android uses an assertive live region; +iOS announces changed error messages through `AccessibilityInfo`. +Custom input renderers should forward the accessibility props they receive. +Explicit `aria-invalid` values are preserved; when omitted, validity is derived +from `error` and the character counter. +Empty fields remain visible to native accessibility before focus and after +clearing. The field content no longer fades with the floating label. + +The filled resting indicator now uses `onSurfaceVariant` and changes to +`onSurface` on hover. An invalid filled field uses `error` at rest and +`onErrorContainer` on hover. The focused indicator continues to use `primary` (or +`error` for an invalid field). Outlined fields continue to use `outline` at rest. + #### Removed props No direct `TextInput` equivalents for: diff --git a/example/src/Examples/TextInputExample.tsx b/example/src/Examples/TextInputExample.tsx index 73341a0cb1..dff5e6f2e4 100644 --- a/example/src/Examples/TextInputExample.tsx +++ b/example/src/Examples/TextInputExample.tsx @@ -81,7 +81,12 @@ const TextInputDemo = ({ variant }: TextInputDemoProps) => { ); const trailingIcon = (props: TextInputAccessoryProps) => ( - setValue('')} /> + setValue('')} + /> ); const inputColor = theme.colors.onSurfaceVariant; @@ -100,8 +105,8 @@ const TextInputDemo = ({ variant }: TextInputDemoProps) => { { label: 'Error', key: 'error' }, { label: 'Disabled', key: 'disabled' }, { label: 'Readonly', key: 'readOnly' }, - { label: 'Leading icon', key: 'leadingIcon' }, - { label: 'Trailing icon', key: 'trailingIcon' }, + { label: 'Decorative leading icon', key: 'leadingIcon' }, + { label: 'Clear text action', key: 'trailingIcon' }, { label: 'Counter', key: 'counter' }, { label: 'Prefix', key: 'showPrefix' }, { label: 'Suffix', key: 'showSuffix' }, @@ -123,7 +128,11 @@ const TextInputDemo = ({ variant }: TextInputDemoProps) => { variant={variant} label={modifiers.label || undefined} placeholder={modifiers.placeholder || undefined} - supportingText={modifiers.helperText || undefined} + supportingText={ + controls.error + ? 'Please check the entered text' + : modifiers.helperText || undefined + } error={controls.error} disabled={controls.disabled} editable={!controls.readOnly} diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index b89e2b35d4..5e1209a795 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + Platform, Pressable, Text, TextInput as NativeTextInput, @@ -28,7 +29,6 @@ import type { InternalTheme, ThemeProp } from '../../theme/types'; export type TextInputAnimationState = { animatedLabelWrapperStyle: StyleProp>>; animatedLabelTextStyle: StyleProp>>; - animatedContainerStyle: StyleProp>>; animatedActiveOutlineStyle?: StyleProp>>; }; @@ -57,13 +57,18 @@ export type TextInputColors = { }; export type GetAccessibilityDataReturn = { - input: AccessibilityProps & { 'aria-invalid'?: boolean }; - supportingText: AccessibilityProps; - counter: AccessibilityProps; + input: AccessibilityProps & { + 'aria-invalid'?: TextInputProps['aria-invalid']; + 'aria-describedby'?: string; + }; + label: { nativeID: string }; + supportingText: AccessibilityProps & { nativeID: string }; + counter: AccessibilityProps & { nativeID: string }; }; export type GetAccessibilityDataProps = { data: TextInputProps; + id: string; inputLength: number; hasError: boolean; hasCounter: boolean; @@ -76,6 +81,7 @@ export type TextInputSharedApi = { input: React.RefObject; theme: InternalTheme; isFocused: boolean; + isHovered?: boolean; isRTL: boolean; isDisabled: boolean; hasAccessory: boolean; @@ -147,7 +153,6 @@ export type TextInputHookReturn = SharedTextInputStyleData & { animatedActiveOutlineStyles: | StyleProp>> | undefined; - animatedContainerStyle: StyleProp>>; animatedLabelWrapperStyles: StyleProp>>; containerStyles: StyleProp; fieldStyles: StyleProp; @@ -167,12 +172,16 @@ export type TextInputHookReturn = SharedTextInputStyleData & { onFocus: (e: FocusEvent) => void; onBlur: (e: BlurEvent) => void; focusInput: () => void; + onHoverIn: () => void; + onHoverOut: () => void; }; export type TextInputRenderProps = React.ComponentPropsWithoutRef< typeof NativeTextInput > & { ref?: React.RefObject; + 'aria-describedby'?: string; + 'aria-invalid'?: TextInputProps['aria-invalid']; }; export type TextInputHandles = Pick< @@ -181,6 +190,17 @@ export type TextInputHandles = Pick< >; export type TextInputProps = NativeTextInputProps & { + /** + * Overrides the field's invalid state on web, including grammar or spelling + * errors. Defaults to the error state or an exceeded character counter. + */ + 'aria-invalid'?: React.AriaAttributes['aria-invalid']; + /** + * Space-separated IDs of additional descriptions on web. The IDs of rendered + * supporting text and the character counter are appended automatically. + * On Android and iOS, provide external descriptions with `accessibilityHint`. + */ + 'aria-describedby'?: string; /** * Imperative handle exposing a subset of native `TextInput` methods * with side-effect handling (e.g. `clear()` syncs internal state and animations). @@ -204,11 +224,15 @@ export type TextInputProps = NativeTextInputProps & { label?: string; /** * Supporting text to display below the input (Material Design 3). + * Associated with the field through `aria-describedby` on web and included + * in the native accessibility hint. When `error` is true, it is announced + * as an alert. It does not become part of the field's accessible name. */ supportingText?: string; /** * When `true`, displays a character counter below the input on the trailing * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. + * Associated with the field alongside supporting text. */ counter?: boolean; /** @@ -324,7 +348,6 @@ function TextInput({ animatedActiveOutlineStyles, animatedLabelWrapperStyles, animatedLabelTextStyles, - animatedContainerStyle, containerStyles, inputStyles, prefixStyles, @@ -343,6 +366,8 @@ function TextInput({ onChangeText, onFocus, onBlur, + onHoverIn, + onHoverOut, } = useTextInput({ ref, error, @@ -360,8 +385,25 @@ function TextInput({ }); return ( - - + + { + if (event.nativeEvent.pointerType !== 'touch') onHoverIn(); + } + : undefined + } + onPointerLeave={Platform.OS === 'web' ? onHoverOut : undefined} + > {/* Disabled tint overlay — filled variant only. A childless absolutely-positioned View whose translucent fill is applied via the `opacity` style, so it never affects label/input rendering and works @@ -383,7 +425,10 @@ function TextInput({ {!!label && ( - + {label} @@ -398,7 +443,8 @@ function TextInput({ }) : null} - + {/* Keep the field visible to native accessibility even before focus. */} + {hasPrefix && {prefix}} {render({ @@ -406,9 +452,10 @@ function TextInput({ selectionColor, cursorColor, placeholderTextColor, - ...accessibilityProps.input, ...rest, + ...accessibilityProps.input, editable: isEditable, + readOnly: disabled ? true : rest.readOnly, placeholder, style: inputStyles, onChangeText, @@ -417,7 +464,7 @@ function TextInput({ })} {hasSuffix && {suffix}} - + {renderTrailingAccessory ? ( renderTrailingAccessory({ @@ -434,6 +481,7 @@ function TextInput({ {!!supportingText && ( diff --git a/src/components/TextInput/TextInputIcon.tsx b/src/components/TextInput/TextInputIcon.tsx index 012e2f45f9..b9e6be2755 100644 --- a/src/components/TextInput/TextInputIcon.tsx +++ b/src/components/TextInput/TextInputIcon.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; @@ -5,6 +6,7 @@ import { ACCESSORY_SIZE } from './constants'; import { styles } from './styles'; import { getIconColor } from './utils'; import { useInternalTheme } from '../../core/theming'; +import hasTouchHandler from '../../utils/hasTouchHandler'; import IconButton from '../IconButton/IconButton'; import type { Props as IconButtonProps } from '../IconButton/IconButton'; @@ -16,12 +18,30 @@ export type TextInputAccessoryProps = { }; export type TextInputIconProps = TextInputAccessoryProps & - Omit; + Omit< + IconButtonProps, + keyof TextInputAccessoryProps | 'aria-label' | 'accessibilityLabel' + > & + ( + | { + onPress?: undefined; + onLongPress?: undefined; + onPressIn?: undefined; + onPressOut?: undefined; + 'aria-label'?: string; + accessibilityLabel?: string; + } + | { 'aria-label': string; accessibilityLabel?: string } + | { 'aria-label'?: string; accessibilityLabel: string } + ); /** * A component to render a leading / trailing icon in the TextInput * (return it from `startAccessory` or `endAccessory`). Accepts icon-specific props as well as * `TextInputAccessoryProps`, which TextInput passes into those render props. + * Without press handlers, the icon is decorative and hidden from assistive technology. + * An actionable icon requires an `aria-label` (or `accessibilityLabel`) that names + * its action, such as "Clear text" or "Show password". * * ## Usage * ```js @@ -36,7 +56,7 @@ export type TextInputIconProps = TextInputAccessoryProps & * ); * * const clearAccessory = (props) => ( - * setText('')} /> + * setText('')} /> * ); * * return ( @@ -64,6 +84,7 @@ const TextInputIcon = ({ disabled, theme: themeOverride, onPress, + multiline: _multiline, ...rest }: TextInputIconProps) => { const theme = useInternalTheme(themeOverride); @@ -77,17 +98,52 @@ const TextInputIcon = ({ isDisabled: disabled, }); + const actionable = hasTouchHandler({ onPress, ...rest }); + const iconRef = React.useRef(null); + React.useImperativeHandle( + rest.ref, + () => iconRef.current, + [] + ); + const accessibleName = rest['aria-label'] ?? rest.accessibilityLabel; + React.useEffect(() => { + if (__DEV__ && actionable && !accessibleName?.trim()) { + console.warn( + 'TextInput.Icon: an actionable icon requires an aria-label or accessibilityLabel.' + ); + } + }, [actionable, accessibleName]); + + void _multiline; + const onPressHandler = disabled ? undefined : onPress; return ( - + ); diff --git a/src/components/TextInput/hooks.ts b/src/components/TextInput/hooks.ts index d00667550c..7620ae7c5c 100644 --- a/src/components/TextInput/hooks.ts +++ b/src/components/TextInput/hooks.ts @@ -1,11 +1,16 @@ import { useEffect, useImperativeHandle, + useId, useRef, useState, type RefObject, } from 'react'; -import { TextInput as NativeTextInput } from 'react-native'; +import { + AccessibilityInfo, + Platform, + TextInput as NativeTextInput, +} from 'react-native'; import type { BlurEvent, FocusEvent } from 'react-native'; import { @@ -107,10 +112,6 @@ const useTextInputAnimation = ({ ), })); - const animatedContainerStyle = useAnimatedStyle(() => ({ - opacity: floatSV.value, - })); - const animatedActiveOutlineStyle = useAnimatedStyle(() => ({ transform: [{ scaleX: focusSV.value }], })); @@ -118,7 +119,6 @@ const useTextInputAnimation = ({ return { animatedLabelWrapperStyle, animatedLabelTextStyle, - animatedContainerStyle, animatedActiveOutlineStyle: variant === 'filled' ? animatedActiveOutlineStyle : undefined, runFocusAnimation, @@ -253,6 +253,7 @@ const useTextInputLayout = ({ theme, flags, isFocused, + isHovered, animation, }: { variant: TextInputVariant; @@ -261,6 +262,7 @@ const useTextInputLayout = ({ theme: InternalTheme; flags: TextInputFlags; isFocused: boolean; + isHovered: boolean; animation: TextInputAnimationState; }): TextInputLayoutState => { const { isRTL, isDisabled, hasError, hasAccessory, hasSuffix } = flags; @@ -283,6 +285,7 @@ const useTextInputLayout = ({ input, theme, isFocused, + isHovered, isRTL, isDisabled, hasAccessory, @@ -299,6 +302,7 @@ const useTextInputLayout = ({ input, theme, isFocused, + isHovered, isRTL, isDisabled, hasAccessory, @@ -323,6 +327,8 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { const { ref, variant = 'filled', theme: themeOverride } = props; const input = useRef(null); + const id = useId(); + const [isHovered, setIsHovered] = useState(false); const init = useRef(false); const theme = useInternalTheme(themeOverride); @@ -416,10 +422,12 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { theme, flags, isFocused, + isHovered, animation, }); const accessibilityProps = getAccessibilityData({ + id, hasError: flags.hasError, hasCounter: flags.hasCounter, isDisabled: flags.isDisabled, @@ -427,6 +435,15 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { inputLength, }); + // 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]); + const counterText = `${inputLength}/${props.maxLength}`; const renderLeadingAccessory = flags.isRTL @@ -452,7 +469,6 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { selectionColor, cursorColor, animatedActiveOutlineStyles: undefined, - animatedContainerStyle: animation.animatedContainerStyle, placeholder, counterText, accessibilityProps, @@ -463,5 +479,7 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { onFocus, onBlur, focusInput, + onHoverIn: () => setIsHovered(true), + onHoverOut: () => setIsHovered(false), }; }; diff --git a/src/components/TextInput/utils.ts b/src/components/TextInput/utils.ts index 3eed947f83..a6908420ea 100644 --- a/src/components/TextInput/utils.ts +++ b/src/components/TextInput/utils.ts @@ -168,37 +168,37 @@ export const getIconColor = ({ }; /** - * Returns the raw outline color for a filled field. The disabled state's - * alpha is intentionally NOT baked in here — it is applied via the `opacity` - * style on the (childless) outline View so the value can be a `PlatformColor` - * on Android, which the `color` library cannot parse at runtime. + * Resolve the filled indicator or outlined border color. Disabled opacity is + * applied by the outline View so native PlatformColor values stay intact. */ export const getOutlineColor = ({ theme, hasError, isFocused, isDisabled, + isHovered = false, + variant = 'outlined', }: { theme: InternalTheme; isFocused: boolean; hasError: boolean; isDisabled: boolean; + isHovered?: boolean; + variant?: TextInputVariant; }) => { - const { - colors: { error, onSurface, primary, outline }, - } = theme; + const { colors } = theme; + if (isDisabled) return colors.onSurface; if (hasError) { - return error; + return variant === 'filled' && isHovered && !isFocused + ? colors.onErrorContainer + : colors.error; } - if (isDisabled) { - return onSurface; - } - if (isFocused) { - return primary; + if (isFocused) return colors.primary; + if (variant === 'filled') { + return isHovered ? colors.onSurface : colors.onSurfaceVariant; } - - return outline; + return colors.outline; }; /** @@ -330,6 +330,8 @@ export const getFilledTextInputData = ( hasError, isFocused: false, isDisabled, + variant: 'filled', + isHovered: api.isHovered, }); const activeOutlineColor = getOutlineColor({ @@ -363,10 +365,7 @@ export const getFilledTextInputData = ( animatedLabelWrapperStyle, ]; - const containerStyles: StyleProp = [ - filledStyles.container, - isDisabled && styles.disabled, - ]; + const containerStyles = filledStyles.container; const fieldStyles: StyleProp = [ styles.field, @@ -491,10 +490,7 @@ export const getOutlinedTextInputData = ( * Variant-specific styles */ - const containerStyles: StyleProp = [ - outlinedStyles.container, - isDisabled && styles.disabled, - ]; + const containerStyles = outlinedStyles.container; const fieldStyles: StyleProp = [ styles.field, @@ -568,65 +564,74 @@ export const getOutlinedTextInputData = ( export const getAccessibilityData = ({ data, + id, hasError, hasCounter, isDisabled, inputLength, }: GetAccessibilityDataProps): GetAccessibilityDataReturn => { - const { label, supportingText, ...props } = data; - - const maxLength = props.maxLength; - const shouldEvaluateCounter = !!maxLength && hasCounter; - const isEmptyString = inputLength === 0; - const isCounterExceeded = shouldEvaluateCounter && inputLength > maxLength; - const isCounterReached = shouldEvaluateCounter && inputLength === maxLength; - const isInvalid = hasError || isCounterExceeded; - const isSupportingTextHidden = !!(supportingText && !hasError); - - const chunks: string[] = []; - - if (label) { - chunks.push(label); - } - - if (isSupportingTextHidden) { - chunks.push(supportingText); - } - - if (isEmptyString && props.placeholder && !hasError) { - chunks.push(props.placeholder); - } - - const ariaLabel = chunks.length > 0 ? chunks.join(', ') : label; - - let hint: string | undefined; - - if (isCounterExceeded && !(hasError && supportingText)) { - hint = `Character limit exceeded ${inputLength} of ${maxLength}`; - } - - const counterAccessibilityLabel = shouldEvaluateCounter + const { label, supportingText, maxLength, ...props } = data; + const isCounterExceeded = + hasCounter && maxLength != null && inputLength > maxLength; + const counterAccessibilityLabel = hasCounter ? isCounterExceeded ? `Character limit exceeded ${inputLength} of ${maxLength}` : `Characters entered ${inputLength} of ${maxLength}` : undefined; + const labelId = `${id}-label`; + const supportingTextId = `${id}-supporting-text`; + const counterId = `${id}-counter`; + const describedBy = + [ + props['aria-describedby'], + supportingText ? supportingTextId : undefined, + hasCounter ? counterId : undefined, + ] + .filter(Boolean) + .join(' ') || undefined; + const explicitLabel = props['aria-label'] ?? props.accessibilityLabel; + const labelledBy = + props['aria-labelledby'] ?? + (Platform.OS === 'web' && + label && + explicitLabel == null && + !props.accessibilityLabelledBy + ? labelId + : undefined); + + // React Native 0.85 does not implement described-by relationships on native. + // Expose the same description as a hint without changing the field's name. + const hint = + Platform.OS === 'web' + ? props.accessibilityHint + : [props.accessibilityHint, supportingText, counterAccessibilityLabel] + .filter(Boolean) + .join(', ') || undefined; + return { input: { - 'aria-label': ariaLabel, - 'aria-valuemax': isCounterReached ? maxLength : undefined, - 'aria-valuenow': isCounterReached ? inputLength : undefined, - 'aria-disabled': isDisabled, - 'aria-invalid': isInvalid, + 'aria-label': explicitLabel ?? label, + 'aria-labelledby': labelledBy, + 'aria-describedby': describedBy, + 'aria-disabled': props['aria-disabled'] ?? isDisabled, + 'aria-invalid': props['aria-invalid'] ?? (hasError || isCounterExceeded), accessibilityHint: hint, }, + label: { nativeID: labelId }, supportingText: { - 'aria-hidden': isSupportingTextHidden, - 'aria-live': hasError && supportingText ? 'polite' : undefined, + nativeID: supportingTextId, + role: hasError && supportingText ? 'alert' : undefined, + accessibilityLiveRegion: + Platform.OS === 'android' && hasError && supportingText + ? 'assertive' + : undefined, }, counter: { + nativeID: counterId, 'aria-label': counterAccessibilityLabel, - 'aria-live': 'polite', + 'aria-live': Platform.OS === 'web' ? 'polite' : undefined, + accessibilityLiveRegion: Platform.OS === 'android' ? 'polite' : undefined, }, }; }; diff --git a/src/components/__tests__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx index b87e482c7f..70c211640a 100644 --- a/src/components/__tests__/TextInput.test.tsx +++ b/src/components/__tests__/TextInput.test.tsx @@ -161,7 +161,12 @@ it('renders filled TextInput with TextInput.Icon accessories when error is true' )} endAccessory={(props: TextInputAccessoryProps) => ( - {}} /> + {}} + /> )} /> ) @@ -183,7 +188,12 @@ it('renders outlined TextInput with TextInput.Icon accessories when error is tru )} endAccessory={(props: TextInputAccessoryProps) => ( - {}} /> + {}} + /> )} /> ) @@ -227,10 +237,20 @@ it('disables TextInput.Icon when the field is disabled', async () => { onChangeText={() => {}} disabled startAccessory={(props: TextInputAccessoryProps) => ( - {}} /> + {}} + /> )} endAccessory={(props: TextInputAccessoryProps) => ( - {}} /> + {}} + /> )} /> ); @@ -248,10 +268,20 @@ it('does not disable TextInput.Icon when the field is read-only (editable false) onChangeText={() => {}} editable={false} startAccessory={(props: TextInputAccessoryProps) => ( - {}} /> + {}} + /> )} endAccessory={(props: TextInputAccessoryProps) => ( - {}} /> + {}} + /> )} /> ); @@ -276,7 +306,7 @@ it('renders supporting text below the field', async () => { ).toBeOnTheScreen(); }); -it('uses polite aria-live on error supporting text', async () => { +it('uses an alert for error supporting text', async () => { await render( { /> ); - expect(screen.getByText('Invalid')).toHaveProp('aria-live', 'polite'); + expect(screen.getByText('Invalid')).toHaveProp('role', 'alert'); expect(screen.getByLabelText('Email')).toHaveProp('aria-invalid', true); }); @@ -308,7 +338,7 @@ it('marks the input invalid when error is true without supporting text', async ( expect(input).not.toHaveProp('accessibilityHint'); }); -it('hides helper supporting text from the accessibility tree and omits aria-live', async () => { +it('exposes helper supporting text without including it in the field name', async () => { await render( { +it('keeps supporting text separate when label is omitted', async () => { await render( { /> ); - expect(screen.getByLabelText('Helper only')).toBeOnTheScreen(); + expect(screen.getByTestId('tf-input')).not.toHaveProp('aria-label'); + expect(screen.getByText('Helper only')).toBeOnTheScreen(); }); it('does not mark the input as aria-disabled when editable is false (read-only)', async () => { diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx new file mode 100644 index 0000000000..aeb46c454a --- /dev/null +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -0,0 +1,437 @@ +import { + AccessibilityInfo, + Text, + Platform, + TextInput as NativeTextInput, +} from 'react-native'; + +import { afterEach, expect, it, jest } from '@jest/globals'; + +import { fireEvent, render, screen, userEvent } from '../../test-utils'; +import TextInput from '../TextInput'; +import type { TextInputRenderProps } from '../TextInput/TextInput'; + +const renderInput = jest.fn((props: TextInputRenderProps) => ( + +)); + +afterEach(() => { + jest.restoreAllMocks(); + renderInput.mockClear(); +}); + +it.each(['filled', 'outlined'] as const)( + 'keeps an empty unfocused %s field visible to assistive technology', + async (variant) => { + await render(); + + expect(screen.getByLabelText('Email')).toBeVisible(); + await fireEvent(screen.getByLabelText('Email'), 'focus'); + await fireEvent(screen.getByLabelText('Email'), 'blur'); + expect(screen.getByLabelText('Email')).toBeVisible(); + } +); + +it('preserves accessory refs when switching between decoration and action', async () => { + const cleanup = jest.fn<() => void>(); + const ref = jest.fn(() => cleanup); + const props = { + icon: 'magnify', + style: {}, + disabled: false, + error: false, + multiline: false, + ref, + }; + const { rerender, unmount } = await render(); + const decoration = ref.mock.lastCall; + expect(ref).toHaveBeenCalledWith( + expect.objectContaining({ measure: expect.any(Function) }) + ); + + await rerender( + {}} aria-label="Search" /> + ); + expect(cleanup).not.toHaveBeenCalled(); + expect(ref.mock.lastCall).toEqual(decoration); + + await rerender(); + expect(cleanup).not.toHaveBeenCalled(); + await unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); +}); + +it.each(['filled', 'outlined'] as const)( + 'associates helper and counter without changing the %s field name', + async (variant) => { + await render( + + ); + const inputProps = renderInput.mock.lastCall?.[0]; + const ids = inputProps?.['aria-describedby']?.split(' '); + + expect(screen.getByLabelText('Email')).toBeOnTheScreen(); + expect(inputProps?.['aria-describedby']).toEqual(expect.any(String)); + expect(ids).toHaveLength(2); + expect(screen.getByText('Use a work address')).toHaveProp( + 'nativeID', + ids?.[0] + ); + expect(screen.getByText('0/40')).toHaveProp('nativeID', ids?.[1]); + } +); + +it('keeps generated IDs stable as helper text becomes an error and removes absent descriptions', async () => { + const { rerender } = await render( + + ); + const describedBy = renderInput.mock.lastCall?.[0]['aria-describedby']; + expect(describedBy).toEqual(expect.any(String)); + + await rerender( + + ); + expect(screen.getByLabelText('Email')).toHaveProp( + 'aria-describedby', + describedBy + ); + expect(screen.getByRole('alert')).toHaveTextContent('Invalid address'); + expect(screen.getByRole('alert')).toHaveProp('nativeID', describedBy); + + await rerender(); + expect(screen.getByLabelText('Email')).not.toHaveProp('aria-describedby'); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +it('gives different fields distinct description IDs even with the same test ID', async () => { + await render( + <> + + + + ); + const first = renderInput.mock.calls[0]?.[0]['aria-describedby']; + const second = renderInput.mock.calls[1]?.[0]['aria-describedby']; + expect(first).toEqual(expect.any(String)); + expect(second).toEqual(expect.any(String)); + expect(first).not.toBe(second); +}); + +it('merges external descriptions and preserves an explicit accessible name in a custom renderer', async () => { + await render( + + ); + const describedBy = renderInput.mock.lastCall?.[0]['aria-describedby']; + expect(describedBy).toMatch(/^privacy \S+$/); + expect(screen.getByLabelText('Work email')).toHaveProp( + 'aria-describedby', + describedBy + ); + expect(screen.getByText('Use a work address')).toHaveProp( + 'nativeID', + describedBy?.split(' ')[1] + ); +}); + +it.each(['ios', 'android'] as const)( + 'provides field descriptions as a native hint on %s', + async (platform) => { + jest.replaceProperty(Platform, 'OS', platform); + await render( + + ); + expect(screen.getByLabelText('Bio')).toHaveProp( + 'accessibilityHint', + 'Double tap to edit, Keep it short, Characters entered 2 of 20' + ); + } +); + +it('uses web descriptions without duplicating them in a hint', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + await render(); + expect(screen.getByLabelText('Bio')).not.toHaveProp('accessibilityHint'); +}); + +it.each([true, false, 'true', 'false', 'grammar', 'spelling'] as const)( + 'preserves explicit aria-invalid=%s independently of the error prop', + async (invalid) => { + jest.replaceProperty(Platform, 'OS', 'web'); + const { rerender } = await render( + + ); + expect(renderInput.mock.lastCall?.[0]['aria-invalid']).toBe(invalid); + + await rerender( + + ); + expect(renderInput.mock.lastCall?.[0]['aria-invalid']).toBe(invalid); + + await rerender(); + expect(renderInput.mock.lastCall?.[0]['aria-invalid']).toBe(true); + + await rerender(); + expect(renderInput.mock.lastCall?.[0]['aria-invalid']).toBe(false); + } +); + +it('announces each changed error message once on iOS', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibility') + .mockClear(); + const { rerender } = await render( + + ); + expect(announce).not.toHaveBeenCalled(); + + await rerender( + + ); + expect(announce).toHaveBeenLastCalledWith('Invalid address'); + await rerender( + + ); + expect(announce).toHaveBeenCalledTimes(1); + await rerender( + + ); + expect(announce).toHaveBeenLastCalledWith('Address is required'); + await rerender(); + expect(announce).toHaveBeenCalledTimes(2); +}); + +it('uses an assertive Android live region for an error', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + await render( + + ); + expect(screen.getByRole('alert')).toHaveProp( + 'accessibilityLiveRegion', + 'assertive' + ); +}); + +it('uses a native Android live region for counter updates', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + await render(); + expect(screen.getByText('0/40')).toHaveProp( + 'accessibilityLiveRegion', + 'polite' + ); +}); + +it('keeps a disabled native input read-only when readOnly is explicitly false', async () => { + await render( + + ); + expect(renderInput.mock.lastCall?.[0].editable).toBe(false); + expect(renderInput.mock.lastCall?.[0].readOnly).toBe(true); +}); + +it.each(['onLongPress', 'onPressIn', 'onPressOut'] as const)( + 'preserves an accessory using only %s', + async (handler) => { + const onAction = jest.fn<() => void>(); + await render( + ( + + )} + /> + ); + await userEvent.longPress( + screen.getByRole('button', { name: 'Search action' }) + ); + expect(onAction).toHaveBeenCalledTimes(1); + } +); + +it('renders decorative accessories outside the accessibility tree without buttons', async () => { + await render( + } + endAccessory={(props) => } + /> + ); + expect(screen.queryAllByRole('button')).toHaveLength(0); + expect(screen.queryByText('magnify')).toBeNull(); + expect( + screen.getByText('magnify', { includeHiddenElements: true }) + ).toBeOnTheScreen(); +}); + +it('preserves decorative loading and container visuals without accessible controls', async () => { + await render( + ( + + )} + /> + ); + expect(screen.queryAllByRole('button')).toHaveLength(0); + expect(screen.queryByRole('progressbar')).toBeNull(); + expect( + screen.getByRole('progressbar', { includeHiddenElements: true }) + ).toBeOnTheScreen(); + expect( + screen.getByTestId('search-decoration-container', { + includeHiddenElements: true, + }) + ).toHaveStyle({ backgroundColor: 'pink', borderWidth: 1 }); + expect( + screen.getByTestId('search-decoration', { includeHiddenElements: true }) + ).toHaveStyle({ padding: 2 }); +}); + +it('keeps a named disabled accessory inoperable', async () => { + const onPress = jest.fn<() => void>(); + await render( + ( + + )} + /> + ); + const button = screen.getByRole('button', { name: 'Clear search' }); + expect(button).toBeDisabled(); + await userEvent.press(button); + expect(onPress).not.toHaveBeenCalled(); +}); + +it('blocks every accessory activation handler when the field is disabled', async () => { + const onPress = jest.fn<() => void>(); + const onLongPress = jest.fn<() => void>(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + await render( + ( + + )} + /> + ); + const button = screen.getByRole('button', { name: 'Clear search' }); + await userEvent.longPress(button); + expect(onPress).not.toHaveBeenCalled(); + expect(onLongPress).not.toHaveBeenCalled(); + expect(onPressIn).not.toHaveBeenCalled(); + expect(onPressOut).not.toHaveBeenCalled(); +}); + +it.each(['aria-labelledby', 'accessibilityLabelledBy'] as const)( + 'preserves caller %s relationships', + async (attribute) => { + await render( + <> + Work address + + + ); + expect(renderInput.mock.lastCall?.[0][attribute]).toBe('external-label'); + expect(renderInput.mock.lastCall?.[0]['aria-describedby']).toEqual( + expect.any(String) + ); + } +); + +it('forwards decorative accessory layout callbacks and native IDs', async () => { + const onLayout = jest.fn(); + await render( + ( + + )} + /> + ); + const icon = screen.getByTestId('decoration', { + includeHiddenElements: true, + }); + expect(icon).toHaveProp('nativeID', 'search-icon'); + expect(icon).toHaveProp('onLayout', onLayout); +}); diff --git a/src/components/__tests__/TextInputStates.test.tsx b/src/components/__tests__/TextInputStates.test.tsx new file mode 100644 index 0000000000..1849069edc --- /dev/null +++ b/src/components/__tests__/TextInputStates.test.tsx @@ -0,0 +1,91 @@ +import { Platform, StyleSheet } from 'react-native'; + +import { afterEach, expect, it, jest } from '@jest/globals'; +import { renderHook } from '@testing-library/react-native'; + +import PaperProvider from '../../core/PaperProvider'; +import { getTheme } from '../../core/theming'; +import { act } from '../../test-utils'; +import { useTextInput } from '../TextInput/hooks'; + +const theme = getTheme(); +afterEach(() => { + jest.restoreAllMocks(); +}); + +it.each(['filled', 'outlined'] as const)( + 'applies disabled opacity once to %s text and affixes', + async (variant) => { + const { result } = await renderHook( + () => + useTextInput({ + variant, + disabled: true, + value: 'Sample', + prefix: '$', + suffix: '/100', + }), + { wrapper: PaperProvider } + ); + const containerOpacity = + StyleSheet.flatten(result.current.containerStyles)?.opacity ?? 1; + expect(containerOpacity).toBe(1); + for (const styles of [ + result.current.inputStyles, + result.current.prefixStyles, + result.current.suffixStyles, + ]) { + expect(StyleSheet.flatten(styles)?.opacity).toBe(0.38); + } + } +); + +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( + (props: { error?: boolean; disabled?: boolean }) => useTextInput(props), + { initialProps: {}, wrapper: PaperProvider } + ); + expect( + StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor + ).toBe(theme.colors.onSurfaceVariant); + await act(() => result.current.onHoverIn()); + expect( + StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor + ).toBe(theme.colors.onSurface); + expect( + StyleSheet.flatten(result.current.animatedActiveOutlineStyles) + ).toMatchObject({ backgroundColor: theme.colors.primary }); + await rerender({ error: true }); + expect( + StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor + ).toBe(theme.colors.onErrorContainer); + expect( + StyleSheet.flatten(result.current.animatedActiveOutlineStyles) + ).toMatchObject({ backgroundColor: theme.colors.error }); + await act(() => result.current.onHoverOut()); + expect( + StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor + ).toBe(theme.colors.error); + await act(() => result.current.onHoverIn()); + await rerender({ error: true, disabled: true }); + expect( + StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor + ).toBe(theme.colors.onSurface); + await act(() => result.current.onHoverOut()); + await rerender({}); + expect( + StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor + ).toBe(theme.colors.onSurfaceVariant); +}); + +it('preserves the outlined resting color on hover', async () => { + const { result } = await renderHook( + () => useTextInput({ variant: 'outlined' }), + { wrapper: PaperProvider } + ); + await act(() => result.current.onHoverIn()); + expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( + theme.colors.outline + ); +}); diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index 6efc467066..b533023df5 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -62,7 +62,7 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` "right": 0, }, { - "backgroundColor": "rgba(121, 116, 126, 1)", + "backgroundColor": "rgba(73, 69, 79, 1)", "height": 1, }, false, @@ -115,6 +115,7 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` > Date: Tue, 22 Sep 2026 16:31:53 +0200 Subject: [PATCH 02/17] fix(text-input): address review feedback --- docs/6.x/docs/guides/migration.md | 12 ++-- src/components/TextInput/utils.ts | 15 ++--- .../__tests__/TextInputAccessibility.test.tsx | 44 +++++++++++++-- .../__tests__/TextInputStates.test.tsx | 55 +++++++++++++++++-- 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index ca17c34625..0f3af1e73d 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -403,6 +403,10 @@ referenced by the input's `aria-describedby`. Additional IDs passed through included in `accessibilityHint`, alongside any hint you provide, because React Native does not support native described-by relationships. +When no label or explicit accessible name is supplied, the placeholder names the +field, including after text is entered. A disabled field always exposes its +disabled state, even when `aria-disabled={false}` is supplied. + Error supporting text uses `role="alert"`. Android uses an assertive live region; iOS announces changed error messages through `AccessibilityInfo`. Custom input renderers should forward the accessibility props they receive. @@ -411,10 +415,10 @@ from `error` and the character counter. Empty fields remain visible to native accessibility before focus and after clearing. The field content no longer fades with the floating label. -The filled resting indicator now uses `onSurfaceVariant` and changes to -`onSurface` on hover. An invalid filled field uses `error` at rest and -`onErrorContainer` on hover. The focused indicator continues to use `primary` (or -`error` for an invalid field). Outlined fields continue to use `outline` at rest. +The filled resting indicator now uses `onSurfaceVariant`; outlined fields use +`outline` at rest. Both change to `onSurface` on hover. Invalid fields use `error` +at rest and `onErrorContainer` on hover. Focus takes precedence over hover and +uses `primary` (or `error` for an invalid field). #### Removed props diff --git a/src/components/TextInput/utils.ts b/src/components/TextInput/utils.ts index a6908420ea..2f65b329f2 100644 --- a/src/components/TextInput/utils.ts +++ b/src/components/TextInput/utils.ts @@ -190,15 +190,11 @@ export const getOutlineColor = ({ if (isDisabled) return colors.onSurface; if (hasError) { - return variant === 'filled' && isHovered && !isFocused - ? colors.onErrorContainer - : colors.error; + return isHovered && !isFocused ? colors.onErrorContainer : colors.error; } if (isFocused) return colors.primary; - if (variant === 'filled') { - return isHovered ? colors.onSurface : colors.onSurfaceVariant; - } - return colors.outline; + if (isHovered) return colors.onSurface; + return variant === 'filled' ? colors.onSurfaceVariant : colors.outline; }; /** @@ -478,6 +474,7 @@ export const getOutlinedTextInputData = ( isDisabled, isFocused, hasError, + isHovered: api.isHovered, }); /** @@ -611,10 +608,10 @@ export const getAccessibilityData = ({ return { input: { - 'aria-label': explicitLabel ?? label, + 'aria-label': explicitLabel ?? label ?? props.placeholder, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, - 'aria-disabled': props['aria-disabled'] ?? isDisabled, + 'aria-disabled': isDisabled || (props['aria-disabled'] ?? false), 'aria-invalid': props['aria-invalid'] ?? (hasError || isCounterExceeded), accessibilityHint: hint, }, diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index aeb46c454a..2f7e90640b 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -20,6 +20,43 @@ afterEach(() => { renderInput.mockClear(); }); +it.each(['filled', 'outlined'] as const)( + 'uses the placeholder as the name of an unlabelled %s field, including after typing', + async (variant) => { + await render(); + + const input = screen.getByLabelText('Search'); + await userEvent.type(input, 'Paper'); + expect(screen.getByLabelText('Search')).toHaveDisplayValue('Paper'); + } +); + +it.each([ + { label: 'Search products' }, + { 'aria-label': 'Search products' }, + { accessibilityLabel: 'Search products' }, +])('prefers a supplied name over the placeholder: %j', async (props) => { + await render(); + + expect(screen.getByLabelText('Search products')).toBeOnTheScreen(); + expect(screen.queryByLabelText('Search')).toBeNull(); +}); + +it.each([ + { disabled: true, 'aria-disabled': false, expectedDisabled: true }, + { disabled: true, 'aria-disabled': true, expectedDisabled: true }, + { disabled: false, 'aria-disabled': true, expectedDisabled: true }, + { disabled: false, 'aria-disabled': false, expectedDisabled: false }, +])( + 'keeps the disabled semantics consistent: %j', + async ({ expectedDisabled, ...props }) => { + await render(); + + const input = screen.getByLabelText('Email'); + expect(input).toHaveProp('aria-disabled', expectedDisabled); + } +); + it.each(['filled', 'outlined'] as const)( 'keeps an empty unfocused %s field visible to assistive technology', async (variant) => { @@ -308,7 +345,7 @@ it('renders decorative accessories outside the accessibility tree without button ).toBeOnTheScreen(); }); -it('preserves decorative loading and container visuals without accessible controls', async () => { +it('preserves decorative loading and content styles without accessible controls', async () => { await render( { jest.restoreAllMocks(); }); @@ -79,13 +79,56 @@ it('uses MD3 filled resting and hover colors without overriding the focused or d ).toBe(theme.colors.onSurfaceVariant); }); -it('preserves the outlined resting color on hover', async () => { - const { result } = await renderHook( - () => useTextInput({ variant: 'outlined' }), - { wrapper: PaperProvider } +it('updates the outlined hover color for errors and ignores hover when disabled', async () => { + const { result, rerender } = await renderHook( + (props: { error?: boolean; disabled?: boolean }) => + useTextInput({ variant: 'outlined', ...props }), + { initialProps: {}, wrapper: PaperProvider } + ); + expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( + theme.colors.outline + ); + await act(() => result.current.onHoverIn()); + expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( + theme.colors.onSurface + ); + await rerender({ error: true }); + expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( + theme.colors.onErrorContainer + ); + await act(() => result.current.onHoverOut()); + expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( + theme.colors.error ); await act(() => result.current.onHoverIn()); + await rerender({ error: true, disabled: true }); + expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( + theme.colors.onSurface + ); + await act(() => result.current.onHoverOut()); + await rerender({}); expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( theme.colors.outline ); }); + +it.each(['filled', 'outlined'] as const)( + 'preserves the focused %s indicator when hovered, invalid or disabled', + (variant) => { + const state = { + theme, + variant, + isFocused: true, + isHovered: true, + isDisabled: false, + hasError: false, + }; + expect(getOutlineColor(state)).toBe(theme.colors.primary); + expect(getOutlineColor({ ...state, hasError: true })).toBe( + theme.colors.error + ); + expect( + getOutlineColor({ ...state, hasError: true, isDisabled: true }) + ).toBe(theme.colors.onSurface); + } +); From b0dc6d06d1ab3099cc6031e51ac5f5b5e4316470 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:01:34 +0100 Subject: [PATCH 03/17] refactor(text-input): pass the accessory ref straight to IconButton 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. --- src/components/TextInput/TextInputIcon.tsx | 7 ------- .../__tests__/TextInputAccessibility.test.tsx | 16 +++++++--------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/components/TextInput/TextInputIcon.tsx b/src/components/TextInput/TextInputIcon.tsx index b9e6be2755..8fadc90726 100644 --- a/src/components/TextInput/TextInputIcon.tsx +++ b/src/components/TextInput/TextInputIcon.tsx @@ -99,12 +99,6 @@ const TextInputIcon = ({ }); const actionable = hasTouchHandler({ onPress, ...rest }); - const iconRef = React.useRef(null); - React.useImperativeHandle( - rest.ref, - () => iconRef.current, - [] - ); const accessibleName = rest['aria-label'] ?? rest.accessibilityLabel; React.useEffect(() => { if (__DEV__ && actionable && !accessibleName?.trim()) { @@ -126,7 +120,6 @@ const TextInputIcon = ({ > { - const cleanup = jest.fn<() => void>(); - const ref = jest.fn(() => cleanup); + const ref = jest.fn<(instance: View | null) => void>(); const props = { icon: 'magnify', style: {}, @@ -81,21 +81,19 @@ it('preserves accessory refs when switching between decoration and action', asyn ref, }; const { rerender, unmount } = await render(); - const decoration = ref.mock.lastCall; - expect(ref).toHaveBeenCalledWith( + expect(ref).toHaveBeenCalledTimes(1); + expect(ref).toHaveBeenLastCalledWith( expect.objectContaining({ measure: expect.any(Function) }) ); await rerender( {}} aria-label="Search" /> ); - expect(cleanup).not.toHaveBeenCalled(); - expect(ref.mock.lastCall).toEqual(decoration); - await rerender(); - expect(cleanup).not.toHaveBeenCalled(); + expect(ref).toHaveBeenCalledTimes(1); + await unmount(); - expect(cleanup).toHaveBeenCalledTimes(1); + expect(ref).toHaveBeenLastCalledWith(null); }); it.each(['filled', 'outlined'] as const)( From 709e02f66eb125cfe041428ae47f1cbc9881077d Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:03:30 +0100 Subject: [PATCH 04/17] fix(text-input): read native descriptions only from the hint 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. --- src/components/TextInput/hooks.ts | 7 +- src/components/TextInput/utils.ts | 12 ++- src/components/__tests__/TextInput.test.tsx | 56 ++++++---- .../__tests__/TextInputAccessibility.test.tsx | 102 +++++++++++------- 4 files changed, 110 insertions(+), 67 deletions(-) diff --git a/src/components/TextInput/hooks.ts b/src/components/TextInput/hooks.ts index 7620ae7c5c..0d0cfcfb0c 100644 --- a/src/components/TextInput/hooks.ts +++ b/src/components/TextInput/hooks.ts @@ -435,11 +435,12 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { inputLength, }); - // Native iOS does not implement live regions. Announce changed errors once, - // while Android and web use the rendered alert/live region. + // On native, the error is read from the input's hint and its text node is + // hidden, so a live region would not announce it. Announce changed errors + // instead, while web uses the rendered alert. const errorMessage = flags.hasError ? props.supportingText : undefined; useEffect(() => { - if (Platform.OS === 'ios' && errorMessage) { + if (Platform.OS !== 'web' && errorMessage) { AccessibilityInfo.announceForAccessibility(errorMessage); } }, [errorMessage]); diff --git a/src/components/TextInput/utils.ts b/src/components/TextInput/utils.ts index 2f65b329f2..286e3298a5 100644 --- a/src/components/TextInput/utils.ts +++ b/src/components/TextInput/utils.ts @@ -616,19 +616,21 @@ export const getAccessibilityData = ({ accessibilityHint: hint, }, label: { nativeID: labelId }, + // On native, the hint above already contains these texts. Hide their nodes + // so they are not read twice or merged into the field wrapper's label. supportingText: { nativeID: supportingTextId, - role: hasError && supportingText ? 'alert' : undefined, - accessibilityLiveRegion: - Platform.OS === 'android' && hasError && supportingText - ? 'assertive' + 'aria-hidden': Platform.OS !== 'web', + role: + Platform.OS === 'web' && hasError && supportingText + ? 'alert' : undefined, }, counter: { nativeID: counterId, + 'aria-hidden': Platform.OS !== 'web', 'aria-label': counterAccessibilityLabel, 'aria-live': Platform.OS === 'web' ? 'polite' : undefined, - accessibilityLiveRegion: Platform.OS === 'android' ? 'polite' : undefined, }, }; }; diff --git a/src/components/__tests__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx index 70c211640a..78c67d6a68 100644 --- a/src/components/__tests__/TextInput.test.tsx +++ b/src/components/__tests__/TextInput.test.tsx @@ -306,7 +306,7 @@ it('renders supporting text below the field', async () => { ).toBeOnTheScreen(); }); -it('uses an alert for error supporting text', async () => { +it('marks the input invalid and describes the error supporting text', async () => { await render( { /> ); - expect(screen.getByText('Invalid')).toHaveProp('role', 'alert'); - expect(screen.getByLabelText('Email')).toHaveProp('aria-invalid', true); + const input = screen.getByLabelText('Email'); + expect(input).toHaveProp('aria-invalid', true); + expect(input).toHaveProp('accessibilityHint', 'Invalid'); }); it('marks the input invalid when error is true without supporting text', async () => { @@ -338,7 +339,7 @@ it('marks the input invalid when error is true without supporting text', async ( expect(input).not.toHaveProp('accessibilityHint'); }); -it('exposes helper supporting text without including it in the field name', async () => { +it('describes helper supporting text without including it in the field name', async () => { await render( ); - const supportingText = screen.getByText('Optional', includeHiddenElements); - expect(supportingText).not.toHaveProp('aria-hidden', true); - expect(supportingText).not.toHaveProp('aria-live'); - expect(screen.getByLabelText('Email')).toBeOnTheScreen(); + expect(screen.getByLabelText('Email')).toHaveProp( + 'accessibilityHint', + 'Optional' + ); + expect(screen.getByText('Optional', includeHiddenElements)).not.toHaveProp( + 'aria-live' + ); }); it('keeps supporting text separate when label is omitted', async () => { @@ -366,7 +370,9 @@ it('keeps supporting text separate when label is omitted', async () => { ); expect(screen.getByTestId('tf-input')).not.toHaveProp('aria-label'); - expect(screen.getByText('Helper only')).toBeOnTheScreen(); + expect( + screen.getByText('Helper only', includeHiddenElements) + ).toBeOnTheScreen(); }); it('does not mark the input as aria-disabled when editable is false (read-only)', async () => { @@ -551,8 +557,10 @@ it('shows a character counter when counter is true and maxLength is set (filled) /> ); - expect(screen.getByText('5/100')).toBeOnTheScreen(); - expect(screen.queryByText('0/100')).not.toBeOnTheScreen(); + expect(screen.getByText('5/100', includeHiddenElements)).toBeOnTheScreen(); + expect( + screen.queryByText('0/100', includeHiddenElements) + ).not.toBeOnTheScreen(); }); it('shows a character counter when counter is true and maxLength is set (outlined)', async () => { @@ -567,7 +575,7 @@ it('shows a character counter when counter is true and maxLength is set (outline /> ); - expect(screen.getByText('0/50')).toBeOnTheScreen(); + expect(screen.getByText('0/50', includeHiddenElements)).toBeOnTheScreen(); }); it('updates the character counter when the value changes', async () => { @@ -581,7 +589,7 @@ it('updates the character counter when the value changes', async () => { /> ); - expect(screen.getByText('1/10')).toBeOnTheScreen(); + expect(screen.getByText('1/10', includeHiddenElements)).toBeOnTheScreen(); await rerender( { /> ); - expect(screen.getByText('4/10')).toBeOnTheScreen(); + expect(screen.getByText('4/10', includeHiddenElements)).toBeOnTheScreen(); }); it('does not show a character counter when counter is false', async () => { @@ -606,7 +614,9 @@ it('does not show a character counter when counter is false', async () => { /> ); - expect(screen.queryByText('5/100')).not.toBeOnTheScreen(); + expect( + screen.queryByText('5/100', includeHiddenElements) + ).not.toBeOnTheScreen(); }); it('does not show a character counter when maxLength is missing', async () => { @@ -614,8 +624,10 @@ it('does not show a character counter when maxLength is missing', async () => { {}} counter /> ); - expect(screen.queryByText('5/100')).not.toBeOnTheScreen(); - expect(screen.queryByText(/\//)).not.toBeOnTheScreen(); + expect( + screen.queryByText('5/100', includeHiddenElements) + ).not.toBeOnTheScreen(); + expect(screen.queryByText(/\//, includeHiddenElements)).not.toBeOnTheScreen(); }); it('invokes onFocus and onBlur on the TextInput', async () => { @@ -805,7 +817,7 @@ it('renders the counter as a Text child', async () => { /> ); - expect(screen.getByText('2/80')).toBeOnTheScreen(); + expect(screen.getByText('2/80', includeHiddenElements)).toBeOnTheScreen(); }); it('renders supporting text and counter separately when both are shown', async () => { @@ -823,7 +835,7 @@ it('renders supporting text and counter separately when both are shown', async ( expect( screen.getByText('Help text', includeHiddenElements) ).toBeOnTheScreen(); - expect(screen.getByText('1/10')).toBeOnTheScreen(); + expect(screen.getByText('1/10', includeHiddenElements)).toBeOnTheScreen(); }); it('applies RTL text alignment and writing direction to the TextInput (filled)', async () => { @@ -1249,12 +1261,12 @@ it('updates the character counter for an uncontrolled field with counter enabled /> ); - expect(screen.getByText('1/10')).toBeOnTheScreen(); + expect(screen.getByText('1/10', includeHiddenElements)).toBeOnTheScreen(); await user.type(screen.getByTestId('tf-uncontrolled-counter'), 'bcd'); expect(onChangeText).toHaveBeenCalledWith('abcd'); - expect(screen.getByText('4/10')).toBeOnTheScreen(); + expect(screen.getByText('4/10', includeHiddenElements)).toBeOnTheScreen(); }); it('resets counter and hides prefix/suffix when clear() is called on uncontrolled field while blurred', async () => { @@ -1271,7 +1283,7 @@ it('resets counter and hides prefix/suffix when clear() is called on uncontrolle /> ); - expect(screen.getByText('3/200')).toBeOnTheScreen(); + expect(screen.getByText('3/200', includeHiddenElements)).toBeOnTheScreen(); expect(screen.getByText('$')).toBeOnTheScreen(); expect(screen.getByText('/100')).toBeOnTheScreen(); diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index 375d5ff796..a5e5bac878 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -97,8 +97,9 @@ it('preserves accessory refs when switching between decoration and action', asyn }); it.each(['filled', 'outlined'] as const)( - 'associates helper and counter without changing the %s field name', + 'associates helper and counter on web without changing the %s field name', async (variant) => { + jest.replaceProperty(Platform, 'OS', 'web'); await render( { + jest.replaceProperty(Platform, 'OS', 'web'); const { rerender } = await render( ); @@ -175,6 +177,7 @@ it('gives different fields distinct description IDs even with the same test ID', }); it('merges external descriptions and preserves an explicit accessible name in a custom renderer', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); await render( { jest.replaceProperty(Platform, 'OS', platform); await render( @@ -214,13 +217,33 @@ it.each(['ios', 'android'] as const)( 'accessibilityHint', 'Double tap to edit, Keep it short, Characters entered 2 of 20' ); + expect(screen.queryByText('Keep it short')).toBeNull(); + expect(screen.queryByText('2/20')).toBeNull(); + expect( + screen.getByText('Keep it short', { includeHiddenElements: true }) + ).toBeOnTheScreen(); + expect( + screen.getByText('2/20', { includeHiddenElements: true }) + ).toBeOnTheScreen(); } ); it('uses web descriptions without duplicating them in a hint', async () => { jest.replaceProperty(Platform, 'OS', 'web'); - await render(); + await render( + + ); expect(screen.getByLabelText('Bio')).not.toHaveProp('accessibilityHint'); + expect(screen.getByText('Keep it short')).toBeOnTheScreen(); + expect(screen.getByLabelText('Characters entered 0 of 20')).toHaveProp( + 'aria-live', + 'polite' + ); }); it.each([true, false, 'true', 'false', 'grammar', 'spelling'] as const)( @@ -250,50 +273,55 @@ it.each([true, false, 'true', 'false', 'grammar', 'spelling'] as const)( } ); -it('announces each changed error message once on iOS', async () => { - jest.replaceProperty(Platform, 'OS', 'ios'); +it.each(['ios', 'android'] as const)( + 'announces each changed error message once on %s', + async (platform) => { + jest.replaceProperty(Platform, 'OS', platform); + const announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibility') + .mockClear(); + const { rerender } = await render( + + ); + expect(announce).not.toHaveBeenCalled(); + + await rerender( + + ); + expect(announce).toHaveBeenLastCalledWith('Invalid address'); + await rerender( + + ); + expect(announce).toHaveBeenCalledTimes(1); + await rerender( + + ); + expect(announce).toHaveBeenLastCalledWith('Address is required'); + await rerender(); + expect(announce).toHaveBeenCalledTimes(2); + } +); + +it('announces errors on web through the alert role', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); const announce = jest .spyOn(AccessibilityInfo, 'announceForAccessibility') .mockClear(); const { rerender } = await render( ); - expect(announce).not.toHaveBeenCalled(); + expect(screen.queryByRole('alert')).toBeNull(); await rerender( ); - expect(announce).toHaveBeenLastCalledWith('Invalid address'); - await rerender( - - ); - expect(announce).toHaveBeenCalledTimes(1); - await rerender( - - ); - expect(announce).toHaveBeenLastCalledWith('Address is required'); - await rerender(); - expect(announce).toHaveBeenCalledTimes(2); -}); - -it('uses an assertive Android live region for an error', async () => { - jest.replaceProperty(Platform, 'OS', 'android'); - await render( - - ); - expect(screen.getByRole('alert')).toHaveProp( - 'accessibilityLiveRegion', - 'assertive' - ); -}); - -it('uses a native Android live region for counter updates', async () => { - jest.replaceProperty(Platform, 'OS', 'android'); - await render(); - expect(screen.getByText('0/40')).toHaveProp( - 'accessibilityLiveRegion', - 'polite' - ); + expect(screen.getByRole('alert')).toHaveTextContent('Invalid address'); + expect(announce).not.toHaveBeenCalled(); }); it('keeps a disabled native input read-only when readOnly is explicitly false', async () => { From 1f28d6052d6c9e62d53ddfe7de77ed1527396e4b Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:03:41 +0100 Subject: [PATCH 05/17] fix(text-input): skip announcing errors present on mount 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. --- src/components/TextInput/hooks.ts | 12 +++++++++-- .../__tests__/TextInputAccessibility.test.tsx | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/TextInput/hooks.ts b/src/components/TextInput/hooks.ts index 0d0cfcfb0c..5b65c11b91 100644 --- a/src/components/TextInput/hooks.ts +++ b/src/components/TextInput/hooks.ts @@ -437,12 +437,20 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { // On native, the error is read from the input's hint and its text node is // hidden, so a live region would not announce it. Announce changed errors - // instead, while web uses the rendered alert. + // instead, but not errors present on mount, so a screen that opens with + // several invalid fields does not queue an announcement for each of them. const errorMessage = flags.hasError ? props.supportingText : undefined; + const previousErrorMessage = useRef(errorMessage); useEffect(() => { - if (Platform.OS !== 'web' && errorMessage) { + if ( + Platform.OS !== 'web' && + errorMessage && + errorMessage !== previousErrorMessage.current + ) { AccessibilityInfo.announceForAccessibility(errorMessage); } + + previousErrorMessage.current = errorMessage; }, [errorMessage]); const counterText = `${inputLength}/${props.maxLength}`; diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index a5e5bac878..449bc09299 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -307,6 +307,26 @@ it.each(['ios', 'android'] as const)( } ); +it.each(['ios', 'android'] as const)( + 'does not announce an error that is shown on mount on %s', + async (platform) => { + jest.replaceProperty(Platform, 'OS', platform); + const announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibility') + .mockClear(); + const { rerender } = await render( + + ); + expect(announce).not.toHaveBeenCalled(); + + await rerender( + + ); + expect(announce).toHaveBeenCalledTimes(1); + expect(announce).toHaveBeenLastCalledWith('Address is required'); + } +); + it('announces errors on web through the alert role', async () => { jest.replaceProperty(Platform, 'OS', 'web'); const announce = jest From 991eff3d97bf28c9d34671ca226d951d8835dc90 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:04:31 +0100 Subject: [PATCH 06/17] test(text-input): cover indicator states through getOutlineColor 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. --- .../__tests__/TextInputStates.test.tsx | 177 +++++------------- 1 file changed, 49 insertions(+), 128 deletions(-) diff --git a/src/components/__tests__/TextInputStates.test.tsx b/src/components/__tests__/TextInputStates.test.tsx index 20578e3b48..3dbe770cd2 100644 --- a/src/components/__tests__/TextInputStates.test.tsx +++ b/src/components/__tests__/TextInputStates.test.tsx @@ -1,134 +1,55 @@ -import { Platform, StyleSheet } from 'react-native'; +import { describe, expect, it } from '@jest/globals'; -import { afterEach, expect, it, jest } from '@jest/globals'; -import { renderHook } from '@testing-library/react-native'; - -import PaperProvider from '../../core/PaperProvider'; -import { act } from '../../test-utils'; import { LightTheme as theme } from '../../theme/schemes'; -import { useTextInput } from '../TextInput/hooks'; import { getOutlineColor } from '../TextInput/utils'; -afterEach(() => { - jest.restoreAllMocks(); -}); - -it.each(['filled', 'outlined'] as const)( - 'applies disabled opacity once to %s text and affixes', - async (variant) => { - const { result } = await renderHook( - () => - useTextInput({ - variant, - disabled: true, - value: 'Sample', - prefix: '$', - suffix: '/100', - }), - { wrapper: PaperProvider } - ); - const containerOpacity = - StyleSheet.flatten(result.current.containerStyles)?.opacity ?? 1; - expect(containerOpacity).toBe(1); - for (const styles of [ - result.current.inputStyles, - result.current.prefixStyles, - result.current.suffixStyles, - ]) { - expect(StyleSheet.flatten(styles)?.opacity).toBe(0.38); - } - } -); - -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( - (props: { error?: boolean; disabled?: boolean }) => useTextInput(props), - { initialProps: {}, wrapper: PaperProvider } - ); - expect( - StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor - ).toBe(theme.colors.onSurfaceVariant); - await act(() => result.current.onHoverIn()); - expect( - StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor - ).toBe(theme.colors.onSurface); - expect( - StyleSheet.flatten(result.current.animatedActiveOutlineStyles) - ).toMatchObject({ backgroundColor: theme.colors.primary }); - await rerender({ error: true }); - expect( - StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor - ).toBe(theme.colors.onErrorContainer); - expect( - StyleSheet.flatten(result.current.animatedActiveOutlineStyles) - ).toMatchObject({ backgroundColor: theme.colors.error }); - await act(() => result.current.onHoverOut()); - expect( - StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor - ).toBe(theme.colors.error); - await act(() => result.current.onHoverIn()); - await rerender({ error: true, disabled: true }); - expect( - StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor - ).toBe(theme.colors.onSurface); - await act(() => result.current.onHoverOut()); - await rerender({}); - expect( - StyleSheet.flatten(result.current.outlineStyles)?.backgroundColor - ).toBe(theme.colors.onSurfaceVariant); -}); - -it('updates the outlined hover color for errors and ignores hover when disabled', async () => { - const { result, rerender } = await renderHook( - (props: { error?: boolean; disabled?: boolean }) => - useTextInput({ variant: 'outlined', ...props }), - { initialProps: {}, wrapper: PaperProvider } - ); - expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( - theme.colors.outline - ); - await act(() => result.current.onHoverIn()); - expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( - theme.colors.onSurface - ); - await rerender({ error: true }); - expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( - theme.colors.onErrorContainer - ); - await act(() => result.current.onHoverOut()); - expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( - theme.colors.error - ); - await act(() => result.current.onHoverIn()); - await rerender({ error: true, disabled: true }); - expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( - theme.colors.onSurface - ); - await act(() => result.current.onHoverOut()); - await rerender({}); - expect(StyleSheet.flatten(result.current.outlineStyles)?.borderColor).toBe( - theme.colors.outline - ); -}); +type State = { + isFocused?: boolean; + isHovered?: boolean; + hasError?: boolean; + isDisabled?: boolean; +}; -it.each(['filled', 'outlined'] as const)( - 'preserves the focused %s indicator when hovered, invalid or disabled', - (variant) => { - const state = { - theme, - variant, - isFocused: true, - isHovered: true, - isDisabled: false, - hasError: false, - }; - expect(getOutlineColor(state)).toBe(theme.colors.primary); - expect(getOutlineColor({ ...state, hasError: true })).toBe( - theme.colors.error - ); +describe.each([ + { variant: 'filled', restingRole: 'onSurfaceVariant' }, + { variant: 'outlined', restingRole: 'outline' }, +] as const)('$variant indicator', ({ variant, restingRole }) => { + it.each<{ state: string; props: State; role: keyof typeof theme.colors }>([ + { state: 'at rest', props: {}, role: restingRole }, + { state: 'when hovered', props: { isHovered: true }, role: 'onSurface' }, + { state: 'when focused', props: { isFocused: true }, role: 'primary' }, + { + state: 'when focused and hovered', + props: { isFocused: true, isHovered: true }, + role: 'primary', + }, + { state: 'when invalid', props: { hasError: true }, role: 'error' }, + { + state: 'when invalid and hovered', + props: { hasError: true, isHovered: true }, + role: 'onErrorContainer', + }, + { + state: 'when invalid, focused and hovered', + props: { hasError: true, isFocused: true, isHovered: true }, + role: 'error', + }, + { state: 'when disabled', props: { isDisabled: true }, role: 'onSurface' }, + { + state: 'when disabled, invalid and hovered', + props: { isDisabled: true, hasError: true, isHovered: true }, + role: 'onSurface', + }, + ])('uses $role $state', ({ props, role }) => { expect( - getOutlineColor({ ...state, hasError: true, isDisabled: true }) - ).toBe(theme.colors.onSurface); - } -); + getOutlineColor({ + theme, + variant, + isFocused: false, + hasError: false, + isDisabled: false, + ...props, + }) + ).toBe(theme.colors[role]); + }); +}); From e45eea3ec0063c9294ed1ccad58382b9f50893a3 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 13:05:29 +0100 Subject: [PATCH 07/17] docs(text-input): describe native descriptions and error announcements 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. --- docs/6.x/docs/guides/migration.md | 15 +++++++++++---- src/components/TextInput/TextInput.tsx | 5 +++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 0f3af1e73d..0e61ef2e6a 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -364,7 +364,12 @@ Icons with `onPress`, `onLongPress`, `onPressIn`, or `onPressOut` require an acc label="Search" startAccessory={(props) => } endAccessory={(props) => ( - setValue('')} /> + setValue('')} + /> )} /> ``` @@ -401,14 +406,16 @@ part of its accessible name. On web, their generated `nativeID` values are referenced by the input's `aria-describedby`. Additional IDs passed through `aria-describedby` are preserved. On Android and iOS, these descriptions are included in `accessibilityHint`, alongside any hint you provide, because React -Native does not support native described-by relationships. +Native does not support native described-by relationships. The text below the +field is hidden from assistive technology there, so it is not read twice. When no label or explicit accessible name is supplied, the placeholder names the field, including after text is entered. A disabled field always exposes its disabled state, even when `aria-disabled={false}` is supplied. -Error supporting text uses `role="alert"`. Android uses an assertive live region; -iOS announces changed error messages through `AccessibilityInfo`. +On web, error supporting text uses `role="alert"`. On Android and iOS, a new or +changed error message is announced through `AccessibilityInfo`. An error that is +already shown when the field mounts is not announced. Custom input renderers should forward the accessibility props they receive. Explicit `aria-invalid` values are preserved; when omitted, validity is derived from `error` and the character counter. diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index 5e1209a795..c10bd3b2ae 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -225,8 +225,9 @@ export type TextInputProps = NativeTextInputProps & { /** * Supporting text to display below the input (Material Design 3). * Associated with the field through `aria-describedby` on web and included - * in the native accessibility hint. When `error` is true, it is announced - * as an alert. It does not become part of the field's accessible name. + * in the native accessibility hint. When `error` is true, a new or changed + * message is announced. It does not become part of the field's accessible + * name. */ supportingText?: string; /** From 32ac7d2ad1ee224d5ff7dfd8d49b46829ea6ed27 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:27:51 +0100 Subject: [PATCH 08/17] fix(text-input): queue native error announcements 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. --- src/components/TextInput/hooks.ts | 6 ++- .../__tests__/TextInputAccessibility.test.tsx | 49 ++++++++++++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/components/TextInput/hooks.ts b/src/components/TextInput/hooks.ts index 5b65c11b91..72c45ddf07 100644 --- a/src/components/TextInput/hooks.ts +++ b/src/components/TextInput/hooks.ts @@ -439,6 +439,8 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { // hidden, so a live region would not announce it. Announce changed errors // instead, but not errors present on mount, so a screen that opens with // several invalid fields does not queue an announcement for each of them. + // Queue them so that fields becoming invalid together do not cut each other + // off on iOS. const errorMessage = flags.hasError ? props.supportingText : undefined; const previousErrorMessage = useRef(errorMessage); useEffect(() => { @@ -447,7 +449,9 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => { errorMessage && errorMessage !== previousErrorMessage.current ) { - AccessibilityInfo.announceForAccessibility(errorMessage); + AccessibilityInfo.announceForAccessibilityWithOptions(errorMessage, { + queue: true, + }); } previousErrorMessage.current = errorMessage; diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index 449bc09299..f51c66c376 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -278,7 +278,7 @@ it.each(['ios', 'android'] as const)( async (platform) => { jest.replaceProperty(Platform, 'OS', platform); const announce = jest - .spyOn(AccessibilityInfo, 'announceForAccessibility') + .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions') .mockClear(); const { rerender } = await render( @@ -288,7 +288,9 @@ it.each(['ios', 'android'] as const)( await rerender( ); - expect(announce).toHaveBeenLastCalledWith('Invalid address'); + expect(announce).toHaveBeenLastCalledWith('Invalid address', { + queue: true, + }); await rerender( ); - expect(announce).toHaveBeenLastCalledWith('Address is required'); + expect(announce).toHaveBeenLastCalledWith('Address is required', { + queue: true, + }); await rerender(); expect(announce).toHaveBeenCalledTimes(2); } @@ -312,7 +316,7 @@ it.each(['ios', 'android'] as const)( async (platform) => { jest.replaceProperty(Platform, 'OS', platform); const announce = jest - .spyOn(AccessibilityInfo, 'announceForAccessibility') + .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions') .mockClear(); const { rerender } = await render( @@ -323,14 +327,47 @@ it.each(['ios', 'android'] as const)( ); expect(announce).toHaveBeenCalledTimes(1); - expect(announce).toHaveBeenLastCalledWith('Address is required'); + expect(announce).toHaveBeenLastCalledWith('Address is required', { + queue: true, + }); + } +); + +it.each(['ios', 'android'] as const)( + 'queues the errors of fields that become invalid together on %s', + async (platform) => { + jest.replaceProperty(Platform, 'OS', platform); + const announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions') + .mockClear(); + const { rerender } = await render( + <> + + + + ); + + await rerender( + <> + + + + ); + expect(announce.mock.calls).toEqual([ + ['Invalid address', { queue: true }], + ['Password is required', { queue: true }], + ]); } ); it('announces errors on web through the alert role', async () => { jest.replaceProperty(Platform, 'OS', 'web'); const announce = jest - .spyOn(AccessibilityInfo, 'announceForAccessibility') + .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions') .mockClear(); const { rerender } = await render( From 18250df2e2202691d0e66add9cb20365cde9c962 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:28:58 +0100 Subject: [PATCH 09/17] fix(text-input): let the disabled state override error colors 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. --- docs/6.x/docs/guides/migration.md | 3 ++- src/components/TextInput/TextInput.tsx | 6 ++++- .../TextInput/TextInputErrorIcon.tsx | 5 +++- src/components/TextInput/utils.ts | 14 +++++----- .../__tests__/TextInputStates.test.tsx | 27 +++++++++++++++++++ 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 0e61ef2e6a..da3c47daf1 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -425,7 +425,8 @@ clearing. The field content no longer fades with the floating label. The filled resting indicator now uses `onSurfaceVariant`; outlined fields use `outline` at rest. Both change to `onSurface` on hover. Invalid fields use `error` at rest and `onErrorContainer` on hover. Focus takes precedence over hover and -uses `primary` (or `error` for an invalid field). +uses `primary` (or `error` for an invalid field). A disabled field uses the +disabled colors even when `error` is set, and it is still reported as invalid. #### Removed props diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index c10bd3b2ae..3eec1aa15d 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -475,7 +475,11 @@ function TextInput({ multiline: !!rest.multiline, }) ) : hasError ? ( - + ) : null} diff --git a/src/components/TextInput/TextInputErrorIcon.tsx b/src/components/TextInput/TextInputErrorIcon.tsx index b49e9d8db9..a0af0221d2 100644 --- a/src/components/TextInput/TextInputErrorIcon.tsx +++ b/src/components/TextInput/TextInputErrorIcon.tsx @@ -2,17 +2,20 @@ import { View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import { ACCESSORY_SIZE } from './constants'; +import { getIconColor } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import Icon from '../Icon'; interface TextInputErrorIconProps { style?: StyleProp; + disabled?: boolean; theme?: ThemeProp; } const TextInputErrorIcon = ({ style: wrapperStyle, + disabled = false, theme: themeOverride, }: TextInputErrorIconProps) => { const theme = useInternalTheme(themeOverride); @@ -22,7 +25,7 @@ const TextInputErrorIcon = ({ ); diff --git a/src/components/TextInput/utils.ts b/src/components/TextInput/utils.ts index 286e3298a5..04e8ae44de 100644 --- a/src/components/TextInput/utils.ts +++ b/src/components/TextInput/utils.ts @@ -95,12 +95,12 @@ export const getLabelColor = ({ colors: { error, primary, onSurface, onSurfaceVariant }, } = theme; - if (hasError) { - return error; - } if (isDisabled) { return onSurface; } + if (hasError) { + return error; + } if (isFocused) { return primary; } @@ -120,12 +120,12 @@ export const getSupportingTextColor = ({ colors: { error, onSurface, onSurfaceVariant }, } = theme; - if (hasError) { - return error; - } if (isDisabled) { return onSurface; } + if (hasError) { + return error; + } return onSurfaceVariant; }; @@ -162,8 +162,8 @@ export const getIconColor = ({ isDisabled: boolean; }): ColorValue => { if (iconColor) return iconColor; - if (hasError) return theme.colors.error; if (isDisabled) return theme.colors.onSurface; + if (hasError) return theme.colors.error; return theme.colors.onSurfaceVariant; }; diff --git a/src/components/__tests__/TextInputStates.test.tsx b/src/components/__tests__/TextInputStates.test.tsx index 3dbe770cd2..c41030a2bd 100644 --- a/src/components/__tests__/TextInputStates.test.tsx +++ b/src/components/__tests__/TextInputStates.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, it } from '@jest/globals'; +import { render, screen } from '../../test-utils'; import { LightTheme as theme } from '../../theme/schemes'; +import TextInput from '../TextInput'; import { getOutlineColor } from '../TextInput/utils'; type State = { @@ -53,3 +55,28 @@ describe.each([ ).toBe(theme.colors[role]); }); }); + +it('uses the disabled colors for a disabled invalid field and keeps it invalid', async () => { + await render( + } + /> + ); + + // Label, supporting text, accessory icon and the default error icon. + for (const text of [ + 'Email', + 'Enter a valid email', + 'email', + 'alert-circle', + ]) { + expect(screen.getByText(text, { includeHiddenElements: true })).toHaveStyle( + { color: theme.colors.onSurface } + ); + } + expect(screen.getByLabelText('Email')).toHaveProp('aria-invalid', true); +}); From b70767fa1ca0641ecf5999651f41cdb18e6075b4 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:30:08 +0100 Subject: [PATCH 10/17] fix(text-input): render generated IDs only on web 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. --- src/components/TextInput/TextInput.tsx | 6 ++--- src/components/TextInput/utils.ts | 22 +++++++++---------- .../__tests__/TextInputAccessibility.test.tsx | 2 ++ .../__snapshots__/TextInput.test.tsx.snap | 6 ----- 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index 3eec1aa15d..eb8759f15d 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -61,9 +61,9 @@ export type GetAccessibilityDataReturn = { 'aria-invalid'?: TextInputProps['aria-invalid']; 'aria-describedby'?: string; }; - label: { nativeID: string }; - supportingText: AccessibilityProps & { nativeID: string }; - counter: AccessibilityProps & { nativeID: string }; + label: { nativeID?: string }; + supportingText: AccessibilityProps & { nativeID?: string }; + counter: AccessibilityProps & { nativeID?: string }; }; export type GetAccessibilityDataProps = { diff --git a/src/components/TextInput/utils.ts b/src/components/TextInput/utils.ts index 04e8ae44de..9be52d862f 100644 --- a/src/components/TextInput/utils.ts +++ b/src/components/TextInput/utils.ts @@ -576,24 +576,22 @@ export const getAccessibilityData = ({ : `Characters entered ${inputLength} of ${maxLength}` : undefined; - const labelId = `${id}-label`; - const supportingTextId = `${id}-supporting-text`; - const counterId = `${id}-counter`; + // Generated IDs only link the label and descriptions on web. + const labelId = Platform.OS === 'web' ? `${id}-label` : undefined; + const supportingTextId = + Platform.OS === 'web' && supportingText + ? `${id}-supporting-text` + : undefined; + const counterId = + Platform.OS === 'web' && hasCounter ? `${id}-counter` : undefined; const describedBy = - [ - props['aria-describedby'], - supportingText ? supportingTextId : undefined, - hasCounter ? counterId : undefined, - ] + [props['aria-describedby'], supportingTextId, counterId] .filter(Boolean) .join(' ') || undefined; const explicitLabel = props['aria-label'] ?? props.accessibilityLabel; const labelledBy = props['aria-labelledby'] ?? - (Platform.OS === 'web' && - label && - explicitLabel == null && - !props.accessibilityLabelledBy + (label && explicitLabel == null && !props.accessibilityLabelledBy ? labelId : undefined); diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index f51c66c376..02e3d6db7a 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -153,6 +153,7 @@ it('keeps generated IDs stable as helper text becomes an error and removes absen }); it('gives different fields distinct description IDs even with the same test ID', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); await render( <> { + jest.replaceProperty(Platform, 'OS', 'web'); await render( <> Work address diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index b533023df5..92dd090c3f 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -115,7 +115,6 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` > Date: Fri, 30 Oct 2026 14:31:12 +0100 Subject: [PATCH 11/17] test(text-input): check the name from caller label relationships 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. --- src/components/__tests__/TextInputAccessibility.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index 02e3d6db7a..1a595ceaac 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -523,7 +523,10 @@ it.each(['aria-labelledby', 'accessibilityLabelledBy'] as const)( /> ); - expect(renderInput.mock.lastCall?.[0][attribute]).toBe('external-label'); + expect(screen.getByLabelText('Work address')).toHaveProp( + attribute, + 'external-label' + ); expect(renderInput.mock.lastCall?.[0]['aria-describedby']).toEqual( expect.any(String) ); From 9b686f68f59c2837a0ef237bbbb6dd2fd8ae074a Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 14:31:52 +0100 Subject: [PATCH 12/17] test(text-input): cover the accessory accessible name warning An actionable icon with a blank aria-label warns, while a decorative icon and a named actionable icon do not. --- .../__tests__/TextInputAccessibility.test.tsx | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index 1a595ceaac..bddb37de6f 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -10,7 +10,10 @@ import { afterEach, expect, it, jest } from '@jest/globals'; import { fireEvent, render, screen, userEvent } from '../../test-utils'; import TextInput from '../TextInput'; -import type { TextInputRenderProps } from '../TextInput/TextInput'; +import type { + TextInputProps, + TextInputRenderProps, +} from '../TextInput/TextInput'; const renderInput = jest.fn((props: TextInputRenderProps) => ( @@ -414,6 +417,52 @@ it.each(['onLongPress', 'onPressIn', 'onPressOut'] as const)( } ); +const accessoryNameWarning = + 'TextInput.Icon: an actionable icon requires an aria-label or accessibilityLabel.'; + +it('warns when an actionable accessory has a blank accessible name', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await render( + ( + {}} + /> + )} + /> + ); + expect(warn).toHaveBeenCalledWith(accessoryNameWarning); +}); + +it.each<{ + accessory: string; + endAccessory: NonNullable; +}>([ + { + accessory: 'a decorative accessory', + endAccessory: (props) => , + }, + { + accessory: 'a named actionable accessory', + endAccessory: (props) => ( + {}} + /> + ), + }, +])('does not warn for $accessory', async ({ endAccessory }) => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await render(); + expect(warn).not.toHaveBeenCalled(); +}); + it('renders decorative accessories outside the accessibility tree without buttons', async () => { await render( Date: Fri, 30 Oct 2026 14:32:39 +0100 Subject: [PATCH 13/17] docs(text-input): describe the 6.x behavior in the migration guide 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. --- docs/6.x/docs/guides/migration.md | 60 +++++++++++++++++++------------ 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index da3c47daf1..61fe2be45c 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -355,13 +355,28 @@ import { TextInput, type TextInputProps } from 'react-native-paper'; /> ``` -`TextInput.Icon` is decorative when no press handlers are provided. Decorative icons are -hidden from assistive technology and do not create a keyboard focus stop. -Icons with `onPress`, `onLongPress`, `onPressIn`, or `onPressOut` require an accessible name: +`TextInput.Icon` without a press handler is decorative: it is hidden from +assistive technology and is not a keyboard focus stop. An icon with `onPress`, +`onLongPress`, `onPressIn` or `onPressOut` requires an `aria-label` or +`accessibilityLabel` that names its action: ```tsx +const [value, setValue] = React.useState(''); + +// Before (v5) +} + right={ setValue('')} />} +/> + +// After (v6) } endAccessory={(props) => ( ``` -Supporting text and the character counter describe the field without becoming -part of its accessible name. On web, their generated `nativeID` values are -referenced by the input's `aria-describedby`. Additional IDs passed through -`aria-describedby` are preserved. On Android and iOS, these descriptions are -included in `accessibilityHint`, alongside any hint you provide, because React -Native does not support native described-by relationships. The text below the -field is hidden from assistive technology there, so it is not read twice. +Supporting text and the character counter describe the field instead of +becoming part of its accessible name. On web, the input references them with +`aria-describedby`, and IDs you pass in `aria-describedby` are kept. On Android +and iOS, they are only exposed through `accessibilityHint`, alongside any hint +you pass, and the visible text is hidden from assistive technology. Users who +turn hints off, for example with the VoiceOver Speak Hints setting, do not hear +them. When no label or explicit accessible name is supplied, the placeholder names the field, including after text is entered. A disabled field always exposes its disabled state, even when `aria-disabled={false}` is supplied. On web, error supporting text uses `role="alert"`. On Android and iOS, a new or -changed error message is announced through `AccessibilityInfo`. An error that is -already shown when the field mounts is not announced. -Custom input renderers should forward the accessibility props they receive. -Explicit `aria-invalid` values are preserved; when omitted, validity is derived -from `error` and the character counter. -Empty fields remain visible to native accessibility before focus and after -clearing. The field content no longer fades with the floating label. - -The filled resting indicator now uses `onSurfaceVariant`; outlined fields use -`outline` at rest. Both change to `onSurface` on hover. Invalid fields use `error` -at rest and `onErrorContainer` on hover. Focus takes precedence over hover and -uses `primary` (or `error` for an invalid field). A disabled field uses the -disabled colors even when `error` is set, and it is still reported as invalid. +changed error message is announced through `AccessibilityInfo`, and an error +that is already shown when the field mounts is not announced there. Explicit +`aria-invalid` values are kept; when omitted, validity is derived from `error` +and the character counter. Custom input renderers should forward the +accessibility props they receive. + +The filled indicator uses `onSurfaceVariant` and the outlined border uses +`outline` at rest. On web, both change to `onSurface` on hover, and an invalid +field changes from `error` to `onErrorContainer`. Focus takes precedence over +hover and uses `primary`, or `error` for an invalid field. A disabled field uses +the disabled colors even when `error` is set, and it is still reported as +invalid. #### Removed props From a74bf94e512f3adc175c7cf22b5d9ab5f7d7abdc Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:07:05 +0100 Subject: [PATCH 14/17] docs(text-input): scope aria-invalid notes to web 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. --- docs/6.x/docs/guides/migration.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index 61fe2be45c..3c464e6c42 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -430,17 +430,17 @@ disabled state, even when `aria-disabled={false}` is supplied. On web, error supporting text uses `role="alert"`. On Android and iOS, a new or changed error message is announced through `AccessibilityInfo`, and an error -that is already shown when the field mounts is not announced there. Explicit -`aria-invalid` values are kept; when omitted, validity is derived from `error` -and the character counter. Custom input renderers should forward the +that is already shown when the field mounts is not announced there. On web, +explicit `aria-invalid` values are kept; when omitted, validity is derived from +`error` and the character counter. Custom input renderers should forward the accessibility props they receive. The filled indicator uses `onSurfaceVariant` and the outlined border uses `outline` at rest. On web, both change to `onSurface` on hover, and an invalid field changes from `error` to `onErrorContainer`. Focus takes precedence over hover and uses `primary`, or `error` for an invalid field. A disabled field uses -the disabled colors even when `error` is set, and it is still reported as -invalid. +the disabled colors even when `error` is set. On web it keeps `aria-invalid`; on +Android and iOS the error text stays in its hint. #### Removed props From 7eb373a76bf890ed32f05ac0023b52db801634d4 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:07:06 +0100 Subject: [PATCH 15/17] test(text-input): check the disabled invalid field on web aria-invalid only reaches assistive technology on web, so run the disabled invalid field test there. --- src/components/__tests__/TextInputStates.test.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/__tests__/TextInputStates.test.tsx b/src/components/__tests__/TextInputStates.test.tsx index c41030a2bd..0c60700ebb 100644 --- a/src/components/__tests__/TextInputStates.test.tsx +++ b/src/components/__tests__/TextInputStates.test.tsx @@ -1,10 +1,16 @@ -import { describe, expect, it } from '@jest/globals'; +import { Platform } from 'react-native'; + +import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { render, screen } from '../../test-utils'; import { LightTheme as theme } from '../../theme/schemes'; import TextInput from '../TextInput'; import { getOutlineColor } from '../TextInput/utils'; +afterEach(() => { + jest.restoreAllMocks(); +}); + type State = { isFocused?: boolean; isHovered?: boolean; @@ -56,7 +62,8 @@ describe.each([ }); }); -it('uses the disabled colors for a disabled invalid field and keeps it invalid', async () => { +it('uses the disabled colors for a disabled invalid field and keeps it invalid on web', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); await render( Date: Fri, 30 Oct 2026 15:07:42 +0100 Subject: [PATCH 16/17] test(text-input): check native descriptions carry no generated IDs Assert the native input has no generated aria-describedby and the supporting text and counter render without a nativeID. --- .../__tests__/TextInputAccessibility.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index bddb37de6f..9982cbef54 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -217,18 +217,19 @@ it.each(['ios', 'android'] as const)( maxLength={20} /> ); - expect(screen.getByLabelText('Bio')).toHaveProp( + const input = screen.getByLabelText('Bio'); + expect(input).toHaveProp( 'accessibilityHint', 'Double tap to edit, Keep it short, Characters entered 2 of 20' ); + expect(input).not.toHaveProp('aria-describedby'); expect(screen.queryByText('Keep it short')).toBeNull(); expect(screen.queryByText('2/20')).toBeNull(); - expect( - screen.getByText('Keep it short', { includeHiddenElements: true }) - ).toBeOnTheScreen(); - expect( - screen.getByText('2/20', { includeHiddenElements: true }) - ).toBeOnTheScreen(); + for (const text of ['Keep it short', '2/20']) { + expect( + screen.getByText(text, { includeHiddenElements: true }) + ).not.toHaveProp('nativeID'); + } } ); From 1cc6527d3e85b026df3da9db175f6df7fa8b1de7 Mon Sep 17 00:00:00 2001 From: likevy Date: Fri, 30 Oct 2026 15:08:09 +0100 Subject: [PATCH 17/17] test(text-input): check announcement queueing on iOS only 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. --- .../__tests__/TextInputAccessibility.test.tsx | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/src/components/__tests__/TextInputAccessibility.test.tsx b/src/components/__tests__/TextInputAccessibility.test.tsx index 9982cbef54..9ba6e4ebcb 100644 --- a/src/components/__tests__/TextInputAccessibility.test.tsx +++ b/src/components/__tests__/TextInputAccessibility.test.tsx @@ -338,37 +338,48 @@ it.each(['ios', 'android'] as const)( } ); +const makeTwoFieldsInvalid = async () => { + const announce = jest + .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions') + .mockClear(); + const { rerender } = await render( + <> + + + + ); + + await rerender( + <> + + + + ); + + return announce; +}; + it.each(['ios', 'android'] as const)( - 'queues the errors of fields that become invalid together on %s', + 'announces the error of each field that becomes invalid in the same update on %s', async (platform) => { jest.replaceProperty(Platform, 'OS', platform); - const announce = jest - .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions') - .mockClear(); - const { rerender } = await render( - <> - - - - ); - - await rerender( - <> - - - - ); - expect(announce.mock.calls).toEqual([ - ['Invalid address', { queue: true }], - ['Password is required', { queue: true }], + const announce = await makeTwoFieldsInvalid(); + expect(announce.mock.calls.map(([message]) => message)).toEqual([ + 'Invalid address', + 'Password is required', ]); } ); +it('queues the error announcements on iOS so they do not cut each other off', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const announce = await makeTwoFieldsInvalid(); + expect(announce.mock.calls).toEqual([ + ['Invalid address', { queue: true }], + ['Password is required', { queue: true }], + ]); +}); + it('announces errors on web through the alert role', async () => { jest.replaceProperty(Platform, 'OS', 'web'); const announce = jest