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`] = `