diff --git a/babel.config.js b/babel.config.js index f7b3da3b33..8ba8eb658c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-worklets/plugin'], }; diff --git a/docs/docs/guides/01-getting-started.md b/docs/docs/guides/01-getting-started.md index 19fc3c2dea..b75212845c 100644 --- a/docs/docs/guides/01-getting-started.md +++ b/docs/docs/guides/01-getting-started.md @@ -18,6 +18,16 @@ npm install react-native-paper npm install react-native-safe-area-context ``` +- You also need to install [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) and [react-native-worklets](https://docs.swmansion.com/react-native-worklets/) for animations. + +```bash npm2yarn +npm install react-native-reanimated react-native-worklets +``` + +:::note +If you're using a bare React Native project (not Expo), you need to add `react-native-worklets/plugin` to your `babel.config.js` plugins array. See the [Reanimated installation guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/) for details. +::: + Additionaly for `iOS` platform there is a requirement to link the native parts of the library: ```bash 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..904e43a394 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#L20', 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/package.json b/example/package.json index 3ae36fb1ec..6a412f008c 100644 --- a/example/package.json +++ b/example/package.json @@ -36,11 +36,11 @@ "react-native": "0.81.4", "react-native-gesture-handler": "~2.28.0", "react-native-monorepo-config": "^0.1.6", - "react-native-reanimated": "~4.1.1", + "react-native-reanimated": "^4.3.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-web": "^0.21.0", - "react-native-worklets": "0.5.1", + "react-native-worklets": "^0.8.1", "typeface-roboto": "^1.1.13" }, "devDependencies": { 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..050b473d4b --- /dev/null +++ b/example/src/Examples/TextFieldExample.tsx @@ -0,0 +1,251 @@ +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 status = + controls.error || controls.disabled + ? [ + ...(controls.error ? (['error'] as const) : []), + ...(controls.disabled ? (['disabled'] as const) : []), + ] + : undefined; + + 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/jestSetupAfterEnv.js b/jestSetupAfterEnv.js new file mode 100644 index 0000000000..8080db61ed --- /dev/null +++ b/jestSetupAfterEnv.js @@ -0,0 +1 @@ +require('react-native-reanimated').setUpTests(); diff --git a/package.json b/package.json index fbf4482041..e82d9f6372 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,9 @@ "react-dom": "18.3.1", "react-native": "0.82.1", "react-native-builder-bob": "^0.21.3", + "react-native-reanimated": "^4.3.0", "react-native-safe-area-context": "5.5.2", + "react-native-worklets": "^0.8.1", "react-test-renderer": "19.1.1", "release-it": "^13.4.0", "rimraf": "^3.0.2", @@ -105,7 +107,9 @@ "peerDependencies": { "react": "*", "react-native": "*", - "react-native-safe-area-context": "*" + "react-native-reanimated": ">=4.3.0", + "react-native-safe-area-context": "*", + "react-native-worklets": ">=0.8.1" }, "husky": { "hooks": { @@ -119,6 +123,7 @@ "/testSetup.js" ], "setupFilesAfterEnv": [ + "/jestSetupAfterEnv.js", "@testing-library/jest-native/extend-expect" ], "cacheDirectory": "./cache/jest", diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx new file mode 100644 index 0000000000..203d5520fa --- /dev/null +++ b/src/components/TextField/TextField.tsx @@ -0,0 +1,417 @@ +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 './logic'; +import { $addendumStyle } from './styles'; +import TextFieldErrorIcon from './TextFieldErrorIcon'; +import type { InternalTheme, ThemeProp } from '../../types'; + +export type TextFieldVariant = 'filled' | 'outlined'; + +export type TextFieldStatus = 'error' | 'disabled' | ('error' | 'disabled')[]; + +export interface TextFieldAccessoryProps { + style: StyleProp; + multiline: boolean; + editable: boolean; + status?: TextFieldStatus; +} + +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; + /** + * A style modifier for different input states. Accepts an array so both + * `'error'` and `'disabled'` can be active simultaneously. + */ + status?: TextFieldStatus; + /** + * 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 + * `status` is `error`, 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; + 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, 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, + supportingText, + supportingTextProps, + labelProps, + variant, + pressableStyle: $pressableStyleOverride, + fieldStyle, + containerStyle, + 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..41b8f9a0f5 --- /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..9a51a360fe --- /dev/null +++ b/src/components/TextField/TextFieldIcon.tsx @@ -0,0 +1,115 @@ +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 { $iconStyle, $iconWrapperStyle } from './styles'; +import type { TextFieldAccessoryProps } from './TextField'; +import { getIconColor, parseStatus } 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, + status, + editable, + accessibility, + theme: themeOverride, + onPress, +}: TextFieldIconProps) => { + const theme = useInternalTheme(themeOverride); + + const { hasError, disabled } = parseStatus(status); + + const iconSize = size ?? ACCESSORY_SIZE; + + const iconColor = getIconColor({ + theme, + color, + hasError, + disabled, + }); + + const onPressHandler = editable ? onPress : undefined; + + 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..b0b4150130 --- /dev/null +++ b/src/components/TextField/constants.ts @@ -0,0 +1,68 @@ +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; + +export const PREFIX_END_PADDING = 2; +export const SUFFIX_START_PADDING = 2; + +export const ERROR_ICON_SIZE = 16; + +// =============== +// 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 SUPPORTING_TEXT_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 SUPPORTING_TEXT_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; + +// ================== +// LABEL POSITIONING +// ================== +export const LABEL_START_OFFSET_WITHOUT_ACCESSORY = + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; diff --git a/src/components/TextField/filled/constants.ts b/src/components/TextField/filled/constants.ts new file mode 100644 index 0000000000..838863e65b --- /dev/null +++ b/src/components/TextField/filled/constants.ts @@ -0,0 +1,31 @@ +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_START_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; + +// ================== +// MULTILINE POSITIONING +// ================== + +export const MULTILINE_PADDING_TOP = + ACTIVE_LABEL_FONT_SIZE + TEXT_FIELD_PADDING_VERTICAL; + +// ================== +// OPACITY +// ================== + +export const DISABLED_CONTAINER_OPACITY = 0.04; diff --git a/src/components/TextField/filled/logic.ts b/src/components/TextField/filled/logic.ts new file mode 100644 index 0000000000..3d485d39f1 --- /dev/null +++ b/src/components/TextField/filled/logic.ts @@ -0,0 +1,181 @@ +import { StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { AnimatedStyle } from 'react-native-reanimated'; + +import { + ACTIVE_INDICATOR_SIZE, + INACTIVE_INDICATOR_SIZE, + INPUT_FONT_SIZE, + LABEL_START_OFFSET_WITHOUT_ACCESSORY, + isWeb, +} from '../constants'; +import { $disabledStyle, $inputStyle } from '../styles'; +import type { + FilledTextFieldHookData, + TextFieldProps, + TextFieldSharedApi, +} from '../TextField'; +import { getFieldBackgroundColor, getSharedTextFieldStyleData } from '../utils'; +import { + LABEL_START_OFFSET_WITH_ACCESSORY, + MULTILINE_PADDING_TOP, +} from './constants'; +import { + $containerStyle, + $fieldStyle, + $labelWrapperStyle, + $disabledBackgroundStyle, + $outlineStyle, +} from './styles'; +import { getOutlineColor } from './utils'; + +export const getFilledTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): FilledTextFieldHookData => { + const { + style: $inputStyleOverride, + fieldStyle: $fieldStyleOverride, + containerStyle: $containerStyleOverride, + ...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> + > = [ + $labelWrapperStyle, + { + left: hasAccessory + ? LABEL_START_OFFSET_WITH_ACCESSORY + : LABEL_START_OFFSET_WITHOUT_ACCESSORY, + }, + $animatedLabelWrapperStyle, + ]; + + const $containerStyles: StyleProp = [ + $containerStyle, + disabled && $disabledStyle, + $containerStyleOverride, + ]; + + const $fieldStyles: StyleProp = [ + $fieldStyle, + { + backgroundColor: fieldBackgroundColor, + }, + $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 = disabled + ? [ + $disabledBackgroundStyle, + { + backgroundColor: onSurface, + }, + ] + : undefined; + + const $outlineStyles = [ + $outlineStyle, + { + height: INACTIVE_INDICATOR_SIZE, + backgroundColor: outlineColor, + }, + disabled && $disabledStyle, + ]; + + const $animatedActiveOutlineStyles: StyleProp< + AnimatedStyle> + > = [ + $outlineStyle, + { + height: ACTIVE_INDICATOR_SIZE, + backgroundColor: activeOutlineColor, + }, + disabled && $disabledStyle, + $animatedActiveOutlineStyle, + ]; + + const $inputStyles: StyleProp = [ + $inputStyle, + { + flex: 1, + color: onSurface, + fontSize: INPUT_FONT_SIZE, + textAlign: hasSuffix === shared.isRTL ? 'left' : 'right', + writingDirection: shared.isRTL ? 'rtl' : 'ltr', + }, + textInputProps.multiline && { + height: 'auto', + paddingTop: MULTILINE_PADDING_TOP, + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && $disabledStyle, + $inputStyleOverride, + ]; + + return { + input, + disabled, + hasError, + hasSuffix, + $animatedLabelWrapperStyles, + $containerStyles, + $fieldStyles, + $disabledBackgroundStyles, + $outlineStyles, + $animatedActiveOutlineStyles, + $inputStyles, + ...shared, + }; +}; diff --git a/src/components/TextField/filled/styles.ts b/src/components/TextField/filled/styles.ts new file mode 100644 index 0000000000..f9bb8b5529 --- /dev/null +++ b/src/components/TextField/filled/styles.ts @@ -0,0 +1,45 @@ +import { 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 { DISABLED_CONTAINER_OPACITY } 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, + overflow: 'hidden', +}; + +export const $outlineStyle: ViewStyle = { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, +}; + +export const $containerStyle: ViewStyle = { + flex: 1, + flexDirection: 'row', + alignItems: 'flex-end', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, +}; + +export const $labelWrapperStyle: ViewStyle = { + position: 'absolute', +}; + +export const $disabledBackgroundStyle: ViewStyle = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: DISABLED_CONTAINER_OPACITY, +}; diff --git a/src/components/TextField/filled/utils.ts b/src/components/TextField/filled/utils.ts new file mode 100644 index 0000000000..95b9ec2c9a --- /dev/null +++ b/src/components/TextField/filled/utils.ts @@ -0,0 +1,34 @@ +import type { InternalTheme } from '../../../types'; + +/** + * 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; +}; 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/logic.ts b/src/components/TextField/logic.ts new file mode 100644 index 0000000000..ce657a4bbf --- /dev/null +++ b/src/components/TextField/logic.ts @@ -0,0 +1,247 @@ +import { useImperativeHandle, useRef, useState } from 'react'; +import { + BlurEvent, + FocusEvent, + I18nManager, + StyleProp, + TextInput, + TextStyle, + ViewStyle, +} from 'react-native'; + +import { AnimatedStyle } from 'react-native-reanimated'; + +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 type { + TextFieldHookReturn, + TextFieldProps, + TextFieldSharedApi, +} from './TextField'; +import { getAccentColors, parseStatus } 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 { hasError, disabled: isDisabled } = parseStatus(props.status); + const disabled = props.editable === false || isDisabled; + const isFloating = isFocused || !!props.value; + 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: $selectionColor, cursorColor: $cursorColor } = + getAccentColors({ theme, hasError }); + + const $placeholderTextColor = + props.placeholderTextColor ?? theme.colors.onSurfaceVariant; + + // ======================= + // LABEL ANIMATION + // ======================= + + const { + $animatedLabelWrapperStyle, + $animatedLabelTextStyle, + $animatedActiveOutlineStyle, + $animatedContainerStyle, + } = useTextFieldAnimation({ + 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), + }; +}; + +const useTextFieldAnimation = ({ + 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 : OUTLINED_ACTIVE_LABEL_TOP; + + const top = isFloating ? activeTop : INACTIVE_LABEL_TOP_POSITION; + const fontSize = isFloating + ? ACTIVE_LABEL_FONT_SIZE + : INACTIVE_LABEL_FONT_SIZE; + + const $animatedContainerStyle: StyleProp< + AnimatedStyle> + > = { + 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 + ? LABEL_TRANSLATE_X_WITH_ACCESSORY + : 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, + }; +}; diff --git a/src/components/TextField/outlined/constants.ts b/src/components/TextField/outlined/constants.ts new file mode 100644 index 0000000000..3758b34366 --- /dev/null +++ b/src/components/TextField/outlined/constants.ts @@ -0,0 +1,55 @@ +import { I18nManager } from 'react-native'; + +import { + ACCESSORY_SIZE, + INPUT_FONT_SIZE, + LINE_HEIGHT_DELTA, + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_FIELD_HEIGHT, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_FIELD_PADDING_VERTICAL, +} from '../constants'; + +// ================== +// LAYOUT SUPPORT +// ================== + +const isRTL = I18nManager.getConstants().isRTL; +const layoutSupportMultiplier = isRTL ? -1 : 1; + +// ================== +// LABEL POSITIONING +// ================== +export const LABEL_PADDING_HORIZONTAL = 4; + +export const LABEL_START_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + LABEL_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; + +// ============ +// OPACITY +// ============ + +export const DISABLED_OUTLINE_OPACITY = 0.12; + +// ================== +// MULTILINE POSITIONING +// ================== + +export const MULTILINE_PADDING_TOP = + (TEXT_FIELD_HEIGHT - 2 * TEXT_FIELD_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 - + 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..aa46643cc8 --- /dev/null +++ b/src/components/TextField/outlined/logic.ts @@ -0,0 +1,149 @@ +import { StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { AnimatedStyle } from 'react-native-reanimated'; + +import { + INPUT_FONT_SIZE, + LABEL_START_OFFSET_WITHOUT_ACCESSORY, + isWeb, +} from '../constants'; +import { $disabledStyle, $inputStyle } from '../styles'; +import type { + OutlinedTextFieldHookData, + TextFieldProps, + TextFieldSharedApi, +} from '../TextField'; +import { getSharedTextFieldStyleData } from '../utils'; +import { + LABEL_START_OFFSET_WITH_ACCESSORY, + DISABLED_OUTLINE_OPACITY, + MULTILINE_PADDING_TOP, +} from './constants'; +import { + $containerStyle, + $fieldStyle, + $labelWrapperStyle, + $outlineStyle, +} from './styles'; +import { getOutlineColor } from './utils'; + +export const getOutlinedTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): OutlinedTextFieldHookData => { + const { + style: $inputStyleOverride, + fieldStyle: $fieldStyleOverride, + containerStyle: $containerStyleOverride, + ...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 = [ + $containerStyle, + disabled && $disabledStyle, + $containerStyleOverride, + ]; + + const $fieldStyles: StyleProp = [ + $fieldStyle, + 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 = [ + $outlineStyle, + { + borderWidth: isFocused ? 2 : 1, + borderColor: outlineColor, + }, + disabled && { opacity: DISABLED_OUTLINE_OPACITY }, + $fieldStyleOverride, + ]; + + const $animatedLabelWrapperStyles: StyleProp< + AnimatedStyle> + > = [ + $labelWrapperStyle, + { + left: hasAccessory + ? LABEL_START_OFFSET_WITH_ACCESSORY + : LABEL_START_OFFSET_WITHOUT_ACCESSORY, + backgroundColor: labelBackgroundColor, + }, + $animatedLabelWrapperStyle, + ]; + + const $inputStyles: StyleProp = [ + $inputStyle, + { + 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: MULTILINE_PADDING_TOP, + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && $disabledStyle, + $inputStyleOverride, + ]; + + return { + input, + disabled, + hasError, + hasSuffix, + $animatedLabelWrapperStyles, + $containerStyles, + $fieldStyles, + $disabledBackgroundStyles: undefined, + $outlineStyles, + $inputStyles, + ...shared, + }; +}; diff --git a/src/components/TextField/outlined/styles.ts b/src/components/TextField/outlined/styles.ts new file mode 100644 index 0000000000..689ab2485e --- /dev/null +++ b/src/components/TextField/outlined/styles.ts @@ -0,0 +1,37 @@ +import { 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, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, +}; + +export const $labelWrapperStyle: ViewStyle = { + position: 'absolute', + paddingHorizontal: LABEL_PADDING_HORIZONTAL, +}; diff --git a/src/components/TextField/outlined/utils.ts b/src/components/TextField/outlined/utils.ts new file mode 100644 index 0000000000..6cf25b1a60 --- /dev/null +++ b/src/components/TextField/outlined/utils.ts @@ -0,0 +1,39 @@ +import type { InternalTheme } from '../../../types'; + +/** + * Returns the raw outline color for an outlined 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, + 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 = onSurface; + } + + 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..3e7d430494 --- /dev/null +++ b/src/components/TextField/styles.ts @@ -0,0 +1,68 @@ +import { StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { + ACCESSORY_SIZE, + SUPPORTING_TEXT_FONT_SIZE, + SUPPORTING_TEXT_MARGIN_TOP, + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, +} from './constants'; +import { tokens } from '../../styles/themes/v3/tokens'; + +export const $inputStyle: StyleProp = { + paddingVertical: 0, + paddingHorizontal: 0, + includeFontPadding: false, + fontWeight: '400', +}; + +export const $addendumStyle: ViewStyle = { + flexDirection: 'row', +}; + +export const $supportingTextStyle: TextStyle = { + flex: 1, + marginTop: SUPPORTING_TEXT_MARGIN_TOP, + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: SUPPORTING_TEXT_FONT_SIZE, + fontWeight: '400', + textAlign: 'left', +}; + +export const $counterStyle: TextStyle = { + marginTop: SUPPORTING_TEXT_MARGIN_TOP, + marginStart: 'auto', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: SUPPORTING_TEXT_FONT_SIZE, + fontWeight: '400', + textAlign: 'right', +}; + +export const $trailingAccessoryStyle: ViewStyle = { + width: ACCESSORY_SIZE, + marginEnd: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + alignSelf: 'stretch', + justifyContent: 'center', + alignItems: 'center', +}; + +export const $leadingAccessoryStyle: ViewStyle = { + width: ACCESSORY_SIZE, + marginStart: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + alignSelf: 'stretch', + justifyContent: 'center', + alignItems: 'center', +}; + +export const $disabledStyle: ViewStyle = { + opacity: tokens.md.ref.stateOpacity.disabled, +}; + +export const $iconWrapperStyle: ViewStyle = { + justifyContent: 'center', + alignItems: 'center', +}; + +export const $iconStyle: ViewStyle = { + margin: 0, +}; diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts new file mode 100644 index 0000000000..39cc95e15f --- /dev/null +++ b/src/components/TextField/utils.ts @@ -0,0 +1,246 @@ +import { I18nManager, StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { AnimatedStyle } from 'react-native-reanimated'; + +import { + INPUT_FONT_SIZE, + PREFIX_END_PADDING, + SUFFIX_START_PADDING, +} from './constants'; +import { + $counterStyle, + $disabledStyle, + $inputStyle, + $leadingAccessoryStyle, + $supportingTextStyle, + $trailingAccessoryStyle, +} from './styles'; +import type { + TextFieldProps, + TextFieldSharedApi, + TextFieldStatus, + SharedTextFieldStyleData, +} from './TextField'; +import type { InternalTheme } from '../../types'; + +export const parseStatus = ( + status: TextFieldStatus | undefined +): { hasError: boolean; disabled: boolean } => { + if (!status) return { hasError: false, disabled: false }; + + const list = typeof status === 'string' ? [status] : status; + + return { + hasError: list.includes('error'), + disabled: list.includes('disabled'), + }; +}; + +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; +}; + +/** + * 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> + > = [ + $inputStyle, + { color: labelColor }, + $animatedLabelTextStyle, + disabled && $disabledStyle, + labelProps?.style, + ]; + + const $supportingTextStyles: StyleProp = [ + $supportingTextStyle, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && $disabledStyle, + supportingTextProps?.style, + ]; + + const $counterStyles: StyleProp = [ + $counterStyle, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && $disabledStyle, + counterProps?.style, + ]; + + const $prefixStyles: StyleProp = [ + $inputStyle, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingEnd: PREFIX_END_PADDING, + }, + disabled && $disabledStyle, + prefixProps?.style, + ]; + + const $suffixStyles: StyleProp = [ + $inputStyle, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingStart: SUFFIX_START_PADDING, + }, + disabled && $disabledStyle, + suffixProps?.style, + ]; + + const $leadingAccessoryStyles: StyleProp = [ + $leadingAccessoryStyle, + disabled && $disabledStyle, + ]; + + const $trailingAccessoryStyles: StyleProp = [ + $trailingAccessoryStyle, + disabled && $disabledStyle, + ]; + + return { + isRTL, + $animatedLabelTextStyles, + $supportingTextStyles, + $counterStyles, + $prefixStyles, + $suffixStyles, + $leadingAccessoryStyles, + $trailingAccessoryStyles, + }; +}; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx new file mode 100644 index 0000000000..979d706558 --- /dev/null +++ b/src/components/__tests__/TextField.test.tsx @@ -0,0 +1,1032 @@ +import * as React from 'react'; +import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; + +import { fireEvent, render } from '@testing-library/react-native'; + +import { tokens } from '../../styles/themes/v3/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 status is error', () => { + const tree = render( + {}} + status="error" + StartAccessory={(props: TextFieldAccessoryProps) => ( + + )} + EndAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextField with TextField.Icon accessories when status is error', () => { + const tree = render( + {}} + status="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 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 supporting text when status is error', () => { + const { getByText } = render( + {}} + supportingText="Invalid" + status="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-disabled when status is disabled', () => { + const { getByTestId } = render( + {}} + status="disabled" + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); +}); + +it('marks the input as aria-invalid and aria-disabled when status is error and disabled', () => { + const { getByTestId } = render( + {}} + status={['error', 'disabled']} + 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 status is disabled (filled)', () => { + const { getByTestId } = render( + {}} + status="disabled" + testID="tf-input-dis" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-dis').props.style) + ).toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('applies disabled opacity to the TextInput when status is disabled (outlined)', () => { + const { getByTestId } = render( + {}} + status="disabled" + testID="tf-input-dis-out" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-dis-out').props.style) + ).toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('forwards TextInput props such as testID', () => { + const { getByTestId } = render( + {}} + testID="email-input" + /> + ); + + expect(getByTestId('email-input')).toBeTruthy(); +}); + +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.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.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(); +}); + +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 status, editable, 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 + status="error" + editable={false} + StartAccessory={StartAccessory} + EndAccessory={EndAccessory} + /> + ); + + expect(getByTestId('start-accessory')).toBeTruthy(); + expect(getByTestId('end-accessory')).toBeTruthy(); + expect(startAccessoryProps[0]).toMatchObject({ + status: 'error', + editable: false, + multiline: true, + }); + expect(endAccessoryProps[0]).toMatchObject({ + status: 'error', + editable: false, + multiline: true, + }); +}); + +it('passes compound status array to accessories', () => { + const startAccessoryProps: TextFieldAccessoryProps[] = []; + + function StartAccessory(props: TextFieldAccessoryProps) { + startAccessoryProps.push(props); + return ; + } + + const { getByTestId } = render( + {}} + status={['error', 'disabled']} + StartAccessory={StartAccessory} + /> + ); + + expect(getByTestId('start-acc-compound')).toBeTruthy(); + expect(startAccessoryProps[0].status).toEqual(['error', 'disabled']); + expect(startAccessoryProps[0].editable).toBe(false); +}); + +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__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap new file mode 100644 index 0000000000..cb0645d032 --- /dev/null +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -0,0 +1,3085 @@ +// 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 status is error 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 status is error 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 ec0c4d4786..b17bb919ca 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'; diff --git a/testSetup.js b/testSetup.js index 43346ff860..96afce28f6 100644 --- a/testSetup.js +++ b/testSetup.js @@ -4,6 +4,10 @@ jest.useFakeTimers(); jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); +jest.mock('react-native-worklets', () => + require('react-native-worklets/src/mock') +); + jest.mock('@react-native-vector-icons/material-design-icons', () => { const React = require('react'); const { Text } = require('react-native'); diff --git a/yarn.lock b/yarn.lock index 9696c756fb..5ae8c0517a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1124,7 +1124,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-arrow-functions@npm:^7.0.0-0, @babel/plugin-transform-arrow-functions@npm:^7.24.7, @babel/plugin-transform-arrow-functions@npm:^7.27.1": +"@babel/plugin-transform-arrow-functions@npm:^7.24.7, @babel/plugin-transform-arrow-functions@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1" dependencies: @@ -1183,7 +1183,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.0.0-0, @babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.27.1": +"@babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-class-properties@npm:7.27.1" dependencies: @@ -1207,7 +1207,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-classes@npm:^7.0.0-0, @babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.28.4": +"@babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.28.4": version: 7.28.4 resolution: "@babel/plugin-transform-classes@npm:7.28.4" dependencies: @@ -1481,7 +1481,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.0.0-0, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.27.1": +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.27.1" dependencies: @@ -1541,7 +1541,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-chaining@npm:^7.0.0-0, @babel/plugin-transform-optional-chaining@npm:^7.24.8, @babel/plugin-transform-optional-chaining@npm:^7.27.1, @babel/plugin-transform-optional-chaining@npm:^7.28.5": +"@babel/plugin-transform-optional-chaining@npm:^7.24.8, @babel/plugin-transform-optional-chaining@npm:^7.27.1, @babel/plugin-transform-optional-chaining@npm:^7.28.5": version: 7.28.5 resolution: "@babel/plugin-transform-optional-chaining@npm:7.28.5" dependencies: @@ -1732,7 +1732,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-shorthand-properties@npm:^7.0.0-0, @babel/plugin-transform-shorthand-properties@npm:^7.24.7, @babel/plugin-transform-shorthand-properties@npm:^7.27.1": +"@babel/plugin-transform-shorthand-properties@npm:^7.24.7, @babel/plugin-transform-shorthand-properties@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-shorthand-properties@npm:7.27.1" dependencies: @@ -1766,7 +1766,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-template-literals@npm:>=7, @babel/plugin-transform-template-literals@npm:^7.0.0-0, @babel/plugin-transform-template-literals@npm:^7.27.1": +"@babel/plugin-transform-template-literals@npm:>=7, @babel/plugin-transform-template-literals@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-template-literals@npm:7.27.1" dependencies: @@ -1826,7 +1826,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-unicode-regex@npm:^7.0.0-0, @babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.27.1": +"@babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-unicode-regex@npm:7.27.1" dependencies: @@ -1972,7 +1972,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-typescript@npm:^7.12.1, @babel/preset-typescript@npm:^7.16.7, @babel/preset-typescript@npm:^7.17.12, @babel/preset-typescript@npm:^7.18.6, @babel/preset-typescript@npm:^7.23.0": +"@babel/preset-typescript@npm:^7.12.1, @babel/preset-typescript@npm:^7.17.12, @babel/preset-typescript@npm:^7.18.6, @babel/preset-typescript@npm:^7.23.0, @babel/preset-typescript@npm:^7.27.1": version: 7.28.5 resolution: "@babel/preset-typescript@npm:7.28.5" dependencies: @@ -20025,6 +20025,16 @@ __metadata: languageName: node linkType: hard +"react-native-is-edge-to-edge@npm:^1.3.1": + version: 1.3.1 + resolution: "react-native-is-edge-to-edge@npm:1.3.1" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/28cebd5f1f3632864ff5e342278721d1e5e38627ae73859a8814012116ef15c629fee7137a6c9c97bb05d94bbe639b0b47e69b36fc2735ab53ed31570140663f + languageName: node + linkType: hard + "react-native-monorepo-config@npm:^0.1.6": version: 0.1.10 resolution: "react-native-monorepo-config@npm:0.1.10" @@ -20064,11 +20074,11 @@ __metadata: react-native: "npm:0.81.4" react-native-gesture-handler: "npm:~2.28.0" react-native-monorepo-config: "npm:^0.1.6" - react-native-reanimated: "npm:~4.1.1" + react-native-reanimated: "npm:^4.3.0" react-native-safe-area-context: "npm:~5.6.0" react-native-screens: "npm:~4.16.0" react-native-web: "npm:^0.21.0" - react-native-worklets: "npm:0.5.1" + react-native-worklets: "npm:^0.8.1" typeface-roboto: "npm:^1.1.13" url-loader: "npm:^4.1.1" languageName: unknown @@ -20120,7 +20130,9 @@ __metadata: react-dom: "npm:18.3.1" react-native: "npm:0.82.1" react-native-builder-bob: "npm:^0.21.3" + react-native-reanimated: "npm:^4.3.0" react-native-safe-area-context: "npm:5.5.2" + react-native-worklets: "npm:^0.8.1" react-test-renderer: "npm:19.1.1" release-it: "npm:^13.4.0" rimraf: "npm:^3.0.2" @@ -20129,22 +20141,23 @@ __metadata: peerDependencies: react: "*" react-native: "*" + react-native-reanimated: ">=4.3.0" react-native-safe-area-context: "*" + react-native-worklets: ">=0.8.1" languageName: unknown linkType: soft -"react-native-reanimated@npm:~4.1.1": - version: 4.1.6 - resolution: "react-native-reanimated@npm:4.1.6" +"react-native-reanimated@npm:^4.3.0": + version: 4.3.0 + resolution: "react-native-reanimated@npm:4.3.0" dependencies: - react-native-is-edge-to-edge: "npm:^1.2.1" - semver: "npm:7.7.2" + react-native-is-edge-to-edge: "npm:^1.3.1" + semver: "npm:^7.7.3" peerDependencies: - "@babel/core": ^7.0.0-0 react: "*" - react-native: "*" - react-native-worklets: ">=0.5.0" - checksum: 10c0/924b3a3fc0e6b47b97491122689bc00d59c5c2abf90ba05dd811f1c6d59efb8fb83135e4fa4463241ff937450025b7b335af54ab5f35c15197efaaef90235e91 + react-native: 0.81 - 0.85 + react-native-worklets: 0.8.x + checksum: 10c0/e882660f8876b5571b4cb6fe99cbf123f7329e1282376cd92fb4b45991a765aa364b295781acea2658bee1b0196ea122b624be63b3a906c06a72c2a67ab56486 languageName: node linkType: hard @@ -20229,26 +20242,27 @@ __metadata: languageName: node linkType: hard -"react-native-worklets@npm:0.5.1": - version: 0.5.1 - resolution: "react-native-worklets@npm:0.5.1" - dependencies: - "@babel/plugin-transform-arrow-functions": "npm:^7.0.0-0" - "@babel/plugin-transform-class-properties": "npm:^7.0.0-0" - "@babel/plugin-transform-classes": "npm:^7.0.0-0" - "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.0.0-0" - "@babel/plugin-transform-optional-chaining": "npm:^7.0.0-0" - "@babel/plugin-transform-shorthand-properties": "npm:^7.0.0-0" - "@babel/plugin-transform-template-literals": "npm:^7.0.0-0" - "@babel/plugin-transform-unicode-regex": "npm:^7.0.0-0" - "@babel/preset-typescript": "npm:^7.16.7" +"react-native-worklets@npm:^0.8.1": + version: 0.8.1 + resolution: "react-native-worklets@npm:0.8.1" + dependencies: + "@babel/plugin-transform-arrow-functions": "npm:^7.27.1" + "@babel/plugin-transform-class-properties": "npm:^7.27.1" + "@babel/plugin-transform-classes": "npm:^7.28.4" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.27.1" + "@babel/plugin-transform-optional-chaining": "npm:^7.27.1" + "@babel/plugin-transform-shorthand-properties": "npm:^7.27.1" + "@babel/plugin-transform-template-literals": "npm:^7.27.1" + "@babel/plugin-transform-unicode-regex": "npm:^7.27.1" + "@babel/preset-typescript": "npm:^7.27.1" convert-source-map: "npm:^2.0.0" - semver: "npm:7.7.2" + semver: "npm:^7.7.3" peerDependencies: - "@babel/core": ^7.0.0-0 + "@babel/core": "*" + "@react-native/metro-config": "*" react: "*" - react-native: "*" - checksum: 10c0/9eb9e6dea9abaf889400a6618355ef59af3075f5004a4bec9e4cba6dcfd13d8b63de0d4b29d75c00a3dcf5ad422e1bdb71636c75b1a2ad1c43d8b512f198bdab + react-native: 0.81 - 0.85 + checksum: 10c0/a82edbd65b09a31d973497dac899adcd618677b56f7e5c460fd9f3b1b4ef1547b7bfd2edaa644d1ebf866bf70658bdbd1314c9d0ab2856e98c6975d0e97fd449 languageName: node linkType: hard @@ -21674,15 +21688,6 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.7.2": - version: 7.7.2 - resolution: "semver@npm:7.7.2" - bin: - semver: bin/semver.js - checksum: 10c0/aca305edfbf2383c22571cb7714f48cadc7ac95371b4b52362fb8eeffdfbc0de0669368b82b2b15978f8848f01d7114da65697e56cd8c37b0dab8c58e543f9ea - languageName: node - linkType: hard - "semver@npm:^6.0.0, semver@npm:^6.2.0, semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -21701,6 +21706,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.3": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + "semver@npm:~7.5.4": version: 7.5.4 resolution: "semver@npm:7.5.4"