diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 1dec1bb6ec..ed833ce99e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -170,6 +170,10 @@ const config = { TextInputAffix: 'TextInput/Adornment/TextInputAffix', TextInputIcon: 'TextInput/Adornment/TextInputIcon', }, + TextField: { + TextField: 'TextField/TextField', + TextFieldIcon: 'TextField/TextFieldIcon', + }, ToggleButton: { ToggleButton: 'ToggleButton/ToggleButton', ToggleButtonGroup: 'ToggleButton/ToggleButtonGroup', @@ -210,6 +214,8 @@ const config = { 'src/components/TextInput/Adornment/TextInputAffix.tsx', TextInputIcon: 'src/components/TextInput/Adornment/TextInputIcon.tsx', + TextField: 'src/components/TextField/TextField.tsx', + Text: 'src/components/Typography/Text.tsx', showcase: 'docs/src/components/Showcase.tsx', }; diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index 35f5069433..6687256554 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -11,17 +11,27 @@ const typeDefinitions = { 'https://github.com/callstack/react-native-paper/blob/main/src/components/Icon.tsx#L16', ThemeProp: 'https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties', + '(props: TextFieldAccessoryProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L25', + '(props: TextFieldRenderProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L118', AccessibilityState: 'https://reactnative.dev/docs/accessibility#accessibilitystate', 'StyleProp': 'https://reactnative.dev/docs/view-style-props', 'StyleProp': 'https://reactnative.dev/docs/text-style-props', + TextProps: 'https://reactnative.dev/docs/text#props', + AccessibilityProps: + 'https://reactnative.dev/docs/accessibility#accessibilityprops', }; const renderBadge = (annotation: string) => { const [annotType, ...annotLabel] = annotation.split(' '); // eslint-disable-next-line prettier/prettier - return `${annotLabel.join(' ')}`; + return `${annotLabel.join(' ')}`; }; export default function PropTable({ @@ -56,7 +66,9 @@ export default function PropTable({ if (line.includes('@')) { const annotIndex = line.indexOf('@'); // eslint-disable-next-line prettier/prettier - return `${line.substr(0, annotIndex)} ${renderBadge(line.substr(annotIndex))}`; + return `${line.substr(0, annotIndex)} ${renderBadge( + line.substr(annotIndex) + )}`; } else { return line; } diff --git a/docs/src/data/screenshots.js b/docs/src/data/screenshots.js index c1afa99a6a..ed4f3f26c1 100644 --- a/docs/src/data/screenshots.js +++ b/docs/src/data/screenshots.js @@ -154,6 +154,10 @@ const screenshots = { }, 'TextInput.Affix': 'screenshots/textinput-outline.affix.png', 'TextInput.Icon': 'screenshots/textinput-flat.icon.png', + TextField: { + filled: 'screenshots/text-field-filled.png', + outlined: 'screenshots/text-field-outlined.png', + }, ToggleButton: 'screenshots/toggle-button.png', 'ToggleButton.Group': 'screenshots/toggle-button-group.gif', 'ToggleButton.Row': 'screenshots/toggle-button-row.gif', diff --git a/docs/static/screenshots/text-field-filled.png b/docs/static/screenshots/text-field-filled.png new file mode 100644 index 0000000000..03ab10d37e Binary files /dev/null and b/docs/static/screenshots/text-field-filled.png differ diff --git a/docs/static/screenshots/text-field-outlined.png b/docs/static/screenshots/text-field-outlined.png new file mode 100644 index 0000000000..1abb39e072 Binary files /dev/null and b/docs/static/screenshots/text-field-outlined.png differ diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index af6ed7534c..2dc53f93d4 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -43,6 +43,7 @@ import SwitchExample from './Examples/SwitchExample'; import TeamDetails from './Examples/TeamDetails'; import TeamsList from './Examples/TeamsList'; import TextExample from './Examples/TextExample'; +import TextFieldExample from './Examples/TextFieldExample'; import TextInputExample from './Examples/TextInputExample'; import ThemeExample from './Examples/ThemeExample'; import ThemingWithReactNavigation from './Examples/ThemingWithReactNavigation'; @@ -90,6 +91,7 @@ export const mainExamples: Record< switch: SwitchExample, text: TextExample, textInput: TextInputExample, + textField: TextFieldExample, toggleButton: ToggleButtonExample, tooltipExample: TooltipExample, touchableRipple: TouchableRippleExample, diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx new file mode 100644 index 0000000000..00566385bf --- /dev/null +++ b/example/src/Examples/TextFieldExample.tsx @@ -0,0 +1,226 @@ +import * as React from 'react'; +import { + StyleSheet, + TextInput, + View, + type TextStyle, + type ViewStyle, +} from 'react-native'; + +import { + Divider, + List, + Switch, + Text, + TextField, + TouchableRipple, + type TextFieldAccessoryProps, + type TextFieldVariant, +} from 'react-native-paper'; + +import { useExampleTheme } from '../hooks/useExampleTheme'; +import ScreenWrapper from '../ScreenWrapper'; + +type DemoControls = { + error: boolean; + disabled: boolean; + readOnly: boolean; + leadingIcon: boolean; + trailingIcon: boolean; + counter: boolean; + showPrefix: boolean; + showSuffix: boolean; + multiline: boolean; +}; + +type DemoModifiers = { + label: string; + helperText: string; + placeholder: string; + prefix: string; + suffix: string; +}; + +type TextFieldDemoProps = { + variant: TextFieldVariant; +}; + +const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { + const theme = useExampleTheme(); + + const [value, setValue] = React.useState(''); + + const [controls, setControls] = React.useState({ + error: false, + disabled: false, + readOnly: false, + leadingIcon: false, + trailingIcon: false, + counter: false, + showPrefix: false, + showSuffix: false, + multiline: false, + }); + + const [modifiers, setModifiers] = React.useState({ + label: 'Label', + helperText: 'Supporting text', + placeholder: 'Placeholder', + prefix: '$', + suffix: '/100', + }); + + const toggleControl = (key: keyof DemoControls) => + setControls((prev) => ({ ...prev, [key]: !prev[key] })); + + const setModifier = (key: keyof DemoModifiers, text: string) => + setModifiers((prev) => ({ ...prev, [key]: text })); + + const leadingIcon = (props: TextFieldAccessoryProps) => ( + + ); + + const trailingIcon = (props: TextFieldAccessoryProps) => ( + setValue('')} /> + ); + + const inputColor = theme.colors.onSurfaceVariant; + const borderColor = theme.colors.outlineVariant; + + const modifierInputStyle: TextStyle = { + flex: 1, + color: inputColor, + fontSize: 14, + paddingVertical: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: borderColor, + }; + + const SWITCH_CONTROLS: { label: string; key: keyof DemoControls }[] = [ + { label: 'Error', key: 'error' }, + { label: 'Disabled', key: 'disabled' }, + { label: 'Readonly', key: 'readOnly' }, + { label: 'Leading icon', key: 'leadingIcon' }, + { label: 'Trailing icon', key: 'trailingIcon' }, + { label: 'Counter', key: 'counter' }, + { label: 'Prefix', key: 'showPrefix' }, + { label: 'Suffix', key: 'showSuffix' }, + { label: 'Multiline', key: 'multiline' }, + ]; + + const MODIFIER_FIELDS: { label: string; key: keyof DemoModifiers }[] = [ + { label: 'Label', key: 'label' }, + { label: 'Helper', key: 'helperText' }, + { label: 'Placeholder', key: 'placeholder' }, + { label: 'Prefix', key: 'prefix' }, + { label: 'Suffix', key: 'suffix' }, + ]; + + return ( + + {/* Live TextField */} + + + + + {/* Controls */} + Controls + {SWITCH_CONTROLS.map(({ label, key }) => ( + toggleControl(key)}> + + {label} + + + + + + ))} + + + + {/* Modifiers */} + Modifiers + {MODIFIER_FIELDS.map(({ label, key }) => ( + + + {label} + + setModifier(key, text)} + style={modifierInputStyle} + placeholderTextColor={theme.colors.outline} + placeholder={`Enter ${label.toLowerCase()}…`} + /> + + ))} + + ); +}; + +const TextFieldExample = () => { + return ( + + + + + + + + + ); +}; + +TextFieldExample.title = 'TextField'; + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingVertical: 8, + } satisfies ViewStyle, + demoContainer: { + gap: 4, + } satisfies ViewStyle, + divider: { + marginVertical: 8, + } satisfies ViewStyle, + subheader: { + paddingHorizontal: 0, + } satisfies TextStyle, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 8, + } satisfies ViewStyle, + modifierRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 8, + paddingHorizontal: 8, + } satisfies ViewStyle, + modifierLabel: { + width: 80, + } satisfies TextStyle, +}); + +export default TextFieldExample; diff --git a/jest/testSetup.js b/jest/testSetup.js index 5088ab5585..207c532a7b 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -14,17 +14,14 @@ jest.mock('@react-native-vector-icons/material-design-icons', () => { const MockIcon = ({ name, color, size, style, ...props }) => { return ( - + {name || '□'} ); }; MockIcon.displayName = 'MockedMaterialDesignIcon'; - + return { __esModule: true, default: MockIcon, diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx new file mode 100644 index 0000000000..c163235dfd --- /dev/null +++ b/src/components/TextField/TextField.tsx @@ -0,0 +1,392 @@ +import React from 'react'; +import { + AccessibilityProps, + BlurEvent, + ColorValue, + FocusEvent, + Pressable, + StyleProp, + Text, + TextInput, + TextInputProps, + TextStyle, + View, + ViewStyle, +} from 'react-native'; + +import Animated, { AnimatedStyle } from 'react-native-reanimated'; + +import { useTextField } from './hooks'; +import { styles } from './styles'; +import TextFieldErrorIcon from './TextFieldErrorIcon'; +import type { InternalTheme, ThemeProp } from '../../types'; + +export type GetAccessibilityDataReturn = { + input: AccessibilityProps; + supportingText: AccessibilityProps; + counter: AccessibilityProps; +}; + +export type GetAccessibilityDataProps = { + data: TextFieldProps; + hasError: boolean; + hasCounter: boolean; + isDisabled: boolean; +}; + +export type TextFieldVariant = 'filled' | 'outlined'; + +export type TextFieldAccessoryProps = { + style: StyleProp; + multiline: boolean; + disabled: boolean; + error: boolean; +}; + +export type TextFieldSharedApi = { + input: React.RefObject; + theme: InternalTheme; + isFocused: boolean; + isRTL: boolean; + isDisabled: boolean; + hasAccessory: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyle: StyleProp>>; + animatedLabelTextStyle: StyleProp>>; + animatedActiveOutlineStyle?: StyleProp>>; +}; + +export type SharedTextFieldStyleData = { + isRTL: boolean; + animatedLabelTextStyles: StyleProp>>; + supportingTextStyles: StyleProp; + counterStyles: StyleProp; + prefixStyles: StyleProp; + suffixStyles: StyleProp; + leadingAccessoryStyles: StyleProp; + trailingAccessoryStyles: StyleProp; +}; + +export type FilledTextFieldHookData = SharedTextFieldStyleData & { + input: React.RefObject; + isDisabled: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: StyleProp | undefined; + outlineStyles: StyleProp; + animatedActiveOutlineStyles: StyleProp>>; + inputStyles: StyleProp; +}; + +export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { + input: React.RefObject; + isDisabled: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: undefined; + outlineStyles: StyleProp; + inputStyles: StyleProp; +}; + +export type TextFieldHookReturn = SharedTextFieldStyleData & { + input: React.RefObject; + isDisabled: boolean; + isEditable: boolean | undefined; + hasPrefix: boolean; + hasCounter: boolean; + hasSuffix: boolean; + hasError: boolean; + placeholderTextColor: ColorValue; + selectionColor: ColorValue; + cursorColor: ColorValue; + animatedActiveOutlineStyles: + | StyleProp>> + | undefined; + animatedContainerStyle: StyleProp>>; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: StyleProp | undefined; + outlineStyles: StyleProp; + inputStyles: StyleProp; + placeholder: string | undefined; + counterText: string; + accessibilityProps: GetAccessibilityDataReturn; + renderLeadingAccessory: + | ((props: TextFieldAccessoryProps) => React.ReactNode) + | undefined; + renderTrailingAccessory: + | ((props: TextFieldAccessoryProps) => React.ReactNode) + | undefined; + onFocusHandler: (e: FocusEvent) => void; + onBlurHandler: (e: BlurEvent) => void; + focusInput: () => void; +}; + +export type TextFieldRenderProps = React.ComponentPropsWithRef< + typeof TextInput +>; + +export type TextFieldProps = TextInputProps & { + /** + * Ref forwarded to the underlying TextInput. + */ + ref?: React.Ref; + /** + * - `filled` text fields are often used in dialogs and short forms where their style draws more attention. + * - `outlined` text fields are often used in long forms where their reduced emphasis helps simplify the layout. + */ + variant?: TextFieldVariant; + /** + * When `true`, the field uses error styling and replaces the trailing accessory + * with an error indicator when no `endAccessory` is provided. + */ + error?: boolean; + /** + * The label text to display above the input. + */ + label?: string; + /** + * Supporting text to display below the input (Material Design 3). + */ + supportingText?: string; + /** + * When `true`, displays a character counter below the input on the trailing + * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. + */ + counter?: boolean; + /** + * This is separate from `editable={false}`, which makes the text read-only while the + * input can still be focused and text selected. + */ + disabled?: boolean; + /** + * A short text string displayed at the start of the input (e.g. `"$"`). + */ + prefix?: string; + /** + * A short text string displayed at the end of the input (e.g. `"/100"`). + */ + suffix?: string; + theme?: ThemeProp; + /** + * An optional component to render on the start side of the input (leading in LTR). + * Can be a custom component or `TextField.Icon`. + */ + startAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; + /** + * An optional component to render on the end side of the input (trailing in LTR). + * Can be a custom component or `TextField.Icon`. + */ + endAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; + /** + * Callback to render a custom input component in place of the native `TextInput`. + * Receives all props that would be passed to `TextInput`, allowing integration + * with third-party inputs such as masked inputs. + */ + render?: (props: TextFieldRenderProps) => React.ReactNode; +}; + +const DefaultRenderer = (props: TextFieldRenderProps) => ( + +); + +/** + * A text field lets users enter and edit text. It shows an optional floating label, + * supports `filled` and `outlined` variants, optional supporting text (including + * error state), and start/end accessories. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { TextField } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [text, setText] = React.useState(''); + * + * const searchAccessory = (accessoryProps) => ( + * + * ); + * + * const clearAccessory = ({ style, disabled }) => ( + * setText('')} + * role="button" + * aria-label="Clear text" + * > + * + * + * ); + * + * return ( + * + * ); + * }; + * + * export default MyComponent; + * ``` + * + * @extends TextInput props https://reactnative.dev/docs/textinput#props + */ +function TextField(props: TextFieldProps) { + /* eslint-disable @typescript-eslint/no-unused-vars -- peel TextField-only props before TextInput spread */ + const { + ref, + error, + label, + supportingText, + variant, + theme, + prefix, + suffix, + counter, + disabled, + startAccessory, + endAccessory, + render = DefaultRenderer, + ...textInputProps + } = props; + + const { + input, + isDisabled, + isEditable, + hasPrefix, + hasSuffix, + hasCounter, + hasError, + leadingAccessoryStyles, + trailingAccessoryStyles, + fieldStyles, + disabledBackgroundStyles, + outlineStyles, + animatedActiveOutlineStyles, + animatedLabelWrapperStyles, + animatedLabelTextStyles, + animatedContainerStyle, + containerStyles, + inputStyles, + prefixStyles, + suffixStyles, + supportingTextStyles, + counterStyles, + placeholderTextColor, + selectionColor, + cursorColor, + placeholder, + counterText, + accessibilityProps, + renderLeadingAccessory, + renderTrailingAccessory, + focusInput, + onFocusHandler, + onBlurHandler, + } = useTextField(props); + + return ( + + + {/* 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 + with PlatformColor on Android. */} + {!!disabledBackgroundStyles && ( + + )} + + {/* Inactive indicator — always-visible 1px bottom border (filled) or + full border (outlined); height and color reflect error/disabled state + but do not change on focus */} + + + {/* Active indicator — filled variant only; 2px bar that expands from + the center outward via scaleX (0 → 1) on focus and collapses on blur */} + {!!animatedActiveOutlineStyles && ( + + )} + + {!!label && ( + + + {label} + + + )} + + {renderLeadingAccessory + ? renderLeadingAccessory({ + style: leadingAccessoryStyles, + error: hasError, + disabled: isDisabled, + multiline: !!textInputProps.multiline, + }) + : null} + + + {hasPrefix && {prefix}} + + {render({ + ref: input, + selectionColor, + cursorColor, + placeholderTextColor, + ...accessibilityProps.input, + onFocus: onFocusHandler, + onBlur: onBlurHandler, + ...textInputProps, + editable: isEditable, + placeholder, + style: inputStyles, + })} + + {hasSuffix && {suffix}} + + + {renderTrailingAccessory ? ( + renderTrailingAccessory({ + style: trailingAccessoryStyles, + error: hasError, + disabled: isDisabled, + multiline: !!textInputProps.multiline, + }) + ) : hasError ? ( + + ) : null} + + + + {!!supportingText && ( + + {supportingText} + + )} + + {hasCounter && ( + + {counterText} + + )} + + + ); +} + +export default TextField; diff --git a/src/components/TextField/TextFieldErrorIcon.tsx b/src/components/TextField/TextFieldErrorIcon.tsx new file mode 100644 index 0000000000..bb9974f768 --- /dev/null +++ b/src/components/TextField/TextFieldErrorIcon.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleProp, View, ViewStyle } from 'react-native'; + +import { ACCESSORY_SIZE } from './constants'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import Icon from '../Icon'; + +interface TextFieldErrorIconProps { + style?: StyleProp; + theme?: ThemeProp; +} + +const TextFieldErrorIcon = ({ + style: wrapperStyle, + theme: themeOverride, +}: TextFieldErrorIconProps) => { + const theme = useInternalTheme(themeOverride); + + return ( + + + + ); +}; + +export default TextFieldErrorIcon; diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx new file mode 100644 index 0000000000..e9b7448e63 --- /dev/null +++ b/src/components/TextField/TextFieldIcon.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { ACCESSORY_SIZE } from './constants'; +import { styles } from './styles'; +import type { TextFieldAccessoryProps } from './TextField'; +import { getIconColor } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import IconButton, { + type Props as IconButtonProps, +} from '../IconButton/IconButton'; + +export type TextFieldIconProps = TextFieldAccessoryProps & + Omit; + +/** + * A component to render a leading / trailing icon in the TextField + * (return it from `startAccessory` or `endAccessory`). Accepts icon-specific props as well as + * `TextFieldAccessoryProps`, which TextField passes into those render props. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { TextField } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [text, setText] = React.useState(''); + * + * const searchAccessory = (props) => ( + * + * ); + * + * const clearAccessory = (props) => ( + * setText('')} /> + * ); + * + * return ( + * + * ); + * }; + * + * export default MyComponent; + * ``` + */ +const TextFieldIcon = ({ + icon, + iconColor, + size, + style, + error, + disabled, + theme: themeOverride, + onPress, + ...rest +}: TextFieldIconProps) => { + const theme = useInternalTheme(themeOverride); + + const iconSize = size ?? ACCESSORY_SIZE; + + const color = getIconColor({ + theme, + iconColor, + hasError: error, + isDisabled: disabled, + }); + + const onPressHandler = disabled ? undefined : onPress; + + return ( + + + + ); +}; + +TextFieldIcon.displayName = 'TextField.Icon'; + +export default TextFieldIcon; diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts new file mode 100644 index 0000000000..c1f5534b17 --- /dev/null +++ b/src/components/TextField/constants.ts @@ -0,0 +1,130 @@ +import { PixelRatio } from 'react-native'; + +import { tokens } from '../../theme/tokens'; +import { motionDuration } from '../../theme/tokens/sys/motion'; +import { defaultShapes } from '../../theme/tokens/sys/shape'; + +export const fontScale = PixelRatio.getFontScale(); + +/** + * Common constants for the text field component. + */ + +export const BASELINE_TEXT_FIELD_HEIGHT = 56; +export const BASELINE_TEXT_FIELD_PADDING_VERTICAL = 8; + +export const TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL = 16; +export const TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL = 12; + +export const TEXT_FIELD_HEIGHT = Math.ceil( + BASELINE_TEXT_FIELD_HEIGHT * fontScale +); +export const TEXT_FIELD_PADDING_VERTICAL = Math.ceil( + BASELINE_TEXT_FIELD_PADDING_VERTICAL * fontScale +); + +export const TEXT_FIELD_BORDER_RADIUS = defaultShapes.corner.extraSmall; + +export const LABEL_START_OFFSET_WITHOUT_ACCESSORY = + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const ACCESSORY_SIZE = 24; + +export const PREFIX_END_PADDING = 2; +export const SUFFIX_START_PADDING = 2; + +export const ERROR_ICON_SIZE = 16; + +export const INPUT_FONT_SIZE = tokens.md.sys.typescale.bodyLarge.fontSize; +export const ACTIVE_LABEL_FONT_SIZE = + tokens.md.sys.typescale.bodySmall.fontSize; +export const INACTIVE_LABEL_FONT_SIZE = INPUT_FONT_SIZE; +export const SUPPORTING_TEXT_FONT_SIZE = + tokens.md.sys.typescale.bodySmall.fontSize; + +export const SUPPORTING_TEXT_MARGIN_TOP = 4; + +export const ANIMATION_DURATION_MS = motionDuration.short3; + +export const ACTIVE_INDICATOR_SIZE = 2; +export const INACTIVE_INDICATOR_SIZE = 1; + +/** + * Constants for the filled variant. + */ + +const FILLED_LINE_HEIGHT_DELTA = 3; + +export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; + +export const FILLED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL) * + fontScale +); + +export const FILLED_MULTILINE_PADDING_TOP = + Math.ceil(ACTIVE_LABEL_FONT_SIZE * fontScale) + TEXT_FIELD_PADDING_VERTICAL; + +export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; + +export const FILLED_PADDING_BOTTOM = + TEXT_FIELD_PADDING_VERTICAL + FILLED_LINE_HEIGHT_DELTA; + +/** + * Constants for the outlined variant. + */ + +const OUTLINED_LINE_HEIGHT_DELTA = 2; + +export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; + +export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 - + OUTLINED_LINE_HEIGHT_DELTA) * + fontScale +); + +export const OUTLINED_LABEL_PADDING_HORIZONTAL = 4; + +export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + OUTLINED_LABEL_PADDING_HORIZONTAL; + +export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = Math.ceil( + (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + OUTLINED_LINE_HEIGHT_DELTA) * + fontScale +); + +export const OUTLINED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL - + OUTLINED_LINE_HEIGHT_DELTA) * + fontScale +); + +/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ +export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + OUTLINED_LABEL_PADDING_HORIZONTAL; + +/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ +export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY = + OUTLINED_LABEL_PADDING_HORIZONTAL; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts new file mode 100644 index 0000000000..1072111451 --- /dev/null +++ b/src/components/TextField/hooks.ts @@ -0,0 +1,182 @@ +import { useImperativeHandle, useRef, useState } from 'react'; +import { BlurEvent, FocusEvent, TextInput } from 'react-native'; + +import type { + TextFieldHookReturn, + TextFieldProps, + TextFieldSharedApi, +} from './TextField'; +import { + getAccentColors, + getAccessibilityData, + getFilledTextFieldData, + getOutlinedTextFieldData, + getTextFieldAnimation, +} from './utils'; +import { useLocale } from '../../core/locale'; +import { useInternalTheme } from '../../core/theming'; + +export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { + const { + ref, + variant = 'filled', + theme: themeOverride, + onFocus, + onBlur, + } = props; + + /** + * Hooks + */ + + const input = useRef(null); + + const theme = useInternalTheme(themeOverride); + + const { direction } = useLocale(); + + const [isFocused, setIsFocused] = useState(false); + + useImperativeHandle(ref, () => input.current as TextInput); + + /** + * Constants + */ + + const isRTL = direction === 'rtl'; + const isDisabled = !!props.disabled; + const isEditable = props.disabled ? false : props.editable; + const isFloating = isFocused || !!props.value; + const hasError = !!props.error; + const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; + const hasPrefix = !!props.prefix && isFloating; + const hasSuffix = !!props.suffix && isFloating; + const hasCounter = !!(props.counter && props.maxLength); + + /** + * Theme tokens + */ + + const { selectionColor, cursorColor } = getAccentColors({ + theme, + hasError, + }); + + const placeholderTextColor = + props.placeholderTextColor ?? theme.colors.onSurfaceVariant; + + /** + * Label animation + */ + + const { + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + animatedContainerStyle, + } = getTextFieldAnimation({ + variant, + isFloating, + isFocused, + isRTL, + hasAccessory, + }); + + /** + * Handlers + */ + + const onFocusHandler = (e: FocusEvent) => { + onFocus?.(e); + setIsFocused(true); + }; + + const onBlurHandler = (e: BlurEvent) => { + onBlur?.(e); + setIsFocused(false); + }; + + const focusInput = () => { + if (isDisabled) return; + input.current?.focus(); + }; + + /** + * Shared API + */ + + const api: TextFieldSharedApi = { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }; + + /** + * Components + */ + + const renderLeadingAccessory = isRTL + ? props.endAccessory + : props.startAccessory; + const renderTrailingAccessory = isRTL + ? props.startAccessory + : props.endAccessory; + // https://github.com/facebook/react-native/issues/31573 + const placeholder = isFocused ? props.placeholder : ' '; + const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; + + /** + * Accessibility + */ + + const accessibilityProps = getAccessibilityData({ + hasError, + hasCounter, + isDisabled, + data: props, + }); + + /** + * Styles + */ + + const data = { + isEditable, + isDisabled, + hasPrefix, + hasCounter, + placeholderTextColor, + selectionColor, + cursorColor, + animatedActiveOutlineStyles: undefined, + animatedContainerStyle, + placeholder, + counterText, + accessibilityProps, + renderLeadingAccessory, + renderTrailingAccessory, + onFocusHandler, + onBlurHandler, + focusInput, + }; + + if (variant === 'filled') { + return { + ...data, + ...getFilledTextFieldData(api, props), + }; + } + + return { + ...data, + ...getOutlinedTextFieldData(api, props), + }; +}; diff --git a/src/components/TextField/index.ts b/src/components/TextField/index.ts new file mode 100644 index 0000000000..097b9b6980 --- /dev/null +++ b/src/components/TextField/index.ts @@ -0,0 +1,13 @@ +import TextFieldComponent from './TextField'; +import TextFieldIcon from './TextFieldIcon'; + +const TextField = Object.assign( + // @component ./TextField.tsx + TextFieldComponent, + { + // @component ./TextFieldIcon.tsx + Icon: TextFieldIcon, + } +); + +export default TextField; diff --git a/src/components/TextField/styles.ts b/src/components/TextField/styles.ts new file mode 100644 index 0000000000..c7cc6d3c2c --- /dev/null +++ b/src/components/TextField/styles.ts @@ -0,0 +1,128 @@ +import { StyleSheet } from 'react-native'; + +import { + ACCESSORY_SIZE, + FILLED_DISABLED_CONTAINER_OPACITY, + OUTLINED_LABEL_PADDING_HORIZONTAL, + SUPPORTING_TEXT_FONT_SIZE, + SUPPORTING_TEXT_MARGIN_TOP, + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_FIELD_BORDER_RADIUS, + TEXT_FIELD_HEIGHT, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_FIELD_PADDING_VERTICAL, +} from './constants'; +import { tokens } from '../../theme/tokens'; + +const { bodyLarge, bodySmall } = tokens.md.sys.typescale; + +export const styles = StyleSheet.create({ + input: { + paddingVertical: 0, + paddingHorizontal: 0, + includeFontPadding: false, + fontWeight: bodyLarge.fontWeight, + }, + field: { + flexDirection: 'row', + minHeight: TEXT_FIELD_HEIGHT, + paddingVertical: TEXT_FIELD_PADDING_VERTICAL, + }, + addendum: { + flexDirection: 'row', + }, + supportingText: { + flex: 1, + marginTop: SUPPORTING_TEXT_MARGIN_TOP, + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: SUPPORTING_TEXT_FONT_SIZE, + fontWeight: bodySmall.fontWeight, + textAlign: 'left', + }, + counter: { + marginTop: SUPPORTING_TEXT_MARGIN_TOP, + marginStart: 'auto', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: SUPPORTING_TEXT_FONT_SIZE, + fontWeight: bodySmall.fontWeight, + textAlign: 'right', + }, + trailingAccessory: { + width: ACCESSORY_SIZE, + height: ACCESSORY_SIZE, + alignSelf: 'center', + justifyContent: 'center', + alignItems: 'center', + marginEnd: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + }, + leadingAccessory: { + width: ACCESSORY_SIZE, + height: ACCESSORY_SIZE, + alignSelf: 'center', + justifyContent: 'center', + alignItems: 'center', + marginStart: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + }, + disabled: { + opacity: tokens.md.ref.stateOpacity.disabled, + }, + iconWrapper: { + justifyContent: 'center', + alignItems: 'center', + }, + icon: { + margin: 0, + }, +}); + +export const filledStyles = StyleSheet.create({ + outline: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + pointerEvents: 'none', + }, + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'flex-end', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + }, + labelWrapper: { + position: 'absolute', + pointerEvents: 'none', + }, + disabledBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: FILLED_DISABLED_CONTAINER_OPACITY, + pointerEvents: 'none', + }, +}); + +export const outlinedStyles = StyleSheet.create({ + outline: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: TEXT_FIELD_BORDER_RADIUS, + pointerEvents: 'none', + }, + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + }, + labelWrapper: { + position: 'absolute', + paddingHorizontal: OUTLINED_LABEL_PADDING_HORIZONTAL, + pointerEvents: 'none', + }, +}); diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts new file mode 100644 index 0000000000..3f00fd24b9 --- /dev/null +++ b/src/components/TextField/utils.ts @@ -0,0 +1,699 @@ +import { Platform, StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { AnimatedStyle } from 'react-native-reanimated'; + +import { + ACTIVE_INDICATOR_SIZE, + ACTIVE_LABEL_FONT_SIZE, + ANIMATION_DURATION_MS, + FILLED_ACTIVE_LABEL_TOP_POSITION, + FILLED_LABEL_START_OFFSET_WITH_ACCESSORY, + FILLED_MULTILINE_PADDING_TOP, + INACTIVE_INDICATOR_SIZE, + INACTIVE_LABEL_FONT_SIZE, + FILLED_INACTIVE_LABEL_TOP_POSITION, + OUTLINED_INACTIVE_LABEL_TOP_POSITION, + INPUT_FONT_SIZE, + LABEL_START_OFFSET_WITHOUT_ACCESSORY, + OUTLINED_ACTIVE_LABEL_TOP_POSITION, + OUTLINED_DISABLED_OUTLINE_OPACITY, + OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY, + OUTLINED_MULTILINE_PADDING_TOP, + PREFIX_END_PADDING, + SUFFIX_START_PADDING, + TEXT_FIELD_BORDER_RADIUS, + FILLED_PADDING_BOTTOM, +} from './constants'; +import { filledStyles, outlinedStyles, styles } from './styles'; +import type { + FilledTextFieldHookData, + OutlinedTextFieldHookData, + TextFieldProps, + TextFieldSharedApi, + SharedTextFieldStyleData, + GetAccessibilityDataProps, + GetAccessibilityDataReturn, +} from './TextField'; +import type { InternalTheme } from '../../types'; + +export const getAccentColors = ({ + theme, + hasError, +}: { + theme: InternalTheme; + hasError: boolean; +}) => { + const color = hasError ? theme.colors.error : theme.colors.primary; + + return { + selectionColor: color, + cursorColor: color, + }; +}; + +export const getLabelColor = ({ + theme, + hasError, + isFocused, + isDisabled, +}: { + theme: InternalTheme; + isFocused: boolean; + hasError: boolean; + isDisabled: boolean; +}) => { + const { + colors: { error, primary, onSurface, onSurfaceVariant }, + } = theme; + + if (hasError) { + return error; + } + if (isDisabled) { + return onSurface; + } + if (isFocused) { + return primary; + } + return onSurfaceVariant; +}; + +export const getSupportingTextColor = ({ + theme, + hasError, + isDisabled, +}: { + theme: InternalTheme; + hasError: boolean; + isDisabled: boolean; +}) => { + const { + colors: { error, onSurface, onSurfaceVariant }, + } = theme; + + if (hasError) { + return error; + } + if (isDisabled) { + return onSurface; + } + return onSurfaceVariant; +}; + +/** + * Returns the solid background color for the filled field container, or + * `undefined` when disabled. The disabled tint is rendered + * as a separate overlay View whose alpha is applied via the `opacity` style; + * keeping the alpha out of the color string is what makes the component safe + * to use with `PlatformColor` values on Android. + */ +export const getFieldBackgroundColor = ({ + theme, + isDisabled, +}: { + theme: InternalTheme; + isDisabled: boolean; +}): string | undefined => { + if (isDisabled) { + return undefined; + } + + return theme.colors.surfaceContainerHighest; +}; + +export const getIconColor = ({ + theme, + iconColor, + hasError, + isDisabled, +}: { + theme: InternalTheme; + iconColor?: string; + hasError: boolean; + isDisabled: boolean; +}) => { + if (iconColor) return iconColor; + if (hasError) return theme.colors.error; + if (isDisabled) return theme.colors.onSurface; + 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. + */ +export const getOutlineColor = ({ + theme, + hasError, + isFocused, + isDisabled, +}: { + theme: InternalTheme; + isFocused: boolean; + hasError: boolean; + isDisabled: boolean; +}) => { + const { + colors: { error, onSurface, primary, outline }, + } = theme; + + if (hasError) { + return error; + } + if (isDisabled) { + return onSurface; + } + if (isFocused) { + return primary; + } + + return outline; +}; + +/** + * Computes the style arrays that are identical across the filled and outlined + * variants. Each variant logic function calls this and then only computes its + * own variant-specific styles on top. + * + * Returns `isRTL` as well so callers can use it when building `inputStyles`, + * which is variant-specific (filled adds `MULTILINE_PADDING_TOP`). + */ +export const getSharedTextFieldStyleData = ( + api: TextFieldSharedApi +): SharedTextFieldStyleData => { + const { + theme, + isDisabled, + hasError, + isFocused, + isRTL, + animatedLabelTextStyle, + } = api; + + const labelColor = getLabelColor({ theme, hasError, isFocused, isDisabled }); + + const supportingTextColor = getSupportingTextColor({ + theme, + hasError, + isDisabled, + }); + const { + colors: { onSurfaceVariant }, + } = theme; + + const animatedLabelTextStyles: StyleProp< + AnimatedStyle> + > = [ + styles.input, + { color: labelColor }, + animatedLabelTextStyle, + isDisabled && styles.disabled, + ]; + + const supportingTextStyles: StyleProp = [ + styles.supportingText, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + isDisabled && styles.disabled, + ]; + + const counterStyles: StyleProp = [ + styles.counter, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + isDisabled && styles.disabled, + ]; + + const prefixStyles: StyleProp = [ + styles.input, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingEnd: PREFIX_END_PADDING, + }, + isDisabled && styles.disabled, + ]; + + const suffixStyles: StyleProp = [ + styles.input, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingStart: SUFFIX_START_PADDING, + }, + isDisabled && styles.disabled, + ]; + + const leadingAccessoryStyles: StyleProp = [ + styles.leadingAccessory, + isDisabled && styles.disabled, + ]; + + const trailingAccessoryStyles: StyleProp = [ + styles.trailingAccessory, + isDisabled && styles.disabled, + ]; + + return { + isRTL, + animatedLabelTextStyles, + supportingTextStyles, + counterStyles, + prefixStyles, + suffixStyles, + leadingAccessoryStyles, + trailingAccessoryStyles, + }; +}; + +export const getTextFieldAnimation = ({ + variant, + isFloating, + isFocused, + isRTL, + hasAccessory, +}: { + variant: 'filled' | 'outlined'; + isFloating: boolean; + isFocused: boolean; + isRTL: boolean; + hasAccessory: boolean; +}): { + animatedLabelWrapperStyle: StyleProp>>; + animatedLabelTextStyle: StyleProp>>; + animatedContainerStyle: StyleProp>>; + animatedActiveOutlineStyle?: StyleProp>>; +} => { + const activeTop = + variant === 'filled' + ? FILLED_ACTIVE_LABEL_TOP_POSITION + : OUTLINED_ACTIVE_LABEL_TOP_POSITION; + + const inactiveTop = + variant === 'filled' + ? FILLED_INACTIVE_LABEL_TOP_POSITION + : OUTLINED_INACTIVE_LABEL_TOP_POSITION; + + const top = isFloating ? activeTop : inactiveTop; + const fontSize = isFloating + ? ACTIVE_LABEL_FONT_SIZE + : INACTIVE_LABEL_FONT_SIZE; + + const animatedContainerStyle: StyleProp>> = + { + opacity: isFloating ? 1 : 0, + transitionProperty: 'opacity', + transitionDuration: ANIMATION_DURATION_MS, + }; + + if (variant === 'filled') { + return { + animatedLabelWrapperStyle: { + top, + transitionProperty: 'top', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedLabelTextStyle: { + fontSize, + transitionProperty: 'fontSize', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedActiveOutlineStyle: { + transform: [{ scaleX: isFocused ? 1 : 0 }], + transitionProperty: 'transform', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedContainerStyle, + }; + } + + const distance = hasAccessory + ? OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY + : OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY; + const translateXEnd = (isRTL ? 1 : -1) * distance; + + return { + animatedLabelWrapperStyle: { + top, + transform: [{ translateX: isFloating ? translateXEnd : 0 }], + transitionProperty: ['top', 'transform'], + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedLabelTextStyle: { + fontSize, + transitionProperty: 'fontSize', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedContainerStyle, + }; +}; + +export const getFilledTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): FilledTextFieldHookData => { + const { style: inputStyleOverride, ...textInputProps } = props; + + const { + input, + theme, + hasSuffix, + isDisabled, + hasAccessory, + hasError, + animatedLabelWrapperStyle, + animatedActiveOutlineStyle, + } = api; + + /** + * Theme tokens + */ + const { + colors: { onSurface }, + } = theme; + + const outlineColor = getOutlineColor({ + theme, + hasError, + isFocused: false, + isDisabled, + }); + + const activeOutlineColor = getOutlineColor({ + theme, + hasError, + isFocused: true, + isDisabled, + }); + + const fieldBackgroundColor = getFieldBackgroundColor({ theme, isDisabled }); + + /** + * Shared styles + */ + + const shared = getSharedTextFieldStyleData(api); + + /** + * Variant-specific styles + */ + + const animatedLabelWrapperStyles: StyleProp< + AnimatedStyle> + > = [ + filledStyles.labelWrapper, + { + left: hasAccessory + ? FILLED_LABEL_START_OFFSET_WITH_ACCESSORY + : LABEL_START_OFFSET_WITHOUT_ACCESSORY, + }, + animatedLabelWrapperStyle, + ]; + + const containerStyles: StyleProp = [ + filledStyles.container, + isDisabled && styles.disabled, + ]; + + const fieldStyles: StyleProp = [ + styles.field, + { + paddingBottom: FILLED_PADDING_BOTTOM, + backgroundColor: fieldBackgroundColor, + borderTopStartRadius: TEXT_FIELD_BORDER_RADIUS, + borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, + overflow: 'hidden', + }, + ]; + + /* Disabled tint (DISABLED_CONTAINER_OPACITY) is rendered as a childless overlay so its + alpha can be applied via the `opacity` style without leaking onto the label + and input. The View accepts `PlatformColor` directly. */ + const disabledBackgroundStyles: StyleProp | undefined = isDisabled + ? [ + filledStyles.disabledBackground, + { + backgroundColor: onSurface, + }, + ] + : undefined; + + const outlineStyles: StyleProp = [ + filledStyles.outline, + { + height: INACTIVE_INDICATOR_SIZE, + backgroundColor: outlineColor, + }, + isDisabled && styles.disabled, + ]; + + const animatedActiveOutlineStyles: StyleProp< + AnimatedStyle> + > = [ + filledStyles.outline, + { + height: ACTIVE_INDICATOR_SIZE, + backgroundColor: activeOutlineColor, + }, + isDisabled && styles.disabled, + animatedActiveOutlineStyle, + ]; + + const inputStyles: StyleProp = [ + styles.input, + { + flex: 1, + color: onSurface, + fontSize: INPUT_FONT_SIZE, + textAlign: hasSuffix === shared.isRTL ? 'left' : 'right', + writingDirection: shared.isRTL ? 'rtl' : 'ltr', + }, + textInputProps.multiline && { + height: 'auto', + paddingTop: FILLED_MULTILINE_PADDING_TOP, + }, + //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that + Platform.OS === 'web' && { + outlineStyle: 'none' as const, + }, + isDisabled && styles.disabled, + inputStyleOverride, + ]; + + return { + input, + isDisabled, + hasError, + hasSuffix, + animatedLabelWrapperStyles, + containerStyles, + fieldStyles, + disabledBackgroundStyles, + outlineStyles, + animatedActiveOutlineStyles, + inputStyles, + ...shared, + }; +}; + +export const getOutlinedTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): OutlinedTextFieldHookData => { + const { style: inputStyleOverride, ...textInputProps } = props; + + const { + input, + theme, + isFocused, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + } = api; + + /** + * Theme tokens + */ + + const { + colors: { background: labelBackgroundColor, onSurface }, + } = theme; + + const outlineColor = getOutlineColor({ + theme, + isDisabled, + isFocused, + hasError, + }); + + /** + * Shared styles + */ + + const shared = getSharedTextFieldStyleData(api); + + /** + * Variant-specific styles + */ + + const containerStyles: StyleProp = [ + outlinedStyles.container, + isDisabled && styles.disabled, + ]; + + const fieldStyles: StyleProp = [ + styles.field, + { + borderRadius: TEXT_FIELD_BORDER_RADIUS, + }, + textInputProps.multiline && { alignItems: 'flex-start' }, + ]; + + /* The outline is a childless absolutely-positioned View, so applying + `opacity` here is safe and lets us pass `outlineColor` through unchanged + (including PlatformColor values on Android). */ + const outlineStyles: StyleProp = [ + outlinedStyles.outline, + { + borderWidth: isFocused ? 2 : 1, + borderColor: outlineColor, + }, + isDisabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, + ]; + + const animatedLabelWrapperStyles: StyleProp< + AnimatedStyle> + > = [ + outlinedStyles.labelWrapper, + { + left: hasAccessory + ? OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY + : LABEL_START_OFFSET_WITHOUT_ACCESSORY, + backgroundColor: labelBackgroundColor, + }, + animatedLabelWrapperStyle, + ]; + + const inputStyles: StyleProp = [ + styles.input, + { + flex: 1, + color: onSurface, + fontSize: INPUT_FONT_SIZE, + textAlign: hasSuffix === shared.isRTL ? 'left' : 'right', + writingDirection: shared.isRTL ? 'rtl' : 'ltr', + }, + textInputProps.multiline && { + height: 'auto', + textAlignVertical: 'top', + paddingTop: OUTLINED_MULTILINE_PADDING_TOP, + }, + //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that + Platform.OS === 'web' && { + outlineStyle: 'none' as const, + }, + isDisabled && styles.disabled, + inputStyleOverride, + ]; + + return { + input, + isDisabled, + hasError, + hasSuffix, + animatedLabelWrapperStyles, + containerStyles, + fieldStyles, + disabledBackgroundStyles: undefined, + outlineStyles, + inputStyles, + ...shared, + }; +}; + +export const getAccessibilityData = ({ + data, + hasError, + hasCounter, + isDisabled, +}: GetAccessibilityDataProps): GetAccessibilityDataReturn => { + const { label, supportingText, ...props } = data; + + let textLength = 0; + + if (props.value) { + textLength = props.value.length; + } else if (props.defaultValue) { + textLength = props.defaultValue.length; + } + + const maxLength = props.maxLength; + const shouldEvaluateCounter = !!maxLength && hasCounter; + const isEmptyString = textLength === 0; + const isCounterExceeded = shouldEvaluateCounter && textLength > maxLength; + const isCounterReached = shouldEvaluateCounter && textLength === 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 ${textLength} of ${maxLength}`; + } + + const counterAccessibilityLabel = shouldEvaluateCounter + ? isCounterExceeded + ? `Character limit exceeded ${textLength} of ${maxLength}` + : `Characters entered ${textLength} of ${maxLength}` + : undefined; + + const accessibilityState = { + disabled: isDisabled, + invalid: isInvalid, + ...props.accessibilityState, + } as const; + + return { + input: { + 'aria-label': ariaLabel, + 'aria-valuemax': isCounterReached ? maxLength : undefined, + 'aria-valuenow': isCounterReached ? textLength : undefined, + accessibilityHint: hint, + accessibilityState, + }, + supportingText: { + 'aria-hidden': isSupportingTextHidden, + 'aria-live': hasError && supportingText ? 'polite' : undefined, + }, + counter: { + 'aria-label': counterAccessibilityLabel, + 'aria-live': 'polite', + }, + }; +}; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx new file mode 100644 index 0000000000..5b8f9acbb3 --- /dev/null +++ b/src/components/__tests__/TextField.test.tsx @@ -0,0 +1,1113 @@ +import * as React from 'react'; +import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; + +import { fireEvent, render } from '../../test-utils'; +import { tokens } from '../../theme/tokens'; +import TextField from '../TextField'; +import type { + TextFieldAccessoryProps, + TextFieldRenderProps, +} from '../TextField/TextField'; + +const { stateOpacity } = tokens.md.ref; + +const defaultI18nIsRTL = I18nManager.isRTL; + +const getConstantsOriginal = I18nManager.getConstants.bind(I18nManager); + +beforeAll(() => { + jest.spyOn(I18nManager, 'getConstants').mockImplementation(() => ({ + ...getConstantsOriginal(), + isRTL: I18nManager.isRTL, + })); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +afterEach(() => { + I18nManager.isRTL = defaultI18nIsRTL; +}); + +function firstIndexOfTestIdInTree(tree: unknown, testID: string): number { + const serialized = JSON.stringify(tree); + const match = new RegExp(`"testID":\\s*"${testID}"`).exec(serialized); + return match ? match.index : -1; +} + +/** Locates a Text node whose children are serialized as a one-element JSON string array. */ +function firstIndexOfTextChildArrayInTree(tree: unknown, text: string): number { + return JSON.stringify(tree).indexOf(JSON.stringify([text])); +} + +it('renders filled TextField with label and value', () => { + const tree = render( + {}} /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextField with label and value', () => { + const tree = render( + {}} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders filled TextField with TextField.Icon accessories', () => { + const tree = render( + {}} + startAccessory={(props: TextFieldAccessoryProps) => ( + + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextField with TextField.Icon accessories', () => { + const tree = render( + {}} + startAccessory={(props: TextFieldAccessoryProps) => ( + + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders filled TextField with TextField.Icon accessories when error is true', () => { + const tree = render( + {}} + error + startAccessory={(props: TextFieldAccessoryProps) => ( + + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextField with TextField.Icon accessories when error is true', () => { + const tree = render( + {}} + error + startAccessory={(props: TextFieldAccessoryProps) => ( + + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('fires onPress on TextField.Icon end accessory', () => { + const onClear = jest.fn(); + const { getAllByTestId } = render( + {}} + startAccessory={(props: TextFieldAccessoryProps) => ( + + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + + )} + /> + ); + + fireEvent.press(getAllByTestId('icon-button')[1]); + + expect(onClear).toHaveBeenCalledTimes(1); +}); + +it('disables TextField.Icon when the field is disabled', () => { + const { getAllByTestId } = render( + {}} + disabled + startAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ); + + const buttons = getAllByTestId('icon-button'); + expect(buttons[0].props.accessibilityState?.disabled).toBe(true); + expect(buttons[1].props.accessibilityState?.disabled).toBe(true); +}); + +it('does not disable TextField.Icon when the field is read-only (editable false)', () => { + const { getAllByTestId } = render( + {}} + editable={false} + startAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ); + + const buttons = getAllByTestId('icon-button'); + expect(buttons[0].props.accessibilityState?.disabled).not.toBe(true); + expect(buttons[1].props.accessibilityState?.disabled).not.toBe(true); +}); + +it('renders supporting text below the field', () => { + const { getByText } = render( + {}} + supportingText="Use a valid address" + /> + ); + + expect(getByText('Use a valid address')).toBeTruthy(); +}); + +it('uses polite aria-live on error supporting text', () => { + const { getByText, getByTestId } = render( + {}} + supportingText="Invalid" + error + testID="tf-input" + /> + ); + + expect(getByText('Invalid').props['aria-live']).toBe('polite'); + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); +}); + +it('marks the input invalid when error is true without supporting text', () => { + const { getByTestId } = render( + {}} + error + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); + expect(getByTestId('tf-input').props.accessibilityHint).toBeUndefined(); +}); + +it('hides helper supporting text from the accessibility tree and omits aria-live', () => { + const { getByText, getByTestId } = render( + {}} + supportingText="Optional" + testID="tf-input" + /> + ); + + expect(getByText('Optional').props['aria-hidden']).toBe(true); + expect(getByText('Optional').props['aria-live']).toBeUndefined(); + expect(getByTestId('tf-input').props['aria-label']).toBe('Email, Optional'); +}); + +it('includes supporting text in aria-label when label is omitted', () => { + const { getByTestId } = render( + {}} + supportingText="Helper only" + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-label']).toBe('Helper only'); +}); + +it('does not mark the input as aria-disabled when editable is false (read-only)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props.accessibilityState?.disabled).not.toBe( + true + ); +}); + +it('marks the input as disabled in accessibilityState when disabled is true', () => { + const { getByTestId } = render( + {}} + disabled + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props.accessibilityState?.disabled).toBe(true); +}); + +it('renders the input via render with merged props', () => { + const renderInput = jest.fn((props: TextFieldRenderProps) => ( + + )); + + const { getByTestId } = render( + {}} + render={renderInput} + /> + ); + + expect(getByTestId('custom-input')).toBeTruthy(); + expect(renderInput).toHaveBeenCalled(); + const merged = renderInput.mock.calls[0]?.[0] as TextFieldRenderProps; + expect(merged['aria-label']).toBe('Pin'); + expect(merged.value).toBe('12'); +}); + +it('does not apply disabled opacity to the TextInput when editable is false (filled)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input-ro" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-ro').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('does not apply disabled opacity to the TextInput when editable is false (outlined)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input-ro-out" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-ro-out').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('applies disabled opacity to the TextInput when disabled is true (filled)', () => { + const { getByTestId } = render( + {}} + disabled + testID="tf-input-dis" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-dis').props.style) + ).toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('applies disabled opacity to the TextInput when disabled is true (outlined)', () => { + const { getByTestId } = render( + {}} + disabled + testID="tf-input-dis-out" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-dis-out').props.style) + ).toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('forwards TextInput props such as testID', () => { + const { getByTestId } = render( + {}} + testID="email-input" + /> + ); + + expect(getByTestId('email-input')).toBeTruthy(); +}); + +/* TextField peels these before spreading onto TextInput (see TextField.tsx). + * Custom layout / sub-component styling props are intentionally not supported. */ +it('does not pass TextField-only props through to TextInput', () => { + const { getByTestId } = render( + {}} + error + disabled + testID="tf-native" + /> + ); + + const input = getByTestId('tf-native'); + expect(input.props.variant).toBeUndefined(); + expect(input.props.theme).toBeUndefined(); + expect(input.props.startAccessory).toBeUndefined(); + expect(input.props.endAccessory).toBeUndefined(); + expect(input.props.label).toBeUndefined(); + expect(input.props.supportingText).toBeUndefined(); + expect(input.props.prefix).toBeUndefined(); + expect(input.props.suffix).toBeUndefined(); + expect(input.props.counter).toBeUndefined(); + expect(input.props.error).toBeUndefined(); + expect(input.props.disabled).toBeUndefined(); +}); + +it('shows a character counter when counter is true and maxLength is set (filled)', () => { + const { getByText, queryByText } = render( + {}} + counter + maxLength={100} + /> + ); + + expect(getByText('5/100')).toBeTruthy(); + expect(queryByText('0/100')).toBeNull(); +}); + +it('shows a character counter when counter is true and maxLength is set (outlined)', () => { + const { getByText } = render( + {}} + counter + maxLength={50} + /> + ); + + expect(getByText('0/50')).toBeTruthy(); +}); + +it('updates the character counter when the value changes', () => { + const { getByText, rerender } = render( + {}} + counter + maxLength={10} + /> + ); + + expect(getByText('1/10')).toBeTruthy(); + + rerender( + {}} + counter + maxLength={10} + /> + ); + + expect(getByText('4/10')).toBeTruthy(); +}); + +it('does not show a character counter when counter is false', () => { + const { queryByText } = render( + {}} + maxLength={100} + /> + ); + + expect(queryByText('5/100')).toBeNull(); +}); + +it('does not show a character counter when maxLength is missing', () => { + const { queryByText } = render( + {}} counter /> + ); + + expect(queryByText('5/100')).toBeNull(); + expect(queryByText(/\//)).toBeNull(); +}); + +it('invokes onFocus and onBlur on the TextInput', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const { getByTestId } = render( + {}} + onFocus={onFocus} + onBlur={onBlur} + testID="tf-input" + /> + ); + + const input = getByTestId('tf-input'); + fireEvent(input, 'focus'); + fireEvent(input, 'blur'); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); +}); + +it('focuses the TextInput when the outer Pressable is pressed', () => { + const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + + const { UNSAFE_getByProps, getByTestId } = render( + {}} + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input')).toBeTruthy(); + + /* Pressable is not exposed as a distinct type in the test renderer; match its props. */ + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('does not focus the TextInput when disabled and the Pressable is pressed', () => { + const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + + const { UNSAFE_getByProps } = render( + {}} disabled /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).not.toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('focuses the TextInput when read-only and the Pressable is pressed', () => { + const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + + const { UNSAFE_getByProps } = render( + {}} + editable={false} + /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('exposes the TextInput instance via ref prop', () => { + const ref = React.createRef(); + + render( + {}} + testID="tf-input" + /> + ); + + expect(ref.current).toBeTruthy(); + expect(typeof ref.current?.focus).toBe('function'); +}); + +it('passes error, disabled, and multiline to accessories', () => { + const startAccessoryProps: TextFieldAccessoryProps[] = []; + const endAccessoryProps: TextFieldAccessoryProps[] = []; + + function StartAccessory(props: TextFieldAccessoryProps) { + startAccessoryProps.push(props); + return ; + } + + function EndAccessory(props: TextFieldAccessoryProps) { + endAccessoryProps.push(props); + return ; + } + + const { getByTestId } = render( + {}} + multiline + error + disabled + startAccessory={StartAccessory} + endAccessory={EndAccessory} + /> + ); + + expect(getByTestId('start-accessory')).toBeTruthy(); + expect(getByTestId('end-accessory')).toBeTruthy(); + expect(startAccessoryProps[0]).toMatchObject({ + error: true, + disabled: true, + multiline: true, + }); + expect(endAccessoryProps[0]).toMatchObject({ + error: true, + disabled: true, + multiline: true, + }); +}); + +it('passes error to accessories when the field is disabled', () => { + const startAccessoryProps: TextFieldAccessoryProps[] = []; + + function StartAccessory(props: TextFieldAccessoryProps) { + startAccessoryProps.push(props); + return ; + } + + const { getByTestId } = render( + {}} + error + disabled + startAccessory={StartAccessory} + /> + ); + + expect(getByTestId('start-acc-error-disabled')).toBeTruthy(); + expect(startAccessoryProps[0].error).toBe(true); + expect(startAccessoryProps[0].disabled).toBe(true); +}); + +it('renders supporting text as a Text child', () => { + const { getByText } = render( + {}} + supportingText="Hint" + /> + ); + + expect(getByText('Hint')).toBeTruthy(); +}); + +it('renders the counter as a Text child', () => { + const { getByText } = render( + {}} + counter + maxLength={80} + /> + ); + + expect(getByText('2/80')).toBeTruthy(); +}); + +it('renders supporting text and counter separately when both are shown', () => { + const { getByText } = render( + {}} + supportingText="Help text" + counter + maxLength={10} + /> + ); + + expect(getByText('Help text')).toBeTruthy(); + expect(getByText('1/10')).toBeTruthy(); +}); + +it('applies RTL text alignment and writing direction to the TextInput (filled)', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + testID="tf-input-rtl" + /> + ); + + expect(StyleSheet.flatten(getByTestId('tf-input-rtl').props.style)).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'rtl', + }) + ); +}); + +it('applies RTL text alignment and writing direction to the TextInput (outlined)', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + testID="tf-input-rtl-outlined" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-rtl-outlined').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'rtl', + }) + ); +}); + +it('applies RTL writing direction to supporting text', () => { + I18nManager.isRTL = true; + + const { getByText } = render( + {}} + supportingText="Hint" + /> + ); + + expect(StyleSheet.flatten(getByText('Hint').props.style)).toEqual( + expect.objectContaining({ + writingDirection: 'rtl', + }) + ); +}); + +it('places EndAccessory before StartAccessory in the tree when RTL', () => { + I18nManager.isRTL = true; + + function StartAccessory() { + return ; + } + + function EndAccessory() { + return ; + } + + const { toJSON } = render( + {}} + startAccessory={StartAccessory} + endAccessory={EndAccessory} + testID="tf-input-rtl-order" + /> + ); + + const tree = toJSON(); + expect(firstIndexOfTestIdInTree(tree, 'rtl-acc-from-end-prop')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'rtl-acc-from-start-prop') + ); +}); + +it('places StartAccessory before EndAccessory in the tree when LTR', () => { + I18nManager.isRTL = false; + + function StartAccessory() { + return ; + } + + function EndAccessory() { + return ; + } + + const { toJSON } = render( + {}} + startAccessory={StartAccessory} + endAccessory={EndAccessory} + testID="tf-input-ltr-order" + /> + ); + + const tree = toJSON(); + expect( + firstIndexOfTestIdInTree(tree, 'ltr-acc-from-start-prop') + ).toBeLessThan(firstIndexOfTestIdInTree(tree, 'ltr-acc-from-end-prop')); +}); + +it('does not expose the placeholder string when the TextField is not focused', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + /* Sentinel space avoids iOS multiline UITextView not updating placeholder from nil (react-native#31573). */ + expect(getByTestId('tf-input').props.placeholder).toBe(' '); +}); + +it('shows placeholder when the TextField is focused', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + fireEvent(getByTestId('tf-input'), 'focus'); + + expect(getByTestId('tf-input').props.placeholder).toBe( + 'e.g. user@example.com' + ); +}); + +it('shows placeholder on multiline TextField when focused', () => { + const { getByTestId } = render( + {}} + placeholder="Add a note…" + multiline + testID="tf-multiline" + /> + ); + + expect(getByTestId('tf-multiline').props.placeholder).toBe(' '); + + fireEvent(getByTestId('tf-multiline'), 'focus'); + + expect(getByTestId('tf-multiline').props.placeholder).toBe('Add a note…'); +}); + +it('does not expose the placeholder string again after the TextField loses focus', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + fireEvent(getByTestId('tf-input'), 'focus'); + fireEvent(getByTestId('tf-input'), 'blur'); + + expect(getByTestId('tf-input').props.placeholder).toBe(' '); +}); + +it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order)', () => { + function LoneStartAccessory() { + return ; + } + + I18nManager.isRTL = false; + + const { toJSON: toJsonLtr } = render( + {}} + startAccessory={LoneStartAccessory} + testID="tf-lone-ltr" + /> + ); + + I18nManager.isRTL = true; + + const { toJSON: toJsonRtl } = render( + {}} + startAccessory={LoneStartAccessory} + testID="tf-lone-rtl" + /> + ); + + const ltrTree = toJsonLtr(); + expect(firstIndexOfTestIdInTree(ltrTree, 'lone-start-acc')).toBeLessThan( + firstIndexOfTestIdInTree(ltrTree, 'tf-lone-ltr') + ); + + const rtlTree = toJsonRtl(); + expect(firstIndexOfTestIdInTree(rtlTree, 'tf-lone-rtl')).toBeLessThan( + firstIndexOfTestIdInTree(rtlTree, 'lone-start-acc') + ); +}); + +it('shows prefix and suffix when the field is floating and hides them after value is cleared while blurred', () => { + const { getByTestId, getByText, queryByText, rerender } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + /> + ); + + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + rerender( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + /> + ); + + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); + expect(getByTestId('tf-ps')).toBeTruthy(); +}); + +it('renders prefix and suffix while focused even when value is empty', () => { + const { getByTestId, getByText, queryByText } = render( + {}} + prefix="$" + suffix=" kg" + testID="tf-ps-focus" + /> + ); + + expect(queryByText('$')).toBeNull(); + expect(queryByText(' kg')).toBeNull(); + + fireEvent(getByTestId('tf-ps-focus'), 'focus'); + + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); +}); + +it('places prefix Text before the TextInput and suffix Text after it', () => { + const { toJSON } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-order" + /> + ); + + const tree = toJSON(); + expect(firstIndexOfTextChildArrayInTree(tree, '$')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'tf-order') + ); + expect(firstIndexOfTestIdInTree(tree, 'tf-order')).toBeLessThan( + firstIndexOfTextChildArrayInTree(tree, '/100') + ); +}); + +it('aligns input text toward the suffix when suffix is active (LTR)', () => { + const { getByTestId } = render( + {}} + suffix="/100" + testID="tf-suffix-align-ltr" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-suffix-align-ltr').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'ltr', + }) + ); +}); + +it('aligns input text toward the suffix when suffix is active (RTL)', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + suffix="/100" + testID="tf-suffix-align-rtl" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-suffix-align-rtl').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'left', + writingDirection: 'rtl', + }) + ); +}); + +it('uses default horizontal alignment when suffix prop exists but suffix is not shown yet (LTR)', () => { + const { getByTestId } = render( + {}} + suffix="/100" + testID="tf-no-suffix-yet" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-no-suffix-yet').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'left', + writingDirection: 'ltr', + }) + ); +}); + +it('does not apply the TextInput style prop to prefix or suffix Text', () => { + const { getByTestId, getByText } = render( + {}} + prefix="$" + suffix="]" + style={{ fontSize: 40, letterSpacing: 9 }} + testID="tf-input-style" + /> + ); + + const inputFlat = StyleSheet.flatten( + getByTestId('tf-input-style').props.style + ); + expect(inputFlat).toEqual( + expect.objectContaining({ fontSize: 40, letterSpacing: 9 }) + ); + + const prefixFlat = StyleSheet.flatten(getByText('$').props.style); + const suffixFlat = StyleSheet.flatten(getByText(']').props.style); + + expect(prefixFlat.fontSize).not.toBe(40); + expect(prefixFlat.letterSpacing).toBeUndefined(); + expect(suffixFlat.fontSize).not.toBe(40); + expect(suffixFlat.letterSpacing).toBeUndefined(); +}); diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap new file mode 100644 index 0000000000..c4b83fee84 --- /dev/null +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -0,0 +1,3032 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders filled TextField with TextField.Icon accessories 1`] = ` + + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders filled TextField with TextField.Icon accessories when error is true 1`] = ` + + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders filled TextField with label and value 1`] = ` + + + + + + + Email + + + + + + + + +`; + +exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders outlined TextField with TextField.Icon accessories when error is true 1`] = ` + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders outlined TextField with label and value 1`] = ` + + + + + + Password + + + + + + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 1bb4cbdf9e..e55021f12a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -52,6 +52,7 @@ export { default as Switch } from './components/Switch/Switch'; export { default as Appbar } from './components/Appbar'; export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple'; export { default as TextInput } from './components/TextInput/TextInput'; +export { default as TextField } from './components/TextField'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; export { default as Tooltip } from './components/Tooltip/Tooltip'; @@ -131,6 +132,13 @@ export type { Props as SwitchProps } from './components/Switch/Switch'; export type { Props as TextInputProps } from './components/TextInput/TextInput'; export type { Props as TextInputAffixProps } from './components/TextInput/Adornment/TextInputAffix'; export type { Props as TextInputIconProps } from './components/TextInput/Adornment/TextInputIcon'; +export type { + TextFieldAccessoryProps, + TextFieldProps, + TextFieldRenderProps, + TextFieldVariant, +} from './components/TextField/TextField'; +export type { TextFieldIconProps } from './components/TextField/TextFieldIcon'; export type { Props as ToggleButtonProps } from './components/ToggleButton/ToggleButton'; export type { Props as ToggleButtonGroupProps } from './components/ToggleButton/ToggleButtonGroup'; export type { Props as ToggleButtonRowProps } from './components/ToggleButton/ToggleButtonRow';