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..f25d03ba6a 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -11,17 +11,25 @@ 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#L26', 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 +64,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..a58fedcb02 --- /dev/null +++ b/example/src/Examples/TextFieldExample.tsx @@ -0,0 +1,244 @@ +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'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type DemoControls = { + error: boolean; + disabled: 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; +}; + +// --------------------------------------------------------------------------- +// TextFieldDemo +// --------------------------------------------------------------------------- + +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, + 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 = React.useCallback( + (props: TextFieldAccessoryProps) => ( + + ), + [] + ); + + const TrailingIcon = React.useCallback( + (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: '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()}…`} + /> + + ))} + + ); +}; + +// --------------------------------------------------------------------------- +// TextFieldExample +// --------------------------------------------------------------------------- + +const TextFieldExample = () => { + return ( + + + + + + + + + ); +}; + +TextFieldExample.title = 'TextField'; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +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..e6561e0211 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, @@ -89,6 +86,8 @@ jest.mock('react-native', () => { RN.Animated.loop = loop; RN.Animated.parallel = parallel; + jest.spyOn(RN.PixelRatio, 'getFontScale').mockReturnValue(1); + return RN; }); diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx new file mode 100644 index 0000000000..b610eb5288 --- /dev/null +++ b/src/components/TextField/TextField.tsx @@ -0,0 +1,422 @@ +import React, { ComponentType } from 'react'; +import { + BlurEvent, + ColorValue, + FocusEvent, + Pressable, + StyleProp, + Text, + TextInput, + TextInputProps, + TextProps, + 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 TextFieldVariant = 'filled' | 'outlined'; + +export interface TextFieldAccessoryProps { + style: StyleProp; + multiline: boolean; + disabled: boolean; + error: boolean; +} + +export type TextFieldSharedApi = { + input: React.RefObject; + theme: InternalTheme; + isFocused: boolean; + disabled: 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; + disabled: 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; + disabled: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: undefined; + outlineStyles: StyleProp; + inputStyles: StyleProp; +}; + +export type TextFieldHookReturn = SharedTextFieldStyleData & { + input: React.RefObject; + disabled: boolean; + 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; + LeadingAccessory: ComponentType | undefined; + TrailingAccessory: ComponentType | undefined; + onFocusHandler: (e: FocusEvent) => void; + onBlurHandler: (e: BlurEvent) => void; + focusInput: () => void; +}; + +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; + /** + * When `true`, the field uses error styling and validation semantics (`aria-invalid`). + */ + error?: boolean; + /** + * The label text to display above the input. + */ + label?: string; + /** + * Pass any additional props directly to the label Text component. + */ + labelProps?: TextProps; + /** + * Supporting text to display below the input (Material Design 3). When + * `error` is `true`, this text is styled as an error message. + */ + supportingText?: string; + /** + * Pass any additional props directly to the supporting text `Text` component. + */ + supportingTextProps?: TextProps; + /** + * When `true`, displays a character counter below the input on the trailing + * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. + */ + counter?: boolean; + /** + * Pass any additional props directly to the counter `Text` component. + */ + counterProps?: TextProps; + /** + * A short text string displayed at the start of the input (e.g. `"$"`). + */ + prefix?: string; + /** + * Pass any additional props directly to the prefix `Text` component. + */ + prefixProps?: TextProps; + /** + * A short text string displayed at the end of the input (e.g. `"/100"`). + */ + suffix?: string; + /** + * Pass any additional props directly to the suffix `Text` component. + */ + suffixProps?: TextProps; + /** + * Style overrides for the pressable root element. + */ + pressableStyle?: StyleProp; + /** + * Style overrides for the field container (the bordered row that includes + * StartAccessory, input content, and EndAccessory). + */ + fieldStyle?: StyleProp; + /** + * Style overrides for the input content wrapper (the area containing + * the label and TextInput, excluding accessories). + */ + containerStyle?: StyleProp; + /** + * Style overrides for the indicator layer (the purely visual border or line + * that shows state, not the interactive input). + * - `filled` — applied to both the always-visible bottom edge and the + * animated bar that expands on focus. + * - `outlined` — applied to the rounded border around the field for both states. + */ + outlineStyle?: StyleProp; + 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?: ComponentType; + /** + * An optional component to render on the end side of the input (trailing in LTR). + * Can be a custom component or `TextField.Icon`. + */ + EndAccessory?: ComponentType; +} + +/** + * 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 SearchIcon = (props) => ( + * + * ); + * + * const ClearAccessory = ({ style, disabled }) => ( + * 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, + error, + label, + supportingText, + supportingTextProps, + labelProps, + variant, + pressableStyle: pressableStyleOverride, + fieldStyle, + containerStyle, + outlineStyle, + theme, + StartAccessory, + EndAccessory, + prefix, + prefixProps, + suffix, + suffixProps, + counter, + counterProps, + ...textInputProps + } = props; + + const { + input, + disabled, + hasPrefix, + hasSuffix, + hasCounter, + hasError, + leadingAccessoryStyles, + trailingAccessoryStyles, + fieldStyles, + disabledBackgroundStyles, + outlineStyles, + animatedActiveOutlineStyles, + animatedLabelWrapperStyles, + animatedLabelTextStyles, + animatedContainerStyle, + containerStyles, + inputStyles, + prefixStyles, + suffixStyles, + supportingTextStyles, + counterStyles, + placeholderTextColor, + selectionColor, + cursorColor, + placeholder, + counterText, + LeadingAccessory, + TrailingAccessory, + 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} + + + )} + + {!!LeadingAccessory && ( + + )} + + + {hasPrefix && ( + + {prefix} + + )} + + + + {hasSuffix && ( + + {suffix} + + )} + + + {TrailingAccessory ? ( + + ) : 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..72fb6e7847 --- /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..ab91bafb72 --- /dev/null +++ b/src/components/TextField/TextFieldIcon.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { AccessibilityProps, GestureResponderEvent, View } from 'react-native'; + +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import type { IconSource } from '../Icon'; +import { ACCESSORY_SIZE } from './constants'; +import { styles } from './styles'; +import type { TextFieldAccessoryProps } from './TextField'; +import { getIconColor } from './utils'; +import IconButton from '../IconButton/IconButton'; + +export interface TextFieldIconProps extends TextFieldAccessoryProps { + /** + * Icon to display. + */ + icon: IconSource; + /** + * Color of the icon. + */ + color?: string; + /** + * Size of the icon. + */ + size?: number; + /** + * Accessibility props for the icon button. + */ + accessibility?: AccessibilityProps; + theme?: ThemeProp; + /** + * Function to execute on press. + */ + onPress?: (event: GestureResponderEvent) => void; +} + +/** + * A component to render a leading / trailing icon in the TextField + * (inside `StartAccessory` or `EndAccessory`). Accepts icon-specific props as well as + * `TextFieldAccessoryProps`, which TextField forwards automatically. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { TextField } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [text, setText] = React.useState(''); + * + * const SearchIcon = (props) => ( + * + * ); + * + * const ClearIcon = (props) => ( + * setText('')} /> + * ); + * + * return ( + * + * ); + * }; + * + * export default MyComponent; + * ``` + */ +const TextFieldIcon = ({ + icon, + color, + size, + style, + error, + disabled, + accessibility, + theme: themeOverride, + onPress, +}: TextFieldIconProps) => { + const theme = useInternalTheme(themeOverride); + + const iconSize = size ?? ACCESSORY_SIZE; + + const iconColor = getIconColor({ + theme, + color, + hasError: error, + 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..e4925b9176 --- /dev/null +++ b/src/components/TextField/constants.ts @@ -0,0 +1,112 @@ +import { I18nManager, PixelRatio, Platform } from 'react-native'; + +import { tokens } from '../../theme/tokens'; +import { motionDuration } from '../../theme/tokens/sys/motion'; +import { defaultShapes } from '../../theme/tokens/sys/shape'; + +export const isWeb = Platform.OS === 'web'; + +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 = BASELINE_TEXT_FIELD_HEIGHT * fontScale; +export const TEXT_FIELD_PADDING_VERTICAL = + 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 LINE_HEIGHT_DELTA = 2; +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 INACTIVE_LABEL_TOP_POSITION = + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL - + LINE_HEIGHT_DELTA) * + fontScale; + +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; + +const isRTL = I18nManager.getConstants().isRTL; +const layoutSupportMultiplier = isRTL ? -1 : 1; + +/** + * Constants for the filled variant. + */ + +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_MULTILINE_PADDING_TOP = + ACTIVE_LABEL_FONT_SIZE * fontScale + TEXT_FIELD_PADDING_VERTICAL; + +export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; + +/** + * Constants for the outlined variant. + */ + +export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; + +export const OUTLINED_MULTILINE_PADDING_TOP = + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 - + 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 = + (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale; + +export const OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY = + -layoutSupportMultiplier * + (ACCESSORY_SIZE + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + OUTLINED_LABEL_PADDING_HORIZONTAL); + +export const OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY = + -layoutSupportMultiplier * OUTLINED_LABEL_PADDING_HORIZONTAL; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts new file mode 100644 index 0000000000..b51930b013 --- /dev/null +++ b/src/components/TextField/hooks.ts @@ -0,0 +1,157 @@ +import { useImperativeHandle, useRef, useState } from 'react'; +import { BlurEvent, FocusEvent, I18nManager, TextInput } from 'react-native'; + +import type { + TextFieldHookReturn, + TextFieldProps, + TextFieldSharedApi, +} from './TextField'; +import { + getAccentColors, + getFilledTextFieldData, + getOutlinedTextFieldData, + getTextFieldAnimation, +} from './utils'; +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 [isFocused, setIsFocused] = useState(false); + + useImperativeHandle(ref, () => input.current as TextInput); + + /** + * Constants + */ + + const { isRTL } = I18nManager.getConstants(); + const disabled = props.editable === false; + 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, + 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, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }; + + /** + * Components + */ + + const LeadingAccessory = isRTL ? props.EndAccessory : props.StartAccessory; + const TrailingAccessory = 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}`; + + /** + * Styles + */ + + const data = { + hasPrefix, + hasCounter, + placeholderTextColor, + selectionColor, + cursorColor, + animatedActiveOutlineStyles: undefined, + animatedContainerStyle, + placeholder, + counterText, + LeadingAccessory, + TrailingAccessory, + 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..d6d5ba5bd9 --- /dev/null +++ b/src/components/TextField/styles.ts @@ -0,0 +1,123 @@ +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, + }, + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'flex-end', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + }, + labelWrapper: { + position: 'absolute', + }, + disabledBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: FILLED_DISABLED_CONTAINER_OPACITY, + }, +}); + +export const outlinedStyles = StyleSheet.create({ + outline: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: TEXT_FIELD_BORDER_RADIUS, + }, + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + }, + labelWrapper: { + position: 'absolute', + paddingHorizontal: OUTLINED_LABEL_PADDING_HORIZONTAL, + }, +}); diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts new file mode 100644 index 0000000000..846d992589 --- /dev/null +++ b/src/components/TextField/utils.ts @@ -0,0 +1,636 @@ +import { I18nManager, 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, + 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_X_WITHOUT_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY, + OUTLINED_MULTILINE_PADDING_TOP, + PREFIX_END_PADDING, + SUFFIX_START_PADDING, + TEXT_FIELD_BORDER_RADIUS, + isWeb, +} from './constants'; +import { filledStyles, outlinedStyles, styles } from './styles'; +import type { + FilledTextFieldHookData, + OutlinedTextFieldHookData, + TextFieldProps, + TextFieldSharedApi, + SharedTextFieldStyleData, +} 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, + disabled, +}: { + theme: InternalTheme; + isFocused: boolean; + hasError: boolean; + disabled: boolean; +}) => { + const { + colors: { error, primary, onSurface, onSurfaceVariant }, + } = theme; + + if (hasError) { + return error; + } + if (disabled) { + return onSurface; + } + if (isFocused) { + return primary; + } + return onSurfaceVariant; +}; + +export const getSupportingTextColor = ({ + theme, + hasError, + disabled, +}: { + theme: InternalTheme; + hasError: boolean; + disabled: boolean; +}) => { + const { + colors: { error, onSurface, onSurfaceVariant }, + } = theme; + + if (hasError) { + return error; + } + if (disabled) { + 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, + disabled, +}: { + theme: InternalTheme; + disabled: boolean; +}): string | undefined => { + if (disabled) { + return undefined; + } + + return theme.colors.surfaceContainerHighest; +}; + +export const getIconColor = ({ + theme, + color, + hasError, + disabled, +}: { + theme: InternalTheme; + color?: string; + hasError: boolean; + disabled: boolean; +}) => { + if (color) return color; + if (hasError) return theme.colors.error; + if (disabled) 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, + disabled, +}: { + theme: InternalTheme; + isFocused: boolean; + hasError: boolean; + disabled: boolean; +}) => { + const { + colors: { error, onSurface, primary, outline }, + } = theme; + + if (hasError) { + return error; + } + if (disabled) { + 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, + props: TextFieldProps +): SharedTextFieldStyleData => { + const { isRTL } = I18nManager.getConstants(); + + const { theme, disabled, hasError, isFocused, animatedLabelTextStyle } = api; + const { + labelProps, + supportingTextProps, + counterProps, + prefixProps, + suffixProps, + } = props; + + const labelColor = getLabelColor({ theme, hasError, isFocused, disabled }); + + const supportingTextColor = getSupportingTextColor({ + theme, + hasError, + disabled, + }); + const { + colors: { onSurfaceVariant }, + } = theme; + + const animatedLabelTextStyles: StyleProp< + AnimatedStyle> + > = [ + styles.input, + { color: labelColor }, + animatedLabelTextStyle, + disabled && styles.disabled, + labelProps?.style, + ]; + + const supportingTextStyles: StyleProp = [ + styles.supportingText, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && styles.disabled, + supportingTextProps?.style, + ]; + + const counterStyles: StyleProp = [ + styles.counter, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && styles.disabled, + counterProps?.style, + ]; + + const prefixStyles: StyleProp = [ + styles.input, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingEnd: PREFIX_END_PADDING, + }, + disabled && styles.disabled, + prefixProps?.style, + ]; + + const suffixStyles: StyleProp = [ + styles.input, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingStart: SUFFIX_START_PADDING, + }, + disabled && styles.disabled, + suffixProps?.style, + ]; + + const leadingAccessoryStyles: StyleProp = [ + styles.leadingAccessory, + disabled && styles.disabled, + ]; + + const trailingAccessoryStyles: StyleProp = [ + styles.trailingAccessory, + disabled && styles.disabled, + ]; + + return { + isRTL, + animatedLabelTextStyles, + supportingTextStyles, + counterStyles, + prefixStyles, + suffixStyles, + leadingAccessoryStyles, + trailingAccessoryStyles, + }; +}; + +export const getTextFieldAnimation = ({ + variant, + isFloating, + isFocused, + hasAccessory, +}: { + variant: 'filled' | 'outlined'; + isFloating: boolean; + isFocused: 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 top = isFloating ? activeTop : INACTIVE_LABEL_TOP_POSITION; + 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 translateXEnd = hasAccessory + ? OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY + : OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY; + + 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, + fieldStyle: fieldStyleOverride, + containerStyle: containerStyleOverride, + outlineStyle: outlineStyleOverride, + ...textInputProps + } = props; + + const { + input, + theme, + hasSuffix, + disabled, + hasAccessory, + hasError, + animatedLabelWrapperStyle, + animatedActiveOutlineStyle, + } = api; + + /** + * Theme tokens + */ + const { + colors: { onSurface }, + } = theme; + + const outlineColor = getOutlineColor({ + theme, + hasError, + isFocused: false, + disabled, + }); + + const activeOutlineColor = getOutlineColor({ + theme, + hasError, + isFocused: true, + disabled, + }); + + const fieldBackgroundColor = getFieldBackgroundColor({ theme, disabled }); + + /** + * Shared styles + */ + + const shared = getSharedTextFieldStyleData(api, props); + + /** + * 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, + disabled && styles.disabled, + containerStyleOverride, + ]; + + const fieldStyles: StyleProp = [ + styles.field, + { + backgroundColor: fieldBackgroundColor, + borderTopStartRadius: TEXT_FIELD_BORDER_RADIUS, + borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, + overflow: 'hidden', + }, + fieldStyleOverride, + ]; + + /* 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 = disabled + ? [ + filledStyles.disabledBackground, + { + backgroundColor: onSurface, + }, + ] + : undefined; + + const outlineStyles: StyleProp = [ + filledStyles.outline, + { + height: INACTIVE_INDICATOR_SIZE, + backgroundColor: outlineColor, + }, + disabled && styles.disabled, + outlineStyleOverride, + ]; + + const animatedActiveOutlineStyles: StyleProp< + AnimatedStyle> + > = [ + filledStyles.outline, + { + height: ACTIVE_INDICATOR_SIZE, + backgroundColor: activeOutlineColor, + }, + disabled && styles.disabled, + outlineStyleOverride, + 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, + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && styles.disabled, + inputStyleOverride, + ]; + + return { + input, + disabled, + hasError, + hasSuffix, + animatedLabelWrapperStyles, + containerStyles, + fieldStyles, + disabledBackgroundStyles, + outlineStyles, + animatedActiveOutlineStyles, + inputStyles, + ...shared, + }; +}; + +export const getOutlinedTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): OutlinedTextFieldHookData => { + const { + style: inputStyleOverride, + fieldStyle: fieldStyleOverride, + containerStyle: containerStyleOverride, + outlineStyle: outlineStyleOverride, + ...textInputProps + } = props; + + const { + input, + theme, + isFocused, + disabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + } = api; + + /** + * Theme tokens + */ + + const { + colors: { background: labelBackgroundColor, onSurface }, + } = theme; + + const outlineColor = getOutlineColor({ + theme, + disabled, + isFocused, + hasError, + }); + + /** + * Shared styles + */ + + const shared = getSharedTextFieldStyleData(api, props); + + /** + * Variant-specific styles + */ + + const containerStyles: StyleProp = [ + outlinedStyles.container, + disabled && styles.disabled, + containerStyleOverride, + ]; + + const fieldStyles: StyleProp = [ + styles.field, + { + borderRadius: TEXT_FIELD_BORDER_RADIUS, + }, + textInputProps.multiline && { alignItems: 'flex-start' }, + fieldStyleOverride, + ]; + + /* 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, + }, + disabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, + fieldStyleOverride, + outlineStyleOverride, + ]; + + 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, + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && styles.disabled, + inputStyleOverride, + ]; + + return { + input, + disabled, + hasError, + hasSuffix, + animatedLabelWrapperStyles, + containerStyles, + fieldStyles, + disabledBackgroundStyles: undefined, + outlineStyles, + inputStyles, + ...shared, + }; +}; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx new file mode 100644 index 0000000000..3b07c24baf --- /dev/null +++ b/src/components/__tests__/TextField.test.tsx @@ -0,0 +1,1022 @@ +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 } 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; +} + +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 not editable', () => { + const { getAllByTestId } = render( + {}} + editable={false} + 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('renders supporting text below the field', () => { + const { getByText } = render( + {}} + supportingText="Use a valid address" + /> + ); + + expect(getByText('Use a valid address')).toBeTruthy(); +}); + +it('sets aria-invalid on the input when error is true', () => { + const { getByTestId } = render( + {}} + error + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-invalid']).toBe(true); +}); + +it('uses assertive aria-live on supporting text when error is true', () => { + const { getByText } = render( + {}} + supportingText="Invalid" + error + /> + ); + + expect(getByText('Invalid').props['aria-live']).toBe('assertive'); +}); + +it('uses polite aria-live on supporting text when there is no error', () => { + const { getByText } = render( + {}} + supportingText="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-invalid and aria-disabled when error and editable is false', () => { + const { getByTestId } = render( + {}} + error + editable={false} + testID="tf-input" + /> + ); + + const input = getByTestId('tf-input'); + expect(input.props['aria-invalid']).toBe(true); + expect(input.props['aria-disabled']).toBe(true); +}); + +it('applies disabled opacity to the TextInput when editable is false (filled)', () => { + const { getByTestId } = render( + {}} + editable={false} + 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 editable is false (outlined)', () => { + const { getByTestId } = render( + {}} + editable={false} + 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(); +}); + +it('does not pass TextField-only props through to TextInput', () => { + const { getByTestId } = render( + {}} + error + 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.pressableStyle).toBeUndefined(); + expect(input.props.fieldStyle).toBeUndefined(); + expect(input.props.containerStyle).toBeUndefined(); + expect(input.props.outlineStyle).toBeUndefined(); + expect(input.props.supportingText).toBeUndefined(); + expect(input.props.supportingTextProps).toBeUndefined(); + expect(input.props.prefix).toBeUndefined(); + expect(input.props.prefixProps).toBeUndefined(); + expect(input.props.suffix).toBeUndefined(); + expect(input.props.suffixProps).toBeUndefined(); + expect(input.props.counter).toBeUndefined(); + expect(input.props.counterProps).toBeUndefined(); + expect(input.props.error).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( + {}} + 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 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 + editable={false} + 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 + editable={false} + StartAccessory={StartAccessory} + /> + ); + + expect(getByTestId('start-acc-error-disabled')).toBeTruthy(); + expect(startAccessoryProps[0].error).toBe(true); + expect(startAccessoryProps[0].disabled).toBe(true); +}); + +it('applies supportingTextProps to the supporting Text', () => { + const { getByTestId } = render( + {}} + supportingText="Hint" + supportingTextProps={{ testID: 'supporting-text' }} + /> + ); + + expect(getByTestId('supporting-text').props.children).toBe('Hint'); +}); + +it('applies counterProps to the counter Text', () => { + const { getByTestId } = render( + {}} + counter + maxLength={80} + counterProps={{ testID: 'counter-text' }} + /> + ); + + expect(getByTestId('counter-text').props.children).toBe('2/80'); +}); + +it('does not apply supportingTextProps style to the counter Text', () => { + const { getByTestId } = render( + {}} + counter + maxLength={10} + supportingTextProps={{ style: { fontSize: 9 } }} + counterProps={{ testID: 'counter-text' }} + /> + ); + + expect( + StyleSheet.flatten(getByTestId('counter-text').props.style).fontSize + ).not.toBe(9); +}); + +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 { getByTestId } = render( + {}} + supportingText="Hint" + supportingTextProps={{ testID: 'supporting-text-rtl' }} + /> + ); + + expect( + StyleSheet.flatten(getByTestId('supporting-text-rtl').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, queryByTestId, rerender } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + prefixProps={{ testID: 'tf-prefix' }} + suffixProps={{ testID: 'tf-suffix' }} + /> + ); + + expect(getByTestId('tf-prefix')).toBeTruthy(); + expect(getByTestId('tf-suffix')).toBeTruthy(); + + rerender( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + prefixProps={{ testID: 'tf-prefix' }} + suffixProps={{ testID: 'tf-suffix' }} + /> + ); + + expect(queryByTestId('tf-prefix')).toBeNull(); + expect(queryByTestId('tf-suffix')).toBeNull(); + expect(getByTestId('tf-ps')).toBeTruthy(); +}); + +it('renders prefix and suffix while focused even when value is empty', () => { + const { getByTestId, queryByTestId } = render( + {}} + prefix="$" + suffix=" kg" + testID="tf-ps-focus" + prefixProps={{ testID: 'tf-prefix-focus' }} + suffixProps={{ testID: 'tf-suffix-focus' }} + /> + ); + + expect(queryByTestId('tf-prefix-focus')).toBeNull(); + expect(queryByTestId('tf-suffix-focus')).toBeNull(); + + fireEvent(getByTestId('tf-ps-focus'), 'focus'); + + expect(getByTestId('tf-prefix-focus')).toBeTruthy(); + expect(getByTestId('tf-suffix-focus')).toBeTruthy(); +}); + +it('places prefix Text before the TextInput and suffix Text after it', () => { + const { toJSON } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-order" + prefixProps={{ testID: 'order-prefix' }} + suffixProps={{ testID: 'order-suffix' }} + /> + ); + + const tree = toJSON(); + expect(firstIndexOfTestIdInTree(tree, 'order-prefix')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'tf-order') + ); + expect(firstIndexOfTestIdInTree(tree, 'tf-order')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'order-suffix') + ); +}); + +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 } = render( + {}} + prefix="$" + suffix="]" + style={{ fontSize: 40, letterSpacing: 9 }} + testID="tf-input-style" + prefixProps={{ testID: 'pfx-no-input-style' }} + suffixProps={{ testID: 'sfx-no-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( + getByTestId('pfx-no-input-style').props.style + ); + const suffixFlat = StyleSheet.flatten( + getByTestId('sfx-no-input-style').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__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 0f997739ba..dcd43b927f 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -229,7 +229,7 @@ exports[`DataTable.Header renders data table header 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -310,7 +310,7 @@ exports[`DataTable.Header renders data table header 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -2619,7 +2619,7 @@ exports[`DataTable.Title renders data table title with press handler 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -2747,7 +2747,7 @@ exports[`DataTable.Title renders data table title with sort icon 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -2833,7 +2833,7 @@ exports[`DataTable.Title renders right aligned data table title 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { 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..3f4978cdad --- /dev/null +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -0,0 +1,3105 @@ +// 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..1db43a3a84 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,12 @@ 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 { + TextFieldProps, + TextFieldAccessoryProps, + 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';