diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 72aee6d00f..3c464e6c42 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -355,6 +355,40 @@ import { TextInput, type TextInputProps } from 'react-native-paper';
/>
```
+`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) => (
+ setValue('')}
+ />
+ )}
+/>
+```
+
#### Label and supporting text
- **`label: React.Element | string`** → **`string`**
@@ -382,6 +416,32 @@ import { TextInput, type TextInputProps } from 'react-native-paper';
/>
```
+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`, and an error
+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. On web it keeps `aria-invalid`; on
+Android and iOS the error text stays in its hint.
+
#### 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..eb8759f15d 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,16 @@ 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, a new or changed
+ * message is announced. 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 +349,6 @@ function TextInput({
animatedActiveOutlineStyles,
animatedLabelWrapperStyles,
animatedLabelTextStyles,
- animatedContainerStyle,
containerStyles,
inputStyles,
prefixStyles,
@@ -343,6 +367,8 @@ function TextInput({
onChangeText,
onFocus,
onBlur,
+ onHoverIn,
+ onHoverOut,
} = useTextInput({
ref,
error,
@@ -360,8 +386,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 +426,10 @@ function TextInput({
{!!label && (
-
+
{label}
@@ -398,7 +444,8 @@ function TextInput({
})
: null}
-
+ {/* Keep the field visible to native accessibility even before focus. */}
+
{hasPrefix && {prefix}}
{render({
@@ -406,9 +453,10 @@ function TextInput({
selectionColor,
cursorColor,
placeholderTextColor,
- ...accessibilityProps.input,
...rest,
+ ...accessibilityProps.input,
editable: isEditable,
+ readOnly: disabled ? true : rest.readOnly,
placeholder,
style: inputStyles,
onChangeText,
@@ -417,7 +465,7 @@ function TextInput({
})}
{hasSuffix && {suffix}}
-
+
{renderTrailingAccessory ? (
renderTrailingAccessory({
@@ -427,13 +475,18 @@ function TextInput({
multiline: !!rest.multiline,
})
) : hasError ? (
-
+
) : null}
{!!supportingText && (
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/TextInputIcon.tsx b/src/components/TextInput/TextInputIcon.tsx
index 012e2f45f9..8fadc90726 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,45 @@ const TextInputIcon = ({
isDisabled: disabled,
});
+ const actionable = hasTouchHandler({ onPress, ...rest });
+ 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..72c45ddf07 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,28 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => {
inputLength,
});
+ // 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, 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(() => {
+ if (
+ Platform.OS !== 'web' &&
+ errorMessage &&
+ errorMessage !== previousErrorMessage.current
+ ) {
+ AccessibilityInfo.announceForAccessibilityWithOptions(errorMessage, {
+ queue: true,
+ });
+ }
+
+ previousErrorMessage.current = errorMessage;
+ }, [errorMessage]);
+
const counterText = `${inputLength}/${props.maxLength}`;
const renderLeadingAccessory = flags.isRTL
@@ -452,7 +482,6 @@ export const useTextInput = (props: TextInputProps): TextInputHookReturn => {
selectionColor,
cursorColor,
animatedActiveOutlineStyles: undefined,
- animatedContainerStyle: animation.animatedContainerStyle,
placeholder,
counterText,
accessibilityProps,
@@ -463,5 +492,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..9be52d862f 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,43 +162,39 @@ 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;
};
/**
- * 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;
- }
- if (isDisabled) {
- return onSurface;
- }
- if (isFocused) {
- return primary;
+ return isHovered && !isFocused ? colors.onErrorContainer : colors.error;
}
-
- return outline;
+ if (isFocused) return colors.primary;
+ if (isHovered) return colors.onSurface;
+ return variant === 'filled' ? colors.onSurfaceVariant : colors.outline;
};
/**
@@ -330,6 +326,8 @@ export const getFilledTextInputData = (
hasError,
isFocused: false,
isDisabled,
+ variant: 'filled',
+ isHovered: api.isHovered,
});
const activeOutlineColor = getOutlineColor({
@@ -363,10 +361,7 @@ export const getFilledTextInputData = (
animatedLabelWrapperStyle,
];
- const containerStyles: StyleProp = [
- filledStyles.container,
- isDisabled && styles.disabled,
- ];
+ const containerStyles = filledStyles.container;
const fieldStyles: StyleProp = [
styles.field,
@@ -479,6 +474,7 @@ export const getOutlinedTextInputData = (
isDisabled,
isFocused,
hasError,
+ isHovered: api.isHovered,
});
/**
@@ -491,10 +487,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 +561,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;
+ // 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'], supportingTextId, counterId]
+ .filter(Boolean)
+ .join(' ') || undefined;
+ const explicitLabel = props['aria-label'] ?? props.accessibilityLabel;
+ const labelledBy =
+ props['aria-labelledby'] ??
+ (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 ?? props.placeholder,
+ 'aria-labelledby': labelledBy,
+ 'aria-describedby': describedBy,
+ 'aria-disabled': isDisabled || (props['aria-disabled'] ?? false),
+ 'aria-invalid': props['aria-invalid'] ?? (hasError || isCounterExceeded),
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: {
- 'aria-hidden': isSupportingTextHidden,
- 'aria-live': hasError && supportingText ? 'polite' : undefined,
+ nativeID: supportingTextId,
+ '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': 'polite',
+ 'aria-live': Platform.OS === 'web' ? 'polite' : undefined,
},
};
};
diff --git a/src/components/__tests__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx
index b87e482c7f..78c67d6a68 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('marks the input invalid and describes the error supporting text', async () => {
await render(
{
/>
);
- expect(screen.getByText('Invalid')).toHaveProp('aria-live', 'polite');
- 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 () => {
@@ -308,7 +339,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('describes helper supporting text without including it in the field name', async () => {
await render(
);
- const supportingText = screen.getByText('Optional', includeHiddenElements);
- expect(supportingText).toHaveProp('aria-hidden', true);
- expect(supportingText).not.toHaveProp('aria-live');
- expect(screen.getByLabelText('Email, Optional')).toBeOnTheScreen();
+ expect(screen.getByLabelText('Email')).toHaveProp(
+ 'accessibilityHint',
+ 'Optional'
+ );
+ expect(screen.getByText('Optional', includeHiddenElements)).not.toHaveProp(
+ 'aria-live'
+ );
});
-it('includes supporting text in aria-label when label is omitted', async () => {
+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', includeHiddenElements)
+ ).toBeOnTheScreen();
});
it('does not mark the input as aria-disabled when editable is false (read-only)', async () => {
@@ -520,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 () => {
@@ -536,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 () => {
@@ -550,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 () => {
@@ -575,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 () => {
@@ -583,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 () => {
@@ -774,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 () => {
@@ -792,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 () => {
@@ -1218,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 () => {
@@ -1240,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
new file mode 100644
index 0000000000..9ba6e4ebcb
--- /dev/null
+++ b/src/components/__tests__/TextInputAccessibility.test.tsx
@@ -0,0 +1,618 @@
+import {
+ AccessibilityInfo,
+ Text,
+ Platform,
+ TextInput as NativeTextInput,
+} from 'react-native';
+import type { View } 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 {
+ TextInputProps,
+ TextInputRenderProps,
+} from '../TextInput/TextInput';
+
+const renderInput = jest.fn((props: TextInputRenderProps) => (
+
+));
+
+afterEach(() => {
+ jest.restoreAllMocks();
+ 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) => {
+ 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 ref = jest.fn<(instance: View | null) => void>();
+ const props = {
+ icon: 'magnify',
+ style: {},
+ disabled: false,
+ error: false,
+ multiline: false,
+ ref,
+ };
+ const { rerender, unmount } = await render();
+ expect(ref).toHaveBeenCalledTimes(1);
+ expect(ref).toHaveBeenLastCalledWith(
+ expect.objectContaining({ measure: expect.any(Function) })
+ );
+
+ await rerender(
+ {}} aria-label="Search" />
+ );
+ await rerender();
+ expect(ref).toHaveBeenCalledTimes(1);
+
+ await unmount();
+ expect(ref).toHaveBeenLastCalledWith(null);
+});
+
+it.each(['filled', 'outlined'] as const)(
+ 'associates helper and counter on web without changing the %s field name',
+ async (variant) => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ 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 () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ 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 () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ 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 () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ 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)(
+ 'reads field descriptions only from the native hint on %s',
+ async (platform) => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ await render(
+
+ );
+ 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();
+ for (const text of ['Keep it short', '2/20']) {
+ expect(
+ screen.getByText(text, { includeHiddenElements: true })
+ ).not.toHaveProp('nativeID');
+ }
+ }
+);
+
+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');
+ 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)(
+ '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.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, 'announceForAccessibilityWithOptions')
+ .mockClear();
+ const { rerender } = await render(
+
+ );
+ expect(announce).not.toHaveBeenCalled();
+
+ await rerender(
+
+ );
+ expect(announce).toHaveBeenLastCalledWith('Invalid address', {
+ queue: true,
+ });
+ await rerender(
+
+ );
+ expect(announce).toHaveBeenCalledTimes(1);
+ await rerender(
+
+ );
+ expect(announce).toHaveBeenLastCalledWith('Address is required', {
+ queue: true,
+ });
+ await rerender();
+ expect(announce).toHaveBeenCalledTimes(2);
+ }
+);
+
+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, 'announceForAccessibilityWithOptions')
+ .mockClear();
+ const { rerender } = await render(
+
+ );
+ expect(announce).not.toHaveBeenCalled();
+
+ await rerender(
+
+ );
+ expect(announce).toHaveBeenCalledTimes(1);
+ expect(announce).toHaveBeenLastCalledWith('Address is required', {
+ queue: true,
+ });
+ }
+);
+
+const makeTwoFieldsInvalid = async () => {
+ const announce = jest
+ .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions')
+ .mockClear();
+ const { rerender } = await render(
+ <>
+
+
+ >
+ );
+
+ await rerender(
+ <>
+
+
+ >
+ );
+
+ return announce;
+};
+
+it.each(['ios', 'android'] as const)(
+ 'announces the error of each field that becomes invalid in the same update on %s',
+ async (platform) => {
+ jest.replaceProperty(Platform, 'OS', platform);
+ 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
+ .spyOn(AccessibilityInfo, 'announceForAccessibilityWithOptions')
+ .mockClear();
+ const { rerender } = await render(
+
+ );
+ expect(screen.queryByRole('alert')).toBeNull();
+
+ await rerender(
+
+ );
+ 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 () => {
+ 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);
+ }
+);
+
+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(
+ }
+ 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 content styles 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', { 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) => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ await render(
+ <>
+ Work address
+
+ >
+ );
+ expect(screen.getByLabelText('Work address')).toHaveProp(
+ attribute,
+ '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..0c60700ebb
--- /dev/null
+++ b/src/components/__tests__/TextInputStates.test.tsx
@@ -0,0 +1,89 @@
+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;
+ hasError?: boolean;
+ isDisabled?: boolean;
+};
+
+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({
+ theme,
+ variant,
+ isFocused: false,
+ hasError: false,
+ isDisabled: false,
+ ...props,
+ })
+ ).toBe(theme.colors[role]);
+ });
+});
+
+it('uses the disabled colors for a disabled invalid field and keeps it invalid on web', async () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ 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);
+});
diff --git a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap
index 6efc467066..92dd090c3f 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,
@@ -137,6 +137,8 @@ exports[`renders filled TextInput with TextInput.Icon accessories 1`] = `