diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 1dec1bb6ec..ab4a61be5e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -170,6 +170,7 @@ const config = { TextInputAffix: 'TextInput/Adornment/TextInputAffix', TextInputIcon: 'TextInput/Adornment/TextInputIcon', }, + TextField: 'TextField/TextField', ToggleButton: { ToggleButton: 'ToggleButton/ToggleButton', ToggleButtonGroup: 'ToggleButton/ToggleButtonGroup', @@ -206,6 +207,8 @@ const config = { } const customUrls = { + TextField: 'src/components/TextField/TextField.tsx', + TextInput: 'src/components/TextInput/TextInput.tsx', TextInputAffix: 'src/components/TextInput/Adornment/TextInputAffix.tsx', TextInputIcon: diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index 35f5069433..47ffe3d4f5 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -11,17 +11,23 @@ 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', + 'ComponentType': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L21', 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', }; 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 +62,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..185fa877a3 100644 --- a/docs/src/data/screenshots.js +++ b/docs/src/data/screenshots.js @@ -146,6 +146,10 @@ const screenshots = { 'iOS (disabled)': 'screenshots/switch-disabled.ios.png', }, Text: 'screenshots/typography.png', + TextField: { + filled: 'screenshots/text-field-filled.png', + outlined: 'screenshots/text-field-outlined.png', + }, TextInput: { 'flat (focused)': 'screenshots/textinput-flat.focused.png', 'flat (disabled)': 'screenshots/textinput-flat.disabled.png', 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 016f085caa..bdaa584e12 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'; @@ -89,6 +90,7 @@ export const mainExamples: Record< surface: SurfaceExample, switch: SwitchExample, text: TextExample, + textField: TextFieldExample, textInput: TextInputExample, toggleButton: ToggleButtonExample, tooltipExample: TooltipExample, diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx new file mode 100644 index 0000000000..9b7ebedd0b --- /dev/null +++ b/example/src/Examples/TextFieldExample.tsx @@ -0,0 +1,213 @@ +import * as React from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { + Icon, + List, + TextField, + type TextFieldAccessoryProps, +} from 'react-native-paper'; + +import { useExampleTheme } from '../hooks/useExampleTheme'; +import ScreenWrapper from '../ScreenWrapper'; + +const TextFieldExample = () => { + const { colors } = useExampleTheme(); + const iconMuted = colors.onSurfaceVariant; + const [searchQuery, setSearchQuery] = React.useState(''); + const [email, setEmail] = React.useState(''); + const [filledPassword, setFilledPassword] = React.useState(''); + const [filledNotes, setFilledNotes] = React.useState(''); + const [outlinedSearchQuery, setOutlinedSearchQuery] = React.useState(''); + const [outlinedText, setOutlinedText] = React.useState(''); + const [outlinedPassword, setOutlinedPassword] = React.useState(''); + const [outlinedNotes, setOutlinedNotes] = React.useState(''); + const [errorField, setErrorField] = React.useState('invalid@'); + + const ClearFilledSearchAccessory = ({ + style, + editable, + }: TextFieldAccessoryProps) => { + return ( + setSearchQuery('')} + accessibilityRole="button" + accessibilityLabel="Clear text" + > + + + ); + }; + + const ClearOutlinedSearchAccessory = ({ + style, + editable, + }: TextFieldAccessoryProps) => { + return ( + setOutlinedSearchQuery('')} + accessibilityRole="button" + accessibilityLabel="Clear text" + > + + + ); + }; + + const SearchLeadingAccessory = ({ style }: TextFieldAccessoryProps) => { + return ( + + + + ); + }; + + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +TextFieldExample.title = 'TextField'; + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + field: {}, + section: { + gap: 16, + }, +}); + +export default TextFieldExample; diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx new file mode 100644 index 0000000000..a96b67688f --- /dev/null +++ b/src/components/TextField/TextField.tsx @@ -0,0 +1,267 @@ +import React, { ComponentType } from 'react'; +import { + Animated, + Pressable, + StyleProp, + Text, + TextInput, + TextInputProps, + TextProps, + TextStyle, + View, + ViewStyle, +} from 'react-native'; + +import { useTextField } from './logic'; +import type { InternalTheme, ThemeProp } from '../../types'; + +export type TextFieldVariant = 'filled' | 'outlined'; + +export interface TextFieldAccessoryProps { + style: StyleProp; + status?: 'error' | 'disabled'; + multiline: boolean; + editable: boolean; +} + +export type TextFieldSharedApi = { + input: React.RefObject; + theme: InternalTheme; + isFocused: boolean; + disabled: boolean; + hasAccessory: boolean; + hasError: boolean; + $animatedLabelWrapperStyle: Animated.WithAnimatedObject; + $animatedLabelTextStyle: Animated.WithAnimatedObject; + $animatedPlaceholderStyle: Animated.WithAnimatedObject; +}; + +export interface TextFieldProps extends 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; + /** + * A style modifier for different input states. + */ + status?: 'error' | 'disabled'; + /** + * The label text to display above the input. + */ + label?: string; + /** + * Pass any additional props directly to the label Text component. + */ + labelProps?: TextProps; + /** + * The helper text to display below the input. When `status` is `error`, + * this text is styled as an error message. + */ + helper?: string; + /** + * Pass any additional props directly to the helper Text component. + */ + helperProps?: TextProps; + /** + * Style overrides for the pressable root element. + */ + pressableStyle?: StyleProp; + /** + * Style overrides for the field container (the bordered row that includes + * LeftAccessory, input content, and RightAccessory). + */ + fieldStyle?: StyleProp; + /** + * Style overrides for the input content wrapper (the area containing + * the label and TextInput, excluding accessories). + */ + containerStyle?: StyleProp; + theme?: ThemeProp; + /** + * An optional component to render on the right side of the input. + */ + RightAccessory?: ComponentType; + /** + * An optional component to render on the left side of the input. + */ + LeftAccessory?: ComponentType; +} + +/** + * A text field lets users enter and edit text. It shows an optional floating label, + * supports `filled` and `outlined` variants, optional helper text (including error + * state), and left/right accessories. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Pressable, View } from 'react-native'; + * import { Icon, TextField } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [text, setText] = React.useState(''); + * + * const LeadingAccessory = ({ style }) => ( + * + * + * + * ); + * + * const TrailingAccessory = ({ style, editable }) => ( + * setText('')} + * accessibilityRole="button" + * accessibilityLabel="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, + status, + label, + helper, + helperProps, + labelProps, + variant, + pressableStyle, + fieldStyle, + containerStyle, + theme, + LeftAccessory, + RightAccessory, + ...textInputProps + } = props; + + const { + input, + disabled, + hasError, + $pressableStyles, + $leadingAccessoryStyles, + $trailingAccessoryStyles, + $fieldStyles, + $outlineStyles, + $animatedLabelWrapperStyles, + $animatedLabelTextStyles, + $containerStyles, + $inputStyles, + $helperStyles, + $selectionColor, + $cursorColor, + $animatedPlaceholderStyles, + LeadingAccessory, + TrailingAccessory, + focusInput, + onFocusHandler, + onBlurHandler, + } = useTextField(props); + + return ( + + + + + {!!label && ( + + + {label} + + + )} + + {!!LeadingAccessory && ( + + )} + + + {!!textInputProps.placeholder && !textInputProps.value && ( + + {textInputProps.placeholder} + + )} + + + + + {!!TrailingAccessory && ( + + )} + + + {!!helper && ( + + {helper} + + )} + + ); +} + +export default TextField; diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts new file mode 100644 index 0000000000..3755fa212e --- /dev/null +++ b/src/components/TextField/constants.ts @@ -0,0 +1,64 @@ +import { Platform } from 'react-native'; + +// ================== +// PLATFORM +// ================== +export const isWeb = Platform.OS === 'web'; + +// ===================== +// FIELD LAYOUT +// ===================== +export const TEXT_FIELD_HEIGHT = 56; +export const TEXT_FIELD_PADDING_VERTICAL = 8; +export const TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL = 16; +export const TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL = 12; + +// ================== +// ACCESSORY +// ================== +export const ACCESSORY_SIZE = 24; + +// =============== +// TYPOGRAPHY +// =============== +export const LINE_HEIGHT_DELTA = 2; +export const INPUT_FONT_SIZE = 16; +export const ACTIVE_LABEL_FONT_SIZE = 12; +export const INACTIVE_LABEL_FONT_SIZE = INPUT_FONT_SIZE; +export const HELPER_FONT_SIZE = 12; + +export const INACTIVE_LABEL_TOP_POSITION = + (TEXT_FIELD_HEIGHT - + 2 * TEXT_FIELD_PADDING_VERTICAL - + INACTIVE_LABEL_FONT_SIZE) / + 2 + + TEXT_FIELD_PADDING_VERTICAL - + LINE_HEIGHT_DELTA; + +// ================= +// HELPER TEXT LAYOUT +// ================= +export const HELPER_MARGIN_TOP = 4; + +// ========= +// ANIMATION +// ========= +export const ANIMATION_DURATION_MS = 150; + +// ========= +// INDICATOR +// ========= +export const ACTIVE_INDICATOR_SIZE = 2; +export const INACTIVE_INDICATOR_SIZE = 1; + +// ============ +// SHAPE +// ============ +export const TEXT_FIELD_BORDER_RADIUS = 4; + +// ============ +// OPACITY +// ============ + +export const OUTLINE_ALPHA = 0.12; +export const FILLED_CONTAINER_ALPHA = 0.04; diff --git a/src/components/TextField/filled/constants.ts b/src/components/TextField/filled/constants.ts new file mode 100644 index 0000000000..99e08ff312 --- /dev/null +++ b/src/components/TextField/filled/constants.ts @@ -0,0 +1,27 @@ +import { + ACCESSORY_SIZE, + ACTIVE_LABEL_FONT_SIZE, + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_FIELD_PADDING_VERTICAL, +} from '../constants'; + +// ================== +// LABEL POSITIONING +// ================== + +export const LABEL_LEFT_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const LABEL_LEFT_OFFSET_WITHOUT_ACCESSORY = + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; + +// ================== +// PLACEHOLDER & MULTILINE POSITIONING +// ================== + +export const PADDING_TOP = ACTIVE_LABEL_FONT_SIZE + TEXT_FIELD_PADDING_VERTICAL; diff --git a/src/components/TextField/filled/logic.ts b/src/components/TextField/filled/logic.ts new file mode 100644 index 0000000000..55c04a6724 --- /dev/null +++ b/src/components/TextField/filled/logic.ts @@ -0,0 +1,218 @@ +import { + Animated, + I18nManager, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; + +import { + ACTIVE_INDICATOR_SIZE, + INACTIVE_INDICATOR_SIZE, + INPUT_FONT_SIZE, + isWeb, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, +} from '../constants'; +import { + $disabledStyle, + $helperStyle, + $inputStyle, + $leadingAccessoryStyle, + $trailingAccessoryStyle, +} from '../styles'; +import type { TextFieldProps, TextFieldSharedApi } from '../TextField'; +import { + getFieldBackgroundColor, + getHelperColor, + getLabelColor, +} from '../utils'; +import { + LABEL_LEFT_OFFSET_WITH_ACCESSORY, + LABEL_LEFT_OFFSET_WITHOUT_ACCESSORY, + PADDING_TOP, +} from './constants'; +import { + $containerStyle, + $fieldStyle, + $labelTextStyle, + $labelWrapperStyle, + $outlineStyle, +} from './styles'; +import { getOutlineColor } from './utils'; + +export const getFilledTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +) => { + const { + labelProps, + helperProps, + style: $inputStyleOverride, + fieldStyle: $fieldStyleOverride, + containerStyle: $containerStyleOverride, + ...textInputProps + } = props; + + const { + input, + theme, + isFocused, + disabled, + hasAccessory, + hasError, + $animatedLabelWrapperStyle, + $animatedLabelTextStyle, + $animatedPlaceholderStyle, + } = api; + + // ======================= + // CONSTANTS + // ======================= + + const { isRTL } = I18nManager; + + // ======================= + // THEME TOKENS + // ======================= + + const { + colors: { onSurface }, + } = theme; + + const labelColor = getLabelColor({ + theme, + status: props.status, + isFocused, + disabled, + }); + + const outlineColor = getOutlineColor({ + theme, + status: props.status, + isFocused, + disabled, + }); + + const fieldBackgroundColor = getFieldBackgroundColor({ theme, disabled }); + + const helperColor = getHelperColor({ theme, status: props.status, disabled }); + + // ======================= + // STYLES + // ======================= + + const $animatedLabelWrapperStyles: StyleProp< + Animated.WithAnimatedObject | ViewStyle + > = [ + $labelWrapperStyle, + { + left: hasAccessory + ? LABEL_LEFT_OFFSET_WITH_ACCESSORY + : LABEL_LEFT_OFFSET_WITHOUT_ACCESSORY, + }, + $animatedLabelWrapperStyle, + ]; + + const $animatedLabelTextStyles: StyleProp< + Animated.WithAnimatedObject | TextStyle + > = [ + $labelTextStyle, + { + color: labelColor, + }, + $animatedLabelTextStyle, + disabled && $disabledStyle, + labelProps?.style, + ]; + + const $fieldStyles = [ + $fieldStyle, + { + backgroundColor: fieldBackgroundColor, + }, + $fieldStyleOverride, + ]; + + const $outlineStyles = [ + $outlineStyle, + { + height: isFocused ? ACTIVE_INDICATOR_SIZE : INACTIVE_INDICATOR_SIZE, + backgroundColor: outlineColor, + }, + ]; + + const $containerStyles = [$containerStyle, $containerStyleOverride]; + + const $helperStyles: StyleProp = [ + $helperStyle, + { + color: helperColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && $disabledStyle, + helperProps?.style, + ]; + + const $inputStyles: StyleProp = [ + $inputStyle, + { + color: onSurface, + fontSize: INPUT_FONT_SIZE, + textAlign: isRTL ? 'right' : 'left', + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + textInputProps.multiline && { + height: 'auto' as TextStyle['height'], + paddingTop: PADDING_TOP, + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && $disabledStyle, + $inputStyleOverride, + ]; + + const $leadingAccessoryStyles = [ + $leadingAccessoryStyle, + disabled && $disabledStyle, + ]; + + const $trailingAccessoryStyles = [ + $trailingAccessoryStyle, + disabled && $disabledStyle, + ]; + + const $animatedPlaceholderStyles: StyleProp< + Animated.WithAnimatedObject | TextStyle + > = [ + $inputStyle, + { + position: 'absolute', + top: PADDING_TOP, + left: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: INPUT_FONT_SIZE, + color: + textInputProps.placeholderTextColor ?? theme.colors.onSurfaceVariant, + textAlign: isRTL ? 'right' : 'left', + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && $disabledStyle, + $animatedPlaceholderStyle, + ]; + + return { + input, + disabled, + hasError, + $animatedLabelWrapperStyles, + $animatedLabelTextStyles, + $animatedPlaceholderStyles, + $fieldStyles, + $outlineStyles, + $containerStyles, + $helperStyles, + $inputStyles, + $leadingAccessoryStyles, + $trailingAccessoryStyles, + }; +}; diff --git a/src/components/TextField/filled/styles.ts b/src/components/TextField/filled/styles.ts new file mode 100644 index 0000000000..d949455cb9 --- /dev/null +++ b/src/components/TextField/filled/styles.ts @@ -0,0 +1,40 @@ +import { TextStyle, ViewStyle } from 'react-native'; + +import { + TEXT_FIELD_BORDER_RADIUS, + TEXT_FIELD_HEIGHT, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_FIELD_PADDING_VERTICAL, +} from '../constants'; + +export const $fieldStyle: ViewStyle = { + minHeight: TEXT_FIELD_HEIGHT, + flexDirection: 'row', + paddingVertical: TEXT_FIELD_PADDING_VERTICAL, + borderTopStartRadius: TEXT_FIELD_BORDER_RADIUS, + borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, +}; + +export const $outlineStyle: ViewStyle = { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, +}; + +export const $containerStyle: ViewStyle = { + flex: 1, + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + justifyContent: 'flex-end', +}; + +export const $labelWrapperStyle: ViewStyle = { + position: 'absolute', +}; + +export const $labelTextStyle: TextStyle = { + fontWeight: '400', + includeFontPadding: false, + paddingVertical: 0, + paddingHorizontal: 0, +}; diff --git a/src/components/TextField/filled/utils.ts b/src/components/TextField/filled/utils.ts new file mode 100644 index 0000000000..f373bdff86 --- /dev/null +++ b/src/components/TextField/filled/utils.ts @@ -0,0 +1,34 @@ +import color from 'color'; + +import { tokens } from '../../../styles/themes/v3/tokens'; +import type { InternalTheme } from '../../../types'; + +export const getOutlineColor = ({ + theme, + status, + isFocused, + disabled, +}: { + theme: InternalTheme; + isFocused: boolean; + status?: 'error' | 'disabled'; + disabled: boolean; +}) => { + const { + colors: { error, onSurface, primary, outline }, + } = theme; + + if (disabled) { + return color(onSurface) + .alpha(tokens.md.ref.stateOpacity.disabled) + .rgb() + .string(); + } + if (status === 'error') { + return error; + } + if (isFocused) { + return primary; + } + return outline; +}; diff --git a/src/components/TextField/logic.ts b/src/components/TextField/logic.ts new file mode 100644 index 0000000000..34792ca8b9 --- /dev/null +++ b/src/components/TextField/logic.ts @@ -0,0 +1,231 @@ +import { + useImperativeHandle, + useRef, + useState, + useEffect, + useMemo, +} from 'react'; +import { + Animated, + TextStyle, + ViewStyle, + BlurEvent, + FocusEvent, + I18nManager, + TextInput, +} from 'react-native'; + +import { + ACTIVE_LABEL_FONT_SIZE, + ANIMATION_DURATION_MS, + INACTIVE_LABEL_FONT_SIZE, + INACTIVE_LABEL_TOP_POSITION, +} from './constants'; +import { ACTIVE_LABEL_TOP_POSITION as FILLED_ACTIVE_LABEL_TOP } from './filled/constants'; +import { getFilledTextFieldData } from './filled/logic'; +import { + LABEL_TRANSLATE_X_WITHOUT_ACCESSORY, + LABEL_TRANSLATE_X_WITH_ACCESSORY, + ACTIVE_LABEL_TOP_POSITION as OUTLINED_ACTIVE_LABEL_TOP, +} from './outlined/constants'; +import { getOutlinedTextFieldData } from './outlined/logic'; +import { $pressableStyle } from './styles'; +import type { TextFieldProps, TextFieldSharedApi } from './TextField'; +import { getAccentColors } from './utils'; +import { useInternalTheme } from '../../core/theming'; + +export const useTextField = (props: TextFieldProps) => { + const { + ref, + variant = 'filled', + pressableStyle: $pressableStyleOverride, + theme: themeOverride, + onFocus, + onBlur, + } = props; + + // ======================= + // HOOKS + // ======================= + + const input = useRef(null); + + const theme = useInternalTheme(themeOverride); + + const [isFocused, setIsFocused] = useState(false); + + useImperativeHandle(ref, () => input.current as TextInput); + + // ======================= + // CONSTANTS + // ======================= + + const { isRTL } = I18nManager; + const disabled = props.editable === false || props.status === 'disabled'; + const isFloating = isFocused || !!props.value; + const hasAccessory = isRTL ? !!props.RightAccessory : !!props.LeftAccessory; + const hasError = props.status === 'error'; + + // ======================= + // THEME TOKENS + // ======================= + + const { selectionColor: $selectionColor, cursorColor: $cursorColor } = + getAccentColors({ theme, hasError }); + + // ======================= + // LABEL ANIMATION + // ======================= + + const { + $animatedLabelWrapperStyle, + $animatedLabelTextStyle, + $animatedPlaceholderStyle, + } = useTextFieldAnimation({ + variant, + isFloating, + hasAccessory, + }); + + // ======================= + // HANDLERS + // ======================= + + const onFocusHandler = (e: FocusEvent) => { + onFocus?.(e); + setIsFocused(true); + }; + + const onBlurHandler = (e: BlurEvent) => { + onBlur?.(e); + setIsFocused(false); + }; + + const focusInput = () => { + if (disabled) return; + input.current?.focus(); + }; + + // ======================= + // SHARED API + // ======================= + + const api: TextFieldSharedApi = { + input, + theme, + isFocused, + disabled, + hasAccessory, + hasError, + $animatedLabelWrapperStyle, + $animatedLabelTextStyle, + $animatedPlaceholderStyle, + }; + + const LeadingAccessory = isRTL ? props.RightAccessory : props.LeftAccessory; + const TrailingAccessory = isRTL ? props.LeftAccessory : props.RightAccessory; + + // ======================= + // STYLES + // ======================= + + const $pressableStyles = [$pressableStyle, $pressableStyleOverride]; + + const data = { + isFocused, + $pressableStyles, + $selectionColor, + $cursorColor, + LeadingAccessory, + TrailingAccessory, + onFocusHandler, + onBlurHandler, + focusInput, + }; + + if (variant === 'filled') { + return { + ...data, + ...getFilledTextFieldData(api, props), + }; + } + + return { + ...data, + ...getOutlinedTextFieldData(api, props), + }; +}; + +const useTextFieldAnimation = ({ + variant, + isFloating, + hasAccessory, +}: { + variant: 'filled' | 'outlined'; + isFloating: boolean; + hasAccessory: boolean; +}): { + $animatedLabelWrapperStyle: Animated.WithAnimatedObject; + $animatedLabelTextStyle: Animated.WithAnimatedObject; + $animatedPlaceholderStyle: Animated.WithAnimatedObject; +} => { + const progress = useRef(new Animated.Value(isFloating ? 1 : 0)).current; + + useEffect(() => { + Animated.timing(progress, { + toValue: isFloating ? 1 : 0, + duration: ANIMATION_DURATION_MS, + useNativeDriver: false, + }).start(); + }, [isFloating, progress]); + + return useMemo(() => { + const activeTop = + variant === 'filled' + ? FILLED_ACTIVE_LABEL_TOP + : OUTLINED_ACTIVE_LABEL_TOP; + + const fontSize = progress.interpolate({ + inputRange: [0, 1], + outputRange: [INACTIVE_LABEL_FONT_SIZE, ACTIVE_LABEL_FONT_SIZE], + }); + + const top = progress.interpolate({ + inputRange: [0, 1], + outputRange: [INACTIVE_LABEL_TOP_POSITION, activeTop], + }); + + const opacity = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + + if (variant === 'filled') { + return { + $animatedLabelWrapperStyle: { top }, + $animatedLabelTextStyle: { fontSize }, + $animatedPlaceholderStyle: { opacity }, + }; + } + + const translateXEnd = hasAccessory + ? LABEL_TRANSLATE_X_WITH_ACCESSORY + : LABEL_TRANSLATE_X_WITHOUT_ACCESSORY; + + return { + $animatedLabelWrapperStyle: { + top, + transform: [ + { + translateX: progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, translateXEnd], + }), + }, + ], + }, + $animatedLabelTextStyle: { fontSize }, + $animatedPlaceholderStyle: { opacity }, + }; + }, [variant, hasAccessory, progress]); +}; diff --git a/src/components/TextField/outlined/constants.ts b/src/components/TextField/outlined/constants.ts new file mode 100644 index 0000000000..4fd33f2623 --- /dev/null +++ b/src/components/TextField/outlined/constants.ts @@ -0,0 +1,49 @@ +import { I18nManager } from 'react-native'; + +import { + ACCESSORY_SIZE, + LINE_HEIGHT_DELTA, + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_FIELD_PADDING_VERTICAL, +} from '../constants'; + +// ================== +// LAYOUT SUPPORT +// ================== + +const isRTL = I18nManager.isRTL; +const layoutSupportMultiplier = isRTL ? -1 : 1; + +// ================== +// LABEL POSITIONING +// ================== +export const LABEL_PADDING_HORIZONTAL = 4; + +export const LABEL_LEFT_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + LABEL_PADDING_HORIZONTAL; + +export const LABEL_LEFT_OFFSET_WITHOUT_ACCESSORY = + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const ACTIVE_LABEL_TOP_POSITION = + -TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA; + +export const LABEL_TRANSLATE_X_WITH_ACCESSORY = + -layoutSupportMultiplier * + (ACCESSORY_SIZE + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + LABEL_PADDING_HORIZONTAL); + +export const LABEL_TRANSLATE_X_WITHOUT_ACCESSORY = + -layoutSupportMultiplier * LABEL_PADDING_HORIZONTAL; + +// ================== +// PLACEHOLDER POSITIONING +// ================== + +export const PLACEHOLDER_TOP_POSITION = + TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA; diff --git a/src/components/TextField/outlined/logic.ts b/src/components/TextField/outlined/logic.ts new file mode 100644 index 0000000000..eac62bce0c --- /dev/null +++ b/src/components/TextField/outlined/logic.ts @@ -0,0 +1,205 @@ +import { + Animated, + I18nManager, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; + +import { + INPUT_FONT_SIZE, + isWeb, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, +} from '../constants'; +import { + $disabledStyle, + $helperStyle, + $inputStyle, + $leadingAccessoryStyle, + $trailingAccessoryStyle, +} from '../styles'; +import type { TextFieldProps, TextFieldSharedApi } from '../TextField'; +import { getHelperColor, getLabelColor } from '../utils'; +import { + LABEL_LEFT_OFFSET_WITH_ACCESSORY, + LABEL_LEFT_OFFSET_WITHOUT_ACCESSORY, + PLACEHOLDER_TOP_POSITION, +} from './constants'; +import { + $containerStyle, + $fieldStyle, + $labelTextStyle, + $labelWrapperStyle, + $outlineStyle, +} from './styles'; +import { getOutlineColor } from './utils'; + +export const getOutlinedTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +) => { + const { + labelProps, + helperProps, + style: $inputStyleOverride, + fieldStyle: $fieldStyleOverride, + containerStyle: $containerStyleOverride, + ...textInputProps + } = props; + + const { + input, + theme, + isFocused, + disabled, + hasAccessory, + hasError, + $animatedLabelWrapperStyle, + $animatedLabelTextStyle, + $animatedPlaceholderStyle, + } = api; + + // ======================= + // CONSTANTS + // ======================= + + const { isRTL } = I18nManager; + + // ======================= + // THEME TOKENS + // ======================= + + const { + colors: { background: labelBackgroundColor, onSurface }, + } = theme; + + const labelColor = getLabelColor({ + theme, + status: props.status, + isFocused, + disabled, + }); + + const outlineColor = getOutlineColor({ + theme, + disabled, + isFocused, + hasError, + }); + + const helperColor = getHelperColor({ theme, status: props.status, disabled }); + + // ======================= + // STYLES + // ======================= + + const $fieldStyles = [$fieldStyle, $fieldStyleOverride]; + + const $outlineStyles = [ + $outlineStyle, + { + borderWidth: isFocused ? 2 : 1, + borderColor: outlineColor, + }, + $fieldStyleOverride, + ]; + + const $containerStyles = [$containerStyle, $containerStyleOverride]; + + const $helperStyles: StyleProp = [ + $helperStyle, + { + color: helperColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && $disabledStyle, + helperProps?.style, + ]; + + const $animatedLabelWrapperStyles: StyleProp< + Animated.WithAnimatedObject | ViewStyle + > = [ + $labelWrapperStyle, + { + left: hasAccessory + ? LABEL_LEFT_OFFSET_WITH_ACCESSORY + : LABEL_LEFT_OFFSET_WITHOUT_ACCESSORY, + backgroundColor: labelBackgroundColor, + }, + $animatedLabelWrapperStyle, + ]; + + const $animatedLabelTextStyles: StyleProp< + Animated.WithAnimatedObject | TextStyle + > = [ + $labelTextStyle, + { + color: labelColor, + }, + $animatedLabelTextStyle, + disabled && $disabledStyle, + labelProps?.style, + ]; + + const $inputStyles: StyleProp = [ + $inputStyle, + { + color: onSurface, + fontSize: INPUT_FONT_SIZE, + textAlign: isRTL ? 'right' : 'left', + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + textInputProps.multiline && { + height: 'auto' as TextStyle['height'], + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && $disabledStyle, + $inputStyleOverride, + ]; + + const $leadingAccessoryStyles = [ + $leadingAccessoryStyle, + disabled && $disabledStyle, + ]; + + const $trailingAccessoryStyles = [ + $trailingAccessoryStyle, + disabled && $disabledStyle, + ]; + + const $animatedPlaceholderStyles: StyleProp< + Animated.WithAnimatedObject | TextStyle + > = [ + $inputStyle, + { + position: 'absolute', + top: PLACEHOLDER_TOP_POSITION, + left: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: INPUT_FONT_SIZE, + color: + textInputProps.placeholderTextColor ?? theme.colors.onSurfaceVariant, + textAlign: isRTL ? 'right' : 'left', + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && $disabledStyle, + $animatedPlaceholderStyle, + ]; + + return { + input, + disabled, + hasError, + $animatedLabelWrapperStyles, + $animatedLabelTextStyles, + $animatedPlaceholderStyles, + $fieldStyles, + $outlineStyles, + $containerStyles, + $helperStyles, + $inputStyles, + $leadingAccessoryStyles, + $trailingAccessoryStyles, + }; +}; diff --git a/src/components/TextField/outlined/styles.ts b/src/components/TextField/outlined/styles.ts new file mode 100644 index 0000000000..5790d15296 --- /dev/null +++ b/src/components/TextField/outlined/styles.ts @@ -0,0 +1,43 @@ +import { TextStyle, ViewStyle } from 'react-native'; + +import { + TEXT_FIELD_BORDER_RADIUS, + TEXT_FIELD_HEIGHT, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_FIELD_PADDING_VERTICAL, +} from '../constants'; +import { LABEL_PADDING_HORIZONTAL } from './constants'; + +export const $fieldStyle: ViewStyle = { + minHeight: TEXT_FIELD_HEIGHT, + flexDirection: 'row', + paddingVertical: TEXT_FIELD_PADDING_VERTICAL, + borderRadius: TEXT_FIELD_BORDER_RADIUS, +}; + +export const $outlineStyle: ViewStyle = { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: TEXT_FIELD_BORDER_RADIUS, +}; + +export const $containerStyle: ViewStyle = { + flex: 1, + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + justifyContent: 'center', +}; + +export const $labelWrapperStyle: ViewStyle = { + position: 'absolute', + paddingHorizontal: LABEL_PADDING_HORIZONTAL, +}; + +export const $labelTextStyle: TextStyle = { + fontWeight: '400', + includeFontPadding: false, + paddingVertical: 0, + paddingHorizontal: 0, +}; diff --git a/src/components/TextField/outlined/utils.ts b/src/components/TextField/outlined/utils.ts new file mode 100644 index 0000000000..c74c9d2bb5 --- /dev/null +++ b/src/components/TextField/outlined/utils.ts @@ -0,0 +1,36 @@ +import color from 'color'; + +import type { InternalTheme } from '../../../types'; +import { OUTLINE_ALPHA } from '../constants'; + +export const getOutlineColor = ({ + theme, + isFocused, + disabled, + hasError, +}: { + theme: InternalTheme; + isFocused: boolean; + disabled: boolean; + hasError: boolean; +}) => { + const { + colors: { outline, onSurface, primary, error }, + } = theme; + + let outlineColor = outline; + + if (isFocused) { + outlineColor = primary; + } + + if (disabled) { + outlineColor = color(onSurface).alpha(OUTLINE_ALPHA).rgb().string(); + } + + if (hasError) { + outlineColor = error; + } + + return outlineColor; +}; diff --git a/src/components/TextField/styles.ts b/src/components/TextField/styles.ts new file mode 100644 index 0000000000..c0bd42af27 --- /dev/null +++ b/src/components/TextField/styles.ts @@ -0,0 +1,45 @@ +import { StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { + ACCESSORY_SIZE, + HELPER_FONT_SIZE, + HELPER_MARGIN_TOP, + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, +} from './constants'; +import { tokens } from '../../styles/themes/v3/tokens'; + +export const $pressableStyle: ViewStyle = {}; + +export const $inputStyle: StyleProp = { + fontWeight: '400', + includeFontPadding: false, + paddingVertical: 0, + paddingHorizontal: 0, +}; + +export const $helperStyle: TextStyle = { + marginTop: HELPER_MARGIN_TOP, + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: HELPER_FONT_SIZE, + fontWeight: '400', + textAlign: 'left', +}; + +export const $trailingAccessoryStyle: ViewStyle = { + width: ACCESSORY_SIZE, + marginEnd: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + justifyContent: 'center', + alignItems: 'center', +}; + +export const $leadingAccessoryStyle: ViewStyle = { + width: ACCESSORY_SIZE, + marginStart: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + justifyContent: 'center', + alignItems: 'center', +}; + +export const $disabledStyle: ViewStyle = { + opacity: tokens.md.ref.stateOpacity.disabled, +}; diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts new file mode 100644 index 0000000000..72d62443e3 --- /dev/null +++ b/src/components/TextField/utils.ts @@ -0,0 +1,87 @@ +import color from 'color'; + +import { FILLED_CONTAINER_ALPHA } from './constants'; +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, + status, + isFocused, + disabled, +}: { + theme: InternalTheme; + isFocused: boolean; + status?: 'error' | 'disabled'; + disabled: boolean; +}) => { + const { + colors: { error, primary, onSurface, onSurfaceVariant }, + } = theme; + + if (disabled) { + return onSurface; + } + + if (status === 'error') { + return error; + } + if (isFocused) { + return primary; + } + return onSurfaceVariant; +}; + +export const getHelperColor = ({ + theme, + status, + disabled, +}: { + theme: InternalTheme; + status?: 'error' | 'disabled'; + disabled: boolean; +}) => { + const { + colors: { error, onSurface, onSurfaceVariant }, + } = theme; + + if (disabled) { + return onSurface; + } + + if (status === 'error') { + return error; + } + return onSurfaceVariant; +}; + +export const getFieldBackgroundColor = ({ + theme, + disabled, +}: { + theme: InternalTheme; + disabled: boolean; +}) => { + const { + colors: { onSurface, surfaceContainerHighest }, + } = theme; + if (disabled) { + return color(onSurface).alpha(FILLED_CONTAINER_ALPHA).rgb().string(); + } + + return surfaceContainerHighest; +}; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx new file mode 100644 index 0000000000..eae5b0626a --- /dev/null +++ b/src/components/__tests__/TextField.test.tsx @@ -0,0 +1,518 @@ +import * as React from 'react'; +import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; + +import { fireEvent, render } from '@testing-library/react-native'; + +import TextField, { + type TextFieldAccessoryProps, +} from '../TextField/TextField'; + +const defaultI18nIsRTL = I18nManager.isRTL; + +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; +} + +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 helper text below the field', () => { + const { getByText } = render( + {}} + helper="Use a valid address" + /> + ); + + expect(getByText('Use a valid address')).toBeTruthy(); +}); + +it('sets aria-invalid on the input when status is error', () => { + const { getByTestId } = render( + {}} + status="error" + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-invalid']).toBe(true); +}); + +it('uses assertive aria-live on helper when status is error', () => { + const { getByText } = render( + {}} + helper="Invalid" + status="error" + /> + ); + + expect(getByText('Invalid').props['aria-live']).toBe('assertive'); +}); + +it('uses polite aria-live on helper when there is no error', () => { + const { getByText } = render( + {}} + helper="Optional" + /> + ); + + expect(getByText('Optional').props['aria-live']).toBe('polite'); +}); + +it('marks the input as aria-disabled when editable is false', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); +}); + +it('marks the input as aria-disabled when status is disabled', () => { + const { getByTestId } = render( + {}} + status="disabled" + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); +}); + +it('forwards TextInput props such as testID', () => { + const { getByTestId } = render( + {}} + testID="email-input" + /> + ); + + expect(getByTestId('email-input')).toBeTruthy(); +}); + +it('does not pass TextField-only props through to TextInput', () => { + const { getByTestId } = render( + {}} + testID="tf-native" + /> + ); + + const input = getByTestId('tf-native'); + expect(input.props.variant).toBeUndefined(); + expect(input.props.theme).toBeUndefined(); + expect(input.props.LeftAccessory).toBeUndefined(); + expect(input.props.pressableStyle).toBeUndefined(); + expect(input.props.fieldStyle).toBeUndefined(); + expect(input.props.containerStyle).toBeUndefined(); +}); + +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( + {}} + editable={false} + /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).not.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 status, editable, and multiline to accessories', () => { + const leftProps: TextFieldAccessoryProps[] = []; + const rightProps: TextFieldAccessoryProps[] = []; + + function LeftAccessory(props: TextFieldAccessoryProps) { + leftProps.push(props); + return ; + } + + function RightAccessory(props: TextFieldAccessoryProps) { + rightProps.push(props); + return ; + } + + const { getByTestId } = render( + {}} + multiline + status="error" + editable={false} + LeftAccessory={LeftAccessory} + RightAccessory={RightAccessory} + /> + ); + + expect(getByTestId('left-accessory')).toBeTruthy(); + expect(getByTestId('right-accessory')).toBeTruthy(); + expect(leftProps[0]).toMatchObject({ + status: 'error', + editable: false, + multiline: true, + }); + expect(rightProps[0]).toMatchObject({ + status: 'error', + editable: false, + multiline: true, + }); +}); + +it('applies helperProps to the helper Text', () => { + const { getByTestId } = render( + {}} + helper="Hint" + helperProps={{ testID: 'helper-text' }} + /> + ); + + expect(getByTestId('helper-text').props.children).toBe('Hint'); +}); + +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 helper text', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + helper="Hint" + helperProps={{ testID: 'helper-rtl' }} + /> + ); + + expect(StyleSheet.flatten(getByTestId('helper-rtl').props.style)).toEqual( + expect.objectContaining({ + writingDirection: 'rtl', + }) + ); +}); + +it('places RightAccessory before LeftAccessory in the tree when RTL', () => { + I18nManager.isRTL = true; + + function LeftAccessory() { + return ; + } + + function RightAccessory() { + return ; + } + + const { toJSON } = render( + {}} + LeftAccessory={LeftAccessory} + RightAccessory={RightAccessory} + testID="tf-input-rtl-order" + /> + ); + + const tree = toJSON(); + expect( + firstIndexOfTestIdInTree(tree, 'rtl-acc-from-right-prop') + ).toBeLessThan(firstIndexOfTestIdInTree(tree, 'rtl-acc-from-left-prop')); +}); + +it('places LeftAccessory before RightAccessory in the tree when LTR', () => { + I18nManager.isRTL = false; + + function LeftAccessory() { + return ; + } + + function RightAccessory() { + return ; + } + + const { toJSON } = render( + {}} + LeftAccessory={LeftAccessory} + RightAccessory={RightAccessory} + testID="tf-input-ltr-order" + /> + ); + + const tree = toJSON(); + expect(firstIndexOfTestIdInTree(tree, 'ltr-acc-from-left-prop')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'ltr-acc-from-right-prop') + ); +}); + +it('hides placeholder when the TextField is not focused', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props.placeholder).toBeUndefined(); +}); + +it('shows placeholder when the TextField is focused', () => { + const { getByTestId, getByText } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + fireEvent(getByTestId('tf-input'), 'focus'); + + expect(getByTestId('tf-input').props.placeholder).toBeUndefined(); + expect(getByText('e.g. user@example.com')).toBeTruthy(); +}); + +it('shows placeholder on multiline TextField when focused', () => { + const { getByTestId, getByText } = render( + {}} + placeholder="Add a note…" + multiline + testID="tf-multiline" + /> + ); + + expect(getByTestId('tf-multiline').props.placeholder).toBeUndefined(); + + fireEvent(getByTestId('tf-multiline'), 'focus'); + + expect(getByTestId('tf-multiline').props.placeholder).toBeUndefined(); + expect(getByText('Add a note…')).toBeTruthy(); +}); + +it('hides placeholder 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).toBeUndefined(); +}); + +it('maps a lone LeftAccessory to leading in LTR and trailing in RTL (tree order)', () => { + function LoneLeftAccessory() { + return ; + } + + I18nManager.isRTL = false; + + const { toJSON: toJsonLtr } = render( + {}} + LeftAccessory={LoneLeftAccessory} + testID="tf-lone-ltr" + /> + ); + + I18nManager.isRTL = true; + + const { toJSON: toJsonRtl } = render( + {}} + LeftAccessory={LoneLeftAccessory} + testID="tf-lone-rtl" + /> + ); + + const ltrTree = toJsonLtr(); + expect(firstIndexOfTestIdInTree(ltrTree, 'lone-left-acc')).toBeLessThan( + firstIndexOfTestIdInTree(ltrTree, 'tf-lone-ltr') + ); + + const rtlTree = toJsonRtl(); + expect(firstIndexOfTestIdInTree(rtlTree, 'tf-lone-rtl')).toBeLessThan( + firstIndexOfTestIdInTree(rtlTree, 'lone-left-acc') + ); +}); 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..41bd80f66e --- /dev/null +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -0,0 +1,307 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders filled TextField with label and value 1`] = ` + + + + + + Email + + + + + + + +`; + +exports[`renders outlined TextField with label and value 1`] = ` + + + + + + Password + + + + + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 7e609b709e..f2903fa894 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -51,6 +51,7 @@ export { default as Surface } from './components/Surface'; 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 TextField } from './components/TextField/TextField'; export { default as TextInput } from './components/TextInput/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; @@ -135,6 +136,11 @@ export type { Props as SearchbarProps } from './components/Searchbar'; export type { Props as SnackbarProps } from './components/Snackbar'; export type { Props as SurfaceProps } from './components/Surface'; export type { Props as SwitchProps } from './components/Switch/Switch'; +export type { + TextFieldProps, + TextFieldAccessoryProps, + TextFieldVariant, +} from './components/TextField/TextField'; 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';