From 32523ce7d32403c5defd6eec2766f9ed775851dc Mon Sep 17 00:00:00 2001 From: likevy Date: Tue, 8 Sep 2026 09:28:18 +0200 Subject: [PATCH] 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 a401ad8de8..d0d2082c73 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -256,6 +256,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`** @@ -283,6 +297,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`] = ` >