From daea37930bd3094f18fe1ec286be103b66a08f72 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Thu, 14 May 2026 13:17:03 +0200 Subject: [PATCH 01/26] feat: text field component --- docs/docusaurus.config.js | 6 + docs/src/components/PropTable.tsx | 14 +- docs/src/data/screenshots.js | 4 + docs/static/screenshots/text-field-filled.png | Bin 0 -> 40005 bytes .../screenshots/text-field-outlined.png | Bin 0 -> 25143 bytes example/src/ExampleList.tsx | 2 + example/src/Examples/TextFieldExample.tsx | 244 ++ jest/testSetup.js | 9 +- src/components/TextField/TextField.tsx | 422 +++ .../TextField/TextFieldErrorIcon.tsx | 31 + src/components/TextField/TextFieldIcon.tsx | 112 + src/components/TextField/constants.ts | 112 + src/components/TextField/hooks.ts | 157 + src/components/TextField/index.ts | 13 + src/components/TextField/styles.ts | 123 + src/components/TextField/utils.ts | 636 ++++ src/components/__tests__/TextField.test.tsx | 1022 ++++++ .../__snapshots__/DataTable.test.tsx.snap | 10 +- .../__snapshots__/TextField.test.tsx.snap | 3105 +++++++++++++++++ src/index.tsx | 7 + 20 files changed, 6017 insertions(+), 12 deletions(-) create mode 100644 docs/static/screenshots/text-field-filled.png create mode 100644 docs/static/screenshots/text-field-outlined.png create mode 100644 example/src/Examples/TextFieldExample.tsx create mode 100644 src/components/TextField/TextField.tsx create mode 100644 src/components/TextField/TextFieldErrorIcon.tsx create mode 100644 src/components/TextField/TextFieldIcon.tsx create mode 100644 src/components/TextField/constants.ts create mode 100644 src/components/TextField/hooks.ts create mode 100644 src/components/TextField/index.ts create mode 100644 src/components/TextField/styles.ts create mode 100644 src/components/TextField/utils.ts create mode 100644 src/components/__tests__/TextField.test.tsx create mode 100644 src/components/__tests__/__snapshots__/TextField.test.tsx.snap 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 0000000000000000000000000000000000000000..03ab10d37eb94c5e60b825fbdac149fcae4e7924 GIT binary patch literal 40005 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

w2 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 Swl<>x0000g#C%~n|ML|I!Pyl3MC@AOv3JR7X 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

P9hfvwG&=nSMyK9 zKVcp0F|+upifHE~QU{7$6v9c&4tL>96x{rL%`hAqbXU;$yg&JpfKGIK4H|SLqy8LH z_H#v{6;)8jJ8a*|-{(9la~2RrM;k)>gnU@flIG@ksb|MI zG+FCg!}Ee%!8mO1Q|91_yL4>qt@5!{P;+X7l)>Bp$#^moMlq^mo7wVQ`zF-farhma zz1pCcNQ5`;=UWwYvKZf&(a|-e`w`DE_8tzef-HrPO%0=X$p0J)0B76yq^`oBz7<*L z3a;|eTSx0nHLgu6J{yoxpk*L>ZJ)B@?c||YH1zzpoi&`~OTT~oT@!obD2GR@w+u(+PZoIkNx z6vdJfru2Oi{X&KovaTcvwpo*}SvB8?JHkFN-5UYv3l)UJvDH3`(!P;nR)(*3k4Nr3 z!alL2C2%63u0)hHKbEb2>}OXyS{Y%q$6_BBEJhELO|j8O>!ha9m%Pd?nlz24<5w|7 zFk|8=ER#~;L8Y6%rH7|~Cd_g6Zu!jj^n)H5RLn&9V}L5SSvLoD-FlDPZ$pw|9ze~k ze?x|R-?NzW)I-_`$lez15WmQX^sEG(plUS_<*uyDstQSAiI?u%{>?bABcAeCo7yRd z_RyhX!$}B}M?hoNa-*0WjlU;2!SyzY)=8Krjelr6Ie5O!|=o36t$3Vk+ta^qdr-4pm1s}fFp9uO{_@7 zLGq^OFXGail}sdudi6OhSV05`Rq2G7tUFM{ zTf-hnNuN;#A;{o2ruu92Lfz*t#dr<9_BUWX)k(KDOFuN?s$=SgZ+AEDt!v%k?qU2x z{KvGZnxXy44;0vQmU1Xqh8bY5_r##@F-AY&ebt0J2l7J?iLD$ML=LuH%NZJ!u$vyB zp2DbAv?Z}8xZ_rJANAY`1&Ng12A=imm7Twc^r*LlFKcV`e5gNs#G`d`6Ck=*7Ce9r ze8g3#VU1n(N6bYnCX9ud>0qZ)qXoK(7yhk_>wcqbSA=Crwq&#y?%?blv82aD`^6|= zGQrSmna)2v#x~He$Fv_hE@BayRM7Nf%j3O*1#*lr7hQZ~wtHPhcrd$TO=jCcV5yY> zDJExESHzwfW6eW!6`Sd&4q+f6xV5Qb?s`r0ERXOx1VF#E+Vnhe!@uG&#-eYquc-2v z(xI;&&-7^gw7KHd;M8%>g8XW4$cS%q&+XiUEqfm^JlC4lJoH2^!L?`M^`nE2lR^kU zkw9cg8&hRP*Lr#X1{ATODWl+VnXlx;X)w)o+*nPQoWA8A@Ul9idX`i-u%i!ZbZ*iQ zZ^ilu3E1`O>$TD?3i4lQHBxle1ZJ^i8Fr+G&O009|N0le|G*Laz|RxerbwZ5yNc-p zMXTyg&{WE#r+#-^GkrT@Hm*DXm6uSDbnkIB`^)Icus-{Y*l4;2Q0tA+*VE_OC literal 0 HcmV?d00001 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'; From 72ba9d589930cf93b3ac1f64929c7a0edc02af12 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:02:13 +0200 Subject: [PATCH 02/26] chore: interface to type --- src/components/TextField/TextField.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index b610eb5288..062ed0249c 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -23,12 +23,12 @@ import type { InternalTheme, ThemeProp } from '../../types'; export type TextFieldVariant = 'filled' | 'outlined'; -export interface TextFieldAccessoryProps { +export type TextFieldAccessoryProps = { style: StyleProp; multiline: boolean; disabled: boolean; error: boolean; -} +}; export type TextFieldSharedApi = { input: React.RefObject; @@ -110,7 +110,7 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { focusInput: () => void; }; -export interface TextFieldProps extends TextInputProps { +export type TextFieldProps = TextInputProps & { /** * Ref forwarded to the underlying TextInput. */ @@ -199,7 +199,7 @@ export interface TextFieldProps extends TextInputProps { * 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, From 5446df64a109d3ae764f759a9d143a5c7063ad05 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:25:30 +0200 Subject: [PATCH 03/26] feat: remove override props --- src/components/TextField/TextField.tsx | 71 ++------------ src/components/TextField/utils.ts | 43 +-------- src/components/__tests__/TextField.test.tsx | 93 +++++++------------ .../__snapshots__/TextField.test.tsx.snap | 45 --------- 4 files changed, 47 insertions(+), 205 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 062ed0249c..f129abca9c 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -8,7 +8,6 @@ import { Text, TextInput, TextInputProps, - TextProps, TextStyle, View, ViewStyle, @@ -129,65 +128,22 @@ export type TextFieldProps = TextInputProps & { */ 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. + * Supporting text to display below the input (Material Design 3). */ 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). @@ -253,22 +209,13 @@ function TextField(props: TextFieldProps) { error, label, supportingText, - supportingTextProps, - labelProps, variant, - pressableStyle: pressableStyleOverride, - fieldStyle, - containerStyle, - outlineStyle, theme, StartAccessory, EndAccessory, prefix, - prefixProps, suffix, - suffixProps, counter, - counterProps, ...textInputProps } = props; @@ -307,12 +254,7 @@ function TextField(props: TextFieldProps) { } = useTextField(props); return ( - + {/* Disabled tint overlay — filled variant only. A childless absolutely-positioned View whose translucent fill is applied via the @@ -342,7 +284,7 @@ function TextField(props: TextFieldProps) { pointerEvents="none" style={animatedLabelWrapperStyles} > - + {label} @@ -359,7 +301,7 @@ function TextField(props: TextFieldProps) { {hasPrefix && ( - + {prefix} )} @@ -380,7 +322,7 @@ function TextField(props: TextFieldProps) { /> {hasSuffix && ( - + {suffix} )} @@ -402,7 +344,6 @@ function TextField(props: TextFieldProps) { {!!supportingText && ( {supportingText} @@ -410,7 +351,7 @@ function TextField(props: TextFieldProps) { )} {hasCounter && ( - + {counterText} )} diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 846d992589..f3fe43a4d5 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -180,19 +180,11 @@ export const getOutlineColor = ({ * which is variant-specific (filled adds `MULTILINE_PADDING_TOP`). */ export const getSharedTextFieldStyleData = ( - api: TextFieldSharedApi, - props: TextFieldProps + api: TextFieldSharedApi ): 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 }); @@ -212,7 +204,6 @@ export const getSharedTextFieldStyleData = ( { color: labelColor }, animatedLabelTextStyle, disabled && styles.disabled, - labelProps?.style, ]; const supportingTextStyles: StyleProp = [ @@ -222,7 +213,6 @@ export const getSharedTextFieldStyleData = ( writingDirection: isRTL ? 'rtl' : 'ltr', }, disabled && styles.disabled, - supportingTextProps?.style, ]; const counterStyles: StyleProp = [ @@ -232,7 +222,6 @@ export const getSharedTextFieldStyleData = ( writingDirection: isRTL ? 'rtl' : 'ltr', }, disabled && styles.disabled, - counterProps?.style, ]; const prefixStyles: StyleProp = [ @@ -243,7 +232,6 @@ export const getSharedTextFieldStyleData = ( paddingEnd: PREFIX_END_PADDING, }, disabled && styles.disabled, - prefixProps?.style, ]; const suffixStyles: StyleProp = [ @@ -254,7 +242,6 @@ export const getSharedTextFieldStyleData = ( paddingStart: SUFFIX_START_PADDING, }, disabled && styles.disabled, - suffixProps?.style, ]; const leadingAccessoryStyles: StyleProp = [ @@ -357,13 +344,7 @@ export const getFilledTextFieldData = ( api: TextFieldSharedApi, props: TextFieldProps ): FilledTextFieldHookData => { - const { - style: inputStyleOverride, - fieldStyle: fieldStyleOverride, - containerStyle: containerStyleOverride, - outlineStyle: outlineStyleOverride, - ...textInputProps - } = props; + const { style: inputStyleOverride, ...textInputProps } = props; const { input, @@ -403,7 +384,7 @@ export const getFilledTextFieldData = ( * Shared styles */ - const shared = getSharedTextFieldStyleData(api, props); + const shared = getSharedTextFieldStyleData(api); /** * Variant-specific styles @@ -424,7 +405,6 @@ export const getFilledTextFieldData = ( const containerStyles: StyleProp = [ filledStyles.container, disabled && styles.disabled, - containerStyleOverride, ]; const fieldStyles: StyleProp = [ @@ -435,7 +415,6 @@ export const getFilledTextFieldData = ( borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, overflow: 'hidden', }, - fieldStyleOverride, ]; /* Disabled tint (DISABLED_CONTAINER_OPACITY) is rendered as a childless overlay so its @@ -457,7 +436,6 @@ export const getFilledTextFieldData = ( backgroundColor: outlineColor, }, disabled && styles.disabled, - outlineStyleOverride, ]; const animatedActiveOutlineStyles: StyleProp< @@ -469,7 +447,6 @@ export const getFilledTextFieldData = ( backgroundColor: activeOutlineColor, }, disabled && styles.disabled, - outlineStyleOverride, animatedActiveOutlineStyle, ]; @@ -513,13 +490,7 @@ export const getOutlinedTextFieldData = ( api: TextFieldSharedApi, props: TextFieldProps ): OutlinedTextFieldHookData => { - const { - style: inputStyleOverride, - fieldStyle: fieldStyleOverride, - containerStyle: containerStyleOverride, - outlineStyle: outlineStyleOverride, - ...textInputProps - } = props; + const { style: inputStyleOverride, ...textInputProps } = props; const { input, @@ -551,7 +522,7 @@ export const getOutlinedTextFieldData = ( * Shared styles */ - const shared = getSharedTextFieldStyleData(api, props); + const shared = getSharedTextFieldStyleData(api); /** * Variant-specific styles @@ -560,7 +531,6 @@ export const getOutlinedTextFieldData = ( const containerStyles: StyleProp = [ outlinedStyles.container, disabled && styles.disabled, - containerStyleOverride, ]; const fieldStyles: StyleProp = [ @@ -569,7 +539,6 @@ export const getOutlinedTextFieldData = ( borderRadius: TEXT_FIELD_BORDER_RADIUS, }, textInputProps.multiline && { alignItems: 'flex-start' }, - fieldStyleOverride, ]; /* The outline is a childless absolutely-positioned View, so applying @@ -582,8 +551,6 @@ export const getOutlinedTextFieldData = ( borderColor: outlineColor, }, disabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, - fieldStyleOverride, - outlineStyleOverride, ]; const animatedLabelWrapperStyles: StyleProp< diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 3b07c24baf..f4160fb9bf 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -33,6 +33,11 @@ function firstIndexOfTestIdInTree(tree: unknown, testID: string): number { return match ? match.index : -1; } +/** Locates a Text node whose children are serialized as a one-element JSON string array. */ +function firstIndexOfTextChildArrayInTree(tree: unknown, text: string): number { + return JSON.stringify(tree).indexOf(JSON.stringify([text])); +} + it('renders filled TextField with label and value', () => { const tree = render( {}} /> @@ -308,6 +313,8 @@ it('forwards TextInput props such as testID', () => { expect(getByTestId('email-input')).toBeTruthy(); }); +/* TextField peels these before spreading onto TextInput (see TextField.tsx). + * Custom layout / sub-component styling props are intentionally not supported. */ it('does not pass TextField-only props through to TextInput', () => { const { getByTestId } = render( { 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.label).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(); }); @@ -563,51 +563,47 @@ it('passes error to accessories when the field is disabled', () => { expect(startAccessoryProps[0].disabled).toBe(true); }); -it('applies supportingTextProps to the supporting Text', () => { - const { getByTestId } = render( +it('renders supporting text as a Text child', () => { + const { getByText } = render( {}} supportingText="Hint" - supportingTextProps={{ testID: 'supporting-text' }} /> ); - expect(getByTestId('supporting-text').props.children).toBe('Hint'); + expect(getByText('Hint')).toBeTruthy(); }); -it('applies counterProps to the counter Text', () => { - const { getByTestId } = render( +it('renders the counter as a Text child', () => { + const { getByText } = render( {}} counter maxLength={80} - counterProps={{ testID: 'counter-text' }} /> ); - expect(getByTestId('counter-text').props.children).toBe('2/80'); + expect(getByText('2/80')).toBeTruthy(); }); -it('does not apply supportingTextProps style to the counter Text', () => { - const { getByTestId } = render( +it('renders supporting text and counter separately when both are shown', () => { + const { getByText } = render( {}} + supportingText="Help text" counter maxLength={10} - supportingTextProps={{ style: { fontSize: 9 } }} - counterProps={{ testID: 'counter-text' }} /> ); - expect( - StyleSheet.flatten(getByTestId('counter-text').props.style).fontSize - ).not.toBe(9); + expect(getByText('Help text')).toBeTruthy(); + expect(getByText('1/10')).toBeTruthy(); }); it('applies RTL text alignment and writing direction to the TextInput (filled)', () => { @@ -656,19 +652,16 @@ it('applies RTL text alignment and writing direction to the TextInput (outlined) it('applies RTL writing direction to supporting text', () => { I18nManager.isRTL = true; - const { getByTestId } = render( + const { getByText } = render( {}} supportingText="Hint" - supportingTextProps={{ testID: 'supporting-text-rtl' }} /> ); - expect( - StyleSheet.flatten(getByTestId('supporting-text-rtl').props.style) - ).toEqual( + expect(StyleSheet.flatten(getByText('Hint').props.style)).toEqual( expect.objectContaining({ writingDirection: 'rtl', }) @@ -841,7 +834,7 @@ it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order }); it('shows prefix and suffix when the field is floating and hides them after value is cleared while blurred', () => { - const { getByTestId, queryByTestId, rerender } = render( + const { getByTestId, getByText, queryByText, rerender } = render( ); - expect(getByTestId('tf-prefix')).toBeTruthy(); - expect(getByTestId('tf-suffix')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); rerender( ); - expect(queryByTestId('tf-prefix')).toBeNull(); - expect(queryByTestId('tf-suffix')).toBeNull(); + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); expect(getByTestId('tf-ps')).toBeTruthy(); }); it('renders prefix and suffix while focused even when value is empty', () => { - const { getByTestId, queryByTestId } = render( + const { getByTestId, getByText, queryByText } = 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(); + expect(queryByText('$')).toBeNull(); + expect(queryByText(' kg')).toBeNull(); fireEvent(getByTestId('tf-ps-focus'), 'focus'); - expect(getByTestId('tf-prefix-focus')).toBeTruthy(); - expect(getByTestId('tf-suffix-focus')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); }); it('places prefix Text before the TextInput and suffix Text after it', () => { @@ -907,17 +894,15 @@ it('places prefix Text before the TextInput and suffix Text after it', () => { prefix="$" suffix="/100" testID="tf-order" - prefixProps={{ testID: 'order-prefix' }} - suffixProps={{ testID: 'order-suffix' }} /> ); const tree = toJSON(); - expect(firstIndexOfTestIdInTree(tree, 'order-prefix')).toBeLessThan( + expect(firstIndexOfTextChildArrayInTree(tree, '$')).toBeLessThan( firstIndexOfTestIdInTree(tree, 'tf-order') ); expect(firstIndexOfTestIdInTree(tree, 'tf-order')).toBeLessThan( - firstIndexOfTestIdInTree(tree, 'order-suffix') + firstIndexOfTextChildArrayInTree(tree, '/100') ); }); @@ -987,7 +972,7 @@ it('uses default horizontal alignment when suffix prop exists but suffix is not }); it('does not apply the TextInput style prop to prefix or suffix Text', () => { - const { getByTestId } = render( + const { getByTestId, getByText } = render( { suffix="]" style={{ fontSize: 40, letterSpacing: 9 }} testID="tf-input-style" - prefixProps={{ testID: 'pfx-no-input-style' }} - suffixProps={{ testID: 'sfx-no-input-style' }} /> ); @@ -1008,12 +991,8 @@ it('does not apply the TextInput style prop to prefix or suffix Text', () => { 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 - ); + const prefixFlat = StyleSheet.flatten(getByText('$').props.style); + const suffixFlat = StyleSheet.flatten(getByText(']').props.style); expect(prefixFlat.fontSize).not.toBe(40); expect(prefixFlat.letterSpacing).toBeUndefined(); diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 3f4978cdad..5f39e572e7 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -47,7 +47,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "borderTopStartRadius": 4, "overflow": "hidden", }, - undefined, ] } > @@ -66,7 +65,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "height": 1, }, false, - undefined, ] } /> @@ -95,7 +93,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -121,7 +118,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -204,7 +200,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -222,7 +217,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -414,7 +408,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -432,7 +425,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -695,7 +687,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "borderTopStartRadius": 4, "overflow": "hidden", }, - undefined, ] } > @@ -714,7 +705,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "height": 1, }, false, - undefined, ] } /> @@ -743,7 +733,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "height": 2, }, false, - undefined, { "transform": [ { @@ -769,7 +758,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "height": 2, }, false, - undefined, { "transform": [ { @@ -852,7 +840,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -870,7 +857,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "fontSize": 12, }, false, - undefined, ] } > @@ -1062,7 +1048,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -1080,7 +1065,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -1343,7 +1327,6 @@ exports[`renders filled TextField with label and value 1`] = ` "borderTopStartRadius": 4, "overflow": "hidden", }, - undefined, ] } > @@ -1362,7 +1345,6 @@ exports[`renders filled TextField with label and value 1`] = ` "height": 1, }, false, - undefined, ] } /> @@ -1391,7 +1373,6 @@ exports[`renders filled TextField with label and value 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -1417,7 +1398,6 @@ exports[`renders filled TextField with label and value 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -1500,7 +1480,6 @@ exports[`renders filled TextField with label and value 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -1518,7 +1497,6 @@ exports[`renders filled TextField with label and value 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -1547,7 +1525,6 @@ exports[`renders filled TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -1565,7 +1542,6 @@ exports[`renders filled TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -1663,7 +1639,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "borderRadius": 4, }, undefined, - undefined, ] } > @@ -1684,8 +1659,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "borderWidth": 1, }, false, - undefined, - undefined, ] } /> @@ -1778,7 +1751,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -1796,7 +1768,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -1988,7 +1959,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -2006,7 +1976,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -2267,7 +2236,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "borderRadius": 4, }, undefined, - undefined, ] } > @@ -2288,8 +2256,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "borderWidth": 1, }, false, - undefined, - undefined, ] } /> @@ -2382,7 +2348,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -2400,7 +2365,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "fontSize": 12, }, false, - undefined, ] } > @@ -2592,7 +2556,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -2610,7 +2573,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -2871,7 +2833,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "borderRadius": 4, }, undefined, - undefined, ] } > @@ -2892,8 +2853,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "borderWidth": 1, }, false, - undefined, - undefined, ] } /> @@ -2986,7 +2945,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -3004,7 +2962,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -3033,7 +2990,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -3051,7 +3007,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, From 50fc4d9dceccc831764da22dae383f12b900a09e Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:31:56 +0200 Subject: [PATCH 04/26] chore: pointer events declared in styles --- src/components/TextField/TextField.tsx | 15 ++----- src/components/TextField/styles.ts | 5 +++ .../__snapshots__/TextField.test.tsx.snap | 39 ++++++++++++------- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index f129abca9c..f57b1120f5 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -261,29 +261,22 @@ function TextField(props: TextFieldProps) { `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} diff --git a/src/components/TextField/styles.ts b/src/components/TextField/styles.ts index d6d5ba5bd9..c7cc6d3c2c 100644 --- a/src/components/TextField/styles.ts +++ b/src/components/TextField/styles.ts @@ -81,6 +81,7 @@ export const filledStyles = StyleSheet.create({ left: 0, right: 0, bottom: 0, + pointerEvents: 'none', }, container: { flex: 1, @@ -90,6 +91,7 @@ export const filledStyles = StyleSheet.create({ }, labelWrapper: { position: 'absolute', + pointerEvents: 'none', }, disabledBackground: { position: 'absolute', @@ -98,6 +100,7 @@ export const filledStyles = StyleSheet.create({ right: 0, bottom: 0, opacity: FILLED_DISABLED_CONTAINER_OPACITY, + pointerEvents: 'none', }, }); @@ -109,6 +112,7 @@ export const outlinedStyles = StyleSheet.create({ top: 0, bottom: 0, borderRadius: TEXT_FIELD_BORDER_RADIUS, + pointerEvents: 'none', }, container: { flex: 1, @@ -119,5 +123,6 @@ export const outlinedStyles = StyleSheet.create({ labelWrapper: { position: 'absolute', paddingHorizontal: OUTLINED_LABEL_PADDING_HORIZONTAL, + pointerEvents: 'none', }, }); diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 5f39e572e7..1cbc1b5246 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -51,12 +51,12 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } > Date: Fri, 15 May 2026 11:44:58 +0200 Subject: [PATCH 05/26] chore: isweb to inline check --- src/components/TextField/constants.ts | 4 +--- src/components/TextField/utils.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index e4925b9176..4746a5c172 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -1,11 +1,9 @@ -import { I18nManager, PixelRatio, Platform } from 'react-native'; +import { I18nManager, PixelRatio } from 'react-native'; import { tokens } from '../../theme/tokens'; import { motionDuration } from '../../theme/tokens/sys/motion'; import { defaultShapes } from '../../theme/tokens/sys/shape'; -export const isWeb = Platform.OS === 'web'; - export const fontScale = PixelRatio.getFontScale(); /** diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index f3fe43a4d5..62deecfef4 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -1,4 +1,10 @@ -import { I18nManager, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import { + I18nManager, + Platform, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; import { AnimatedStyle } from 'react-native-reanimated'; @@ -23,7 +29,6 @@ import { PREFIX_END_PADDING, SUFFIX_START_PADDING, TEXT_FIELD_BORDER_RADIUS, - isWeb, } from './constants'; import { filledStyles, outlinedStyles, styles } from './styles'; import type { @@ -463,7 +468,7 @@ export const getFilledTextFieldData = ( height: 'auto', paddingTop: FILLED_MULTILINE_PADDING_TOP, }, - isWeb && { + Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, disabled && styles.disabled, @@ -580,7 +585,7 @@ export const getOutlinedTextFieldData = ( textAlignVertical: 'top', paddingTop: OUTLINED_MULTILINE_PADDING_TOP, }, - isWeb && { + Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, disabled && styles.disabled, From 77049d01a84c2b0edacac251aec1fb8225bc4544 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:48:48 +0200 Subject: [PATCH 06/26] chore: remove unnecessary spyon in jest --- jest/testSetup.js | 2 - .../__snapshots__/DataTable.test.tsx.snap | 10 ++-- .../__snapshots__/TextField.test.tsx.snap | 48 +++++++++---------- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/jest/testSetup.js b/jest/testSetup.js index e6561e0211..207c532a7b 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -86,8 +86,6 @@ 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/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index dcd43b927f..0f997739ba 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": 24, + "maxHeight": 48, }, {}, { @@ -310,7 +310,7 @@ exports[`DataTable.Header renders data table header 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { @@ -2619,7 +2619,7 @@ exports[`DataTable.Title renders data table title with press handler 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { @@ -2747,7 +2747,7 @@ exports[`DataTable.Title renders data table title with sort icon 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { @@ -2833,7 +2833,7 @@ exports[`DataTable.Title renders right aligned data table title 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 1cbc1b5246..36b9b6fada 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -38,8 +38,8 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "backgroundColor": "rgba(230, 224, 233, 1)", @@ -152,7 +152,7 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "left": 52, }, { - "top": 8, + "top": 16, "transitionDuration": 150, "transitionProperty": "top", }, @@ -168,7 +168,7 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "left": 52, }, { - "top": 8, + "top": 16, }, ] } @@ -680,8 +680,8 @@ exports[`renders filled TextField with TextField.Icon accessories when error is [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "backgroundColor": "rgba(230, 224, 233, 1)", @@ -794,7 +794,7 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "left": 52, }, { - "top": 8, + "top": 16, "transitionDuration": 150, "transitionProperty": "top", }, @@ -810,7 +810,7 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "left": 52, }, { - "top": 8, + "top": 16, }, ] } @@ -1322,8 +1322,8 @@ exports[`renders filled TextField with label and value 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "backgroundColor": "rgba(230, 224, 233, 1)", @@ -1436,7 +1436,7 @@ exports[`renders filled TextField with label and value 1`] = ` "left": 16, }, { - "top": 8, + "top": 16, "transitionDuration": 150, "transitionProperty": "top", }, @@ -1452,7 +1452,7 @@ exports[`renders filled TextField with label and value 1`] = ` "left": 16, }, { - "top": 8, + "top": 16, }, ] } @@ -1638,8 +1638,8 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "borderRadius": 4, @@ -1693,7 +1693,7 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -1719,7 +1719,7 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -2236,8 +2236,8 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "borderRadius": 4, @@ -2291,7 +2291,7 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -2317,7 +2317,7 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -2834,8 +2834,8 @@ exports[`renders outlined TextField with label and value 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "borderRadius": 4, @@ -2889,7 +2889,7 @@ exports[`renders outlined TextField with label and value 1`] = ` "left": 16, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -4, @@ -2915,7 +2915,7 @@ exports[`renders outlined TextField with label and value 1`] = ` "left": 16, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -4, From 6a7f01ee2b6047fdf009498f8f1427b8259ffa2b Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:58:03 +0200 Subject: [PATCH 07/26] chore: remove unnecessary comments --- example/src/Examples/TextFieldExample.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx index a58fedcb02..c020d97dcb 100644 --- a/example/src/Examples/TextFieldExample.tsx +++ b/example/src/Examples/TextFieldExample.tsx @@ -21,10 +21,6 @@ import { import { useExampleTheme } from '../hooks/useExampleTheme'; import ScreenWrapper from '../ScreenWrapper'; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - type DemoControls = { error: boolean; disabled: boolean; @@ -44,10 +40,6 @@ type DemoModifiers = { suffix: string; }; -// --------------------------------------------------------------------------- -// TextFieldDemo -// --------------------------------------------------------------------------- - type TextFieldDemoProps = { variant: TextFieldVariant; }; @@ -185,10 +177,6 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { ); }; -// --------------------------------------------------------------------------- -// TextFieldExample -// --------------------------------------------------------------------------- - const TextFieldExample = () => { return ( @@ -204,10 +192,6 @@ const TextFieldExample = () => { TextFieldExample.title = 'TextField'; -// --------------------------------------------------------------------------- -// Styles -// --------------------------------------------------------------------------- - const styles = StyleSheet.create({ container: { paddingHorizontal: 16, From 492964a902acff7a3d31bb125013f2d9d1f1f49e Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 12:27:26 +0200 Subject: [PATCH 08/26] feat: use locale instead of i18n manager --- src/components/TextField/TextField.tsx | 1 + src/components/TextField/constants.ts | 20 ++++++++-------- src/components/TextField/hooks.ts | 9 ++++++-- src/components/TextField/utils.ts | 32 ++++++++++++++------------ 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index f57b1120f5..0acd4d53c8 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -33,6 +33,7 @@ export type TextFieldSharedApi = { input: React.RefObject; theme: InternalTheme; isFocused: boolean; + isRTL: boolean; disabled: boolean; hasAccessory: boolean; hasError: boolean; diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index 4746a5c172..9f5065ad20 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -1,4 +1,4 @@ -import { I18nManager, PixelRatio } from 'react-native'; +import { PixelRatio } from 'react-native'; import { tokens } from '../../theme/tokens'; import { motionDuration } from '../../theme/tokens/sys/motion'; @@ -56,9 +56,6 @@ 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. */ @@ -100,11 +97,12 @@ export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = 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); +/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ +export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + OUTLINED_LABEL_PADDING_HORIZONTAL; -export const OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY = - -layoutSupportMultiplier * OUTLINED_LABEL_PADDING_HORIZONTAL; +/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ +export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY = + OUTLINED_LABEL_PADDING_HORIZONTAL; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index b51930b013..1adeebc6fe 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -1,5 +1,5 @@ import { useImperativeHandle, useRef, useState } from 'react'; -import { BlurEvent, FocusEvent, I18nManager, TextInput } from 'react-native'; +import { BlurEvent, FocusEvent, TextInput } from 'react-native'; import type { TextFieldHookReturn, @@ -12,6 +12,7 @@ import { getOutlinedTextFieldData, getTextFieldAnimation, } from './utils'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { @@ -31,6 +32,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const theme = useInternalTheme(themeOverride); + const { direction } = useLocale(); + const [isFocused, setIsFocused] = useState(false); useImperativeHandle(ref, () => input.current as TextInput); @@ -39,7 +42,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { * Constants */ - const { isRTL } = I18nManager.getConstants(); + const isRTL = direction === 'rtl'; const disabled = props.editable === false; const isFloating = isFocused || !!props.value; const hasError = !!props.error; @@ -73,6 +76,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { variant, isFloating, isFocused, + isRTL, hasAccessory, }); @@ -103,6 +107,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { input, theme, isFocused, + isRTL, disabled, hasAccessory, hasError, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 62deecfef4..b5f283c8a6 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -1,10 +1,4 @@ -import { - I18nManager, - Platform, - StyleProp, - TextStyle, - ViewStyle, -} from 'react-native'; +import { Platform, StyleProp, TextStyle, ViewStyle } from 'react-native'; import { AnimatedStyle } from 'react-native-reanimated'; @@ -23,8 +17,8 @@ import { 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_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY, OUTLINED_MULTILINE_PADDING_TOP, PREFIX_END_PADDING, SUFFIX_START_PADDING, @@ -187,9 +181,14 @@ export const getOutlineColor = ({ export const getSharedTextFieldStyleData = ( api: TextFieldSharedApi ): SharedTextFieldStyleData => { - const { isRTL } = I18nManager.getConstants(); - - const { theme, disabled, hasError, isFocused, animatedLabelTextStyle } = api; + const { + theme, + disabled, + hasError, + isFocused, + isRTL, + animatedLabelTextStyle, + } = api; const labelColor = getLabelColor({ theme, hasError, isFocused, disabled }); @@ -275,11 +274,13 @@ export const getTextFieldAnimation = ({ variant, isFloating, isFocused, + isRTL, hasAccessory, }: { variant: 'filled' | 'outlined'; isFloating: boolean; isFocused: boolean; + isRTL: boolean; hasAccessory: boolean; }): { animatedLabelWrapperStyle: StyleProp>>; @@ -325,9 +326,10 @@ export const getTextFieldAnimation = ({ }; } - const translateXEnd = hasAccessory - ? OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY - : OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY; + const distance = hasAccessory + ? OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY + : OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY; + const translateXEnd = (isRTL ? 1 : -1) * distance; return { animatedLabelWrapperStyle: { From d839884bbf1e1feebfd4f67e748bb75fd673bf97 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 12:49:21 +0200 Subject: [PATCH 09/26] feat: accessories as function not components --- example/src/Examples/TextFieldExample.tsx | 18 ++---- src/components/TextField/TextField.tsx | 62 +++++++++++---------- src/components/TextField/TextFieldIcon.tsx | 12 ++-- src/components/TextField/hooks.ts | 14 +++-- src/components/__tests__/TextField.test.tsx | 46 +++++++-------- 5 files changed, 77 insertions(+), 75 deletions(-) diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx index c020d97dcb..27b8b74a3d 100644 --- a/example/src/Examples/TextFieldExample.tsx +++ b/example/src/Examples/TextFieldExample.tsx @@ -74,18 +74,12 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { const setModifier = (key: keyof DemoModifiers, text: string) => setModifiers((prev) => ({ ...prev, [key]: text })); - const LeadingIcon = React.useCallback( - (props: TextFieldAccessoryProps) => ( - - ), - [] + const leadingIcon = (props: TextFieldAccessoryProps) => ( + ); - const TrailingIcon = React.useCallback( - (props: TextFieldAccessoryProps) => ( - setValue('')} /> - ), - [] + const trailingIcon = (props: TextFieldAccessoryProps) => ( + setValue('')} /> ); const inputColor = theme.colors.onSurfaceVariant; @@ -136,8 +130,8 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { maxLength={controls.counter ? 100 : undefined} prefix={controls.showPrefix ? modifiers.prefix : undefined} suffix={controls.showSuffix ? modifiers.suffix : undefined} - StartAccessory={controls.leadingIcon ? LeadingIcon : undefined} - EndAccessory={controls.trailingIcon ? TrailingIcon : undefined} + startAccessory={controls.leadingIcon ? leadingIcon : undefined} + endAccessory={controls.trailingIcon ? trailingIcon : undefined} /> diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 0acd4d53c8..3161592de3 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType } from 'react'; +import React from 'react'; import { BlurEvent, ColorValue, @@ -103,8 +103,12 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { inputStyles: StyleProp; placeholder: string | undefined; counterText: string; - LeadingAccessory: ComponentType | undefined; - TrailingAccessory: ComponentType | undefined; + renderLeadingAccessory: + | ((props: TextFieldAccessoryProps) => React.ReactNode) + | undefined; + renderTrailingAccessory: + | ((props: TextFieldAccessoryProps) => React.ReactNode) + | undefined; onFocusHandler: (e: FocusEvent) => void; onBlurHandler: (e: BlurEvent) => void; focusInput: () => void; @@ -150,12 +154,12 @@ export type TextFieldProps = TextInputProps & { * 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; + startAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; /** * An optional component to render on the end side of the input (trailing in LTR). * Can be a custom component or `TextField.Icon`. */ - EndAccessory?: ComponentType; + endAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; }; /** @@ -171,11 +175,11 @@ export type TextFieldProps = TextInputProps & { * const MyComponent = () => { * const [text, setText] = React.useState(''); * - * const SearchIcon = (props) => ( - * + * const searchAccessory = (accessoryProps) => ( + * * ); * - * const ClearAccessory = ({ style, disabled }) => ( + * const clearAccessory = ({ style, disabled }) => ( * * ); * }; @@ -212,8 +216,8 @@ function TextField(props: TextFieldProps) { supportingText, variant, theme, - StartAccessory, - EndAccessory, + startAccessory, + endAccessory, prefix, suffix, counter, @@ -247,8 +251,8 @@ function TextField(props: TextFieldProps) { cursorColor, placeholder, counterText, - LeadingAccessory, - TrailingAccessory, + renderLeadingAccessory, + renderTrailingAccessory, focusInput, onFocusHandler, onBlurHandler, @@ -284,14 +288,14 @@ function TextField(props: TextFieldProps) { )} - {!!LeadingAccessory && ( - - )} + {renderLeadingAccessory + ? renderLeadingAccessory({ + style: leadingAccessoryStyles, + error: hasError, + disabled, + multiline: !!textInputProps.multiline, + }) + : null} {hasPrefix && ( @@ -322,13 +326,13 @@ function TextField(props: TextFieldProps) { )} - {TrailingAccessory ? ( - + {renderTrailingAccessory ? ( + renderTrailingAccessory({ + style: trailingAccessoryStyles, + error: hasError, + disabled, + multiline: !!textInputProps.multiline, + }) ) : hasError ? ( ) : null} diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index ab91bafb72..99de90a981 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -36,8 +36,8 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { /** * 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. + * (return it from `startAccessory` or `endAccessory`). Accepts icon-specific props as well as + * `TextFieldAccessoryProps`, which TextField passes into those render props. * * ## Usage * ```js @@ -47,11 +47,11 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { * const MyComponent = () => { * const [text, setText] = React.useState(''); * - * const SearchIcon = (props) => ( + * const searchAccessory = (props) => ( * * ); * - * const ClearIcon = (props) => ( + * const clearAccessory = (props) => ( * setText('')} /> * ); * @@ -60,8 +60,8 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { * label="Search" * value={text} * onChangeText={setText} - * StartAccessory={SearchIcon} - * EndAccessory={ClearIcon} + * startAccessory={searchAccessory} + * endAccessory={clearAccessory} * /> * ); * }; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 1adeebc6fe..39d134faec 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -46,7 +46,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const disabled = props.editable === false; const isFloating = isFocused || !!props.value; const hasError = !!props.error; - const hasAccessory = isRTL ? !!props.EndAccessory : !!props.StartAccessory; + const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; const hasPrefix = !!props.prefix && isFloating; const hasSuffix = !!props.suffix && isFloating; const hasCounter = !!(props.counter && props.maxLength); @@ -121,8 +121,12 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { * Components */ - const LeadingAccessory = isRTL ? props.EndAccessory : props.StartAccessory; - const TrailingAccessory = isRTL ? props.StartAccessory : props.EndAccessory; + const renderLeadingAccessory = isRTL + ? props.endAccessory + : props.startAccessory; + const renderTrailingAccessory = isRTL + ? props.startAccessory + : props.endAccessory; // https://github.com/facebook/react-native/issues/31573 const placeholder = isFocused ? props.placeholder : ' '; const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; @@ -141,8 +145,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animatedContainerStyle, placeholder, counterText, - LeadingAccessory, - TrailingAccessory, + renderLeadingAccessory, + renderTrailingAccessory, onFocusHandler, onBlurHandler, focusInput, diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index f4160fb9bf..4568e262ee 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -65,10 +65,10 @@ it('renders filled TextField with TextField.Icon accessories', () => { label="Search" value="q" onChangeText={() => {}} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( )} /> @@ -84,10 +84,10 @@ it('renders outlined TextField with TextField.Icon accessories', () => { label="Search" value="q" onChangeText={() => {}} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( )} /> @@ -103,10 +103,10 @@ it('renders filled TextField with TextField.Icon accessories when error is true' value="q" onChangeText={() => {}} error - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( {}} /> )} /> @@ -123,10 +123,10 @@ it('renders outlined TextField with TextField.Icon accessories when error is tru value="q" onChangeText={() => {}} error - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( {}} /> )} /> @@ -142,10 +142,10 @@ it('fires onPress on TextField.Icon end accessory', () => { label="Search" value="x" onChangeText={() => {}} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( { value="x" onChangeText={() => {}} editable={false} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( )} /> @@ -330,8 +330,8 @@ it('does not pass TextField-only props through to TextInput', () => { 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.startAccessory).toBeUndefined(); + expect(input.props.endAccessory).toBeUndefined(); expect(input.props.label).toBeUndefined(); expect(input.props.supportingText).toBeUndefined(); expect(input.props.prefix).toBeUndefined(); @@ -520,8 +520,8 @@ it('passes error, disabled, and multiline to accessories', () => { multiline error editable={false} - StartAccessory={StartAccessory} - EndAccessory={EndAccessory} + startAccessory={StartAccessory} + endAccessory={EndAccessory} /> ); @@ -554,7 +554,7 @@ it('passes error to accessories when the field is disabled', () => { onChangeText={() => {}} error editable={false} - StartAccessory={StartAccessory} + startAccessory={StartAccessory} /> ); @@ -684,8 +684,8 @@ it('places EndAccessory before StartAccessory in the tree when RTL', () => { label="Email" value="" onChangeText={() => {}} - StartAccessory={StartAccessory} - EndAccessory={EndAccessory} + startAccessory={StartAccessory} + endAccessory={EndAccessory} testID="tf-input-rtl-order" /> ); @@ -712,8 +712,8 @@ it('places StartAccessory before EndAccessory in the tree when LTR', () => { label="Email" value="" onChangeText={() => {}} - StartAccessory={StartAccessory} - EndAccessory={EndAccessory} + startAccessory={StartAccessory} + endAccessory={EndAccessory} testID="tf-input-ltr-order" /> ); @@ -805,7 +805,7 @@ it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order label="Email" value="" onChangeText={() => {}} - StartAccessory={LoneStartAccessory} + startAccessory={LoneStartAccessory} testID="tf-lone-ltr" /> ); @@ -817,7 +817,7 @@ it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order label="Email" value="" onChangeText={() => {}} - StartAccessory={LoneStartAccessory} + startAccessory={LoneStartAccessory} testID="tf-lone-rtl" /> ); From dbb91ff26cbc155eef794f4383c28c7f50e45b4c Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 13:16:31 +0200 Subject: [PATCH 10/26] feat: introduce separate disable props --- example/src/Examples/TextFieldExample.tsx | 6 +- src/components/TextField/TextField.tsx | 25 ++-- src/components/TextField/TextFieldIcon.tsx | 2 +- src/components/TextField/hooks.ts | 9 +- src/components/TextField/utils.ts | 82 ++++++------- src/components/__tests__/TextField.test.tsx | 125 ++++++++++++++++++-- 6 files changed, 183 insertions(+), 66 deletions(-) diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx index 27b8b74a3d..00566385bf 100644 --- a/example/src/Examples/TextFieldExample.tsx +++ b/example/src/Examples/TextFieldExample.tsx @@ -24,6 +24,7 @@ import ScreenWrapper from '../ScreenWrapper'; type DemoControls = { error: boolean; disabled: boolean; + readOnly: boolean; leadingIcon: boolean; trailingIcon: boolean; counter: boolean; @@ -52,6 +53,7 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { const [controls, setControls] = React.useState({ error: false, disabled: false, + readOnly: false, leadingIcon: false, trailingIcon: false, counter: false, @@ -97,6 +99,7 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { const SWITCH_CONTROLS: { label: string; key: keyof DemoControls }[] = [ { label: 'Error', key: 'error' }, { label: 'Disabled', key: 'disabled' }, + { label: 'Readonly', key: 'readOnly' }, { label: 'Leading icon', key: 'leadingIcon' }, { label: 'Trailing icon', key: 'trailingIcon' }, { label: 'Counter', key: 'counter' }, @@ -122,7 +125,8 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { placeholder={modifiers.placeholder || undefined} supportingText={modifiers.helperText || undefined} error={controls.error} - editable={!controls.disabled} + disabled={controls.disabled} + editable={!controls.readOnly} value={value} onChangeText={setValue} multiline={controls.multiline} diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 3161592de3..4998da76db 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -34,7 +34,7 @@ export type TextFieldSharedApi = { theme: InternalTheme; isFocused: boolean; isRTL: boolean; - disabled: boolean; + isDisabled: boolean; hasAccessory: boolean; hasError: boolean; hasSuffix: boolean; @@ -56,7 +56,7 @@ export type SharedTextFieldStyleData = { export type FilledTextFieldHookData = SharedTextFieldStyleData & { input: React.RefObject; - disabled: boolean; + isDisabled: boolean; hasError: boolean; hasSuffix: boolean; animatedLabelWrapperStyles: StyleProp>>; @@ -70,7 +70,7 @@ export type FilledTextFieldHookData = SharedTextFieldStyleData & { export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { input: React.RefObject; - disabled: boolean; + isDisabled: boolean; hasError: boolean; hasSuffix: boolean; animatedLabelWrapperStyles: StyleProp>>; @@ -83,7 +83,8 @@ export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { export type TextFieldHookReturn = SharedTextFieldStyleData & { input: React.RefObject; - disabled: boolean; + isDisabled: boolean; + isEditable: boolean | undefined; hasPrefix: boolean; hasCounter: boolean; hasSuffix: boolean; @@ -141,6 +142,11 @@ export type TextFieldProps = TextInputProps & { * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. */ counter?: boolean; + /** + * This is separate from `editable={false}`, which makes the text read-only while the + * input can still be focused and text selected. + */ + disabled?: boolean; /** * A short text string displayed at the start of the input (e.g. `"$"`). */ @@ -221,12 +227,14 @@ function TextField(props: TextFieldProps) { prefix, suffix, counter, + disabled, ...textInputProps } = props; const { input, - disabled, + isDisabled, + isEditable, hasPrefix, hasSuffix, hasCounter, @@ -292,7 +300,7 @@ function TextField(props: TextFieldProps) { ? renderLeadingAccessory({ style: leadingAccessoryStyles, error: hasError, - disabled, + disabled: isDisabled, multiline: !!textInputProps.multiline, }) : null} @@ -306,7 +314,7 @@ function TextField(props: TextFieldProps) { @@ -330,7 +339,7 @@ function TextField(props: TextFieldProps) { renderTrailingAccessory({ style: trailingAccessoryStyles, error: hasError, - disabled, + disabled: isDisabled, multiline: !!textInputProps.multiline, }) ) : hasError ? ( diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index 99de90a981..f34275a7d9 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -88,7 +88,7 @@ const TextFieldIcon = ({ theme, color, hasError: error, - disabled, + isDisabled: disabled, }); const onPressHandler = disabled ? undefined : onPress; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 39d134faec..0cb64a01be 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -43,7 +43,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { */ const isRTL = direction === 'rtl'; - const disabled = props.editable === false; + const isDisabled = !!props.disabled; + const isEditable = props.disabled ? false : props.editable; const isFloating = isFocused || !!props.value; const hasError = !!props.error; const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; @@ -95,7 +96,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { }; const focusInput = () => { - if (disabled) return; + if (isDisabled) return; input.current?.focus(); }; @@ -108,7 +109,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { theme, isFocused, isRTL, - disabled, + isDisabled, hasAccessory, hasError, hasSuffix, @@ -136,6 +137,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { */ const data = { + isEditable, + isDisabled, hasPrefix, hasCounter, placeholderTextColor, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index b5f283c8a6..6c79d0bb65 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -53,12 +53,12 @@ export const getLabelColor = ({ theme, hasError, isFocused, - disabled, + isDisabled, }: { theme: InternalTheme; isFocused: boolean; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { const { colors: { error, primary, onSurface, onSurfaceVariant }, @@ -67,7 +67,7 @@ export const getLabelColor = ({ if (hasError) { return error; } - if (disabled) { + if (isDisabled) { return onSurface; } if (isFocused) { @@ -79,11 +79,11 @@ export const getLabelColor = ({ export const getSupportingTextColor = ({ theme, hasError, - disabled, + isDisabled, }: { theme: InternalTheme; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { const { colors: { error, onSurface, onSurfaceVariant }, @@ -92,7 +92,7 @@ export const getSupportingTextColor = ({ if (hasError) { return error; } - if (disabled) { + if (isDisabled) { return onSurface; } return onSurfaceVariant; @@ -107,12 +107,12 @@ export const getSupportingTextColor = ({ */ export const getFieldBackgroundColor = ({ theme, - disabled, + isDisabled, }: { theme: InternalTheme; - disabled: boolean; + isDisabled: boolean; }): string | undefined => { - if (disabled) { + if (isDisabled) { return undefined; } @@ -123,16 +123,16 @@ export const getIconColor = ({ theme, color, hasError, - disabled, + isDisabled, }: { theme: InternalTheme; color?: string; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { if (color) return color; if (hasError) return theme.colors.error; - if (disabled) return theme.colors.onSurface; + if (isDisabled) return theme.colors.onSurface; return theme.colors.onSurfaceVariant; }; @@ -146,12 +146,12 @@ export const getOutlineColor = ({ theme, hasError, isFocused, - disabled, + isDisabled, }: { theme: InternalTheme; isFocused: boolean; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { const { colors: { error, onSurface, primary, outline }, @@ -160,7 +160,7 @@ export const getOutlineColor = ({ if (hasError) { return error; } - if (disabled) { + if (isDisabled) { return onSurface; } if (isFocused) { @@ -183,19 +183,19 @@ export const getSharedTextFieldStyleData = ( ): SharedTextFieldStyleData => { const { theme, - disabled, + isDisabled, hasError, isFocused, isRTL, animatedLabelTextStyle, } = api; - const labelColor = getLabelColor({ theme, hasError, isFocused, disabled }); + const labelColor = getLabelColor({ theme, hasError, isFocused, isDisabled }); const supportingTextColor = getSupportingTextColor({ theme, hasError, - disabled, + isDisabled, }); const { colors: { onSurfaceVariant }, @@ -207,7 +207,7 @@ export const getSharedTextFieldStyleData = ( styles.input, { color: labelColor }, animatedLabelTextStyle, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const supportingTextStyles: StyleProp = [ @@ -216,7 +216,7 @@ export const getSharedTextFieldStyleData = ( color: supportingTextColor, writingDirection: isRTL ? 'rtl' : 'ltr', }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const counterStyles: StyleProp = [ @@ -225,7 +225,7 @@ export const getSharedTextFieldStyleData = ( color: supportingTextColor, writingDirection: isRTL ? 'rtl' : 'ltr', }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const prefixStyles: StyleProp = [ @@ -235,7 +235,7 @@ export const getSharedTextFieldStyleData = ( color: onSurfaceVariant, paddingEnd: PREFIX_END_PADDING, }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const suffixStyles: StyleProp = [ @@ -245,17 +245,17 @@ export const getSharedTextFieldStyleData = ( color: onSurfaceVariant, paddingStart: SUFFIX_START_PADDING, }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const leadingAccessoryStyles: StyleProp = [ styles.leadingAccessory, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const trailingAccessoryStyles: StyleProp = [ styles.trailingAccessory, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; return { @@ -357,7 +357,7 @@ export const getFilledTextFieldData = ( input, theme, hasSuffix, - disabled, + isDisabled, hasAccessory, hasError, animatedLabelWrapperStyle, @@ -375,17 +375,17 @@ export const getFilledTextFieldData = ( theme, hasError, isFocused: false, - disabled, + isDisabled, }); const activeOutlineColor = getOutlineColor({ theme, hasError, isFocused: true, - disabled, + isDisabled, }); - const fieldBackgroundColor = getFieldBackgroundColor({ theme, disabled }); + const fieldBackgroundColor = getFieldBackgroundColor({ theme, isDisabled }); /** * Shared styles @@ -411,7 +411,7 @@ export const getFilledTextFieldData = ( const containerStyles: StyleProp = [ filledStyles.container, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const fieldStyles: StyleProp = [ @@ -427,7 +427,7 @@ export const getFilledTextFieldData = ( /* 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 + const disabledBackgroundStyles: StyleProp | undefined = isDisabled ? [ filledStyles.disabledBackground, { @@ -442,7 +442,7 @@ export const getFilledTextFieldData = ( height: INACTIVE_INDICATOR_SIZE, backgroundColor: outlineColor, }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const animatedActiveOutlineStyles: StyleProp< @@ -453,7 +453,7 @@ export const getFilledTextFieldData = ( height: ACTIVE_INDICATOR_SIZE, backgroundColor: activeOutlineColor, }, - disabled && styles.disabled, + isDisabled && styles.disabled, animatedActiveOutlineStyle, ]; @@ -473,13 +473,13 @@ export const getFilledTextFieldData = ( Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, - disabled && styles.disabled, + isDisabled && styles.disabled, inputStyleOverride, ]; return { input, - disabled, + isDisabled, hasError, hasSuffix, animatedLabelWrapperStyles, @@ -503,7 +503,7 @@ export const getOutlinedTextFieldData = ( input, theme, isFocused, - disabled, + isDisabled, hasAccessory, hasError, hasSuffix, @@ -520,7 +520,7 @@ export const getOutlinedTextFieldData = ( const outlineColor = getOutlineColor({ theme, - disabled, + isDisabled, isFocused, hasError, }); @@ -537,7 +537,7 @@ export const getOutlinedTextFieldData = ( const containerStyles: StyleProp = [ outlinedStyles.container, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const fieldStyles: StyleProp = [ @@ -557,7 +557,7 @@ export const getOutlinedTextFieldData = ( borderWidth: isFocused ? 2 : 1, borderColor: outlineColor, }, - disabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, + isDisabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, ]; const animatedLabelWrapperStyles: StyleProp< @@ -590,13 +590,13 @@ export const getOutlinedTextFieldData = ( Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, - disabled && styles.disabled, + isDisabled && styles.disabled, inputStyleOverride, ]; return { input, - disabled, + isDisabled, hasError, hasSuffix, animatedLabelWrapperStyles, diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 4568e262ee..931d8ee68b 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -161,18 +161,18 @@ it('fires onPress on TextField.Icon end accessory', () => { expect(onClear).toHaveBeenCalledTimes(1); }); -it('disables TextField.Icon when the field is not editable', () => { +it('disables TextField.Icon when the field is disabled', () => { const { getAllByTestId } = render( {}} - editable={false} + disabled startAccessory={(props: TextFieldAccessoryProps) => ( - + {}} /> )} endAccessory={(props: TextFieldAccessoryProps) => ( - + {}} /> )} /> ); @@ -182,6 +182,27 @@ it('disables TextField.Icon when the field is not editable', () => { expect(buttons[1].props.accessibilityState?.disabled).toBe(true); }); +it('does not disable TextField.Icon when the field is read-only (editable false)', () => { + const { getAllByTestId } = render( + {}} + editable={false} + startAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ); + + const buttons = getAllByTestId('icon-button'); + expect(buttons[0].props.accessibilityState?.disabled).not.toBe(true); + expect(buttons[1].props.accessibilityState?.disabled).not.toBe(true); +}); + it('renders supporting text below the field', () => { const { getByText } = render( { expect(getByText('Optional').props['aria-live']).toBe('polite'); }); -it('marks the input as aria-disabled when editable is false', () => { +it('does not mark the input as aria-disabled when editable is false (read-only)', () => { const { getByTestId } = render( { /> ); + expect(getByTestId('tf-input').props['aria-disabled']).not.toBe(true); +}); + +it('marks the input as aria-disabled when disabled is true', () => { + const { getByTestId } = render( + {}} + disabled + 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', () => { +it('marks the input as aria-invalid but not aria-disabled when error and read-only', () => { const { getByTestId } = render( ); + const input = getByTestId('tf-input'); + expect(input.props['aria-invalid']).toBe(true); + expect(input.props['aria-disabled']).not.toBe(true); +}); + +it('marks the input as aria-invalid and aria-disabled when error and disabled', () => { + const { getByTestId } = render( + {}} + 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 editable is false (filled)', () => { +it('does not apply disabled opacity to the TextInput when editable is false (filled)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input-ro" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-ro').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('does not apply disabled opacity to the TextInput when editable is false (outlined)', () => { const { getByTestId } = render( {}} editable={false} + testID="tf-input-ro-out" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-ro-out').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('applies disabled opacity to the TextInput when disabled is true (filled)', () => { + const { getByTestId } = render( + {}} + disabled testID="tf-input-dis" /> ); @@ -283,14 +368,14 @@ it('applies disabled opacity to the TextInput when editable is false (filled)', ).toMatchObject({ opacity: stateOpacity.disabled }); }); -it('applies disabled opacity to the TextInput when editable is false (outlined)', () => { +it('applies disabled opacity to the TextInput when disabled is true (outlined)', () => { const { getByTestId } = render( {}} - editable={false} + disabled testID="tf-input-dis-out" /> ); @@ -323,6 +408,7 @@ it('does not pass TextField-only props through to TextInput', () => { value="" onChangeText={() => {}} error + disabled testID="tf-native" /> ); @@ -338,6 +424,7 @@ it('does not pass TextField-only props through to TextInput', () => { expect(input.props.suffix).toBeUndefined(); expect(input.props.counter).toBeUndefined(); expect(input.props.error).toBeUndefined(); + expect(input.props.disabled).toBeUndefined(); }); it('shows a character counter when counter is true and maxLength is set (filled)', () => { @@ -465,6 +552,20 @@ it('focuses the TextInput when the outer Pressable is pressed', () => { it('does not focus the TextInput when disabled and the Pressable is pressed', () => { const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + const { UNSAFE_getByProps } = render( + {}} disabled /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).not.toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('focuses the TextInput when read-only and the Pressable is pressed', () => { + const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + const { UNSAFE_getByProps } = render( { onChangeText={() => {}} multiline error - editable={false} + disabled startAccessory={StartAccessory} endAccessory={EndAccessory} /> @@ -553,7 +654,7 @@ it('passes error to accessories when the field is disabled', () => { value="" onChangeText={() => {}} error - editable={false} + disabled startAccessory={StartAccessory} /> ); From b03311ee5a89c09e6a8bf4d208d15d814e635683 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Mon, 18 May 2026 09:56:41 +0200 Subject: [PATCH 11/26] feat: render prop --- docs/src/components/PropTable.tsx | 6 +- src/components/TextField/TextField.tsx | 51 +++++++++++------ src/components/__tests__/TextField.test.tsx | 56 ++++++------------- .../__snapshots__/TextField.test.tsx.snap | 6 -- src/index.tsx | 3 +- 5 files changed, 55 insertions(+), 67 deletions(-) diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index f25d03ba6a..6687256554 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -11,8 +11,10 @@ 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', + '(props: TextFieldAccessoryProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L25', + '(props: TextFieldRenderProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L118', AccessibilityState: 'https://reactnative.dev/docs/accessibility#accessibilitystate', 'StyleProp': 'https://reactnative.dev/docs/view-style-props', diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 4998da76db..34fc6918dc 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -115,6 +115,10 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { focusInput: () => void; }; +export type TextFieldRenderProps = React.ComponentPropsWithRef< + typeof TextInput +>; + export type TextFieldProps = TextInputProps & { /** * Ref forwarded to the underlying TextInput. @@ -126,7 +130,8 @@ export type TextFieldProps = TextInputProps & { */ variant?: TextFieldVariant; /** - * When `true`, the field uses error styling and validation semantics (`aria-invalid`). + * When `true`, the field uses error styling and replaces the trailing accessory + * with an error indicator when no `endAccessory` is provided. */ error?: boolean; /** @@ -166,8 +171,18 @@ export type TextFieldProps = TextInputProps & { * Can be a custom component or `TextField.Icon`. */ endAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; + /** + * Callback to render a custom input component in place of the native `TextInput`. + * Receives all props that would be passed to `TextInput`, allowing integration + * with third-party inputs such as masked inputs. + */ + render?: (props: TextFieldRenderProps) => React.ReactNode; }; +const DefaultRenderer = (props: TextFieldRenderProps) => ( + +); + /** * A text field lets users enter and edit text. It shows an optional floating label, * supports `filled` and `outlined` variants, optional supporting text (including @@ -222,12 +237,13 @@ function TextField(props: TextFieldProps) { supportingText, variant, theme, - startAccessory, - endAccessory, prefix, suffix, counter, disabled, + startAccessory, + endAccessory, + render = DefaultRenderer, ...textInputProps } = props; @@ -312,21 +328,20 @@ function TextField(props: TextFieldProps) { )} - + {render({ + 'aria-label': label, + 'aria-disabled': isDisabled, + ref: input, + selectionColor, + cursorColor, + placeholderTextColor, + onFocus: onFocusHandler, + onBlur: onBlurHandler, + ...textInputProps, + editable: isEditable, + placeholder, + style: inputStyles, + })} {hasSuffix && ( diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 931d8ee68b..657eca8d87 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -4,7 +4,10 @@ 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'; +import type { + TextFieldAccessoryProps, + TextFieldRenderProps, +} from '../TextField/TextField'; const { stateOpacity } = tokens.md.ref; @@ -216,20 +219,6 @@ it('renders supporting text below the field', () => { 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( { expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); }); -it('marks the input as aria-invalid but not aria-disabled when error and read-only', () => { - 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']).not.toBe(true); -}); +it('renders the input via render with merged props', () => { + const renderInput = jest.fn((props: TextFieldRenderProps) => ( + + )); -it('marks the input as aria-invalid and aria-disabled when error and disabled', () => { const { getByTestId } = render( {}} - error - disabled - testID="tf-input" + render={renderInput} /> ); - const input = getByTestId('tf-input'); - expect(input.props['aria-invalid']).toBe(true); - expect(input.props['aria-disabled']).toBe(true); + expect(getByTestId('custom-input')).toBeTruthy(); + expect(renderInput).toHaveBeenCalled(); + const merged = renderInput.mock.calls[0]?.[0] as TextFieldRenderProps; + expect(merged['aria-label']).toBe('Pin'); + expect(merged.value).toBe('12'); }); it('does not apply disabled opacity to the TextInput when editable is false (filled)', () => { diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 36b9b6fada..5452cbce02 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -435,7 +435,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` > Date: Mon, 18 May 2026 11:15:53 +0200 Subject: [PATCH 12/26] chore: text field icon props --- src/components/TextField/TextFieldIcon.tsx | 51 ++-- src/components/TextField/utils.ts | 6 +- src/components/__tests__/TextField.test.tsx | 10 +- .../__snapshots__/TextField.test.tsx.snap | 240 +++++++----------- 4 files changed, 111 insertions(+), 196 deletions(-) diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index f34275a7d9..e9b7448e63 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -1,38 +1,17 @@ import React from 'react'; -import { AccessibilityProps, GestureResponderEvent, View } from 'react-native'; +import { 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'; +import { useInternalTheme } from '../../core/theming'; +import IconButton, { + type Props as IconButtonProps, +} 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; -} +export type TextFieldIconProps = TextFieldAccessoryProps & + Omit; /** * A component to render a leading / trailing icon in the TextField @@ -71,22 +50,22 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { */ const TextFieldIcon = ({ icon, - color, + iconColor, size, style, error, disabled, - accessibility, theme: themeOverride, onPress, + ...rest }: TextFieldIconProps) => { const theme = useInternalTheme(themeOverride); const iconSize = size ?? ACCESSORY_SIZE; - const iconColor = getIconColor({ + const color = getIconColor({ theme, - color, + iconColor, hasError: error, isDisabled: disabled, }); @@ -94,14 +73,14 @@ const TextFieldIcon = ({ const onPressHandler = disabled ? undefined : onPress; return ( - + ); diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 6c79d0bb65..fd3ce01510 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -121,16 +121,16 @@ export const getFieldBackgroundColor = ({ export const getIconColor = ({ theme, - color, + iconColor, hasError, isDisabled, }: { theme: InternalTheme; - color?: string; + iconColor?: string; hasError: boolean; isDisabled: boolean; }) => { - if (color) return color; + if (iconColor) return iconColor; if (hasError) return theme.colors.error; if (isDisabled) return theme.colors.onSurface; return theme.colors.onSurfaceVariant; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 657eca8d87..a0dc05d03b 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -69,10 +69,10 @@ it('renders filled TextField with TextField.Icon accessories', () => { value="q" onChangeText={() => {}} startAccessory={(props: TextFieldAccessoryProps) => ( - + )} endAccessory={(props: TextFieldAccessoryProps) => ( - + )} /> ).toJSON(); @@ -88,10 +88,10 @@ it('renders outlined TextField with TextField.Icon accessories', () => { value="q" onChangeText={() => {}} startAccessory={(props: TextFieldAccessoryProps) => ( - + )} endAccessory={(props: TextFieldAccessoryProps) => ( - + )} /> ).toJSON(); @@ -153,7 +153,7 @@ it('fires onPress on TextField.Icon end accessory', () => { {...props} icon="close" onPress={onClear} - accessibility={{ accessibilityLabel: 'Clear' }} + accessibilityLabel="Clear" /> )} /> diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 5452cbce02..d2cfdeecaa 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -227,33 +227,22 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` Date: Mon, 18 May 2026 12:10:28 +0200 Subject: [PATCH 13/26] chore: ts style declarations --- src/components/TextField/utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index fd3ce01510..f37cf2bb8d 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -470,8 +470,9 @@ export const getFilledTextFieldData = ( height: 'auto', paddingTop: FILLED_MULTILINE_PADDING_TOP, }, + //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that Platform.OS === 'web' && { - outlineStyle: 'none' as TextStyle['outlineStyle'], + outlineStyle: 'none' as const, }, isDisabled && styles.disabled, inputStyleOverride, @@ -587,8 +588,9 @@ export const getOutlinedTextFieldData = ( textAlignVertical: 'top', paddingTop: OUTLINED_MULTILINE_PADDING_TOP, }, + //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that Platform.OS === 'web' && { - outlineStyle: 'none' as TextStyle['outlineStyle'], + outlineStyle: 'none' as const, }, isDisabled && styles.disabled, inputStyleOverride, From 996d92c2af8550fb9bf398d67c94c3fcdc285e2e Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Tue, 19 May 2026 08:55:33 +0200 Subject: [PATCH 14/26] feat: a11y --- src/components/TextField/TextField.tsx | 39 +++++---- .../TextField/TextFieldErrorIcon.tsx | 2 +- src/components/TextField/hooks.ts | 13 +++ src/components/TextField/utils.ts | 79 +++++++++++++++++++ src/components/__tests__/TextField.test.tsx | 53 ++++++++++--- .../__snapshots__/TextField.test.tsx.snap | 42 ++++++++-- 6 files changed, 196 insertions(+), 32 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 34fc6918dc..c163235dfd 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + AccessibilityProps, BlurEvent, ColorValue, FocusEvent, @@ -20,6 +21,19 @@ import { styles } from './styles'; import TextFieldErrorIcon from './TextFieldErrorIcon'; import type { InternalTheme, ThemeProp } from '../../types'; +export type GetAccessibilityDataReturn = { + input: AccessibilityProps; + supportingText: AccessibilityProps; + counter: AccessibilityProps; +}; + +export type GetAccessibilityDataProps = { + data: TextFieldProps; + hasError: boolean; + hasCounter: boolean; + isDisabled: boolean; +}; + export type TextFieldVariant = 'filled' | 'outlined'; export type TextFieldAccessoryProps = { @@ -104,6 +118,7 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { inputStyles: StyleProp; placeholder: string | undefined; counterText: string; + accessibilityProps: GetAccessibilityDataReturn; renderLeadingAccessory: | ((props: TextFieldAccessoryProps) => React.ReactNode) | undefined; @@ -205,8 +220,8 @@ const DefaultRenderer = (props: TextFieldRenderProps) => ( * style={style} * disabled={disabled} * onPress={() => setText('')} - * accessibilityRole="button" - * accessibilityLabel="Clear text" + * role="button" + * aria-label="Clear text" * > * * @@ -275,6 +290,7 @@ function TextField(props: TextFieldProps) { cursorColor, placeholder, counterText, + accessibilityProps, renderLeadingAccessory, renderTrailingAccessory, focusInput, @@ -322,19 +338,14 @@ function TextField(props: TextFieldProps) { : null} - {hasPrefix && ( - - {prefix} - - )} + {hasPrefix && {prefix}} {render({ - 'aria-label': label, - 'aria-disabled': isDisabled, ref: input, selectionColor, cursorColor, placeholderTextColor, + ...accessibilityProps.input, onFocus: onFocusHandler, onBlur: onBlurHandler, ...textInputProps, @@ -343,11 +354,7 @@ function TextField(props: TextFieldProps) { style: inputStyles, })} - {hasSuffix && ( - - {suffix} - - )} + {hasSuffix && {suffix}} {renderTrailingAccessory ? ( @@ -365,7 +372,7 @@ function TextField(props: TextFieldProps) { {!!supportingText && ( {supportingText} @@ -373,7 +380,7 @@ function TextField(props: TextFieldProps) { )} {hasCounter && ( - + {counterText} )} diff --git a/src/components/TextField/TextFieldErrorIcon.tsx b/src/components/TextField/TextFieldErrorIcon.tsx index 72fb6e7847..bb9974f768 100644 --- a/src/components/TextField/TextFieldErrorIcon.tsx +++ b/src/components/TextField/TextFieldErrorIcon.tsx @@ -18,7 +18,7 @@ const TextFieldErrorIcon = ({ const theme = useInternalTheme(themeOverride); return ( - + { const placeholder = isFocused ? props.placeholder : ' '; const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; + /** + * Accessibility + */ + + const accessibilityProps = getAccessibilityData({ + hasError, + hasCounter, + isDisabled, + data: props, + }); + /** * Styles */ @@ -148,6 +160,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animatedContainerStyle, placeholder, counterText, + accessibilityProps, renderLeadingAccessory, renderTrailingAccessory, onFocusHandler, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index f37cf2bb8d..c9568b4405 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -31,6 +31,8 @@ import type { TextFieldProps, TextFieldSharedApi, SharedTextFieldStyleData, + GetAccessibilityDataProps, + GetAccessibilityDataReturn, } from './TextField'; import type { InternalTheme } from '../../types'; @@ -610,3 +612,80 @@ export const getOutlinedTextFieldData = ( ...shared, }; }; + +export const getAccessibilityData = ({ + data, + hasError, + hasCounter, + isDisabled, +}: GetAccessibilityDataProps): GetAccessibilityDataReturn => { + const { label, supportingText, ...props } = data; + + let textLength = 0; + + if (props.value) { + textLength = props.value.length; + } else if (props.defaultValue) { + textLength = props.defaultValue.length; + } + + const maxLength = props.maxLength; + const shouldEvaluateCounter = !!maxLength && hasCounter; + const isEmptyString = textLength === 0; + const isCounterExceeded = shouldEvaluateCounter && textLength > maxLength; + const isCounterReached = shouldEvaluateCounter && textLength === maxLength; + const isInvalid = hasError || isCounterExceeded; + const isSupportingTextHidden = !!(supportingText && !hasError); + + const chunks: string[] = []; + + if (label) { + chunks.push(label); + } + + if (isSupportingTextHidden) { + chunks.push(supportingText); + } + + if (isEmptyString && props.placeholder && !hasError) { + chunks.push(props.placeholder); + } + + const ariaLabel = chunks.length > 0 ? chunks.join(', ') : label; + + let hint: string | undefined; + + if (isCounterExceeded && !(hasError && supportingText)) { + hint = `Character limit exceeded ${textLength} of ${maxLength}`; + } + + const counterAccessibilityLabel = shouldEvaluateCounter + ? isCounterExceeded + ? `Character limit exceeded ${textLength} of ${maxLength}` + : `Characters entered ${textLength} of ${maxLength}` + : undefined; + + const accessibilityState = { + disabled: isDisabled, + invalid: isInvalid, + ...props.accessibilityState, + } as const; + + return { + input: { + 'aria-label': ariaLabel, + 'aria-valuemax': isCounterReached ? maxLength : undefined, + 'aria-valuenow': isCounterReached ? textLength : undefined, + accessibilityHint: hint, + accessibilityState, + }, + supportingText: { + 'aria-hidden': isSupportingTextHidden, + 'aria-live': hasError && supportingText ? 'polite' : undefined, + }, + counter: { + 'aria-label': counterAccessibilityLabel, + 'aria-live': 'polite', + }, + }; +}; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index a0dc05d03b..5b8f9acbb3 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -219,31 +219,64 @@ it('renders supporting text below the field', () => { expect(getByText('Use a valid address')).toBeTruthy(); }); -it('uses assertive aria-live on supporting text when error is true', () => { - const { getByText } = render( +it('uses polite aria-live on error supporting text', () => { + const { getByText, getByTestId } = render( {}} supportingText="Invalid" error + testID="tf-input" /> ); - expect(getByText('Invalid').props['aria-live']).toBe('assertive'); + expect(getByText('Invalid').props['aria-live']).toBe('polite'); + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); }); -it('uses polite aria-live on supporting text when there is no error', () => { - const { getByText } = render( +it('marks the input invalid when error is true without supporting text', () => { + const { getByTestId } = render( + {}} + error + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); + expect(getByTestId('tf-input').props.accessibilityHint).toBeUndefined(); +}); + +it('hides helper supporting text from the accessibility tree and omits aria-live', () => { + const { getByText, getByTestId } = render( {}} supportingText="Optional" + testID="tf-input" /> ); - expect(getByText('Optional').props['aria-live']).toBe('polite'); + expect(getByText('Optional').props['aria-hidden']).toBe(true); + expect(getByText('Optional').props['aria-live']).toBeUndefined(); + expect(getByTestId('tf-input').props['aria-label']).toBe('Email, Optional'); +}); + +it('includes supporting text in aria-label when label is omitted', () => { + const { getByTestId } = render( + {}} + supportingText="Helper only" + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-label']).toBe('Helper only'); }); it('does not mark the input as aria-disabled when editable is false (read-only)', () => { @@ -257,10 +290,12 @@ it('does not mark the input as aria-disabled when editable is false (read-only)' /> ); - expect(getByTestId('tf-input').props['aria-disabled']).not.toBe(true); + expect(getByTestId('tf-input').props.accessibilityState?.disabled).not.toBe( + true + ); }); -it('marks the input as aria-disabled when disabled is true', () => { +it('marks the input as disabled in accessibilityState when disabled is true', () => { const { getByTestId } = render( { /> ); - expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); + expect(getByTestId('tf-input').props.accessibilityState?.disabled).toBe(true); }); it('renders the input via render with merged props', () => { diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index d2cfdeecaa..da32e01fcf 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -426,7 +426,12 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } > Date: Tue, 19 May 2026 12:00:01 +0200 Subject: [PATCH 15/26] chore: dimensions round --- src/components/TextField/constants.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index 9f5065ad20..be60fb6a62 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -16,9 +16,12 @@ 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_HEIGHT = Math.ceil( + BASELINE_TEXT_FIELD_HEIGHT * fontScale +); +export const TEXT_FIELD_PADDING_VERTICAL = Math.ceil( + BASELINE_TEXT_FIELD_PADDING_VERTICAL * fontScale +); export const TEXT_FIELD_BORDER_RADIUS = defaultShapes.corner.extraSmall; @@ -40,14 +43,15 @@ 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 = +export const INACTIVE_LABEL_TOP_POSITION = Math.ceil( ((BASELINE_TEXT_FIELD_HEIGHT - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 + BASELINE_TEXT_FIELD_PADDING_VERTICAL - LINE_HEIGHT_DELTA) * - fontScale; + fontScale +); export const SUPPORTING_TEXT_MARGIN_TOP = 4; @@ -68,7 +72,7 @@ export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = 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; + Math.ceil(ACTIVE_LABEL_FONT_SIZE * fontScale) + TEXT_FIELD_PADDING_VERTICAL; export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; @@ -78,13 +82,14 @@ export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; -export const OUTLINED_MULTILINE_PADDING_TOP = +export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( ((BASELINE_TEXT_FIELD_HEIGHT - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 - LINE_HEIGHT_DELTA) * - fontScale; + fontScale +); export const OUTLINED_LABEL_PADDING_HORIZONTAL = 4; @@ -94,8 +99,9 @@ export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = 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_ACTIVE_LABEL_TOP_POSITION = Math.ceil( + (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale +); /** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY = From 7adba7d2e6056ff24cd7d6f550e86eaa80f4f8fb Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Tue, 19 May 2026 13:18:22 +0200 Subject: [PATCH 16/26] chore: style adjustment --- src/components/TextField/constants.ts | 42 +++++++++++++------ src/components/TextField/utils.ts | 12 +++++- .../__snapshots__/TextField.test.tsx.snap | 3 ++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index be60fb6a62..c1f5534b17 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -35,7 +35,6 @@ 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; @@ -43,16 +42,6 @@ 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 = Math.ceil( - ((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; @@ -64,6 +53,8 @@ export const INACTIVE_INDICATOR_SIZE = 1; * Constants for the filled variant. */ +const FILLED_LINE_HEIGHT_DELTA = 3; + export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = ACCESSORY_SIZE + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + @@ -71,15 +62,29 @@ export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; +export const FILLED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL) * + fontScale +); + export const FILLED_MULTILINE_PADDING_TOP = Math.ceil(ACTIVE_LABEL_FONT_SIZE * fontScale) + TEXT_FIELD_PADDING_VERTICAL; export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; +export const FILLED_PADDING_BOTTOM = + TEXT_FIELD_PADDING_VERTICAL + FILLED_LINE_HEIGHT_DELTA; + /** * Constants for the outlined variant. */ +const OUTLINED_LINE_HEIGHT_DELTA = 2; + export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( @@ -87,7 +92,7 @@ export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 - - LINE_HEIGHT_DELTA) * + OUTLINED_LINE_HEIGHT_DELTA) * fontScale ); @@ -100,7 +105,18 @@ export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = OUTLINED_LABEL_PADDING_HORIZONTAL; export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = Math.ceil( - (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale + (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + OUTLINED_LINE_HEIGHT_DELTA) * + fontScale +); + +export const OUTLINED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL - + OUTLINED_LINE_HEIGHT_DELTA) * + fontScale ); /** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index c9568b4405..3f00fd24b9 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -11,7 +11,8 @@ import { FILLED_MULTILINE_PADDING_TOP, INACTIVE_INDICATOR_SIZE, INACTIVE_LABEL_FONT_SIZE, - INACTIVE_LABEL_TOP_POSITION, + FILLED_INACTIVE_LABEL_TOP_POSITION, + OUTLINED_INACTIVE_LABEL_TOP_POSITION, INPUT_FONT_SIZE, LABEL_START_OFFSET_WITHOUT_ACCESSORY, OUTLINED_ACTIVE_LABEL_TOP_POSITION, @@ -23,6 +24,7 @@ import { PREFIX_END_PADDING, SUFFIX_START_PADDING, TEXT_FIELD_BORDER_RADIUS, + FILLED_PADDING_BOTTOM, } from './constants'; import { filledStyles, outlinedStyles, styles } from './styles'; import type { @@ -295,7 +297,12 @@ export const getTextFieldAnimation = ({ ? FILLED_ACTIVE_LABEL_TOP_POSITION : OUTLINED_ACTIVE_LABEL_TOP_POSITION; - const top = isFloating ? activeTop : INACTIVE_LABEL_TOP_POSITION; + const inactiveTop = + variant === 'filled' + ? FILLED_INACTIVE_LABEL_TOP_POSITION + : OUTLINED_INACTIVE_LABEL_TOP_POSITION; + + const top = isFloating ? activeTop : inactiveTop; const fontSize = isFloating ? ACTIVE_LABEL_FONT_SIZE : INACTIVE_LABEL_FONT_SIZE; @@ -419,6 +426,7 @@ export const getFilledTextFieldData = ( const fieldStyles: StyleProp = [ styles.field, { + paddingBottom: FILLED_PADDING_BOTTOM, backgroundColor: fieldBackgroundColor, borderTopStartRadius: TEXT_FIELD_BORDER_RADIUS, borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index da32e01fcf..c4b83fee84 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -46,6 +46,7 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "borderTopEndRadius": 4, "borderTopStartRadius": 4, "overflow": "hidden", + "paddingBottom": 19, }, ] } @@ -676,6 +677,7 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "borderTopEndRadius": 4, "borderTopStartRadius": 4, "overflow": "hidden", + "paddingBottom": 19, }, ] } @@ -1306,6 +1308,7 @@ exports[`renders filled TextField with label and value 1`] = ` "borderTopEndRadius": 4, "borderTopStartRadius": 4, "overflow": "hidden", + "paddingBottom": 19, }, ] } From 83224056ee56d6456ab9d22be8295a2f0c21d017 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 09:50:43 +0200 Subject: [PATCH 17/26] chore: refactor --- src/components/TextField/TextField.tsx | 30 ++ src/components/TextField/hooks.ts | 372 +++++++++++++++++-------- src/components/TextField/utils.ts | 15 + 3 files changed, 302 insertions(+), 115 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index c163235dfd..227b87b43b 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -19,8 +19,29 @@ import Animated, { AnimatedStyle } from 'react-native-reanimated'; import { useTextField } from './hooks'; import { styles } from './styles'; import TextFieldErrorIcon from './TextFieldErrorIcon'; +import { getTextFieldAnimation } from './utils'; import type { InternalTheme, ThemeProp } from '../../types'; +export type TextFieldFlags = { + isRTL: boolean; + isDisabled: boolean; + isEditable: boolean | undefined; + hasError: boolean; + hasCounter: boolean; + hasAccessory: boolean; + isFloating: boolean; + hasPrefix: boolean; + hasSuffix: boolean; +}; + +export type TextFieldColors = { + selectionColor: ColorValue; + cursorColor: ColorValue; + placeholderTextColor: ColorValue; +}; + +export type TextFieldAnimationState = ReturnType; + export type GetAccessibilityDataReturn = { input: AccessibilityProps; supportingText: AccessibilityProps; @@ -95,6 +116,15 @@ export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { inputStyles: StyleProp; }; +export type TextFieldLayoutData = + | FilledTextFieldHookData + | OutlinedTextFieldHookData; + +export type TextFieldLayoutState = Omit< + TextFieldLayoutData, + 'input' | 'isDisabled' | 'hasError' | 'hasSuffix' +>; + export type TextFieldHookReturn = SharedTextFieldStyleData & { input: React.RefObject; isDisabled: boolean; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 1072111451..775255e86c 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -1,182 +1,324 @@ -import { useImperativeHandle, useRef, useState } from 'react'; +import { + useImperativeHandle, + useMemo, + useRef, + useState, + type RefObject, +} from 'react'; import { BlurEvent, FocusEvent, TextInput } from 'react-native'; import type { + GetAccessibilityDataReturn, + TextFieldAnimationState, + TextFieldColors, + TextFieldFlags, TextFieldHookReturn, + TextFieldLayoutState, TextFieldProps, - TextFieldSharedApi, + TextFieldVariant, } from './TextField'; import { getAccentColors, getAccessibilityData, getFilledTextFieldData, + getLayoutState, getOutlinedTextFieldData, getTextFieldAnimation, } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; +import type { InternalTheme } from '../../types'; -export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { - const { - ref, - variant = 'filled', - theme: themeOverride, - onFocus, - onBlur, - } = props; +const useTextFieldRef = (ref: TextFieldProps['ref']) => { + const input = useRef(null); - /** - * Hooks - */ + useImperativeHandle(ref, () => input.current as TextInput); - const input = useRef(null); + return input; +}; - const theme = useInternalTheme(themeOverride); +const useTextFieldFocus = ( + props: Pick, + input: RefObject, + isDisabled: boolean +) => { + const [isFocused, setIsFocused] = useState(false); - const { direction } = useLocale(); + const onFocusHandler = (e: FocusEvent) => { + props.onFocus?.(e); + setIsFocused(true); + }; - const [isFocused, setIsFocused] = useState(false); + const onBlurHandler = (e: BlurEvent) => { + props.onBlur?.(e); + setIsFocused(false); + }; - useImperativeHandle(ref, () => input.current as TextInput); + const focusInput = () => { + if (isDisabled) return; + input.current?.focus(); + }; - /** - * Constants - */ + return { + isFocused, + onFocusHandler, + onBlurHandler, + focusInput, + }; +}; +const useTextFieldFlags = ( + props: TextFieldProps, + isFocused: boolean +): TextFieldFlags => { + const { direction } = useLocale(); const isRTL = direction === 'rtl'; - const isDisabled = !!props.disabled; - const isEditable = props.disabled ? false : props.editable; const isFloating = isFocused || !!props.value; - const hasError = !!props.error; - const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; - const hasPrefix = !!props.prefix && isFloating; - const hasSuffix = !!props.suffix && isFloating; - const hasCounter = !!(props.counter && props.maxLength); - /** - * Theme tokens - */ + return { + isRTL, + isFloating, + isDisabled: !!props.disabled, + isEditable: props.disabled ? false : props.editable, + hasError: !!props.error, + hasCounter: !!(props.counter && props.maxLength), + hasAccessory: isRTL ? !!props.endAccessory : !!props.startAccessory, + hasPrefix: !!props.prefix && isFloating, + hasSuffix: !!props.suffix && isFloating, + }; +}; - const { selectionColor, cursorColor } = getAccentColors({ - theme, - hasError, - }); +const useTextFieldColors = ( + theme: InternalTheme, + hasError: boolean, + placeholderTextColor: TextFieldProps['placeholderTextColor'] +): TextFieldColors => + useMemo( + () => ({ + ...getAccentColors({ theme, hasError }), + placeholderTextColor: + placeholderTextColor ?? theme.colors.onSurfaceVariant, + }), + [theme, hasError, placeholderTextColor] + ); - const placeholderTextColor = - props.placeholderTextColor ?? theme.colors.onSurfaceVariant; +const useTextFieldAnimation = ({ + variant, + isFloating, + isFocused, + isRTL, + hasAccessory, +}: { + variant: TextFieldVariant; + isFloating: boolean; + isFocused: boolean; + isRTL: boolean; + hasAccessory: boolean; +}): TextFieldAnimationState => + useMemo( + () => + getTextFieldAnimation({ + variant, + isFloating, + isFocused, + isRTL, + hasAccessory, + }), + [variant, isFloating, isFocused, isRTL, hasAccessory] + ); - /** - * Label animation - */ +const useTextFieldLayout = ({ + variant, + props, + input, + theme, + flags, + isFocused, + animation, +}: { + variant: TextFieldVariant; + props: TextFieldProps; + input: RefObject; + theme: InternalTheme; + flags: TextFieldFlags; + isFocused: boolean; + animation: TextFieldAnimationState; +}): TextFieldLayoutState => { + const { isRTL, isDisabled, hasError, hasAccessory, hasSuffix, isFloating } = + flags; + + const { multiline } = props; const { animatedLabelWrapperStyle, animatedLabelTextStyle, animatedActiveOutlineStyle, - animatedContainerStyle, - } = getTextFieldAnimation({ - variant, - isFloating, - isFocused, - isRTL, - hasAccessory, - }); + } = animation; + + return useMemo( + () => + getLayoutState( + variant === 'filled' + ? getFilledTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ) + : getOutlinedTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ) + ), + /** + * `input` is a stable ref. `props` is omitted — only `multiline` affects layout. + * `style` is omitted — assumed stable; dynamic `style` changes won't invalidate layout. + * `isFloating` + `isFocused` cover all animation inputs (replacing individual style objects). + */ + // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment + [ + variant, + theme, + isFocused, + isFloating, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + multiline, + ] + ); +}; - /** - * Handlers - */ +const useTextFieldAccessibility = ( + props: TextFieldProps, + flags: Pick +): GetAccessibilityDataReturn => { + const { hasError, hasCounter, isDisabled } = flags; - const onFocusHandler = (e: FocusEvent) => { - onFocus?.(e); - setIsFocused(true); - }; + const { value, defaultValue, label, supportingText, placeholder, maxLength } = + props; - const onBlurHandler = (e: BlurEvent) => { - onBlur?.(e); - setIsFocused(false); - }; + return useMemo( + () => + getAccessibilityData({ + hasError, + hasCounter, + isDisabled, + data: props, + }), + // `props` is omitted — fields read by `getAccessibilityData` are listed explicitly. + // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment + [ + value, + defaultValue, + label, + supportingText, + placeholder, + maxLength, + hasError, + hasCounter, + isDisabled, + ] + ); +}; - const focusInput = () => { - if (isDisabled) return; - input.current?.focus(); - }; +const useTextFieldCounter = ( + value: TextFieldProps['value'], + maxLength: TextFieldProps['maxLength'] +) => useMemo(() => `${value?.length ?? 0}/${maxLength}`, [value, maxLength]); + +export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { + const { ref, variant = 'filled', theme: themeOverride } = props; + + const input = useTextFieldRef(ref); + + const theme = useInternalTheme(themeOverride); + + const { isFocused, onFocusHandler, onBlurHandler, focusInput } = + useTextFieldFocus(props, input, !!props.disabled); + + const flags = useTextFieldFlags(props, isFocused); - /** - * Shared API - */ + const { selectionColor, cursorColor, placeholderTextColor } = + useTextFieldColors(theme, flags.hasError, props.placeholderTextColor); + + const animation = useTextFieldAnimation({ + variant, + isFloating: flags.isFloating, + isFocused, + isRTL: flags.isRTL, + hasAccessory: flags.hasAccessory, + }); - const api: TextFieldSharedApi = { + const layout = useTextFieldLayout({ + variant, + props, input, theme, + flags, isFocused, - isRTL, - isDisabled, - hasAccessory, - hasError, - hasSuffix, - animatedLabelWrapperStyle, - animatedLabelTextStyle, - animatedActiveOutlineStyle, - }; + animation, + }); - /** - * Components - */ + const accessibilityProps = useTextFieldAccessibility(props, flags); - const renderLeadingAccessory = isRTL + const counterText = useTextFieldCounter(props.value, props.maxLength); + + const renderLeadingAccessory = flags.isRTL ? props.endAccessory : props.startAccessory; - const renderTrailingAccessory = isRTL + const renderTrailingAccessory = flags.isRTL ? props.startAccessory : props.endAccessory; + // https://github.com/facebook/react-native/issues/31573 const placeholder = isFocused ? props.placeholder : ' '; - const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; - /** - * Accessibility - */ - - const accessibilityProps = getAccessibilityData({ - hasError, - hasCounter, - isDisabled, - data: props, - }); - - /** - * Styles - */ - - const data = { - isEditable, - isDisabled, - hasPrefix, - hasCounter, + return { + input, + isDisabled: flags.isDisabled, + isEditable: flags.isEditable, + hasPrefix: flags.hasPrefix, + hasCounter: flags.hasCounter, + hasSuffix: flags.hasSuffix, + hasError: flags.hasError, placeholderTextColor, selectionColor, cursorColor, animatedActiveOutlineStyles: undefined, - animatedContainerStyle, + animatedContainerStyle: animation.animatedContainerStyle, placeholder, counterText, accessibilityProps, + ...layout, renderLeadingAccessory, renderTrailingAccessory, onFocusHandler, onBlurHandler, focusInput, }; - - if (variant === 'filled') { - return { - ...data, - ...getFilledTextFieldData(api, props), - }; - } - - return { - ...data, - ...getOutlinedTextFieldData(api, props), - }; }; diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 3f00fd24b9..2095724e6a 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -30,11 +30,13 @@ import { filledStyles, outlinedStyles, styles } from './styles'; import type { FilledTextFieldHookData, OutlinedTextFieldHookData, + TextFieldLayoutData, TextFieldProps, TextFieldSharedApi, SharedTextFieldStyleData, GetAccessibilityDataProps, GetAccessibilityDataReturn, + TextFieldLayoutState, } from './TextField'; import type { InternalTheme } from '../../types'; @@ -697,3 +699,16 @@ export const getAccessibilityData = ({ }, }; }; + +export const getLayoutState = ( + layout: TextFieldLayoutData +): TextFieldLayoutState => { + const { input, isDisabled, hasError, hasSuffix, ...layoutState } = layout; + + void input; + void isDisabled; + void hasError; + void hasSuffix; + + return layoutState; +}; From 8effa885761364909d093573eb94a2344168c542 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 10:30:27 +0200 Subject: [PATCH 18/26] feat: uncontrolled variant --- src/components/TextField/TextField.tsx | 19 +++++--- src/components/TextField/hooks.ts | 63 +++++++++++++++++++------- src/components/TextField/utils.ts | 2 - 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 227b87b43b..8003d4602e 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -127,6 +127,7 @@ export type TextFieldLayoutState = Omit< export type TextFieldHookReturn = SharedTextFieldStyleData & { input: React.RefObject; + value: string | undefined; isDisabled: boolean; isEditable: boolean | undefined; hasPrefix: boolean; @@ -155,8 +156,9 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { renderTrailingAccessory: | ((props: TextFieldAccessoryProps) => React.ReactNode) | undefined; - onFocusHandler: (e: FocusEvent) => void; - onBlurHandler: (e: BlurEvent) => void; + onChangeText: (text: string) => void; + onFocus: (e: FocusEvent) => void; + onBlur: (e: BlurEvent) => void; focusInput: () => void; }; @@ -276,6 +278,7 @@ const DefaultRenderer = (props: TextFieldRenderProps) => ( function TextField(props: TextFieldProps) { /* eslint-disable @typescript-eslint/no-unused-vars -- peel TextField-only props before TextInput spread */ const { + defaultValue: _defaultValue, ref, error, label, @@ -294,6 +297,7 @@ function TextField(props: TextFieldProps) { const { input, + value, isDisabled, isEditable, hasPrefix, @@ -324,8 +328,9 @@ function TextField(props: TextFieldProps) { renderLeadingAccessory, renderTrailingAccessory, focusInput, - onFocusHandler, - onBlurHandler, + onChangeText, + onFocus, + onBlur, } = useTextField(props); return ( @@ -376,12 +381,14 @@ function TextField(props: TextFieldProps) { cursorColor, placeholderTextColor, ...accessibilityProps.input, - onFocus: onFocusHandler, - onBlur: onBlurHandler, ...textInputProps, + value, editable: isEditable, placeholder, style: inputStyles, + onChangeText, + onFocus, + onBlur, })} {hasSuffix && {suffix}} diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 775255e86c..9a4a4c2c6f 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -37,6 +37,28 @@ const useTextFieldRef = (ref: TextFieldProps['ref']) => { return input; }; +const useTextFieldValue = ( + props: Pick +) => { + const isControlled = props.value !== undefined; + + const [uncontrolledValue, setUncontrolledValue] = useState( + isControlled ? props.value : props.defaultValue + ); + + const value = isControlled ? props.value : uncontrolledValue; + + const onChangeText = (text: string) => { + if (!isControlled) { + setUncontrolledValue(text); + } + + props.onChangeText?.(text); + }; + + return { value, onChangeText }; +}; + const useTextFieldFocus = ( props: Pick, input: RefObject, @@ -44,12 +66,12 @@ const useTextFieldFocus = ( ) => { const [isFocused, setIsFocused] = useState(false); - const onFocusHandler = (e: FocusEvent) => { + const onFocus = (e: FocusEvent) => { props.onFocus?.(e); setIsFocused(true); }; - const onBlurHandler = (e: BlurEvent) => { + const onBlur = (e: BlurEvent) => { props.onBlur?.(e); setIsFocused(false); }; @@ -61,19 +83,20 @@ const useTextFieldFocus = ( return { isFocused, - onFocusHandler, - onBlurHandler, + onFocus, + onBlur, focusInput, }; }; const useTextFieldFlags = ( props: TextFieldProps, - isFocused: boolean + isFocused: boolean, + value: TextFieldProps['value'] ): TextFieldFlags => { const { direction } = useLocale(); const isRTL = direction === 'rtl'; - const isFloating = isFocused || !!props.value; + const isFloating = isFocused || !!value; return { isRTL, @@ -215,12 +238,12 @@ const useTextFieldLayout = ({ const useTextFieldAccessibility = ( props: TextFieldProps, + value: TextFieldProps['value'], flags: Pick ): GetAccessibilityDataReturn => { const { hasError, hasCounter, isDisabled } = flags; - const { value, defaultValue, label, supportingText, placeholder, maxLength } = - props; + const { label, supportingText, placeholder, maxLength } = props; return useMemo( () => @@ -228,13 +251,12 @@ const useTextFieldAccessibility = ( hasError, hasCounter, isDisabled, - data: props, + data: { ...props, value }, }), // `props` is omitted — fields read by `getAccessibilityData` are listed explicitly. // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment [ value, - defaultValue, label, supportingText, placeholder, @@ -258,10 +280,15 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const theme = useInternalTheme(themeOverride); - const { isFocused, onFocusHandler, onBlurHandler, focusInput } = - useTextFieldFocus(props, input, !!props.disabled); + const { value, onChangeText } = useTextFieldValue(props); + + const { isFocused, onFocus, onBlur, focusInput } = useTextFieldFocus( + props, + input, + !!props.disabled + ); - const flags = useTextFieldFlags(props, isFocused); + const flags = useTextFieldFlags(props, isFocused, value); const { selectionColor, cursorColor, placeholderTextColor } = useTextFieldColors(theme, flags.hasError, props.placeholderTextColor); @@ -284,9 +311,9 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animation, }); - const accessibilityProps = useTextFieldAccessibility(props, flags); + const accessibilityProps = useTextFieldAccessibility(props, value, flags); - const counterText = useTextFieldCounter(props.value, props.maxLength); + const counterText = useTextFieldCounter(value, props.maxLength); const renderLeadingAccessory = flags.isRTL ? props.endAccessory @@ -300,6 +327,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { return { input, + value, isDisabled: flags.isDisabled, isEditable: flags.isEditable, hasPrefix: flags.hasPrefix, @@ -317,8 +345,9 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { ...layout, renderLeadingAccessory, renderTrailingAccessory, - onFocusHandler, - onBlurHandler, + onChangeText, + onFocus, + onBlur, focusInput, }; }; diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 2095724e6a..f14fd6fa54 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -635,8 +635,6 @@ export const getAccessibilityData = ({ if (props.value) { textLength = props.value.length; - } else if (props.defaultValue) { - textLength = props.defaultValue.length; } const maxLength = props.maxLength; From 2dbd99d8c975b8bc5a2031a810de51b59cf51412 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 11:10:19 +0200 Subject: [PATCH 19/26] chore: docs --- docs/src/components/PropTable.tsx | 4 ++-- src/components/TextField/TextField.tsx | 8 +------- src/components/TextField/TextFieldIcon.tsx | 19 +++++++++++++------ src/components/__tests__/TextField.test.tsx | 6 ++---- src/index.tsx | 6 ++++-- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index 6687256554..05e8f809c4 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -12,9 +12,9 @@ const typeDefinitions = { ThemeProp: 'https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties', '(props: TextFieldAccessoryProps) => React.ReactNode': - 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L25', + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextFieldIcon.tsx#L11', '(props: TextFieldRenderProps) => React.ReactNode': - 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L118', + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L159', AccessibilityState: 'https://reactnative.dev/docs/accessibility#accessibilitystate', 'StyleProp': 'https://reactnative.dev/docs/view-style-props', diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 8003d4602e..eb431d43c8 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -19,6 +19,7 @@ import Animated, { AnimatedStyle } from 'react-native-reanimated'; import { useTextField } from './hooks'; import { styles } from './styles'; import TextFieldErrorIcon from './TextFieldErrorIcon'; +import type { TextFieldAccessoryProps } from './TextFieldIcon'; import { getTextFieldAnimation } from './utils'; import type { InternalTheme, ThemeProp } from '../../types'; @@ -57,13 +58,6 @@ export type GetAccessibilityDataProps = { export type TextFieldVariant = 'filled' | 'outlined'; -export type TextFieldAccessoryProps = { - style: StyleProp; - multiline: boolean; - disabled: boolean; - error: boolean; -}; - export type TextFieldSharedApi = { input: React.RefObject; theme: InternalTheme; diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index e9b7448e63..86d3a5f2ed 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -1,17 +1,22 @@ import React from 'react'; -import { View } from 'react-native'; +import { StyleProp, View, ViewStyle } from 'react-native'; import { ACCESSORY_SIZE } from './constants'; import { styles } from './styles'; -import type { TextFieldAccessoryProps } from './TextField'; import { getIconColor } from './utils'; import { useInternalTheme } from '../../core/theming'; -import IconButton, { - type Props as IconButtonProps, -} from '../IconButton/IconButton'; +import type { $Omit } from '../../types'; +import IconButton from '../IconButton/IconButton'; + +export type TextFieldAccessoryProps = { + style: StyleProp; + multiline: boolean; + disabled: boolean; + error: boolean; +}; export type TextFieldIconProps = TextFieldAccessoryProps & - Omit; + $Omit, keyof TextFieldAccessoryProps>; /** * A component to render a leading / trailing icon in the TextField @@ -47,6 +52,8 @@ export type TextFieldIconProps = TextFieldAccessoryProps & * * export default MyComponent; * ``` + * + * @extends IconButton props https://callstack.github.io/react-native-paper/docs/components/IconButton */ const TextFieldIcon = ({ icon, diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 5b8f9acbb3..b73ba5cc16 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -4,10 +4,8 @@ import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; import { fireEvent, render } from '../../test-utils'; import { tokens } from '../../theme/tokens'; import TextField from '../TextField'; -import type { - TextFieldAccessoryProps, - TextFieldRenderProps, -} from '../TextField/TextField'; +import type { TextFieldRenderProps } from '../TextField/TextField'; +import type { TextFieldAccessoryProps } from '../TextField/TextFieldIcon'; const { stateOpacity } = tokens.md.ref; diff --git a/src/index.tsx b/src/index.tsx index e55021f12a..a6ba4bf99e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -133,12 +133,14 @@ export type { Props as TextInputProps } from './components/TextInput/TextInput'; export type { Props as TextInputAffixProps } from './components/TextInput/Adornment/TextInputAffix'; export type { Props as TextInputIconProps } from './components/TextInput/Adornment/TextInputIcon'; export type { - TextFieldAccessoryProps, TextFieldProps, TextFieldRenderProps, TextFieldVariant, } from './components/TextField/TextField'; -export type { TextFieldIconProps } from './components/TextField/TextFieldIcon'; +export type { + TextFieldAccessoryProps, + 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'; From 0f534fd0da0d4f62188286bd2af237672ae4d893 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 11:38:16 +0200 Subject: [PATCH 20/26] chore: refactor --- src/components/TextField/hooks.ts | 196 ++++++++++-------------------- src/components/TextField/utils.ts | 15 --- 2 files changed, 67 insertions(+), 144 deletions(-) diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 9a4a4c2c6f..60272d808e 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -8,9 +8,7 @@ import { import { BlurEvent, FocusEvent, TextInput } from 'react-native'; import type { - GetAccessibilityDataReturn, TextFieldAnimationState, - TextFieldColors, TextFieldFlags, TextFieldHookReturn, TextFieldLayoutState, @@ -21,7 +19,6 @@ import { getAccentColors, getAccessibilityData, getFilledTextFieldData, - getLayoutState, getOutlinedTextFieldData, getTextFieldAnimation, } from './utils'; @@ -29,14 +26,6 @@ import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { InternalTheme } from '../../types'; -const useTextFieldRef = (ref: TextFieldProps['ref']) => { - const input = useRef(null); - - useImperativeHandle(ref, () => input.current as TextInput); - - return input; -}; - const useTextFieldValue = ( props: Pick ) => { @@ -95,6 +84,7 @@ const useTextFieldFlags = ( value: TextFieldProps['value'] ): TextFieldFlags => { const { direction } = useLocale(); + const isRTL = direction === 'rtl'; const isFloating = isFocused || !!value; @@ -111,45 +101,6 @@ const useTextFieldFlags = ( }; }; -const useTextFieldColors = ( - theme: InternalTheme, - hasError: boolean, - placeholderTextColor: TextFieldProps['placeholderTextColor'] -): TextFieldColors => - useMemo( - () => ({ - ...getAccentColors({ theme, hasError }), - placeholderTextColor: - placeholderTextColor ?? theme.colors.onSurfaceVariant, - }), - [theme, hasError, placeholderTextColor] - ); - -const useTextFieldAnimation = ({ - variant, - isFloating, - isFocused, - isRTL, - hasAccessory, -}: { - variant: TextFieldVariant; - isFloating: boolean; - isFocused: boolean; - isRTL: boolean; - hasAccessory: boolean; -}): TextFieldAnimationState => - useMemo( - () => - getTextFieldAnimation({ - variant, - isFloating, - isFocused, - isRTL, - hasAccessory, - }), - [variant, isFloating, isFocused, isRTL, hasAccessory] - ); - const useTextFieldLayout = ({ variant, props, @@ -179,42 +130,54 @@ const useTextFieldLayout = ({ } = animation; return useMemo( - () => - getLayoutState( - variant === 'filled' - ? getFilledTextFieldData( - { - input, - theme, - isFocused, - isRTL, - isDisabled, - hasAccessory, - hasError, - hasSuffix, - animatedLabelWrapperStyle, - animatedLabelTextStyle, - animatedActiveOutlineStyle, - }, - props - ) - : getOutlinedTextFieldData( - { - input, - theme, - isFocused, - isRTL, - isDisabled, - hasAccessory, - hasError, - hasSuffix, - animatedLabelWrapperStyle, - animatedLabelTextStyle, - animatedActiveOutlineStyle, - }, - props - ) - ), + () => { + const { + input: _input, + isDisabled: _isDisabled, + hasError: _hasError, + hasSuffix: _hasSuffix, + ...layout + } = variant === 'filled' + ? getFilledTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ) + : getOutlinedTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ); + + void _input; + void _isDisabled; + void _hasError; + void _hasSuffix; + + return layout; + }, /** * `input` is a stable ref. `props` is omitted — only `multiline` affects layout. * `style` is omitted — assumed stable; dynamic `style` changes won't invalidate layout. @@ -236,47 +199,10 @@ const useTextFieldLayout = ({ ); }; -const useTextFieldAccessibility = ( - props: TextFieldProps, - value: TextFieldProps['value'], - flags: Pick -): GetAccessibilityDataReturn => { - const { hasError, hasCounter, isDisabled } = flags; - - const { label, supportingText, placeholder, maxLength } = props; - - return useMemo( - () => - getAccessibilityData({ - hasError, - hasCounter, - isDisabled, - data: { ...props, value }, - }), - // `props` is omitted — fields read by `getAccessibilityData` are listed explicitly. - // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment - [ - value, - label, - supportingText, - placeholder, - maxLength, - hasError, - hasCounter, - isDisabled, - ] - ); -}; - -const useTextFieldCounter = ( - value: TextFieldProps['value'], - maxLength: TextFieldProps['maxLength'] -) => useMemo(() => `${value?.length ?? 0}/${maxLength}`, [value, maxLength]); - export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const { ref, variant = 'filled', theme: themeOverride } = props; - const input = useTextFieldRef(ref); + const input = useRef(null); const theme = useInternalTheme(themeOverride); @@ -290,10 +216,17 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const flags = useTextFieldFlags(props, isFocused, value); - const { selectionColor, cursorColor, placeholderTextColor } = - useTextFieldColors(theme, flags.hasError, props.placeholderTextColor); + useImperativeHandle(ref, () => input.current as TextInput); + + const { selectionColor, cursorColor } = getAccentColors({ + theme, + hasError: flags.hasError, + }); - const animation = useTextFieldAnimation({ + const placeholderTextColor = + props.placeholderTextColor ?? theme.colors.onSurfaceVariant; + + const animation = getTextFieldAnimation({ variant, isFloating: flags.isFloating, isFocused, @@ -311,9 +244,14 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animation, }); - const accessibilityProps = useTextFieldAccessibility(props, value, flags); + const accessibilityProps = getAccessibilityData({ + hasError: flags.hasError, + hasCounter: flags.hasCounter, + isDisabled: flags.isDisabled, + data: { ...props, value }, + }); - const counterText = useTextFieldCounter(value, props.maxLength); + const counterText = `${value?.length ?? 0}/${props.maxLength}`; const renderLeadingAccessory = flags.isRTL ? props.endAccessory diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index f14fd6fa54..3491543ec8 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -30,13 +30,11 @@ import { filledStyles, outlinedStyles, styles } from './styles'; import type { FilledTextFieldHookData, OutlinedTextFieldHookData, - TextFieldLayoutData, TextFieldProps, TextFieldSharedApi, SharedTextFieldStyleData, GetAccessibilityDataProps, GetAccessibilityDataReturn, - TextFieldLayoutState, } from './TextField'; import type { InternalTheme } from '../../types'; @@ -697,16 +695,3 @@ export const getAccessibilityData = ({ }, }; }; - -export const getLayoutState = ( - layout: TextFieldLayoutData -): TextFieldLayoutState => { - const { input, isDisabled, hasError, hasSuffix, ...layoutState } = layout; - - void input; - void isDisabled; - void hasError; - void hasSuffix; - - return layoutState; -}; From 635c09277fb9957487c816b19362a7f884ba99df Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Mon, 25 May 2026 09:51:03 +0200 Subject: [PATCH 21/26] chore: uncontrolled variant state management --- src/components/TextField/TextField.tsx | 5 +- src/components/TextField/hooks.ts | 53 +++++++++++++++------ src/components/TextField/utils.ts | 21 ++++---- src/components/__tests__/TextField.test.tsx | 36 ++++++++++++++ 4 files changed, 83 insertions(+), 32 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index eb431d43c8..6fa6311345 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -51,6 +51,7 @@ export type GetAccessibilityDataReturn = { export type GetAccessibilityDataProps = { data: TextFieldProps; + inputLength: number; hasError: boolean; hasCounter: boolean; isDisabled: boolean; @@ -121,7 +122,6 @@ export type TextFieldLayoutState = Omit< export type TextFieldHookReturn = SharedTextFieldStyleData & { input: React.RefObject; - value: string | undefined; isDisabled: boolean; isEditable: boolean | undefined; hasPrefix: boolean; @@ -272,7 +272,6 @@ const DefaultRenderer = (props: TextFieldRenderProps) => ( function TextField(props: TextFieldProps) { /* eslint-disable @typescript-eslint/no-unused-vars -- peel TextField-only props before TextInput spread */ const { - defaultValue: _defaultValue, ref, error, label, @@ -291,7 +290,6 @@ function TextField(props: TextFieldProps) { const { input, - value, isDisabled, isEditable, hasPrefix, @@ -376,7 +374,6 @@ function TextField(props: TextFieldProps) { placeholderTextColor, ...accessibilityProps.input, ...textInputProps, - value, editable: isEditable, placeholder, style: inputStyles, diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 60272d808e..cad4cfa7dd 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -26,26 +26,49 @@ import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { InternalTheme } from '../../types'; -const useTextFieldValue = ( - props: Pick +const useTextFieldInput = ( + props: Pick< + TextFieldProps, + 'value' | 'defaultValue' | 'onChangeText' | 'counter' | 'maxLength' + > ) => { const isControlled = props.value !== undefined; + const hasCounter = !!(props.counter && props.maxLength); - const [uncontrolledValue, setUncontrolledValue] = useState( - isControlled ? props.value : props.defaultValue - ); + const initialText = isControlled ? props.value : props.defaultValue; + + const [hasValue, setHasValue] = useState(!!initialText); + const [charCount, setCharCount] = useState(initialText?.length ?? 0); - const value = isControlled ? props.value : uncontrolledValue; + const inputLength = isControlled + ? props.value?.length ?? 0 + : hasCounter + ? charCount + : hasValue + ? 1 + : 0; const onChangeText = (text: string) => { if (!isControlled) { - setUncontrolledValue(text); + const next = text.length > 0; + + if (hasCounter) { + setCharCount(text.length); + } + + if (next !== hasValue) { + setHasValue(next); + } } props.onChangeText?.(text); }; - return { value, onChangeText }; + return { + hasValue: isControlled ? !!props.value : hasValue, + inputLength, + onChangeText, + }; }; const useTextFieldFocus = ( @@ -81,12 +104,12 @@ const useTextFieldFocus = ( const useTextFieldFlags = ( props: TextFieldProps, isFocused: boolean, - value: TextFieldProps['value'] + hasValue: boolean ): TextFieldFlags => { const { direction } = useLocale(); const isRTL = direction === 'rtl'; - const isFloating = isFocused || !!value; + const isFloating = isFocused || hasValue; return { isRTL, @@ -206,7 +229,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const theme = useInternalTheme(themeOverride); - const { value, onChangeText } = useTextFieldValue(props); + const { hasValue, inputLength, onChangeText } = useTextFieldInput(props); const { isFocused, onFocus, onBlur, focusInput } = useTextFieldFocus( props, @@ -214,7 +237,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { !!props.disabled ); - const flags = useTextFieldFlags(props, isFocused, value); + const flags = useTextFieldFlags(props, isFocused, hasValue); useImperativeHandle(ref, () => input.current as TextInput); @@ -248,10 +271,11 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { hasError: flags.hasError, hasCounter: flags.hasCounter, isDisabled: flags.isDisabled, - data: { ...props, value }, + data: props, + inputLength, }); - const counterText = `${value?.length ?? 0}/${props.maxLength}`; + const counterText = `${inputLength}/${props.maxLength}`; const renderLeadingAccessory = flags.isRTL ? props.endAccessory @@ -265,7 +289,6 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { return { input, - value, isDisabled: flags.isDisabled, isEditable: flags.isEditable, hasPrefix: flags.hasPrefix, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 3491543ec8..b9254daab7 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -626,20 +626,15 @@ export const getAccessibilityData = ({ hasError, hasCounter, isDisabled, + inputLength, }: GetAccessibilityDataProps): GetAccessibilityDataReturn => { const { label, supportingText, ...props } = data; - let textLength = 0; - - if (props.value) { - textLength = props.value.length; - } - const maxLength = props.maxLength; const shouldEvaluateCounter = !!maxLength && hasCounter; - const isEmptyString = textLength === 0; - const isCounterExceeded = shouldEvaluateCounter && textLength > maxLength; - const isCounterReached = shouldEvaluateCounter && textLength === maxLength; + const isEmptyString = inputLength === 0; + const isCounterExceeded = shouldEvaluateCounter && inputLength > maxLength; + const isCounterReached = shouldEvaluateCounter && inputLength === maxLength; const isInvalid = hasError || isCounterExceeded; const isSupportingTextHidden = !!(supportingText && !hasError); @@ -662,13 +657,13 @@ export const getAccessibilityData = ({ let hint: string | undefined; if (isCounterExceeded && !(hasError && supportingText)) { - hint = `Character limit exceeded ${textLength} of ${maxLength}`; + hint = `Character limit exceeded ${inputLength} of ${maxLength}`; } const counterAccessibilityLabel = shouldEvaluateCounter ? isCounterExceeded - ? `Character limit exceeded ${textLength} of ${maxLength}` - : `Characters entered ${textLength} of ${maxLength}` + ? `Character limit exceeded ${inputLength} of ${maxLength}` + : `Characters entered ${inputLength} of ${maxLength}` : undefined; const accessibilityState = { @@ -681,7 +676,7 @@ export const getAccessibilityData = ({ input: { 'aria-label': ariaLabel, 'aria-valuemax': isCounterReached ? maxLength : undefined, - 'aria-valuenow': isCounterReached ? textLength : undefined, + 'aria-valuenow': isCounterReached ? inputLength : undefined, accessibilityHint: hint, accessibilityState, }, diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index b73ba5cc16..cef54a1b42 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -1109,3 +1109,39 @@ it('does not apply the TextInput style prop to prefix or suffix Text', () => { expect(suffixFlat.fontSize).not.toBe(40); expect(suffixFlat.letterSpacing).toBeUndefined(); }); + +it('passes defaultValue to the native input when uncontrolled without counter', () => { + const { getByTestId } = render( + {}} + testID="tf-uncontrolled" + /> + ); + + const input = getByTestId('tf-uncontrolled'); + expect(input.props.defaultValue).toBe('hello'); + expect(input.props.value).toBeUndefined(); +}); + +it('updates the character counter for an uncontrolled field with counter enabled', () => { + const onChangeText = jest.fn(); + const { getByTestId, getByText } = render( + + ); + + expect(getByText('1/10')).toBeTruthy(); + + fireEvent.changeText(getByTestId('tf-uncontrolled-counter'), 'abcd'); + + expect(onChangeText).toHaveBeenCalledWith('abcd'); + expect(getByText('4/10')).toBeTruthy(); +}); From 0d114b2790d3bf7b5fa88679741189a6ef36e191 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 27 May 2026 10:37:15 +0200 Subject: [PATCH 22/26] chore: event-driven animations --- src/components/TextField/TextField.tsx | 25 +- src/components/TextField/constants.ts | 9 +- src/components/TextField/hooks.ts | 191 +++++++++++-- src/components/TextField/utils.ts | 118 +++----- src/components/__tests__/TextField.test.tsx | 128 ++++++++- .../__snapshots__/TextField.test.tsx.snap | 252 +++++++----------- src/index.tsx | 1 + 7 files changed, 443 insertions(+), 281 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 6fa6311345..005587cb8a 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -20,9 +20,20 @@ import { useTextField } from './hooks'; import { styles } from './styles'; import TextFieldErrorIcon from './TextFieldErrorIcon'; import type { TextFieldAccessoryProps } from './TextFieldIcon'; -import { getTextFieldAnimation } from './utils'; import type { InternalTheme, ThemeProp } from '../../types'; +export type TextFieldAnimationState = { + animatedLabelWrapperStyle: StyleProp>>; + animatedLabelTextStyle: StyleProp>>; + animatedContainerStyle: StyleProp>>; + animatedActiveOutlineStyle?: StyleProp>>; +}; + +export type TextFieldAnimationHandlers = { + runFocusAnimation: (hasText: boolean) => void; + runBlurAnimation: (hasText: boolean) => void; +}; + export type TextFieldFlags = { isRTL: boolean; isDisabled: boolean; @@ -41,8 +52,6 @@ export type TextFieldColors = { placeholderTextColor: ColorValue; }; -export type TextFieldAnimationState = ReturnType; - export type GetAccessibilityDataReturn = { input: AccessibilityProps; supportingText: AccessibilityProps; @@ -160,11 +169,17 @@ export type TextFieldRenderProps = React.ComponentPropsWithRef< typeof TextInput >; +export type TextFieldHandles = Pick< + TextInput, + 'focus' | 'clear' | 'blur' | 'isFocused' | 'setNativeProps' | 'setSelection' +>; + export type TextFieldProps = TextInputProps & { /** - * Ref forwarded to the underlying TextInput. + * Imperative handle exposing a subset of TextInput methods + * with side-effect handling (e.g. `clear()` syncs internal state and animations). */ - ref?: React.Ref; + 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. diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index c1f5534b17..320b5d0f16 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -1,7 +1,9 @@ import { PixelRatio } from 'react-native'; +import { Easing } from 'react-native-reanimated'; + import { tokens } from '../../theme/tokens'; -import { motionDuration } from '../../theme/tokens/sys/motion'; +import { motionDuration, motionEasing } from '../../theme/tokens/sys/motion'; import { defaultShapes } from '../../theme/tokens/sys/shape'; export const fontScale = PixelRatio.getFontScale(); @@ -49,6 +51,11 @@ export const ANIMATION_DURATION_MS = motionDuration.short3; export const ACTIVE_INDICATOR_SIZE = 2; export const INACTIVE_INDICATOR_SIZE = 1; +export const TIMING_CONFIG = { + duration: ANIMATION_DURATION_MS, + easing: Easing.bezier(...motionEasing.standard), +} as const; + /** * Constants for the filled variant. */ diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index cad4cfa7dd..5883e81f0b 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -7,9 +7,22 @@ import { } from 'react'; import { BlurEvent, FocusEvent, TextInput } from 'react-native'; +import { + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { + ACTIVE_LABEL_FONT_SIZE, + INACTIVE_LABEL_FONT_SIZE, + TIMING_CONFIG, +} from './constants'; import type { TextFieldAnimationState, TextFieldFlags, + TextFieldAnimationHandlers, TextFieldHookReturn, TextFieldLayoutState, TextFieldProps, @@ -20,12 +33,94 @@ import { getAccessibilityData, getFilledTextFieldData, getOutlinedTextFieldData, - getTextFieldAnimation, + getTextFieldAnimationLayout, } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { InternalTheme } from '../../types'; +const useTextFieldAnimation = ({ + variant, + isRTL, + hasAccessory, + value, + defaultValue, +}: { + variant: TextFieldVariant; + isRTL: boolean; + hasAccessory: boolean; + value: string | undefined; + defaultValue: string | undefined; +}): TextFieldAnimationState & TextFieldAnimationHandlers => { + const initialText = value ?? defaultValue ?? ''; + + const focusSV = useSharedValue(0); + const floatSV = useSharedValue(initialText.length > 0 ? 1 : 0); + + const { activeTop, inactiveTop, translateXEnd } = getTextFieldAnimationLayout( + { + variant, + hasAccessory, + isRTL, + } + ); + + const runFocusAnimation = (hasText: boolean) => { + focusSV.value = withTiming(1, TIMING_CONFIG); + + if (!hasText) { + floatSV.value = withTiming(1, TIMING_CONFIG); + } + }; + + const runBlurAnimation = (hasText: boolean) => { + focusSV.value = withTiming(0, TIMING_CONFIG); + + floatSV.value = withTiming(hasText ? 1 : 0, TIMING_CONFIG); + }; + + const animatedLabelWrapperStyle = useAnimatedStyle(() => { + const top = interpolate(floatSV.value, [0, 1], [inactiveTop, activeTop]); + + if (variant === 'filled') { + return { top }; + } + + return { + top, + transform: [ + { translateX: interpolate(floatSV.value, [0, 1], [0, translateXEnd]) }, + ], + }; + }); + + const animatedLabelTextStyle = useAnimatedStyle(() => ({ + fontSize: interpolate( + floatSV.value, + [0, 1], + [INACTIVE_LABEL_FONT_SIZE, ACTIVE_LABEL_FONT_SIZE] + ), + })); + + const animatedContainerStyle = useAnimatedStyle(() => ({ + opacity: floatSV.value, + })); + + const animatedActiveOutlineStyle = useAnimatedStyle(() => ({ + transform: [{ scaleX: focusSV.value }], + })); + + return { + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedContainerStyle, + animatedActiveOutlineStyle: + variant === 'filled' ? animatedActiveOutlineStyle : undefined, + runFocusAnimation, + runBlurAnimation, + }; +}; + const useTextFieldInput = ( props: Pick< TextFieldProps, @@ -37,6 +132,8 @@ const useTextFieldInput = ( const initialText = isControlled ? props.value : props.defaultValue; + const ref = useRef(initialText ?? ''); + const [hasValue, setHasValue] = useState(!!initialText); const [charCount, setCharCount] = useState(initialText?.length ?? 0); @@ -48,7 +145,17 @@ const useTextFieldInput = ( ? 1 : 0; + const getHasText = () => { + if (isControlled) { + return !!props.value; + } + + return ref.current.length > 0; + }; + const onChangeText = (text: string) => { + ref.current = text; + if (!isControlled) { const next = text.length > 0; @@ -67,6 +174,7 @@ const useTextFieldInput = ( return { hasValue: isControlled ? !!props.value : hasValue, inputLength, + getHasText, onChangeText, }; }; @@ -74,22 +182,31 @@ const useTextFieldInput = ( const useTextFieldFocus = ( props: Pick, input: RefObject, - isDisabled: boolean + isDisabled: boolean, + { runFocusAnimation, runBlurAnimation }: TextFieldAnimationHandlers, + getHasText: () => boolean ) => { const [isFocused, setIsFocused] = useState(false); const onFocus = (e: FocusEvent) => { props.onFocus?.(e); + setIsFocused(true); + + runFocusAnimation(getHasText()); }; const onBlur = (e: BlurEvent) => { props.onBlur?.(e); + setIsFocused(false); + + runBlurAnimation(getHasText()); }; const focusInput = () => { if (isDisabled) return; + input.current?.focus(); }; @@ -104,11 +221,10 @@ const useTextFieldFocus = ( const useTextFieldFlags = ( props: TextFieldProps, isFocused: boolean, - hasValue: boolean + hasValue: boolean, + isRTL: boolean, + hasAccessory: boolean ): TextFieldFlags => { - const { direction } = useLocale(); - - const isRTL = direction === 'rtl'; const isFloating = isFocused || hasValue; return { @@ -118,7 +234,7 @@ const useTextFieldFlags = ( isEditable: props.disabled ? false : props.editable, hasError: !!props.error, hasCounter: !!(props.counter && props.maxLength), - hasAccessory: isRTL ? !!props.endAccessory : !!props.startAccessory, + hasAccessory, hasPrefix: !!props.prefix && isFloating, hasSuffix: !!props.suffix && isFloating, }; @@ -141,8 +257,7 @@ const useTextFieldLayout = ({ isFocused: boolean; animation: TextFieldAnimationState; }): TextFieldLayoutState => { - const { isRTL, isDisabled, hasError, hasAccessory, hasSuffix, isFloating } = - flags; + const { isRTL, isDisabled, hasError, hasAccessory, hasSuffix } = flags; const { multiline } = props; @@ -204,14 +319,14 @@ const useTextFieldLayout = ({ /** * `input` is a stable ref. `props` is omitted — only `multiline` affects layout. * `style` is omitted — assumed stable; dynamic `style` changes won't invalidate layout. - * `isFloating` + `isFocused` cover all animation inputs (replacing individual style objects). + * Animated styles are stable `useAnimatedStyle` objects and are omitted from deps. + * `isFocused` drives static focus styles (label color, outline border). */ // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment [ variant, theme, isFocused, - isFloating, isRTL, isDisabled, hasAccessory, @@ -229,17 +344,55 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const theme = useInternalTheme(themeOverride); - const { hasValue, inputLength, onChangeText } = useTextFieldInput(props); + const { direction } = useLocale(); + + const isRTL = direction === 'rtl'; + const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; + + const { hasValue, inputLength, getHasText, onChangeText } = + useTextFieldInput(props); + + const animation = useTextFieldAnimation({ + variant, + isRTL, + hasAccessory, + value: props.value, + defaultValue: props.defaultValue, + }); const { isFocused, onFocus, onBlur, focusInput } = useTextFieldFocus( props, input, - !!props.disabled + !!props.disabled, + animation, + getHasText ); - const flags = useTextFieldFlags(props, isFocused, hasValue); + const flags = useTextFieldFlags( + props, + isFocused, + hasValue, + isRTL, + hasAccessory + ); - useImperativeHandle(ref, () => input.current as TextInput); + useImperativeHandle(ref, () => ({ + focus: () => input.current?.focus(), + clear: () => { + input.current?.clear(); + + onChangeText(''); + + if (!input.current?.isFocused()) { + animation.runBlurAnimation(false); + } + }, + blur: () => input.current?.blur(), + isFocused: () => input.current?.isFocused() || false, + setNativeProps: (args: Object) => input.current?.setNativeProps(args), + setSelection: (start: number, end: number) => + input.current?.setSelection(start, end), + })); const { selectionColor, cursorColor } = getAccentColors({ theme, @@ -249,14 +402,6 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const placeholderTextColor = props.placeholderTextColor ?? theme.colors.onSurfaceVariant; - const animation = getTextFieldAnimation({ - variant, - isFloating: flags.isFloating, - isFocused, - isRTL: flags.isRTL, - hasAccessory: flags.hasAccessory, - }); - const layout = useTextFieldLayout({ variant, props, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index b9254daab7..86b0ac8816 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -4,19 +4,16 @@ import { AnimatedStyle } from 'react-native-reanimated'; import { ACTIVE_INDICATOR_SIZE, - ACTIVE_LABEL_FONT_SIZE, - ANIMATION_DURATION_MS, FILLED_ACTIVE_LABEL_TOP_POSITION, + FILLED_INACTIVE_LABEL_TOP_POSITION, FILLED_LABEL_START_OFFSET_WITH_ACCESSORY, FILLED_MULTILINE_PADDING_TOP, INACTIVE_INDICATOR_SIZE, - INACTIVE_LABEL_FONT_SIZE, - FILLED_INACTIVE_LABEL_TOP_POSITION, - OUTLINED_INACTIVE_LABEL_TOP_POSITION, INPUT_FONT_SIZE, LABEL_START_OFFSET_WITHOUT_ACCESSORY, OUTLINED_ACTIVE_LABEL_TOP_POSITION, OUTLINED_DISABLED_OUTLINE_OPACITY, + OUTLINED_INACTIVE_LABEL_TOP_POSITION, OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY, OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY, OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY, @@ -32,12 +29,41 @@ import type { OutlinedTextFieldHookData, TextFieldProps, TextFieldSharedApi, + TextFieldVariant, SharedTextFieldStyleData, GetAccessibilityDataProps, GetAccessibilityDataReturn, } from './TextField'; import type { InternalTheme } from '../../types'; +export const getTextFieldAnimationLayout = ({ + variant, + hasAccessory, + isRTL, +}: { + variant: TextFieldVariant; + hasAccessory: boolean; + isRTL: boolean; +}) => { + const activeTop = + variant === 'filled' + ? FILLED_ACTIVE_LABEL_TOP_POSITION + : OUTLINED_ACTIVE_LABEL_TOP_POSITION; + + const inactiveTop = + variant === 'filled' + ? FILLED_INACTIVE_LABEL_TOP_POSITION + : OUTLINED_INACTIVE_LABEL_TOP_POSITION; + + const distance = hasAccessory + ? OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY + : OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY; + + const translateXEnd = (isRTL ? 1 : -1) * distance; + + return { activeTop, inactiveTop, translateXEnd }; +}; + export const getAccentColors = ({ theme, hasError, @@ -274,88 +300,6 @@ export const getSharedTextFieldStyleData = ( }; }; -export const getTextFieldAnimation = ({ - variant, - isFloating, - isFocused, - isRTL, - hasAccessory, -}: { - variant: 'filled' | 'outlined'; - isFloating: boolean; - isFocused: boolean; - isRTL: boolean; - hasAccessory: boolean; -}): { - animatedLabelWrapperStyle: StyleProp>>; - animatedLabelTextStyle: StyleProp>>; - animatedContainerStyle: StyleProp>>; - animatedActiveOutlineStyle?: StyleProp>>; -} => { - const activeTop = - variant === 'filled' - ? FILLED_ACTIVE_LABEL_TOP_POSITION - : OUTLINED_ACTIVE_LABEL_TOP_POSITION; - - const inactiveTop = - variant === 'filled' - ? FILLED_INACTIVE_LABEL_TOP_POSITION - : OUTLINED_INACTIVE_LABEL_TOP_POSITION; - - const top = isFloating ? activeTop : inactiveTop; - const fontSize = isFloating - ? ACTIVE_LABEL_FONT_SIZE - : INACTIVE_LABEL_FONT_SIZE; - - const animatedContainerStyle: StyleProp>> = - { - opacity: isFloating ? 1 : 0, - transitionProperty: 'opacity', - transitionDuration: ANIMATION_DURATION_MS, - }; - - if (variant === 'filled') { - return { - animatedLabelWrapperStyle: { - top, - transitionProperty: 'top', - transitionDuration: ANIMATION_DURATION_MS, - }, - animatedLabelTextStyle: { - fontSize, - transitionProperty: 'fontSize', - transitionDuration: ANIMATION_DURATION_MS, - }, - animatedActiveOutlineStyle: { - transform: [{ scaleX: isFocused ? 1 : 0 }], - transitionProperty: 'transform', - transitionDuration: ANIMATION_DURATION_MS, - }, - animatedContainerStyle, - }; - } - - const distance = hasAccessory - ? OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY - : OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY; - const translateXEnd = (isRTL ? 1 : -1) * distance; - - return { - animatedLabelWrapperStyle: { - top, - transform: [{ translateX: isFloating ? translateXEnd : 0 }], - transitionProperty: ['top', 'transform'], - transitionDuration: ANIMATION_DURATION_MS, - }, - animatedLabelTextStyle: { - fontSize, - transitionProperty: 'fontSize', - transitionDuration: ANIMATION_DURATION_MS, - }, - animatedContainerStyle, - }; -}; - export const getFilledTextFieldData = ( api: TextFieldSharedApi, props: TextFieldProps diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index cef54a1b42..fd4e7997fe 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -1,10 +1,13 @@ import * as React from 'react'; import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; -import { fireEvent, render } from '../../test-utils'; +import { act, fireEvent, render } from '../../test-utils'; import { tokens } from '../../theme/tokens'; import TextField from '../TextField'; -import type { TextFieldRenderProps } from '../TextField/TextField'; +import type { + TextFieldRenderProps, + TextFieldHandles, +} from '../TextField/TextField'; import type { TextFieldAccessoryProps } from '../TextField/TextFieldIcon'; const { stateOpacity } = tokens.md.ref; @@ -592,7 +595,7 @@ it('focuses the TextInput when read-only and the Pressable is pressed', () => { }); it('exposes the TextInput instance via ref prop', () => { - const ref = React.createRef(); + const ref = React.createRef(); render( { expect(ref.current).toBeTruthy(); expect(typeof ref.current?.focus).toBe('function'); + expect(typeof ref.current?.clear).toBe('function'); + expect(typeof ref.current?.blur).toBe('function'); + expect(typeof ref.current?.isFocused).toBe('function'); + expect(typeof ref.current?.setNativeProps).toBe('function'); + expect(typeof ref.current?.setSelection).toBe('function'); }); it('passes error, disabled, and multiline to accessories', () => { @@ -1145,3 +1153,117 @@ it('updates the character counter for an uncontrolled field with counter enabled expect(onChangeText).toHaveBeenCalledWith('abcd'); expect(getByText('4/10')).toBeTruthy(); }); + +it('resets counter and hides prefix/suffix when clear() is called on uncontrolled field while blurred', () => { + const ref = React.createRef(); + const { getByText, queryByText } = render( + + ); + + expect(getByText('3/200')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + act(() => { + ref.current?.clear(); + }); + + expect(getByText('0/200')).toBeTruthy(); + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); +}); + +it('resets counter but keeps prefix/suffix visible when clear() is called on uncontrolled field while focused', () => { + const ref = React.createRef(); + const { getByTestId, getByText } = render( + + ); + + expect(getByText('2/100')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); + + fireEvent(getByTestId('tf-clear-focused'), 'focus'); + + act(() => { + ref.current?.clear(); + }); + + expect(getByText('0/100')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); +}); + +it('notifies the parent via onChangeText when clear() is called on a controlled field', () => { + const ref = React.createRef(); + const onChangeText = jest.fn(); + const { getByTestId } = render( + + ); + + const input = getByTestId('tf-controlled'); + expect(input.props.value).toBe('test@example.com'); + + act(() => { + ref.current?.clear(); + }); + + expect(onChangeText).toHaveBeenCalledWith(''); + expect(onChangeText).toHaveBeenCalledTimes(1); +}); + +it('hides prefix/suffix when blurring after clear() was called while focused', () => { + const ref = React.createRef(); + const { getByTestId, getByText, queryByText } = render( + + ); + + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + fireEvent(getByTestId('tf-clear-then-blur'), 'focus'); + + act(() => { + ref.current?.clear(); + }); + + // While focused, prefix/suffix stay visible + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + fireEvent(getByTestId('tf-clear-then-blur'), 'blur'); + + // After blur with no text, prefix/suffix should be hidden + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); +}); diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index c4b83fee84..8bfb78d564 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -78,7 +78,13 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "transform": [ + { + "scaleX": 0, + }, + ], + }, } } jestInlineStyle={ @@ -95,15 +101,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "height": 2, }, false, - { - "transform": [ - { - "scaleX": 0, - }, - ], - "transitionDuration": 150, - "transitionProperty": "transform", - }, ] } style={ @@ -140,7 +137,9 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "top": 16, + }, } } jestInlineStyle={ @@ -152,11 +151,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` { "left": 52, }, - { - "top": 16, - "transitionDuration": 150, - "transitionProperty": "top", - }, ] } style={ @@ -183,7 +177,9 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "fontSize": 12, + }, } } jestInlineStyle={ @@ -197,11 +193,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` { "color": "rgba(73, 69, 79, 1)", }, - { - "fontSize": 12, - "transitionDuration": 150, - "transitionProperty": "fontSize", - }, false, ] } @@ -390,7 +381,9 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "opacity": 1, + }, } } jestInlineStyle={ @@ -404,11 +397,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` }, false, ], - { - "opacity": 1, - "transitionDuration": 150, - "transitionProperty": "opacity", - }, ] } style={ @@ -709,7 +697,13 @@ exports[`renders filled TextField with TextField.Icon accessories when error is } jestAnimatedStyle={ { - "value": {}, + "value": { + "transform": [ + { + "scaleX": 0, + }, + ], + }, } } jestInlineStyle={ @@ -726,15 +720,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "height": 2, }, false, - { - "transform": [ - { - "scaleX": 0, - }, - ], - "transitionDuration": 150, - "transitionProperty": "transform", - }, ] } style={ @@ -771,7 +756,9 @@ exports[`renders filled TextField with TextField.Icon accessories when error is } jestAnimatedStyle={ { - "value": {}, + "value": { + "top": 16, + }, } } jestInlineStyle={ @@ -783,11 +770,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is { "left": 52, }, - { - "top": 16, - "transitionDuration": 150, - "transitionProperty": "top", - }, ] } style={ @@ -814,7 +796,9 @@ exports[`renders filled TextField with TextField.Icon accessories when error is } jestAnimatedStyle={ { - "value": {}, + "value": { + "fontSize": 12, + }, } } jestInlineStyle={ @@ -828,11 +812,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is { "color": "rgba(179, 38, 30, 1)", }, - { - "fontSize": 12, - "transitionDuration": 150, - "transitionProperty": "fontSize", - }, false, ] } @@ -1021,7 +1000,9 @@ exports[`renders filled TextField with TextField.Icon accessories when error is } jestAnimatedStyle={ { - "value": {}, + "value": { + "opacity": 1, + }, } } jestInlineStyle={ @@ -1035,11 +1016,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is }, false, ], - { - "opacity": 1, - "transitionDuration": 150, - "transitionProperty": "opacity", - }, ] } style={ @@ -1340,7 +1316,13 @@ exports[`renders filled TextField with label and value 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "transform": [ + { + "scaleX": 0, + }, + ], + }, } } jestInlineStyle={ @@ -1357,15 +1339,6 @@ exports[`renders filled TextField with label and value 1`] = ` "height": 2, }, false, - { - "transform": [ - { - "scaleX": 0, - }, - ], - "transitionDuration": 150, - "transitionProperty": "transform", - }, ] } style={ @@ -1402,7 +1375,9 @@ exports[`renders filled TextField with label and value 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "top": 16, + }, } } jestInlineStyle={ @@ -1414,11 +1389,6 @@ exports[`renders filled TextField with label and value 1`] = ` { "left": 16, }, - { - "top": 16, - "transitionDuration": 150, - "transitionProperty": "top", - }, ] } style={ @@ -1445,7 +1415,9 @@ exports[`renders filled TextField with label and value 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "fontSize": 12, + }, } } jestInlineStyle={ @@ -1459,11 +1431,6 @@ exports[`renders filled TextField with label and value 1`] = ` { "color": "rgba(73, 69, 79, 1)", }, - { - "fontSize": 12, - "transitionDuration": 150, - "transitionProperty": "fontSize", - }, false, ] } @@ -1497,7 +1464,9 @@ exports[`renders filled TextField with label and value 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "opacity": 1, + }, } } jestInlineStyle={ @@ -1511,11 +1480,6 @@ exports[`renders filled TextField with label and value 1`] = ` }, false, ], - { - "opacity": 1, - "transitionDuration": 150, - "transitionProperty": "opacity", - }, ] } style={ @@ -1661,7 +1625,14 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "top": -12, + "transform": [ + { + "translateX": -36, + }, + ], + }, } } jestInlineStyle={ @@ -1675,19 +1646,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "backgroundColor": "rgba(254, 247, 255, 1)", "left": 48, }, - { - "top": -12, - "transform": [ - { - "translateX": -36, - }, - ], - "transitionDuration": 150, - "transitionProperty": [ - "top", - "transform", - ], - }, ] } style={ @@ -1721,7 +1679,9 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "fontSize": 12, + }, } } jestInlineStyle={ @@ -1735,11 +1695,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` { "color": "rgba(73, 69, 79, 1)", }, - { - "fontSize": 12, - "transitionDuration": 150, - "transitionProperty": "fontSize", - }, false, ] } @@ -1928,7 +1883,9 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "opacity": 1, + }, } } jestInlineStyle={ @@ -1942,11 +1899,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` }, false, ], - { - "opacity": 1, - "transitionDuration": 150, - "transitionProperty": "opacity", - }, ] } style={ @@ -2247,7 +2199,14 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i } jestAnimatedStyle={ { - "value": {}, + "value": { + "top": -12, + "transform": [ + { + "translateX": -36, + }, + ], + }, } } jestInlineStyle={ @@ -2261,19 +2220,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "backgroundColor": "rgba(254, 247, 255, 1)", "left": 48, }, - { - "top": -12, - "transform": [ - { - "translateX": -36, - }, - ], - "transitionDuration": 150, - "transitionProperty": [ - "top", - "transform", - ], - }, ] } style={ @@ -2307,7 +2253,9 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i } jestAnimatedStyle={ { - "value": {}, + "value": { + "fontSize": 12, + }, } } jestInlineStyle={ @@ -2321,11 +2269,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i { "color": "rgba(179, 38, 30, 1)", }, - { - "fontSize": 12, - "transitionDuration": 150, - "transitionProperty": "fontSize", - }, false, ] } @@ -2514,7 +2457,9 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i } jestAnimatedStyle={ { - "value": {}, + "value": { + "opacity": 1, + }, } } jestInlineStyle={ @@ -2528,11 +2473,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i }, false, ], - { - "opacity": 1, - "transitionDuration": 150, - "transitionProperty": "opacity", - }, ] } style={ @@ -2833,7 +2773,14 @@ exports[`renders outlined TextField with label and value 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "top": -12, + "transform": [ + { + "translateX": -4, + }, + ], + }, } } jestInlineStyle={ @@ -2847,19 +2794,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "backgroundColor": "rgba(254, 247, 255, 1)", "left": 16, }, - { - "top": -12, - "transform": [ - { - "translateX": -4, - }, - ], - "transitionDuration": 150, - "transitionProperty": [ - "top", - "transform", - ], - }, ] } style={ @@ -2893,7 +2827,9 @@ exports[`renders outlined TextField with label and value 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "fontSize": 12, + }, } } jestInlineStyle={ @@ -2907,11 +2843,6 @@ exports[`renders outlined TextField with label and value 1`] = ` { "color": "rgba(73, 69, 79, 1)", }, - { - "fontSize": 12, - "transitionDuration": 150, - "transitionProperty": "fontSize", - }, false, ] } @@ -2945,7 +2876,9 @@ exports[`renders outlined TextField with label and value 1`] = ` } jestAnimatedStyle={ { - "value": {}, + "value": { + "opacity": 1, + }, } } jestInlineStyle={ @@ -2959,11 +2892,6 @@ exports[`renders outlined TextField with label and value 1`] = ` }, false, ], - { - "opacity": 1, - "transitionDuration": 150, - "transitionProperty": "opacity", - }, ] } style={ diff --git a/src/index.tsx b/src/index.tsx index a6ba4bf99e..c643523308 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -136,6 +136,7 @@ export type { TextFieldProps, TextFieldRenderProps, TextFieldVariant, + TextFieldHandles, } from './components/TextField/TextField'; export type { TextFieldAccessoryProps, From f5d660511965918181179b959b0a098b18bae7f4 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 27 May 2026 11:22:33 +0200 Subject: [PATCH 23/26] chore: implementation details --- src/components/TextField/TextField.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 005587cb8a..08f75860d4 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -181,8 +181,13 @@ export type TextFieldProps = TextInputProps & { */ 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. + * Determines the visual style of the text field. + * + * - `filled` — filled background with an animated underline; higher visual emphasis. + * - `outlined` — stroke outline only; lower visual emphasis. + * + * `filled` is a good fit for dialogs and short forms. `outlined` is common in long + * forms where a lighter visual weight keeps the layout easier to scan. */ variant?: TextFieldVariant; /** @@ -235,7 +240,7 @@ export type TextFieldProps = TextInputProps & { render?: (props: TextFieldRenderProps) => React.ReactNode; }; -const DefaultRenderer = (props: TextFieldRenderProps) => ( +const defaultRenderer = (props: TextFieldRenderProps) => ( ); @@ -299,7 +304,7 @@ function TextField(props: TextFieldProps) { disabled, startAccessory, endAccessory, - render = DefaultRenderer, + render = defaultRenderer, ...textInputProps } = props; From 3ecda7e33305d42cefbb2187c60db16b4380c7cd Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 27 May 2026 12:10:34 +0200 Subject: [PATCH 24/26] feat: text input migration --- docs/docs/guides/12-migration.md | 127 + docs/docusaurus.config.js | 24 +- docs/package.json | 1 + .../plugins/docusaurus-react-native-plugin.js | 9 + docs/src/components/BannerExample.tsx | 2 +- docs/src/components/PropTable.tsx | 8 +- docs/src/data/screenshots.js | 9 - docs/src/data/themeColors.js | 11 - example/src/ExampleList.tsx | 2 - example/src/Examples/TextFieldExample.tsx | 226 - example/src/Examples/TextInputExample.tsx | 1037 +--- src/components/HelperText/HelperText.tsx | 175 - src/components/HelperText/utils.ts | 28 - src/components/TextField/TextField.tsx | 440 -- src/components/TextField/index.ts | 13 - src/components/TextInput/Addons/Outline.tsx | 60 - src/components/TextInput/Addons/Underline.tsx | 75 - .../Adornment/TextInputAdornment.tsx | 201 - .../TextInput/Adornment/TextInputAffix.tsx | 219 - .../TextInput/Adornment/TextInputIcon.tsx | 185 - src/components/TextInput/Adornment/enums.tsx | 12 - src/components/TextInput/Adornment/types.tsx | 11 - src/components/TextInput/Adornment/utils.ts | 36 - src/components/TextInput/Label/InputLabel.tsx | 224 - .../TextInput/Label/LabelBackground.tsx | 100 - src/components/TextInput/TextInput.tsx | 899 ++-- .../TextInputErrorIcon.tsx} | 8 +- src/components/TextInput/TextInputFlat.tsx | 468 -- .../TextInputIcon.tsx} | 26 +- .../TextInput/TextInputOutlined.tsx | 447 -- .../{TextField => TextInput}/constants.ts | 56 +- src/components/TextInput/constants.tsx | 39 - src/components/TextInput/helpers.tsx | 501 -- .../{TextField => TextInput}/hooks.ts | 86 +- src/components/TextInput/index.ts | 13 + .../{TextField => TextInput}/styles.ts | 28 +- src/components/TextInput/types.tsx | 155 - .../{TextField => TextInput}/utils.ts | 52 +- src/components/__tests__/HelperText.test.tsx | 31 - src/components/__tests__/TextField.test.tsx | 1269 ----- src/components/__tests__/TextInput.test.tsx | 1889 ++++--- .../__snapshots__/TextField.test.tsx.snap | 2960 ----------- .../__snapshots__/TextInput.test.tsx.snap | 4487 +++++++++-------- src/index.tsx | 24 +- yarn.lock | 8 + 45 files changed, 4373 insertions(+), 12308 deletions(-) create mode 100644 docs/docs/guides/12-migration.md delete mode 100644 example/src/Examples/TextFieldExample.tsx delete mode 100644 src/components/HelperText/HelperText.tsx delete mode 100644 src/components/HelperText/utils.ts delete mode 100644 src/components/TextField/TextField.tsx delete mode 100644 src/components/TextField/index.ts delete mode 100644 src/components/TextInput/Addons/Outline.tsx delete mode 100644 src/components/TextInput/Addons/Underline.tsx delete mode 100644 src/components/TextInput/Adornment/TextInputAdornment.tsx delete mode 100644 src/components/TextInput/Adornment/TextInputAffix.tsx delete mode 100644 src/components/TextInput/Adornment/TextInputIcon.tsx delete mode 100644 src/components/TextInput/Adornment/enums.tsx delete mode 100644 src/components/TextInput/Adornment/types.tsx delete mode 100644 src/components/TextInput/Adornment/utils.ts delete mode 100644 src/components/TextInput/Label/InputLabel.tsx delete mode 100644 src/components/TextInput/Label/LabelBackground.tsx rename src/components/{TextField/TextFieldErrorIcon.tsx => TextInput/TextInputErrorIcon.tsx} (81%) delete mode 100644 src/components/TextInput/TextInputFlat.tsx rename src/components/{TextField/TextFieldIcon.tsx => TextInput/TextInputIcon.tsx} (74%) delete mode 100644 src/components/TextInput/TextInputOutlined.tsx rename src/components/{TextField => TextInput}/constants.ts (66%) delete mode 100644 src/components/TextInput/constants.tsx delete mode 100644 src/components/TextInput/helpers.tsx rename src/components/{TextField => TextInput}/hooks.ts (86%) create mode 100644 src/components/TextInput/index.ts rename src/components/{TextField => TextInput}/styles.ts (78%) delete mode 100644 src/components/TextInput/types.tsx rename src/components/{TextField => TextInput}/utils.ts (94%) delete mode 100644 src/components/__tests__/HelperText.test.tsx delete mode 100644 src/components/__tests__/TextField.test.tsx delete mode 100644 src/components/__tests__/__snapshots__/TextField.test.tsx.snap diff --git a/docs/docs/guides/12-migration.md b/docs/docs/guides/12-migration.md new file mode 100644 index 0000000000..6d9a35424a --- /dev/null +++ b/docs/docs/guides/12-migration.md @@ -0,0 +1,127 @@ +--- +title: Migration from Paper 5.x to 6.x +--- + +TBC + +## Component and types + +The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. + +```tsx +import { TextInput, type TextInputProps } from 'react-native-paper'; +``` + +## Visual / variant + +- **`mode="flat"`** → **`variant="filled"`** +- **`mode="outlined"`** → **`variant="outlined"`** + +```tsx +// Before (v5) + + + +// After (v6) + + +``` + +## Icons and adornments + +- **`left` / `right`** → **`startAccessory` / `endAccessory`** +- **`TextInput.Icon`** → **`TextInput.Icon`** +- **`TextInput.Affix`** → **`prefix` / `suffix`**, or **`TextInput.Icon`**, or **`startAccessory` / `endAccessory`** + +```tsx +// Before (v5) +} + right={} +/> + +// After (v6) + } + endAccessory={(p) => } + maxLength={100} + prefix="$" + suffix="/100" + counter +/> +``` + +## Label, helper, error, disabled + +- **`label: React.Element | string`** → **`string`** +- **`error` / `disabled`** → **`error`** and **`disabled`** +- **`HelperText`** was removed; use **`supportingText`**. + +```tsx +// Before (v5) +<> + + + Enter a valid email + + + +// After (v6) + +``` + +## Styling / behavior removed + +No direct `TextInput` equivalents for: + +- **`dense`**, **`contentStyle`**, **`underlineStyle`** +- **`underlineColor`**, **`activeUnderlineColor`**, **`outlineColor`**, **`activeOutlineColor`**, **`textColor`** +- **`render`** + +Use **`style`** on the inner input and the **`theme`** for colors. + +```tsx +import { MD3LightTheme, TextInput } from 'react-native-paper'; + +const theme = { + ...MD3LightTheme, + colors: { + ...MD3LightTheme.colors, + outline: '#79747E', + primary: '#6750A4', + }, +}; + +// Before (v5) + + +// After (v6) + +``` diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index ed833ce99e..7bc8d7a5ab 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -126,7 +126,6 @@ const config = { AnimatedFAB: 'FAB/AnimatedFAB', FABGroup: 'FAB/FABGroup', }, - HelperText: { HelperText: 'HelperText/HelperText' }, IconButton: { IconButton: 'IconButton/IconButton', }, @@ -167,12 +166,7 @@ const config = { }, TextInput: { TextInput: 'TextInput/TextInput', - TextInputAffix: 'TextInput/Adornment/TextInputAffix', - TextInputIcon: 'TextInput/Adornment/TextInputIcon', - }, - TextField: { - TextField: 'TextField/TextField', - TextFieldIcon: 'TextField/TextFieldIcon', + TextInputIcon: 'TextInput/TextInputIcon', }, ToggleButton: { ToggleButton: 'ToggleButton/ToggleButton', @@ -210,11 +204,8 @@ const config = { } const customUrls = { - TextInputAffix: - 'src/components/TextInput/Adornment/TextInputAffix.tsx', - TextInputIcon: - 'src/components/TextInput/Adornment/TextInputIcon.tsx', - TextField: 'src/components/TextField/TextField.tsx', + TextInput: 'src/components/TextInput/TextInput.tsx', + TextInputIcon: 'src/components/TextInput/TextInputIcon.tsx', Text: 'src/components/Typography/Text.tsx', showcase: 'docs/src/components/Showcase.tsx', @@ -334,14 +325,7 @@ const config = { 'https://snack.expo.dev/@react-native-paper/more-examples---snackbar-rendered-regardless-of-the-parent-positioning', }, }, - knownIssues: { - TextInput: { - 'Outline overlaps label': - 'https://github.com/callstack/react-native-paper/issues/3759#issuecomment-1601235262', - 'Long text wraps to a second line': - 'https://github.com/callstack/react-native-paper/issues/2581#issuecomment-790251987', - }, - }, + knownIssues: {}, themeColors, screenshots, extendedExamples, diff --git a/docs/package.json b/docs/package.json index e8dc71c7e5..994a05fe16 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,6 +29,7 @@ "color": "^4.2.3", "marked": "^4.1.1", "prism-react-renderer": "^1.3.5", + "process": "^0.11.10", "react": "17.0.2", "react-color": "^2.19.3", "react-dom": "17.0.2", diff --git a/docs/plugins/docusaurus-react-native-plugin.js b/docs/plugins/docusaurus-react-native-plugin.js index 3a05c223bc..8eb32ee4a4 100644 --- a/docs/plugins/docusaurus-react-native-plugin.js +++ b/docs/plugins/docusaurus-react-native-plugin.js @@ -1,4 +1,5 @@ const path = require('path'); +const webpack = require('webpack'); module.exports = function () { return { @@ -17,6 +18,14 @@ module.exports = function () { }, extensions: ['.web.js'], }, + plugins: [ + new webpack.ProvidePlugin({ + process: 'process/browser.js', + }), + new webpack.DefinePlugin({ + __DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'), + }), + ], }; }, }; diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx index 7f4acdc7bc..d2133eecab 100644 --- a/docs/src/components/BannerExample.tsx +++ b/docs/src/components/BannerExample.tsx @@ -113,7 +113,7 @@ const BannerExample = () => { /> setText(text)} /> diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index 05e8f809c4..eaf87beba2 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -11,10 +11,10 @@ const typeDefinitions = { 'https://github.com/callstack/react-native-paper/blob/main/src/components/Icon.tsx#L16', ThemeProp: 'https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties', - '(props: TextFieldAccessoryProps) => React.ReactNode': - 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextFieldIcon.tsx#L11', - '(props: TextFieldRenderProps) => React.ReactNode': - 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L159', + '(props: TextInputAccessoryProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextInput/TextInputIcon.tsx#L11', + '(props: TextInputRenderProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextInput/TextInput.tsx#L168', AccessibilityState: 'https://reactnative.dev/docs/accessibility#accessibilitystate', 'StyleProp': 'https://reactnative.dev/docs/view-style-props', diff --git a/docs/src/data/screenshots.js b/docs/src/data/screenshots.js index ed4f3f26c1..04b4760e56 100644 --- a/docs/src/data/screenshots.js +++ b/docs/src/data/screenshots.js @@ -79,7 +79,6 @@ const screenshots = { }, AnimatedFAB: 'screenshots/animated-fab.gif', 'FAB.Group': 'screenshots/fab-group.gif', - HelperText: 'screenshots/helper-text.gif', Icon: 'screenshots/icon.png', IconButton: { default: 'screenshots/icon-button-1.png', @@ -147,14 +146,6 @@ const screenshots = { }, Text: 'screenshots/typography.png', TextInput: { - 'flat (focused)': 'screenshots/textinput-flat.focused.png', - 'flat (disabled)': 'screenshots/textinput-flat.disabled.png', - 'outlined (focused)': 'screenshots/textinput-outlined.focused.png', - 'outlined (disabled)': 'screenshots/textinput-outlined.disabled.png', - }, - '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', }, diff --git a/docs/src/data/themeColors.js b/docs/src/data/themeColors.js index f12b955341..2e97dd1bce 100644 --- a/docs/src/data/themeColors.js +++ b/docs/src/data/themeColors.js @@ -190,17 +190,6 @@ const themeColors = { 'textColor/iconColor': 'theme.colors.primary', }, }, - HelperText: { - disabled: { - textColor: 'theme.colors.onSurfaceDisabled', - }, - default: { - textColor: 'theme.colors.onSurfaceVariant', - }, - error: { - textColor: 'theme.colors.error', - }, - }, IconButton: { selected: { default: { diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 2dc53f93d4..af6ed7534c 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -43,7 +43,6 @@ 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'; @@ -91,7 +90,6 @@ 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 deleted file mode 100644 index 00566385bf..0000000000 --- a/example/src/Examples/TextFieldExample.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import * as React from 'react'; -import { - StyleSheet, - TextInput, - View, - type TextStyle, - type ViewStyle, -} from 'react-native'; - -import { - Divider, - List, - Switch, - Text, - TextField, - TouchableRipple, - type TextFieldAccessoryProps, - type TextFieldVariant, -} from 'react-native-paper'; - -import { useExampleTheme } from '../hooks/useExampleTheme'; -import ScreenWrapper from '../ScreenWrapper'; - -type DemoControls = { - error: boolean; - disabled: boolean; - readOnly: boolean; - leadingIcon: boolean; - trailingIcon: boolean; - counter: boolean; - showPrefix: boolean; - showSuffix: boolean; - multiline: boolean; -}; - -type DemoModifiers = { - label: string; - helperText: string; - placeholder: string; - prefix: string; - suffix: string; -}; - -type TextFieldDemoProps = { - variant: TextFieldVariant; -}; - -const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { - const theme = useExampleTheme(); - - const [value, setValue] = React.useState(''); - - const [controls, setControls] = React.useState({ - error: false, - disabled: false, - readOnly: false, - leadingIcon: false, - trailingIcon: false, - counter: false, - showPrefix: false, - showSuffix: false, - multiline: false, - }); - - const [modifiers, setModifiers] = React.useState({ - label: 'Label', - helperText: 'Supporting text', - placeholder: 'Placeholder', - prefix: '$', - suffix: '/100', - }); - - const toggleControl = (key: keyof DemoControls) => - setControls((prev) => ({ ...prev, [key]: !prev[key] })); - - const setModifier = (key: keyof DemoModifiers, text: string) => - setModifiers((prev) => ({ ...prev, [key]: text })); - - const leadingIcon = (props: TextFieldAccessoryProps) => ( - - ); - - const trailingIcon = (props: TextFieldAccessoryProps) => ( - setValue('')} /> - ); - - const inputColor = theme.colors.onSurfaceVariant; - const borderColor = theme.colors.outlineVariant; - - const modifierInputStyle: TextStyle = { - flex: 1, - color: inputColor, - fontSize: 14, - paddingVertical: 4, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: borderColor, - }; - - const SWITCH_CONTROLS: { label: string; key: keyof DemoControls }[] = [ - { label: 'Error', key: 'error' }, - { label: 'Disabled', key: 'disabled' }, - { label: 'Readonly', key: 'readOnly' }, - { label: 'Leading icon', key: 'leadingIcon' }, - { label: 'Trailing icon', key: 'trailingIcon' }, - { label: 'Counter', key: 'counter' }, - { label: 'Prefix', key: 'showPrefix' }, - { label: 'Suffix', key: 'showSuffix' }, - { label: 'Multiline', key: 'multiline' }, - ]; - - const MODIFIER_FIELDS: { label: string; key: keyof DemoModifiers }[] = [ - { label: 'Label', key: 'label' }, - { label: 'Helper', key: 'helperText' }, - { label: 'Placeholder', key: 'placeholder' }, - { label: 'Prefix', key: 'prefix' }, - { label: 'Suffix', key: 'suffix' }, - ]; - - return ( - - {/* Live TextField */} - - - - - {/* Controls */} - Controls - {SWITCH_CONTROLS.map(({ label, key }) => ( - toggleControl(key)}> - - {label} - - - - - - ))} - - - - {/* Modifiers */} - Modifiers - {MODIFIER_FIELDS.map(({ label, key }) => ( - - - {label} - - setModifier(key, text)} - style={modifierInputStyle} - placeholderTextColor={theme.colors.outline} - placeholder={`Enter ${label.toLowerCase()}…`} - /> - - ))} - - ); -}; - -const TextFieldExample = () => { - return ( - - - - - - - - - ); -}; - -TextFieldExample.title = 'TextField'; - -const styles = StyleSheet.create({ - container: { - paddingHorizontal: 16, - paddingVertical: 8, - } satisfies ViewStyle, - demoContainer: { - gap: 4, - } satisfies ViewStyle, - divider: { - marginVertical: 8, - } satisfies ViewStyle, - subheader: { - paddingHorizontal: 0, - } satisfies TextStyle, - switchRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 8, - paddingHorizontal: 8, - } satisfies ViewStyle, - modifierRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - paddingVertical: 8, - paddingHorizontal: 8, - } satisfies ViewStyle, - modifierLabel: { - width: 80, - } satisfies TextStyle, -}); - -export default TextFieldExample; diff --git a/example/src/Examples/TextInputExample.tsx b/example/src/Examples/TextInputExample.tsx index fbe11afd0a..4709c6cc71 100644 --- a/example/src/Examples/TextInputExample.tsx +++ b/example/src/Examples/TextInputExample.tsx @@ -1,899 +1,226 @@ import * as React from 'react'; import { - KeyboardAvoidingView, - Platform, StyleSheet, - Text, + TextInput as NativeTextInput, View, + type TextStyle, + type ViewStyle, } from 'react-native'; -import Icon from '@expo/vector-icons/MaterialCommunityIcons'; -import { useFonts } from 'expo-font'; import { - configureFonts, - HelperText, + Divider, List, - MD3Colors, + Switch, + Text, TextInput, + TouchableRipple, + type TextInputAccessoryProps, + type TextInputVariant, } from 'react-native-paper'; -import { inputReducer, State } from '../../utils'; import { useExampleTheme } from '../hooks/useExampleTheme'; import ScreenWrapper from '../ScreenWrapper'; -const MAX_LENGTH = 20; - -const initialState: State = { - text: '', - customIconText: '', - name: '', - outlinedText: '', - largeText: '', - flatTextPassword: 'Password', - flatLongText: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam vitae odio quis dolor tempor mattis at non sem. Suspendisse et sem tincidunt, accumsan massa eleifend, euismod dui. Praesent eget urna lectus.', - outlinedLargeText: '', - outlinedCustomLabel: '', - outlinedTextPassword: '', - outlinedLongText: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam vitae odio quis dolor tempor mattis at non sem. Suspendisse et sem tincidunt, accumsan massa eleifend, euismod dui. Praesent eget urna lectus.', - nameNoPadding: '', - customStyleText: '', - nameRequired: '', - flatDenseText: '', - flatDense: '', - outlinedDenseText: '', - outlinedDense: '', - flatMultiline: '', - flatTextArea: '', - flatUnderlineColors: '', - outlinedMultiline: '', - outlinedTextArea: '', - outlinedColors: '', - outlinedLongLabel: '', - maxLengthName: '', - flatTextSecureEntry: true, - outlineTextSecureEntry: true, - iconsColor: { - flatLeftIcon: undefined, - flatRightIcon: undefined, - outlineLeftIcon: undefined, - outlineRightIcon: undefined, - customIcon: undefined, - }, +type DemoControls = { + error: boolean; + disabled: boolean; + readOnly: boolean; + leadingIcon: boolean; + trailingIcon: boolean; + counter: boolean; + showPrefix: boolean; + showSuffix: boolean; + multiline: boolean; }; -type AvoidingViewProps = { - children: React.ReactNode; +type DemoModifiers = { + label: string; + helperText: string; + placeholder: string; + prefix: string; + suffix: string; }; -type ExpandedId = string | number | undefined; - -const TextInputAvoidingView = ({ children }: AvoidingViewProps) => { - return Platform.OS === 'ios' ? ( - - {children} - - ) : ( - <>{children} - ); +type TextInputDemoProps = { + variant: TextInputVariant; }; -const TextInputExample = () => { - const [state, dispatch] = React.useReducer(inputReducer, initialState); - const { - text, - customIconText, - name, - outlinedText, - largeText, - flatTextPassword, - flatLongText, - outlinedLargeText, - outlinedCustomLabel, - outlinedTextPassword, - outlinedLongText, - nameNoPadding, - customStyleText, - nameRequired, - flatDenseText, - flatDense, - outlinedDenseText, - outlinedDense, - flatMultiline, - flatTextArea, - flatUnderlineColors, - outlinedMultiline, - outlinedTextArea, - outlinedColors, - maxLengthName, - flatTextSecureEntry, - outlineTextSecureEntry, - iconsColor: { - flatLeftIcon, - flatRightIcon, - outlineLeftIcon, - outlineRightIcon, - customIcon, - }, - } = state; - - const _isUsernameValid = (name: string) => /^[a-zA-Z]*$/.test(name); - +const TextInputDemo = ({ variant }: TextInputDemoProps) => { const theme = useExampleTheme(); - const inputActionHandler = (type: keyof State, payload: string) => - dispatch({ - type: type, - payload: payload, - }); + const [value, setValue] = React.useState(''); + + const [controls, setControls] = React.useState({ + error: false, + disabled: false, + readOnly: false, + leadingIcon: false, + trailingIcon: false, + counter: false, + showPrefix: false, + showSuffix: false, + multiline: false, + }); - const changeIconColor = (name: keyof State['iconsColor']) => { - const color = state.iconsColor[name]; + const [modifiers, setModifiers] = React.useState({ + label: 'Label', + helperText: 'Supporting text', + placeholder: 'Placeholder', + prefix: '$', + suffix: '/100', + }); - const newColors = { - ...state.iconsColor, - [name]: !color ? theme.colors.primary : undefined, - }; + const toggleControl = (key: keyof DemoControls) => + setControls((prev) => ({ ...prev, [key]: !prev[key] })); - dispatch({ - type: 'iconsColor', - payload: newColors, - }); - }; + const setModifier = (key: keyof DemoModifiers, text: string) => + setModifiers((prev) => ({ ...prev, [key]: text })); - const [fontsLoaded] = useFonts({ - Abel: require('../../assets/fonts/Abel-Regular.ttf'), - }); + const leadingIcon = (props: TextInputAccessoryProps) => ( + + ); - const [expandedId, setExpandedId] = React.useState('flat'); + const trailingIcon = (props: TextInputAccessoryProps) => ( + setValue('')} /> + ); - const onAccordionPress = (id: string | number) => - setExpandedId(expandedId === id ? undefined : id); + const inputColor = theme.colors.onSurfaceVariant; + const borderColor = theme.colors.outlineVariant; - return ( - - - - - inputActionHandler('text', text)} - left={ - { - changeIconColor('flatLeftIcon'); - }} - /> - } - maxLength={100} - right={} - /> - - inputActionHandler('customIconText', text) - } - maxLength={100} - right={} - left={ - ( - { - changeIconColor('customIcon'); - }} - /> - )} - /> - } - /> - - inputActionHandler('largeText', largeText) - } - left={} - right={ - { - changeIconColor('flatRightIcon'); - }} - /> - } - /> - - inputActionHandler('flatTextPassword', flatTextPassword) - } - secureTextEntry={flatTextSecureEntry} - right={ - - dispatch({ - type: 'flatTextSecureEntry', - payload: !flatTextSecureEntry, - }) - } - forceTextInputFocus={false} - /> - } - /> - - inputActionHandler('flatLongText', flatLongText) - } - /> - - - - inputActionHandler('outlinedText', outlinedText) - } - left={ - { - changeIconColor('outlineLeftIcon'); - }} - /> - } - maxLength={100} - right={} - /> - - inputActionHandler('outlinedLargeText', outlinedLargeText) - } - left={} - right={ - { - changeIconColor('outlineRightIcon'); - }} - /> - } - /> - Custom label} - placeholder="Type something" - value={outlinedCustomLabel} - onChangeText={(outlinedCustomLabel) => - inputActionHandler('outlinedCustomLabel', outlinedCustomLabel) - } - /> - - inputActionHandler('outlinedTextPassword', outlinedTextPassword) - } - secureTextEntry={outlineTextSecureEntry} - right={ - - dispatch({ - type: 'outlineTextSecureEntry', - payload: !outlineTextSecureEntry, - }) - } - /> - } - /> - - inputActionHandler('outlinedLongText', outlinedLongText) - } - /> - - - - - { - changeIconColor('flatLeftIcon'); - }} - /> - } - right={} - /> - - - { - changeIconColor('flatLeftIcon'); - }} - /> - } - right={} - /> - - - - inputActionHandler('flatDenseText', flatDenseText) - } - left={} - right={ - - focused ? theme.colors?.primary : undefined - } - /> - } - /> - - inputActionHandler('flatDense', flatDense) - } - /> - - inputActionHandler('outlinedDenseText', outlinedDenseText) - } - left={} - /> - - inputActionHandler('outlinedDense', outlinedDense) - } - /> - - - - inputActionHandler('flatMultiline', flatMultiline) - } - /> - - inputActionHandler('flatTextArea', flatTextArea) - } - /> - - - - - inputActionHandler('outlinedMultiline', outlinedMultiline) - } - /> - - inputActionHandler('outlinedTextArea', outlinedTextArea) - } - /> - - - - - - - inputActionHandler('name', name)} - /> - - Error: Only letters are allowed - - - - - inputActionHandler('maxLengthName', maxLengthName) - } - maxLength={MAX_LENGTH} - /> - - - Error: Numbers and special characters are not allowed - - - {maxLengthName.length} / {MAX_LENGTH} - - - - - - - * - {' '} - Label as component - - } - style={styles.noPaddingInput} - placeholder="Enter username, required" - value={nameRequired} - error={!nameRequired} - onChangeText={(nameRequired) => - inputActionHandler('nameRequired', nameRequired) - } - /> - - Error: Username is required - - - - - - inputActionHandler('flatUnderlineColors', flatUnderlineColors) - } - underlineColor={MD3Colors.primary70} - activeUnderlineColor={MD3Colors.tertiary50} - /> - - inputActionHandler('outlinedColors', outlinedColors) - } - outlineColor={MD3Colors.primary70} - activeOutlineColor={MD3Colors.tertiary50} - /> - - inputActionHandler('outlinedLongLabel', outlinedLongLabel) - } - /> + const modifierInputStyle: TextStyle = { + flex: 1, + color: inputColor, + fontSize: 14, + paddingVertical: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: borderColor, + }; - - inputActionHandler('customStyleText', customStyleText) - } - contentStyle={styles.inputContentStyle} - /> + const SWITCH_CONTROLS: { label: string; key: keyof DemoControls }[] = [ + { label: 'Error', key: 'error' }, + { label: 'Disabled', key: 'disabled' }, + { label: 'Readonly', key: 'readOnly' }, + { label: 'Leading icon', key: 'leadingIcon' }, + { label: 'Trailing icon', key: 'trailingIcon' }, + { label: 'Counter', key: 'counter' }, + { label: 'Prefix', key: 'showPrefix' }, + { label: 'Suffix', key: 'showSuffix' }, + { label: 'Multiline', key: 'multiline' }, + ]; + + const MODIFIER_FIELDS: { label: string; key: keyof DemoModifiers }[] = [ + { label: 'Label', key: 'label' }, + { label: 'Helper', key: 'helperText' }, + { label: 'Placeholder', key: 'placeholder' }, + { label: 'Prefix', key: 'prefix' }, + { label: 'Suffix', key: 'suffix' }, + ]; - - - inputActionHandler('nameNoPadding', nameNoPadding) - } - /> - - Error: Only letters are allowed - - + return ( + + {/* Live TextInput */} + + + + + {/* 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()}…`} + /> + + ))} + + ); +}; - - - - - - - - - - - - - - - - - - - - - - - - - {fontsLoaded ? ( - - - - ) : null} - - - - - - - - - - - - - - - - - - - - - - } - /> - - - - - - - - } - /> - - - - - - +const TextInputExample = () => { + return ( + + + + + + + + ); }; TextInputExample.title = 'TextInput'; const styles = StyleSheet.create({ - helpersWrapper: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - wrapper: { - flex: 1, - }, - helper: { - flexShrink: 1, - }, - counterHelper: { - textAlign: 'right', - }, - inputContainerStyle: { - margin: 8, - }, - inputContentStyle: { - paddingLeft: 50, - fontWeight: 'bold', - fontStyle: 'italic', - }, - fontSize: { - fontSize: 32, - }, - textArea: { - height: 80, - }, - // eslint-disable-next-line react-native/no-color-literals - noPaddingInput: { - backgroundColor: 'transparent', + container: { + paddingHorizontal: 16, + paddingVertical: 8, + } satisfies ViewStyle, + demoContainer: { + gap: 4, + } satisfies ViewStyle, + divider: { + marginVertical: 8, + } satisfies ViewStyle, + subheader: { paddingHorizontal: 0, - }, - centeredText: { - textAlign: 'center', - }, - fixedHeight: { - height: 100, - }, - row: { - margin: 8, + } satisfies TextStyle, + switchRow: { + flexDirection: 'row', + alignItems: 'center', justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 8, + } satisfies ViewStyle, + modifierRow: { flexDirection: 'row', - }, - month: { - flex: 1, - marginRight: 4, - }, - year: { - flex: 1, - marginLeft: 4, - }, - inputLabelText: { - color: MD3Colors.tertiary70, - }, - left: { - width: '30%', - }, - right: { - width: '70%', - }, - autoText: { - textAlign: 'auto', - }, + alignItems: 'center', + gap: 12, + paddingVertical: 8, + paddingHorizontal: 8, + } satisfies ViewStyle, + modifierLabel: { + width: 80, + } satisfies TextStyle, }); export default TextInputExample; diff --git a/src/components/HelperText/HelperText.tsx b/src/components/HelperText/HelperText.tsx deleted file mode 100644 index 6fc44d58a5..0000000000 --- a/src/components/HelperText/HelperText.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import * as React from 'react'; -import { - Animated, - LayoutChangeEvent, - StyleProp, - StyleSheet, - TextStyle, - View, -} from 'react-native'; - -import { getTextColor } from './utils'; -import { useInternalTheme } from '../../core/theming'; -import type { $Omit, ThemeProp } from '../../types'; -import AnimatedText from '../Typography/AnimatedText'; - -export type Props = $Omit< - $Omit, 'padding'>, - 'type' -> & { - /** - * Type of the helper text. - */ - type: 'error' | 'info'; - /** - * Text content of the HelperText. - */ - children: React.ReactNode; - /** - * Whether to display the helper text. - */ - visible?: boolean; - /** - * Whether to apply padding to the helper text. - */ - padding?: 'none' | 'normal'; - /** - * Whether the text input tied with helper text is disabled. - */ - disabled?: boolean; - style?: StyleProp; - /** - * @optional - */ - theme?: ThemeProp; - /** - * TestID used for testing purposes - */ - testID?: string; -}; - -/** - * Helper text is used in conjuction with input elements to provide additional hints for the user. - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { View } from 'react-native'; - * import { HelperText, TextInput } from 'react-native-paper'; - * - * const MyComponent = () => { - * const [text, setText] = React.useState(''); - * - * const onChangeText = text => setText(text); - * - * const hasErrors = () => { - * return !text.includes('@'); - * }; - * - * return ( - * - * - * - * Email address is invalid! - * - * - * ); - * }; - * - * export default MyComponent; - * ``` - */ -const HelperText = ({ - style, - type = 'info', - visible = true, - theme: themeOverrides, - onLayout, - padding = 'normal', - disabled, - ...rest -}: Props) => { - const theme = useInternalTheme(themeOverrides); - const { current: shown } = React.useRef( - new Animated.Value(visible ? 1 : 0) - ); - - let { current: textHeight } = React.useRef(0); - - const { scale } = theme.animation; - - const { maxFontSizeMultiplier = 1.5 } = rest; - - React.useEffect(() => { - if (visible) { - // show text - Animated.timing(shown, { - toValue: 1, - duration: 150 * scale, - useNativeDriver: true, - }).start(); - } else { - // hide text - Animated.timing(shown, { - toValue: 0, - duration: 180 * scale, - useNativeDriver: true, - }).start(); - } - }, [visible, scale, shown]); - - const handleTextLayout = (e: LayoutChangeEvent) => { - onLayout?.(e); - textHeight = e.nativeEvent.layout.height; - }; - - const { color: textColor, opacity: textOpacity } = getTextColor({ - theme, - disabled, - type, - }); - - return ( - - - {rest.children} - - - ); -}; - -const styles = StyleSheet.create({ - text: { - fontSize: 12, - paddingVertical: 4, - }, - padding: { - paddingHorizontal: 12, - }, -}); - -export default HelperText; diff --git a/src/components/HelperText/utils.ts b/src/components/HelperText/utils.ts deleted file mode 100644 index 6c7fe16a58..0000000000 --- a/src/components/HelperText/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { tokens } from '../../theme/tokens'; -import type { InternalTheme } from '../../types'; - -const { stateOpacity } = tokens.md.ref; - -type BaseProps = { - theme: InternalTheme; - disabled?: boolean; - type?: 'error' | 'info'; -}; - -export function getTextColor({ theme, disabled, type }: BaseProps) { - if (type === 'error') { - return { color: theme.colors.error, opacity: stateOpacity.enabled }; - } - - if (disabled) { - return { - color: theme.colors.onSurfaceVariant, - opacity: stateOpacity.disabled, - }; - } - - return { - color: theme.colors.onSurfaceVariant, - opacity: stateOpacity.enabled, - }; -} diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx deleted file mode 100644 index 08f75860d4..0000000000 --- a/src/components/TextField/TextField.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import React from 'react'; -import { - AccessibilityProps, - BlurEvent, - ColorValue, - FocusEvent, - Pressable, - StyleProp, - Text, - TextInput, - TextInputProps, - TextStyle, - View, - ViewStyle, -} from 'react-native'; - -import Animated, { AnimatedStyle } from 'react-native-reanimated'; - -import { useTextField } from './hooks'; -import { styles } from './styles'; -import TextFieldErrorIcon from './TextFieldErrorIcon'; -import type { TextFieldAccessoryProps } from './TextFieldIcon'; -import type { InternalTheme, ThemeProp } from '../../types'; - -export type TextFieldAnimationState = { - animatedLabelWrapperStyle: StyleProp>>; - animatedLabelTextStyle: StyleProp>>; - animatedContainerStyle: StyleProp>>; - animatedActiveOutlineStyle?: StyleProp>>; -}; - -export type TextFieldAnimationHandlers = { - runFocusAnimation: (hasText: boolean) => void; - runBlurAnimation: (hasText: boolean) => void; -}; - -export type TextFieldFlags = { - isRTL: boolean; - isDisabled: boolean; - isEditable: boolean | undefined; - hasError: boolean; - hasCounter: boolean; - hasAccessory: boolean; - isFloating: boolean; - hasPrefix: boolean; - hasSuffix: boolean; -}; - -export type TextFieldColors = { - selectionColor: ColorValue; - cursorColor: ColorValue; - placeholderTextColor: ColorValue; -}; - -export type GetAccessibilityDataReturn = { - input: AccessibilityProps; - supportingText: AccessibilityProps; - counter: AccessibilityProps; -}; - -export type GetAccessibilityDataProps = { - data: TextFieldProps; - inputLength: number; - hasError: boolean; - hasCounter: boolean; - isDisabled: boolean; -}; - -export type TextFieldVariant = 'filled' | 'outlined'; - -export type TextFieldSharedApi = { - input: React.RefObject; - theme: InternalTheme; - isFocused: boolean; - isRTL: boolean; - isDisabled: boolean; - hasAccessory: boolean; - hasError: boolean; - hasSuffix: boolean; - animatedLabelWrapperStyle: StyleProp>>; - animatedLabelTextStyle: StyleProp>>; - animatedActiveOutlineStyle?: StyleProp>>; -}; - -export type SharedTextFieldStyleData = { - isRTL: boolean; - animatedLabelTextStyles: StyleProp>>; - supportingTextStyles: StyleProp; - counterStyles: StyleProp; - prefixStyles: StyleProp; - suffixStyles: StyleProp; - leadingAccessoryStyles: StyleProp; - trailingAccessoryStyles: StyleProp; -}; - -export type FilledTextFieldHookData = SharedTextFieldStyleData & { - input: React.RefObject; - isDisabled: boolean; - hasError: boolean; - hasSuffix: boolean; - animatedLabelWrapperStyles: StyleProp>>; - containerStyles: StyleProp; - fieldStyles: StyleProp; - disabledBackgroundStyles: StyleProp | undefined; - outlineStyles: StyleProp; - animatedActiveOutlineStyles: StyleProp>>; - inputStyles: StyleProp; -}; - -export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { - input: React.RefObject; - isDisabled: boolean; - hasError: boolean; - hasSuffix: boolean; - animatedLabelWrapperStyles: StyleProp>>; - containerStyles: StyleProp; - fieldStyles: StyleProp; - disabledBackgroundStyles: undefined; - outlineStyles: StyleProp; - inputStyles: StyleProp; -}; - -export type TextFieldLayoutData = - | FilledTextFieldHookData - | OutlinedTextFieldHookData; - -export type TextFieldLayoutState = Omit< - TextFieldLayoutData, - 'input' | 'isDisabled' | 'hasError' | 'hasSuffix' ->; - -export type TextFieldHookReturn = SharedTextFieldStyleData & { - input: React.RefObject; - isDisabled: boolean; - isEditable: boolean | undefined; - hasPrefix: boolean; - hasCounter: boolean; - hasSuffix: boolean; - hasError: boolean; - placeholderTextColor: ColorValue; - selectionColor: ColorValue; - cursorColor: ColorValue; - animatedActiveOutlineStyles: - | StyleProp>> - | undefined; - animatedContainerStyle: StyleProp>>; - animatedLabelWrapperStyles: StyleProp>>; - containerStyles: StyleProp; - fieldStyles: StyleProp; - disabledBackgroundStyles: StyleProp | undefined; - outlineStyles: StyleProp; - inputStyles: StyleProp; - placeholder: string | undefined; - counterText: string; - accessibilityProps: GetAccessibilityDataReturn; - renderLeadingAccessory: - | ((props: TextFieldAccessoryProps) => React.ReactNode) - | undefined; - renderTrailingAccessory: - | ((props: TextFieldAccessoryProps) => React.ReactNode) - | undefined; - onChangeText: (text: string) => void; - onFocus: (e: FocusEvent) => void; - onBlur: (e: BlurEvent) => void; - focusInput: () => void; -}; - -export type TextFieldRenderProps = React.ComponentPropsWithRef< - typeof TextInput ->; - -export type TextFieldHandles = Pick< - TextInput, - 'focus' | 'clear' | 'blur' | 'isFocused' | 'setNativeProps' | 'setSelection' ->; - -export type TextFieldProps = TextInputProps & { - /** - * Imperative handle exposing a subset of TextInput methods - * with side-effect handling (e.g. `clear()` syncs internal state and animations). - */ - ref?: React.Ref; - /** - * Determines the visual style of the text field. - * - * - `filled` — filled background with an animated underline; higher visual emphasis. - * - `outlined` — stroke outline only; lower visual emphasis. - * - * `filled` is a good fit for dialogs and short forms. `outlined` is common in long - * forms where a lighter visual weight keeps the layout easier to scan. - */ - variant?: TextFieldVariant; - /** - * When `true`, the field uses error styling and replaces the trailing accessory - * with an error indicator when no `endAccessory` is provided. - */ - error?: boolean; - /** - * The label text to display above the input. - */ - label?: string; - /** - * Supporting text to display below the input (Material Design 3). - */ - supportingText?: string; - /** - * When `true`, displays a character counter below the input on the trailing - * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. - */ - counter?: boolean; - /** - * This is separate from `editable={false}`, which makes the text read-only while the - * input can still be focused and text selected. - */ - disabled?: boolean; - /** - * A short text string displayed at the start of the input (e.g. `"$"`). - */ - prefix?: string; - /** - * A short text string displayed at the end of the input (e.g. `"/100"`). - */ - suffix?: string; - theme?: ThemeProp; - /** - * An optional component to render on the start side of the input (leading in LTR). - * Can be a custom component or `TextField.Icon`. - */ - startAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; - /** - * An optional component to render on the end side of the input (trailing in LTR). - * Can be a custom component or `TextField.Icon`. - */ - endAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; - /** - * Callback to render a custom input component in place of the native `TextInput`. - * Receives all props that would be passed to `TextInput`, allowing integration - * with third-party inputs such as masked inputs. - */ - render?: (props: TextFieldRenderProps) => React.ReactNode; -}; - -const defaultRenderer = (props: TextFieldRenderProps) => ( - -); - -/** - * A text field lets users enter and edit text. It shows an optional floating label, - * supports `filled` and `outlined` variants, optional supporting text (including - * error state), and start/end accessories. - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { TextField } from 'react-native-paper'; - * - * const MyComponent = () => { - * const [text, setText] = React.useState(''); - * - * const searchAccessory = (accessoryProps) => ( - * - * ); - * - * const clearAccessory = ({ style, disabled }) => ( - * setText('')} - * role="button" - * aria-label="Clear text" - * > - * - * - * ); - * - * return ( - * - * ); - * }; - * - * export default MyComponent; - * ``` - * - * @extends TextInput props https://reactnative.dev/docs/textinput#props - */ -function TextField(props: TextFieldProps) { - /* eslint-disable @typescript-eslint/no-unused-vars -- peel TextField-only props before TextInput spread */ - const { - ref, - error, - label, - supportingText, - variant, - theme, - prefix, - suffix, - counter, - disabled, - startAccessory, - endAccessory, - render = defaultRenderer, - ...textInputProps - } = props; - - const { - input, - isDisabled, - isEditable, - hasPrefix, - hasSuffix, - hasCounter, - hasError, - leadingAccessoryStyles, - trailingAccessoryStyles, - fieldStyles, - disabledBackgroundStyles, - outlineStyles, - animatedActiveOutlineStyles, - animatedLabelWrapperStyles, - animatedLabelTextStyles, - animatedContainerStyle, - containerStyles, - inputStyles, - prefixStyles, - suffixStyles, - supportingTextStyles, - counterStyles, - placeholderTextColor, - selectionColor, - cursorColor, - placeholder, - counterText, - accessibilityProps, - renderLeadingAccessory, - renderTrailingAccessory, - focusInput, - onChangeText, - onFocus, - onBlur, - } = useTextField(props); - - return ( - - - {/* Disabled tint overlay — filled variant only. A childless - absolutely-positioned View whose translucent fill is applied via the - `opacity` style, so it never affects label/input rendering and works - with PlatformColor on Android. */} - {!!disabledBackgroundStyles && ( - - )} - - {/* Inactive indicator — always-visible 1px bottom border (filled) or - full border (outlined); height and color reflect error/disabled state - but do not change on focus */} - - - {/* Active indicator — filled variant only; 2px bar that expands from - the center outward via scaleX (0 → 1) on focus and collapses on blur */} - {!!animatedActiveOutlineStyles && ( - - )} - - {!!label && ( - - - {label} - - - )} - - {renderLeadingAccessory - ? renderLeadingAccessory({ - style: leadingAccessoryStyles, - error: hasError, - disabled: isDisabled, - multiline: !!textInputProps.multiline, - }) - : null} - - - {hasPrefix && {prefix}} - - {render({ - ref: input, - selectionColor, - cursorColor, - placeholderTextColor, - ...accessibilityProps.input, - ...textInputProps, - editable: isEditable, - placeholder, - style: inputStyles, - onChangeText, - onFocus, - onBlur, - })} - - {hasSuffix && {suffix}} - - - {renderTrailingAccessory ? ( - renderTrailingAccessory({ - style: trailingAccessoryStyles, - error: hasError, - disabled: isDisabled, - multiline: !!textInputProps.multiline, - }) - ) : hasError ? ( - - ) : null} - - - - {!!supportingText && ( - - {supportingText} - - )} - - {hasCounter && ( - - {counterText} - - )} - - - ); -} - -export default TextField; diff --git a/src/components/TextField/index.ts b/src/components/TextField/index.ts deleted file mode 100644 index 097b9b6980..0000000000 --- a/src/components/TextField/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/TextInput/Addons/Outline.tsx b/src/components/TextInput/Addons/Outline.tsx deleted file mode 100644 index 39ffa48b56..0000000000 --- a/src/components/TextInput/Addons/Outline.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import { - StyleSheet, - ColorValue, - StyleProp, - View, - ViewStyle, -} from 'react-native'; - -import { TextInputLabelProp } from '../types'; - -type OutlineProps = { - activeColor: string; - backgroundColor: ColorValue; - hasActiveOutline?: boolean; - outlineColor?: string; - roundness?: number; - label?: TextInputLabelProp; - style?: StyleProp; -}; - -export const Outline = ({ - label, - activeColor, - backgroundColor, - hasActiveOutline, - outlineColor, - roundness, - style, -}: OutlineProps) => ( - -); - -const styles = StyleSheet.create({ - outline: { - position: 'absolute', - left: 0, - right: 0, - top: 6, - bottom: 0, - }, - noLabelOutline: { - top: 0, - }, -}); diff --git a/src/components/TextInput/Addons/Underline.tsx b/src/components/TextInput/Addons/Underline.tsx deleted file mode 100644 index 22a8e419cc..0000000000 --- a/src/components/TextInput/Addons/Underline.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from 'react'; -import { Animated, StyleSheet, StyleProp, ViewStyle } from 'react-native'; - -import type { ThemeProp } from 'src/types'; - -type UnderlineProps = { - parentState: { - focused: boolean; - }; - error?: boolean; - colors?: { - error?: string; - }; - activeColor: string; - underlineColorCustom?: string; - hasActiveOutline?: boolean; - disabledOpacity?: number; - style?: StyleProp; - theme?: ThemeProp; -}; - -export const Underline = ({ - parentState, - error, - colors, - activeColor, - underlineColorCustom, - hasActiveOutline, - disabledOpacity, - style, - theme: _themeOverrides, -}: UnderlineProps) => { - let backgroundColor = parentState.focused - ? activeColor - : underlineColorCustom; - - if (error) backgroundColor = colors?.error; - - const activeScale = 2; - - return ( - - ); -}; - -const styles = StyleSheet.create({ - underline: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - height: 2, - zIndex: 1, - }, - md3Underline: { - height: 1, - }, -}); diff --git a/src/components/TextInput/Adornment/TextInputAdornment.tsx b/src/components/TextInput/Adornment/TextInputAdornment.tsx deleted file mode 100644 index 6295908025..0000000000 --- a/src/components/TextInput/Adornment/TextInputAdornment.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React from 'react'; -import type { - LayoutChangeEvent, - TextStyle, - StyleProp, - Animated, - DimensionValue, -} from 'react-native'; - -import { AdornmentSide, AdornmentType, InputMode } from './enums'; -import TextInputAffix, { AffixAdornment } from './TextInputAffix'; -import TextInputIcon, { IconAdornment } from './TextInputIcon'; -import type { - AdornmentConfig, - AdornmentStyleAdjustmentForNativeInput, -} from './types'; -import { getConstants } from '../helpers'; - -export function getAdornmentConfig({ - left, - right, -}: { - left?: React.ReactNode; - right?: React.ReactNode; -}): Array { - let adornmentConfig: any[] = []; - if (left || right) { - [ - { side: AdornmentSide.Left, adornment: left }, - { side: AdornmentSide.Right, adornment: right }, - ].forEach(({ side, adornment }) => { - if (adornment && React.isValidElement(adornment)) { - let type; - if (adornment.type === TextInputAffix) { - type = AdornmentType.Affix; - } else if (adornment.type === TextInputIcon) { - type = AdornmentType.Icon; - } - adornmentConfig.push({ - side, - type, - }); - } - }); - } - - return adornmentConfig; -} - -export function getAdornmentStyleAdjustmentForNativeInput({ - adornmentConfig, - leftAffixWidth, - rightAffixWidth, - paddingHorizontal, - inputOffset = 0, - mode, -}: { - inputOffset?: number; - adornmentConfig: AdornmentConfig[]; - leftAffixWidth: number; - rightAffixWidth: number; - mode?: 'outlined' | 'flat'; - paddingHorizontal?: DimensionValue; -}): AdornmentStyleAdjustmentForNativeInput | {} { - const { OUTLINED_INPUT_OFFSET, ADORNMENT_OFFSET } = getConstants(); - - if (adornmentConfig.length) { - const adornmentStyleAdjustmentForNativeInput = adornmentConfig.map( - ({ type, side }: AdornmentConfig) => { - const isLeftSide = side === AdornmentSide.Left; - const inputModeAdornemntOffset = - mode === InputMode.Outlined - ? ADORNMENT_OFFSET + OUTLINED_INPUT_OFFSET - : ADORNMENT_OFFSET; - const paddingKey = `padding${captalize(side)}`; - const affixWidth = isLeftSide ? leftAffixWidth : rightAffixWidth; - const padding = - typeof paddingHorizontal === 'number' - ? paddingHorizontal - : inputModeAdornemntOffset; - const offset = affixWidth + padding; - - const isAffix = type === AdornmentType.Affix; - const marginKey = `margin${captalize(side)}`; - - return { - [marginKey]: isAffix ? 0 : offset, - [paddingKey]: isAffix ? offset : inputOffset, - }; - } - ); - const allStyleAdjustmentsMerged = - adornmentStyleAdjustmentForNativeInput.reduce( - (mergedStyles, currentStyle) => { - return { - ...mergedStyles, - ...currentStyle, - }; - }, - {} - ); - return allStyleAdjustmentsMerged; - } else { - return [{}]; - } -} - -const captalize = (text: string) => - text.charAt(0).toUpperCase() + text.slice(1); - -export interface TextInputAdornmentProps { - forceFocus: () => void; - adornmentConfig: AdornmentConfig[]; - topPosition: { - [AdornmentType.Affix]: { - [AdornmentSide.Left]: number | null; - [AdornmentSide.Right]: number | null; - }; - [AdornmentType.Icon]: number; - }; - onAffixChange: { - [AdornmentSide.Left]: (event: LayoutChangeEvent) => void; - [AdornmentSide.Right]: (event: LayoutChangeEvent) => void; - }; - left?: React.ReactNode; - right?: React.ReactNode; - textStyle?: StyleProp; - visible?: Animated.Value; - isTextInputFocused: boolean; - paddingHorizontal?: DimensionValue; - maxFontSizeMultiplier?: number | undefined | null; - disabled?: boolean; -} - -const TextInputAdornment: React.FunctionComponent = ({ - adornmentConfig, - left, - right, - onAffixChange, - textStyle, - visible, - topPosition, - isTextInputFocused, - forceFocus, - paddingHorizontal, - maxFontSizeMultiplier, - disabled, -}) => { - if (adornmentConfig.length) { - return ( - <> - {adornmentConfig.map(({ type, side }: AdornmentConfig) => { - let inputAdornmentComponent; - if (side === AdornmentSide.Left) { - inputAdornmentComponent = left; - } else if (side === AdornmentSide.Right) { - inputAdornmentComponent = right; - } - - const commonProps = { - side: side, - testID: `${side}-${type}-adornment`, - isTextInputFocused, - paddingHorizontal, - disabled, - }; - if (type === AdornmentType.Icon) { - return ( - - ); - } else if (type === AdornmentType.Affix) { - return ( - - ); - } else { - return null; - } - })} - - ); - } else { - return null; - } -}; - -export default TextInputAdornment; diff --git a/src/components/TextInput/Adornment/TextInputAffix.tsx b/src/components/TextInput/Adornment/TextInputAffix.tsx deleted file mode 100644 index 94a0c08662..0000000000 --- a/src/components/TextInput/Adornment/TextInputAffix.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import React from 'react'; -import { - Animated, - DimensionValue, - GestureResponderEvent, - LayoutChangeEvent, - Pressable, - StyleProp, - StyleSheet, - Text, - TextStyle, - ViewStyle, -} from 'react-native'; - -import { AdornmentSide } from './enums'; -import { getTextColor } from './utils'; -import { useInternalTheme } from '../../../core/theming'; -import type { ThemeProp } from '../../../types'; -import { getConstants } from '../helpers'; - -export type Props = { - /** - * Text to show. - */ - text: string; - onLayout?: (event: LayoutChangeEvent) => void; - /** - * Function to execute on press. - */ - onPress?: (e: GestureResponderEvent) => void; - /** - * Accessibility label for the affix. This is read by the screen reader when the user taps the affix. - */ - accessibilityLabel?: string; - /** - * Style that is passed to the Text element. - */ - textStyle?: StyleProp; - /** - * @optional - */ - theme?: ThemeProp; -}; - -type ContextState = { - topPosition: number | null; - onLayout?: (event: LayoutChangeEvent) => void; - visible?: Animated.Value; - textStyle?: StyleProp; - side: AdornmentSide; - paddingHorizontal?: DimensionValue; - maxFontSizeMultiplier?: number | undefined | null; - testID?: string; - disabled?: boolean; -}; - -const AffixContext = React.createContext({ - textStyle: { fontFamily: '', color: '' }, - topPosition: null, - side: AdornmentSide.Left, -}); - -const AffixAdornment: React.FunctionComponent< - { - affix: React.ReactNode; - testID: string; - } & ContextState -> = ({ - affix, - side, - textStyle, - topPosition, - onLayout, - visible, - paddingHorizontal, - maxFontSizeMultiplier, - testID, - disabled, -}) => { - return ( - - {affix} - - ); -}; - -/** - * A component to render a leading / trailing text in the TextInput - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { TextInput } from 'react-native-paper'; - * - * const MyComponent = () => { - * const [text, setText] = React.useState(''); - * - * return ( - * } - * /> - * ); - * }; - * - * export default MyComponent; - * ``` - */ - -const TextInputAffix = ({ - text, - textStyle: labelStyle, - theme: themeOverrides, - onLayout: onTextLayout, - onPress, - accessibilityLabel = text, -}: Props) => { - const theme = useInternalTheme(themeOverrides); - const { AFFIX_OFFSET } = getConstants(); - - const { - textStyle, - onLayout, - topPosition, - side, - visible, - paddingHorizontal, - maxFontSizeMultiplier, - testID, - disabled, - } = React.useContext(AffixContext); - - const offset = - typeof paddingHorizontal === 'number' ? paddingHorizontal : AFFIX_OFFSET; - - const style = { - top: topPosition, - [side]: offset, - } as ViewStyle; - - const { color: textColor, opacity: textOpacity } = getTextColor({ - theme, - disabled, - }); - - const content = ( - - {text} - - ); - - return ( - - {onPress ? ( - - {content} - - ) : ( - content - )} - - ); -}; - -TextInputAffix.displayName = 'TextInput.Affix'; - -const styles = StyleSheet.create({ - container: { - position: 'absolute', - justifyContent: 'center', - alignItems: 'center', - }, -}); - -export default TextInputAffix; - -// @component-docs ignore-next-line -export { TextInputAffix, AffixAdornment }; diff --git a/src/components/TextInput/Adornment/TextInputIcon.tsx b/src/components/TextInput/Adornment/TextInputIcon.tsx deleted file mode 100644 index 0fc2c7c906..0000000000 --- a/src/components/TextInput/Adornment/TextInputIcon.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React from 'react'; -import { - GestureResponderEvent, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; - -import { getIconColor } from './utils'; -import { useInternalTheme } from '../../../core/theming'; -import type { $Omit, ThemeProp } from '../../../types'; -import type { IconSource } from '../../Icon'; -import IconButton from '../../IconButton/IconButton'; -import { ICON_SIZE } from '../constants'; -import { getConstants } from '../helpers'; - -export type Props = $Omit< - React.ComponentProps, - 'icon' | 'theme' | 'color' | 'iconColor' -> & { - /** - * @renamed Renamed from 'name' to 'icon` in v5.x - * Icon to show. - */ - icon: IconSource; - /** - * Function to execute on press. - */ - onPress?: (e: GestureResponderEvent) => void; - /** - * Whether the TextInput will focus after onPress. - */ - forceTextInputFocus?: boolean; - /** - * Color of the icon or a function receiving a boolean indicating whether the TextInput is focused and returning the color. - */ - color?: ((isTextInputFocused: boolean) => string | undefined) | string; - style?: StyleProp; - /** - * @optional - */ - theme?: ThemeProp; -}; - -type StyleContextType = { - style: StyleProp; - isTextInputFocused: boolean; - forceFocus: () => void; - testID: string; - disabled?: boolean; -}; - -const StyleContext = React.createContext({ - style: {}, - isTextInputFocused: false, - forceFocus: () => {}, - testID: '', -}); - -const IconAdornment: React.FunctionComponent< - { - testID: string; - icon: React.ReactNode; - topPosition: number; - side: 'left' | 'right'; - disabled?: boolean; - } & Omit -> = ({ - icon, - topPosition, - side, - isTextInputFocused, - forceFocus, - testID, - disabled, -}) => { - const { ICON_OFFSET } = getConstants(); - - const style = { - top: topPosition, - [side]: ICON_OFFSET, - }; - const contextState = { - style, - isTextInputFocused, - forceFocus, - testID, - disabled, - }; - - return ( - {icon} - ); -}; - -/** - * A component to render a leading / trailing icon in the TextInput - * - * ## Usage - * ```js - * import * as React from 'react'; - * import { TextInput } from 'react-native-paper'; - * - * const MyComponent = () => { - * const [text, setText] = React.useState(''); - * - * return ( - * } - * /> - * ); - * }; - * - * export default MyComponent; - * ``` - */ - -const TextInputIcon = ({ - icon, - onPress, - forceTextInputFocus = true, - color: customColor, - theme: themeOverrides, - ...rest -}: Props) => { - const { style, isTextInputFocused, forceFocus, testID, disabled } = - React.useContext(StyleContext); - - const onPressWithFocusControl = React.useCallback( - (e: GestureResponderEvent) => { - if (forceTextInputFocus && !isTextInputFocused) { - forceFocus(); - } - - onPress?.(e); - }, - [forceTextInputFocus, forceFocus, isTextInputFocused, onPress] - ); - - const theme = useInternalTheme(themeOverrides); - - const { color: iconColor, opacity: iconOpacity } = getIconColor({ - theme, - disabled, - isTextInputFocused, - customColor, - }); - - return ( - - - - ); -}; -TextInputIcon.displayName = 'TextInput.Icon'; - -const styles = StyleSheet.create({ - container: { - position: 'absolute', - width: ICON_SIZE, - height: ICON_SIZE, - justifyContent: 'center', - alignItems: 'center', - }, - iconButton: { - margin: 0, - }, -}); - -export default TextInputIcon; - -// @component-docs ignore-next-line -export { IconAdornment }; diff --git a/src/components/TextInput/Adornment/enums.tsx b/src/components/TextInput/Adornment/enums.tsx deleted file mode 100644 index 9a364f7215..0000000000 --- a/src/components/TextInput/Adornment/enums.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export enum AdornmentType { - Icon = 'icon', - Affix = 'affix', -} -export enum AdornmentSide { - Right = 'right', - Left = 'left', -} -export enum InputMode { - Outlined = 'outlined', - Flat = 'flat', -} diff --git a/src/components/TextInput/Adornment/types.tsx b/src/components/TextInput/Adornment/types.tsx deleted file mode 100644 index fbd81c936a..0000000000 --- a/src/components/TextInput/Adornment/types.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { AdornmentSide, AdornmentType } from './enums'; - -export type AdornmentConfig = { - side: AdornmentSide; - type: AdornmentType; -}; -export type AdornmentStyleAdjustmentForNativeInput = { - adornmentStyleAdjustmentForNativeInput: Array< - { paddingRight: number; paddingLeft: number } | {} - >; -}; diff --git a/src/components/TextInput/Adornment/utils.ts b/src/components/TextInput/Adornment/utils.ts deleted file mode 100644 index 40acdafbdb..0000000000 --- a/src/components/TextInput/Adornment/utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { tokens } from '../../../theme/tokens'; -import type { InternalTheme } from '../../../types'; - -const { stateOpacity } = tokens.md.ref; - -type BaseProps = { - theme: InternalTheme; - disabled?: boolean; -}; - -export function getTextColor({ theme, disabled }: BaseProps) { - return { - color: theme.colors.onSurfaceVariant, - opacity: disabled ? stateOpacity.disabled : stateOpacity.enabled, - }; -} - -export function getIconColor({ - theme, - isTextInputFocused, - disabled, - customColor, -}: BaseProps & { - isTextInputFocused: boolean; - customColor?: ((isTextInputFocused: boolean) => string | undefined) | string; -}) { - const color = - typeof customColor === 'function' - ? customColor(isTextInputFocused) - : customColor ?? theme.colors.onSurfaceVariant; - - const opacity = - disabled && !customColor ? stateOpacity.disabled : stateOpacity.enabled; - - return { color, opacity }; -} diff --git a/src/components/TextInput/Label/InputLabel.tsx b/src/components/TextInput/Label/InputLabel.tsx deleted file mode 100644 index 6fd869a1c3..0000000000 --- a/src/components/TextInput/Label/InputLabel.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import React from 'react'; -import { - Animated, - ColorValue, - Platform, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native'; - -import AnimatedText from '../../Typography/AnimatedText'; -import { getConstants } from '../helpers'; -import type { InputLabelProps } from '../types'; - -const InputLabel = (props: InputLabelProps) => { - const { - labeled, - wiggle, - error, - focused, - labelLayoutWidth, - labelLayoutHeight, - labelBackground, - label, - labelError, - onLayoutAnimatedText, - onLabelTextLayout, - hasActiveOutline, - activeColor, - placeholderStyle, - baseLabelTranslateX, - baseLabelTranslateY, - font, - fontSize, - lineHeight, - fontWeight, - placeholderOpacity, - wiggleOffsetX, - labelScale, - topPosition, - paddingLeft, - paddingRight, - backgroundColor, - roundness, - placeholderColor, - disabledOpacity, - opacity, - errorColor, - labelTranslationXOffset, - maxFontSizeMultiplier, - testID, - inputContainerLayout, - scaledLabel, - } = props; - - const { INPUT_PADDING_HORIZONTAL } = getConstants(); - const { width } = useWindowDimensions(); - - const isWeb = Platform.OS === 'web'; - - const paddingOffset = - paddingLeft && paddingRight ? { paddingLeft, paddingRight } : {}; - - const labelTranslationX = { - transform: [ - { - // Offset label scale since RN doesn't support transform origin - translateX: labeled.interpolate({ - inputRange: [0, 1], - outputRange: [baseLabelTranslateX, labelTranslationXOffset || 0], - }), - }, - ], - }; - - const labelStyle = { - ...font, - fontSize, - lineHeight, - fontWeight, - opacity: labeled.interpolate({ - inputRange: [0, 1], - outputRange: [hasActiveOutline ? 1 : 0, 0], - }), - transform: [ - { - // Wiggle the label when there's an error - translateX: wiggle - ? error.interpolate({ - inputRange: [0, 0.5, 1], - outputRange: [0, wiggleOffsetX, 0], - }) - : 0, - }, - { - // Move label to top - translateY: - baseLabelTranslateY !== 0 - ? labeled.interpolate({ - inputRange: [0, 1], - outputRange: [baseLabelTranslateY, 0], - }) - : 0, - }, - { - // Make label smaller - scale: - labelScale !== 0 - ? labeled.interpolate({ - inputRange: [0, 1], - outputRange: [labelScale, 1], - }) - : labeled, - }, - ], - }; - - const labelWidth = - (inputContainerLayout.width + INPUT_PADDING_HORIZONTAL / 2) / - (scaledLabel ? labelScale : 1); - - const commonStyles = [ - placeholderStyle, - { - top: topPosition, - }, - { - maxWidth: labelWidth, - }, - labelStyle, - paddingOffset || {}, - ]; - - const textColor = ( - labelError && errorColor ? errorColor : placeholderColor - ) as ColorValue; - - return ( - // Position colored placeholder and gray placeholder on top of each other and crossfade them - // This gives the effect of animating the color, but allows us to use native driver - - - - {labelBackground?.({ - labeled, - labelLayoutWidth, - labelLayoutHeight, - labelStyle, - placeholderStyle, - baseLabelTranslateX, - topPosition, - label, - backgroundColor, - roundness, - maxFontSizeMultiplier: maxFontSizeMultiplier, - testID, - })} - - {label} - - - {label} - - - - - ); -}; - -const styles = StyleSheet.create({ - overflow: { - overflow: 'hidden', - }, - labelContainer: { - zIndex: 3, - }, -}); - -export default React.memo(InputLabel); diff --git a/src/components/TextInput/Label/LabelBackground.tsx b/src/components/TextInput/Label/LabelBackground.tsx deleted file mode 100644 index 409606d208..0000000000 --- a/src/components/TextInput/Label/LabelBackground.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import * as React from 'react'; -import { Animated, StyleSheet } from 'react-native'; - -import AnimatedText from '../../Typography/AnimatedText'; -import type { LabelBackgroundProps } from '../types'; - -const LabelBackground = ({ - labeled, - labelLayoutWidth, - labelLayoutHeight, - placeholderStyle, - baseLabelTranslateX, - topPosition, - backgroundColor, - roundness, - labelStyle, - maxFontSizeMultiplier, - testID, -}: LabelBackgroundProps) => { - const opacity = labeled.interpolate({ - inputRange: [0, 0.6], - outputRange: [1, 0], - }); - - const labelTranslationX = { - translateX: labeled.interpolate({ - inputRange: [0, 1], - outputRange: [-baseLabelTranslateX, 0], - }), - }; - - const labelTextScaleY = { - scaleY: labeled.interpolate({ - inputRange: [0, 1], - outputRange: [0.2, 1], - }), - }; - - const labelTextTransform = [...labelStyle.transform, labelTextScaleY]; - - const isRounded = roundness > 6; - const roundedEdgeCover = isRounded ? ( - - ) : null; - - return [ - roundedEdgeCover, - , - ]; -}; - -export default LabelBackground; - -const styles = StyleSheet.create({ - view: { - position: 'absolute', - top: 6, - left: 10, - width: 12, - }, - // eslint-disable-next-line react-native/no-color-literals - outlinedLabel: { - position: 'absolute', - left: 8, - paddingHorizontal: 0, - color: 'transparent', - }, -}); diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index 7c289d9152..e8439f6881 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -1,202 +1,253 @@ -import * as React from 'react'; +import React from 'react'; import { - Animated, - LayoutChangeEvent, + AccessibilityProps, + BlurEvent, + ColorValue, + FocusEvent, + Pressable, StyleProp, + Text, TextInput as NativeTextInput, + TextInputProps as NativeTextInputProps, TextStyle, + View, ViewStyle, - NativeSyntheticEvent, - TextLayoutEventData, } from 'react-native'; -import TextInputAffix, { - Props as TextInputAffixProps, -} from './Adornment/TextInputAffix'; -import TextInputIcon, { - Props as TextInputIconProps, -} from './Adornment/TextInputIcon'; -import TextInputFlat from './TextInputFlat'; -import TextInputOutlined from './TextInputOutlined'; -import type { RenderProps, TextInputLabelProp } from './types'; -import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../types'; -import { forwardRef } from '../../utils/forwardRef'; -import { roundLayoutSize } from '../../utils/roundLayoutSize'; - -const BLUR_ANIMATION_DURATION = 180; -const FOCUS_ANIMATION_DURATION = 150; - -export type Props = React.ComponentPropsWithRef & { - /** - * Mode of the TextInput. - * - `flat` - flat input with an underline. - * - `outlined` - input with an outline. - * - * In `outlined` mode, the background color of the label is derived from `colors?.background` in theme or the `backgroundColor` style. - * This component render TextInputOutlined or TextInputFlat based on that props - */ - mode?: 'flat' | 'outlined'; - /** - * The adornment placed on the left side of the input. It can be either `TextInput.Icon` or `TextInput.Affix`. - */ - left?: React.ReactNode; - /** - * The adornment placed on the right side of the input. It can be either `TextInput.Icon` or `TextInput.Affix`. - */ - right?: React.ReactNode; - /** - * If true, user won't be able to interact with the component. - */ - disabled?: boolean; +import Animated, { AnimatedStyle } from 'react-native-reanimated'; + +import { useTextInput } from './hooks'; +import { styles } from './styles'; +import TextInputErrorIcon from './TextInputErrorIcon'; +import type { TextInputAccessoryProps } from './TextInputIcon'; +import type { InternalTheme, ThemeProp } from '../../types'; + +export type TextInputAnimationState = { + animatedLabelWrapperStyle: StyleProp>>; + animatedLabelTextStyle: StyleProp>>; + animatedContainerStyle: StyleProp>>; + animatedActiveOutlineStyle?: StyleProp>>; +}; + +export type TextInputAnimationHandlers = { + runFocusAnimation: (hasText: boolean) => void; + runBlurAnimation: (hasText: boolean) => void; +}; + +export type TextInputFlags = { + isRTL: boolean; + isDisabled: boolean; + isEditable: boolean | undefined; + hasError: boolean; + hasCounter: boolean; + hasAccessory: boolean; + isFloating: boolean; + hasPrefix: boolean; + hasSuffix: boolean; +}; + +export type TextInputColors = { + selectionColor: ColorValue; + cursorColor: ColorValue; + placeholderTextColor: ColorValue; +}; + +export type GetAccessibilityDataReturn = { + input: AccessibilityProps; + supportingText: AccessibilityProps; + counter: AccessibilityProps; +}; + +export type GetAccessibilityDataProps = { + data: TextInputProps; + inputLength: number; + hasError: boolean; + hasCounter: boolean; + isDisabled: boolean; +}; + +export type TextInputVariant = 'filled' | 'outlined'; + +export type TextInputSharedApi = { + input: React.RefObject; + theme: InternalTheme; + isFocused: boolean; + isRTL: boolean; + isDisabled: boolean; + hasAccessory: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyle: StyleProp>>; + animatedLabelTextStyle: StyleProp>>; + animatedActiveOutlineStyle?: StyleProp>>; +}; + +export type SharedTextInputStyleData = { + isRTL: boolean; + animatedLabelTextStyles: StyleProp>>; + supportingTextStyles: StyleProp; + counterStyles: StyleProp; + prefixStyles: StyleProp; + suffixStyles: StyleProp; + leadingAccessoryStyles: StyleProp; + trailingAccessoryStyles: StyleProp; +}; + +export type FilledTextInputHookData = SharedTextInputStyleData & { + input: React.RefObject; + isDisabled: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: StyleProp | undefined; + outlineStyles: StyleProp; + animatedActiveOutlineStyles: StyleProp>>; + inputStyles: StyleProp; +}; + +export type OutlinedTextInputHookData = SharedTextInputStyleData & { + input: React.RefObject; + isDisabled: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: undefined; + outlineStyles: StyleProp; + inputStyles: StyleProp; +}; + +export type TextInputLayoutData = + | FilledTextInputHookData + | OutlinedTextInputHookData; + +export type TextInputLayoutState = Omit< + TextInputLayoutData, + 'input' | 'isDisabled' | 'hasError' | 'hasSuffix' +>; + +export type TextInputHookReturn = SharedTextInputStyleData & { + input: React.RefObject; + isDisabled: boolean; + isEditable: boolean | undefined; + hasPrefix: boolean; + hasCounter: boolean; + hasSuffix: boolean; + hasError: boolean; + placeholderTextColor: ColorValue; + selectionColor: ColorValue; + cursorColor: ColorValue; + animatedActiveOutlineStyles: + | StyleProp>> + | undefined; + animatedContainerStyle: StyleProp>>; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: StyleProp | undefined; + outlineStyles: StyleProp; + inputStyles: StyleProp; + placeholder: string | undefined; + counterText: string; + accessibilityProps: GetAccessibilityDataReturn; + renderLeadingAccessory: + | ((props: TextInputAccessoryProps) => React.ReactNode) + | undefined; + renderTrailingAccessory: + | ((props: TextInputAccessoryProps) => React.ReactNode) + | undefined; + onChangeText: (text: string) => void; + onFocus: (e: FocusEvent) => void; + onBlur: (e: BlurEvent) => void; + focusInput: () => void; +}; + +export type TextInputRenderProps = React.ComponentPropsWithRef< + typeof NativeTextInput +>; + +export type TextInputHandles = Pick< + NativeTextInput, + 'focus' | 'clear' | 'blur' | 'isFocused' | 'setNativeProps' | 'setSelection' +>; + +export type TextInputProps = NativeTextInputProps & { /** - * The text or component to use for the floating label. + * Imperative handle exposing a subset of NativeTextInput methods + * with side-effect handling (e.g. `clear()` syncs internal state and animations). */ - label?: TextInputLabelProp; + ref?: React.Ref; /** - * Placeholder for the input. + * Determines the visual style of the text input. + * + * - `filled` — filled background with an animated underline; higher visual emphasis. + * - `outlined` — stroke outline only; lower visual emphasis. + * + * `filled` is a good fit for dialogs and short forms. `outlined` is common in long + * forms where a lighter visual weight keeps the layout easier to scan. */ - placeholder?: string; + variant?: TextInputVariant; /** - * Whether to style the TextInput with error style. + * When `true`, the field uses error styling and replaces the trailing accessory + * with an error indicator when no `endAccessory` is provided. */ error?: boolean; /** - * Callback that is called when the text input's text changes. Changed text is passed as an argument to the callback handler. - */ - onChangeText?: Function; - /** - * Selection color of the input. On iOS, it sets both the selection color and cursor color. - * On Android, it sets only the selection color. - */ - selectionColor?: string; - /** - * @platform Android only - * Cursor (or "caret") color of the input on Android. - * This property has no effect on iOS. - */ - cursorColor?: string; - /** - * Inactive underline color of the input. - */ - underlineColor?: string; - /** - * Active underline color of the input. - */ - activeUnderlineColor?: string; - /** - * Inactive outline color of the input. - */ - outlineColor?: string; - /** - * Active outline color of the input. - */ - activeOutlineColor?: string; - /** - * Color of the text in the input. - */ - textColor?: string; - /** - * Sets min height with densed layout. For `TextInput` in `flat` mode - * height is `64dp` or in dense layout - `52dp` with label or `40dp` without label. - * For `TextInput` in `outlined` mode - * height is `56dp` or in dense layout - `40dp` regardless of label. - * When you apply `height` prop in style the `dense` prop affects only `paddingVertical` inside `TextInput` - */ - dense?: boolean; - /** - * Whether the input can have multiple lines. + * The label text to display above the input. */ - multiline?: boolean; + label?: string; /** - * @platform Android only - * The number of lines to show in the input (Android only). + * Supporting text to display below the input (Material Design 3). */ - numberOfLines?: number; + supportingText?: string; /** - * Callback that is called when the text input is focused. + * When `true`, displays a character counter below the input on the trailing + * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. */ - onFocus?: (args: any) => void; + counter?: boolean; /** - * Callback that is called when the text input is blurred. + * This is separate from `editable={false}`, which makes the text read-only while the + * input can still be focused and text selected. */ - onBlur?: (args: any) => void; - /** - * - * Callback to render a custom input component such as `react-native-text-input-mask` - * instead of the default `TextInput` component from `react-native`. - * - * Example: - * ```js - * - * - * } - * /> - * ``` - */ - render?: (props: RenderProps) => React.ReactNode; - /** - * Value of the text input. - */ - value?: string; + disabled?: boolean; /** - * Pass `fontSize` prop to modify the font size inside `TextInput`. - * Pass `height` prop to set `TextInput` height. When `height` is passed, - * `dense` prop will affect only input's `paddingVertical`. - * Pass `paddingHorizontal` to modify horizontal padding. - * This can be used to get MD Guidelines v1 TextInput look. + * A short text string displayed at the start of the input (e.g. `"$"`). */ - style?: StyleProp; + prefix?: string; /** - * @optional + * A short text string displayed at the end of the input (e.g. `"/100"`). */ + suffix?: string; theme?: ThemeProp; /** - * testID to be used on tests. + * An optional component to render on the start side of the input (leading in LTR). + * Can be a custom component or `TextInput.Icon`. */ - testID?: string; + startAccessory?: (props: TextInputAccessoryProps) => React.ReactNode; /** - * Pass custom style directly to the input itself. - * Overrides input style - * Example: `paddingLeft`, `backgroundColor` + * An optional component to render on the end side of the input (trailing in LTR). + * Can be a custom component or `TextInput.Icon`. */ - contentStyle?: StyleProp; + endAccessory?: (props: TextInputAccessoryProps) => React.ReactNode; /** - * Pass style to override the default style of outlined wrapper. - * Overrides style when mode is set to `outlined` - * Example: `borderRadius`, `borderColor` + * Callback to render a custom input component in place of the native `NativeTextInput`. + * Receives all props that would be passed to `NativeTextInput`, allowing integration + * with third-party inputs such as masked inputs. */ - outlineStyle?: StyleProp; - /** - * Pass style to override the default style of underlined wrapper. - * Overrides style when mode is set to `flat` - * Example: `borderRadius`, `borderColor` - */ - underlineStyle?: StyleProp; + render?: (props: TextInputRenderProps) => React.ReactNode; }; -interface CompoundedComponent - extends React.ForwardRefExoticComponent< - Props & React.RefAttributes - > { - Icon: React.FunctionComponent; - Affix: React.FunctionComponent>; -} - -type TextInputHandles = Pick< - NativeTextInput, - 'focus' | 'clear' | 'blur' | 'isFocused' | 'setNativeProps' | 'setSelection' ->; - -const DefaultRenderer = (props: RenderProps) => ; +const defaultRenderer = (props: TextInputRenderProps) => ( + +); /** - * A component to allow users to input text. + * A text input 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 @@ -204,13 +255,31 @@ const DefaultRenderer = (props: RenderProps) => ; * import { TextInput } from 'react-native-paper'; * * const MyComponent = () => { - * const [text, setText] = React.useState(""); + * const [text, setText] = React.useState(''); + * + * const searchAccessory = (accessoryProps) => ( + * + * ); + * + * const clearAccessory = ({ style, disabled }) => ( + * setText('')} + * role="button" + * aria-label="Clear text" + * > + * + * + * ); * * return ( * setText(text)} + * onChangeText={setText} + * startAccessory={searchAccessory} + * endAccessory={clearAccessory} * /> * ); * }; @@ -220,358 +289,152 @@ const DefaultRenderer = (props: RenderProps) => ; * * @extends TextInput props https://reactnative.dev/docs/textinput#props */ -const TextInput = forwardRef( - ( - { - mode = 'flat', - dense = false, - disabled = false, - error: errorProp = false, - multiline = false, - editable = true, - contentStyle, - render = DefaultRenderer, - theme: themeOverrides, - ...rest - }: Props, - ref - ) => { - const theme = useInternalTheme(themeOverrides); - const isControlled = rest.value !== undefined; - const validInputValue = isControlled ? rest.value : rest.defaultValue; - - const { current: labeled } = React.useRef( - new Animated.Value(validInputValue ? 0 : 1) - ); - const { current: error } = React.useRef( - new Animated.Value(errorProp ? 1 : 0) - ); - const [focused, setFocused] = React.useState(false); - const [displayPlaceholder, setDisplayPlaceholder] = - React.useState(false); - const [uncontrolledValue, setUncontrolledValue] = React.useState< - string | undefined - >(validInputValue); - // Use value from props instead of local state when input is controlled - const value = isControlled ? rest.value : uncontrolledValue; - - const [labelTextLayout, setLabelTextLayout] = React.useState({ - width: 33, - }); - - const [inputContainerLayout, setInputContainerLayout] = React.useState({ - width: 65, - }); - - const [labelLayout, setLabelLayout] = React.useState<{ - measured: boolean; - width: number; - height: number; - }>({ - measured: false, - width: 0, - height: 0, - }); - const [leftLayout, setLeftLayout] = React.useState<{ - height: number | null; - width: number | null; - }>({ - width: null, - height: null, - }); - const [rightLayout, setRightLayout] = React.useState<{ - height: number | null; - width: number | null; - }>({ - width: null, - height: null, - }); - - const timer = React.useRef(undefined); - const root = React.useRef(null); - - const { scale } = theme.animation; - - React.useImperativeHandle(ref, () => ({ - focus: () => root.current?.focus(), - clear: () => root.current?.clear(), - setNativeProps: (args: Object) => root.current?.setNativeProps(args), - isFocused: () => root.current?.isFocused() || false, - blur: () => root.current?.blur(), - forceFocus: () => root.current?.focus(), - setSelection: (start: number, end: number) => - root.current?.setSelection(start, end), - })); - - React.useEffect(() => { - // When the input has an error, we wiggle the label and apply error styles - if (errorProp) { - // show error - Animated.timing(error, { - toValue: 1, - duration: FOCUS_ANIMATION_DURATION * scale, - // To prevent this - https://github.com/callstack/react-native-paper/issues/941 - useNativeDriver: true, - }).start(); - } else { - // hide error - { - Animated.timing(error, { - toValue: 0, - duration: BLUR_ANIMATION_DURATION * scale, - // To prevent this - https://github.com/callstack/react-native-paper/issues/941 - useNativeDriver: true, - }).start(); - } - } - }, [errorProp, scale, error]); - - React.useEffect(() => { - // Show placeholder text only if the input is focused, or there's no label - // We don't show placeholder if there's a label because the label acts as placeholder - // When focused, the label moves up, so we can show a placeholder - if (focused || !rest.label) { - // If the user wants to use the contextMenu, when changing the placeholder, the contextMenu is closed - // This is a workaround to mitigate this behavior in scenarios where the placeholder is not specified. - if (rest.placeholder) { - // Display placeholder in a delay to offset the label animation - // If we show it immediately, they'll overlap and look ugly - timer.current = setTimeout( - () => setDisplayPlaceholder(true), - 50 - ) as unknown as NodeJS.Timeout; - } - } else { - // hidePlaceholder - setDisplayPlaceholder(false); - } - - return () => { - if (timer.current) { - clearTimeout(timer.current); - } - }; - }, [focused, rest.label, rest.placeholder]); - - React.useEffect(() => { - labeled.stopAnimation(); - // The label should be minimized if the text input is focused, or has text - // In minimized mode, the label moves up and becomes small - // workaround for animated regression for react native > 0.61 - // https://github.com/callstack/react-native-paper/pull/1440 - if (value || focused) { - // minimize label - Animated.timing(labeled, { - toValue: 0, - duration: BLUR_ANIMATION_DURATION * scale, - // To prevent this - https://github.com/callstack/react-native-paper/issues/941 - useNativeDriver: true, - }).start(); - } else { - // restore label - Animated.timing(labeled, { - toValue: 1, - duration: FOCUS_ANIMATION_DURATION * scale, - // To prevent this - https://github.com/callstack/react-native-paper/issues/941 - useNativeDriver: true, - }).start(); - } - }, [focused, value, labeled, scale]); - - const onLeftAffixLayoutChange = React.useCallback( - (event: LayoutChangeEvent) => { - const height = roundLayoutSize(event.nativeEvent.layout.height); - const width = roundLayoutSize(event.nativeEvent.layout.width); - - if (width !== leftLayout.width || height !== leftLayout.height) { - setLeftLayout({ - width, - height, - }); - } - }, - [leftLayout.height, leftLayout.width] - ); - - const onRightAffixLayoutChange = React.useCallback( - (event: LayoutChangeEvent) => { - const width = roundLayoutSize(event.nativeEvent.layout.width); - const height = roundLayoutSize(event.nativeEvent.layout.height); - - if (width !== rightLayout.width || height !== rightLayout.height) { - setRightLayout({ - width, - height, - }); - } - }, - [rightLayout.height, rightLayout.width] - ); - - const handleFocus = (args: any) => { - if (disabled || !editable) { - return; - } - - setFocused(true); - - rest.onFocus?.(args); - }; - - const handleBlur = (args: Object) => { - if (!editable) { - return; - } - - setFocused(false); - rest.onBlur?.(args); - }; - - const handleChangeText = (value: string) => { - if (!editable || disabled) { - return; - } - - if (!isControlled) { - // Keep track of value in local state when input is not controlled - setUncontrolledValue(value); - } - rest.onChangeText?.(value); - }; - - const handleLayoutAnimatedText = React.useCallback( - (e: LayoutChangeEvent) => { - const width = roundLayoutSize(e.nativeEvent.layout.width); - const height = roundLayoutSize(e.nativeEvent.layout.height); - - if (width !== labelLayout.width || height !== labelLayout.height) { - setLabelLayout({ - width, - height, - measured: true, - }); - } - }, - [labelLayout.height, labelLayout.width] - ); - - const handleLabelTextLayout = React.useCallback( - ({ nativeEvent }: NativeSyntheticEvent) => { - setLabelTextLayout({ - width: nativeEvent.lines.reduce( - (acc, line) => acc + Math.ceil(line.width), - 0 - ), - }); - }, - [] - ); - - const handleInputContainerLayout = React.useCallback( - ({ nativeEvent: { layout } }: LayoutChangeEvent) => { - setInputContainerLayout({ - width: layout.width, - }); - }, - [] - ); - - const forceFocus = React.useCallback(() => root.current?.focus(), []); - - const { maxFontSizeMultiplier = 1.5 } = rest; - - const scaledLabel = !!(value || focused); - - if (mode === 'outlined') { - return ( - { - root.current = ref; - }} - onFocus={handleFocus} - forceFocus={forceFocus} - onBlur={handleBlur} - onChangeText={handleChangeText} - onLayoutAnimatedText={handleLayoutAnimatedText} - onInputLayout={handleInputContainerLayout} - onLabelTextLayout={handleLabelTextLayout} - onLeftAffixLayoutChange={onLeftAffixLayoutChange} - onRightAffixLayoutChange={onRightAffixLayoutChange} - maxFontSizeMultiplier={maxFontSizeMultiplier} - contentStyle={contentStyle} - scaledLabel={scaledLabel} - /> - ); - } - - return ( - { - root.current = ref; - }} - onFocus={handleFocus} - forceFocus={forceFocus} - onBlur={handleBlur} - onInputLayout={handleInputContainerLayout} - onChangeText={handleChangeText} - onLayoutAnimatedText={handleLayoutAnimatedText} - onLabelTextLayout={handleLabelTextLayout} - onLeftAffixLayoutChange={onLeftAffixLayoutChange} - onRightAffixLayoutChange={onRightAffixLayoutChange} - maxFontSizeMultiplier={maxFontSizeMultiplier} - contentStyle={contentStyle} - scaledLabel={scaledLabel} - /> - ); - } -) as CompoundedComponent; -// @component ./Adornment/TextInputIcon.tsx -TextInput.Icon = TextInputIcon; - -// @component ./Adornment/TextInputAffix.tsx -// @ts-ignore Types of property 'theme' are incompatible. -TextInput.Affix = TextInputAffix; +function TextInput(props: TextInputProps) { + /* eslint-disable @typescript-eslint/no-unused-vars -- peel TextInput-only props before NativeTextInput spread */ + const { + ref, + error, + label, + supportingText, + variant, + theme, + prefix, + suffix, + counter, + disabled, + startAccessory, + endAccessory, + render = defaultRenderer, + ...textInputProps + } = props; + + const { + input, + isDisabled, + isEditable, + hasPrefix, + hasSuffix, + hasCounter, + hasError, + leadingAccessoryStyles, + trailingAccessoryStyles, + fieldStyles, + disabledBackgroundStyles, + outlineStyles, + animatedActiveOutlineStyles, + animatedLabelWrapperStyles, + animatedLabelTextStyles, + animatedContainerStyle, + containerStyles, + inputStyles, + prefixStyles, + suffixStyles, + supportingTextStyles, + counterStyles, + placeholderTextColor, + selectionColor, + cursorColor, + placeholder, + counterText, + accessibilityProps, + renderLeadingAccessory, + renderTrailingAccessory, + focusInput, + onChangeText, + onFocus, + onBlur, + } = useTextInput(props); + + return ( + + + {/* Disabled tint overlay — filled variant only. A childless + absolutely-positioned View whose translucent fill is applied via the + `opacity` style, so it never affects label/input rendering and works + with PlatformColor on Android. */} + {!!disabledBackgroundStyles && ( + + )} + + {/* Inactive indicator — always-visible 1px bottom border (filled) or + full border (outlined); height and color reflect error/disabled state + but do not change on focus */} + + + {/* Active indicator — filled variant only; 2px bar that expands from + the center outward via scaleX (0 → 1) on focus and collapses on blur */} + {!!animatedActiveOutlineStyles && ( + + )} + + {!!label && ( + + + {label} + + + )} + + {renderLeadingAccessory + ? renderLeadingAccessory({ + style: leadingAccessoryStyles, + error: hasError, + disabled: isDisabled, + multiline: !!textInputProps.multiline, + }) + : null} + + + {hasPrefix && {prefix}} + + {render({ + ref: input, + selectionColor, + cursorColor, + placeholderTextColor, + ...accessibilityProps.input, + ...textInputProps, + editable: isEditable, + placeholder, + style: inputStyles, + onChangeText, + onFocus, + onBlur, + })} + + {hasSuffix && {suffix}} + + + {renderTrailingAccessory ? ( + renderTrailingAccessory({ + style: trailingAccessoryStyles, + error: hasError, + disabled: isDisabled, + multiline: !!textInputProps.multiline, + }) + ) : hasError ? ( + + ) : null} + + + + {!!supportingText && ( + + {supportingText} + + )} + + {hasCounter && ( + + {counterText} + + )} + + + ); +} export default TextInput; diff --git a/src/components/TextField/TextFieldErrorIcon.tsx b/src/components/TextInput/TextInputErrorIcon.tsx similarity index 81% rename from src/components/TextField/TextFieldErrorIcon.tsx rename to src/components/TextInput/TextInputErrorIcon.tsx index bb9974f768..3af74ec3ad 100644 --- a/src/components/TextField/TextFieldErrorIcon.tsx +++ b/src/components/TextInput/TextInputErrorIcon.tsx @@ -6,15 +6,15 @@ import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import Icon from '../Icon'; -interface TextFieldErrorIconProps { +interface TextInputErrorIconProps { style?: StyleProp; theme?: ThemeProp; } -const TextFieldErrorIcon = ({ +const TextInputErrorIcon = ({ style: wrapperStyle, theme: themeOverride, -}: TextFieldErrorIconProps) => { +}: TextInputErrorIconProps) => { const theme = useInternalTheme(themeOverride); return ( @@ -28,4 +28,4 @@ const TextFieldErrorIcon = ({ ); }; -export default TextFieldErrorIcon; +export default TextInputErrorIcon; diff --git a/src/components/TextInput/TextInputFlat.tsx b/src/components/TextInput/TextInputFlat.tsx deleted file mode 100644 index f7235ea5d2..0000000000 --- a/src/components/TextInput/TextInputFlat.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import * as React from 'react'; -import { - Platform, - StyleSheet, - TextInput as NativeTextInput, - TextStyle, - View, - Animated, -} from 'react-native'; - -import { Underline } from './Addons/Underline'; -import { AdornmentSide, AdornmentType, InputMode } from './Adornment/enums'; -import TextInputAdornment, { - TextInputAdornmentProps, -} from './Adornment/TextInputAdornment'; -import { - getAdornmentConfig, - getAdornmentStyleAdjustmentForNativeInput, -} from './Adornment/TextInputAdornment'; -import { - ADORNMENT_SIZE, - LABEL_PADDING_TOP_DENSE, - LABEL_WIGGLE_X_OFFSET, - MAXIMIZED_LABEL_FONT_SIZE, - MINIMIZED_LABEL_FONT_SIZE, - MINIMIZED_LABEL_Y_OFFSET, - MIN_DENSE_HEIGHT, - MIN_DENSE_HEIGHT_WL, -} from './constants'; -import { - adjustPaddingFlat, - calculateFlatAffixTopPosition, - calculateFlatInputHorizontalPadding, - calculateInputHeight, - calculateLabelTopPosition, - calculatePadding, - getConstants, - getFlatInputColors, - Padding, -} from './helpers'; -import InputLabel from './Label/InputLabel'; -import type { ChildTextInputProps, RenderProps } from './types'; -import { useLocale } from '../../core/locale'; - -const TextInputFlat = ({ - disabled = false, - editable = true, - label, - error = false, - selectionColor: customSelectionColor, - cursorColor, - underlineColor, - underlineStyle, - activeUnderlineColor, - textColor, - dense, - style, - theme, - render = (props: RenderProps) => , - multiline = false, - parentState, - innerRef, - onFocus, - forceFocus, - onBlur, - onChangeText, - onLayoutAnimatedText, - onLabelTextLayout, - onLeftAffixLayoutChange, - onRightAffixLayoutChange, - onInputLayout, - left, - right, - placeholderTextColor, - testID = 'text-input-flat', - contentStyle, - scaledLabel, - ...rest -}: ChildTextInputProps) => { - const isAndroid = Platform.OS === 'android'; - const { direction } = useLocale(); - const isRTL = direction === 'rtl'; - const { colors } = theme; - const roundness = theme.shapes.corner.extraSmall; - const font = theme.fonts.bodyLarge; - const hasActiveOutline = parentState.focused || error; - - const { LABEL_PADDING_TOP, FLAT_INPUT_OFFSET, MIN_HEIGHT, MIN_WIDTH } = - getConstants(); - - const { - fontSize: fontSizeStyle, - lineHeight: lineHeightStyle, - fontWeight, - height, - paddingHorizontal, - textAlign, - ...viewStyle - } = (StyleSheet.flatten(style) || {}) as TextStyle; - const fontSize = fontSizeStyle || MAXIMIZED_LABEL_FONT_SIZE; - const lineHeight = - lineHeightStyle || (Platform.OS === 'web' ? fontSize * 1.2 : undefined); - - const isPaddingHorizontalPassed = - paddingHorizontal !== undefined && typeof paddingHorizontal === 'number'; - - const adornmentConfig = getAdornmentConfig({ - left, - right, - }); - - let { paddingLeft, paddingRight } = calculateFlatInputHorizontalPadding({ - adornmentConfig, - }); - - if (isPaddingHorizontalPassed) { - paddingLeft = paddingHorizontal as number; - paddingRight = paddingHorizontal as number; - } - - const { leftLayout, rightLayout } = parentState; - - const rightAffixWidth = right - ? rightLayout.width || ADORNMENT_SIZE - : ADORNMENT_SIZE; - - const leftAffixWidth = left - ? leftLayout.width || ADORNMENT_SIZE - : ADORNMENT_SIZE; - - const adornmentStyleAdjustmentForNativeInput = - getAdornmentStyleAdjustmentForNativeInput({ - adornmentConfig, - rightAffixWidth, - leftAffixWidth, - paddingHorizontal, - inputOffset: FLAT_INPUT_OFFSET, - mode: InputMode.Flat, - }); - - const { - inputTextColor, - activeColor, - disabledOpacity, - underlineColorCustom, - placeholderColor, - errorColor, - backgroundColor, - selectionColor, - } = getFlatInputColors({ - underlineColor, - activeUnderlineColor, - customSelectionColor, - textColor, - disabled, - error, - theme, - }); - - const containerStyle = { - backgroundColor, - borderTopLeftRadius: roundness, - borderTopRightRadius: roundness, - }; - - const labelScale = MINIMIZED_LABEL_FONT_SIZE / fontSize; - const fontScale = MAXIMIZED_LABEL_FONT_SIZE / fontSize; - - const labelWidth = parentState.labelLayout.width; - const labelHeight = parentState.labelLayout.height; - const labelHalfWidth = labelWidth / 2; - const labelHalfHeight = labelHeight / 2; - - const baseLabelTranslateX = - (isRTL ? 1 : -1) * (labelHalfWidth - (labelScale * labelWidth) / 2) + - (1 - labelScale) * (isRTL ? -1 : 1) * paddingLeft; - - const minInputHeight = dense - ? (label ? MIN_DENSE_HEIGHT_WL : MIN_DENSE_HEIGHT) - LABEL_PADDING_TOP_DENSE - : MIN_HEIGHT - LABEL_PADDING_TOP; - - const inputHeight = calculateInputHeight(labelHeight, height, minInputHeight); - - const topPosition = calculateLabelTopPosition( - labelHeight, - inputHeight, - multiline && height ? 0 : !height ? minInputHeight / 2 : 0 - ); - - if (height && typeof height !== 'number') { - // eslint-disable-next-line - console.warn('Currently we support only numbers in height prop'); - } - - const paddingSettings = { - height: height ? +height : null, - labelHalfHeight, - offset: FLAT_INPUT_OFFSET, - multiline: multiline ? multiline : null, - dense: dense ? dense : null, - topPosition, - fontSize, - lineHeight, - label, - scale: fontScale, - isAndroid, - styles: StyleSheet.flatten( - dense ? styles.inputFlatDense : styles.inputFlat - ) as Padding, - }; - - const pad = calculatePadding(paddingSettings); - - const paddingFlat = adjustPaddingFlat({ - ...paddingSettings, - pad, - }); - - const baseLabelTranslateY = - -labelHalfHeight - (topPosition + MINIMIZED_LABEL_Y_OFFSET); - - const { current: placeholderOpacityAnims } = React.useRef([ - new Animated.Value(0), - new Animated.Value(1), - ]); - - const placeholderOpacity = hasActiveOutline - ? parentState.labeled - : placeholderOpacityAnims[parentState.labelLayout.measured ? 1 : 0]; - - // We don't want to show placeholder if label is displayed, because they overlap. - // Before it was done by setting placeholder's value to " ", but inputs have the same props - // what causes broken styles due to: https://github.com/facebook/react-native/issues/48249 - const placeholderTextColorBasedOnState = parentState.displayPlaceholder - ? placeholderTextColor ?? placeholderColor - : 'transparent'; - - const minHeight = - height || - (dense ? (label ? MIN_DENSE_HEIGHT_WL : MIN_DENSE_HEIGHT) : MIN_HEIGHT); - - const flatHeight = - inputHeight + - (!height ? (dense ? LABEL_PADDING_TOP_DENSE : LABEL_PADDING_TOP) : 0); - - const iconTopPosition = (flatHeight - ADORNMENT_SIZE) / 2; - - const leftAffixTopPosition = leftLayout.height - ? calculateFlatAffixTopPosition({ - height: flatHeight, - ...paddingFlat, - affixHeight: leftLayout.height, - }) - : null; - - const rightAffixTopPosition = rightLayout.height - ? calculateFlatAffixTopPosition({ - height: flatHeight, - ...paddingFlat, - affixHeight: rightLayout.height, - }) - : null; - - const labelProps = { - label, - onLayoutAnimatedText, - onLabelTextLayout, - placeholderOpacity, - labelError: error, - placeholderStyle: styles.placeholder, - baseLabelTranslateY, - baseLabelTranslateX, - font, - fontSize, - lineHeight, - fontWeight, - labelScale, - wiggleOffsetX: LABEL_WIGGLE_X_OFFSET, - topPosition, - paddingLeft: isAndroid ? (isRTL ? paddingRight : paddingLeft) : paddingLeft, - paddingRight: isAndroid - ? isRTL - ? paddingLeft - : paddingRight - : paddingRight, - hasActiveOutline, - activeColor, - placeholderColor, - disabledOpacity, - errorColor, - roundness, - maxFontSizeMultiplier: rest.maxFontSizeMultiplier, - testID, - contentStyle, - inputContainerLayout: parentState.inputContainerLayout, - labelTextLayout: parentState.labelTextLayout, - opacity: - parentState.value || parentState.focused - ? parentState.labelLayout.measured - ? 1 - : 0 - : 1, - }; - - const affixTopPosition = { - [AdornmentSide.Left]: leftAffixTopPosition, - [AdornmentSide.Right]: rightAffixTopPosition, - }; - const onAffixChange = { - [AdornmentSide.Left]: onLeftAffixLayoutChange, - [AdornmentSide.Right]: onRightAffixLayoutChange, - }; - - let adornmentProps: TextInputAdornmentProps = { - paddingHorizontal, - adornmentConfig, - forceFocus, - topPosition: { - [AdornmentType.Affix]: affixTopPosition, - [AdornmentType.Icon]: iconTopPosition, - }, - onAffixChange, - isTextInputFocused: parentState.focused, - maxFontSizeMultiplier: rest.maxFontSizeMultiplier, - disabled, - }; - if (adornmentConfig.length) { - adornmentProps = { - ...adornmentProps, - left, - right, - textStyle: { ...font, fontSize, lineHeight, fontWeight }, - visible: parentState.labeled, - }; - } - - return ( - - - - {!isAndroid && multiline && !!label && !disabled && ( - // Workaround for: https://github.com/callstack/react-native-paper/issues/2799 - // Patch for a multiline TextInput with fixed height, which allow to avoid covering input label with its value. - - )} - {label ? ( - - ) : null} - {render?.({ - ...rest, - ref: innerRef, - onChangeText, - placeholder: rest.placeholder, - editable: !disabled && editable, - selectionColor, - cursorColor: - typeof cursorColor === 'undefined' ? activeColor : cursorColor, - placeholderTextColor: placeholderTextColorBasedOnState, - onFocus, - onBlur, - underlineColorAndroid: 'transparent', - multiline, - style: [ - styles.input, - multiline && height ? { height: flatHeight } : {}, - paddingFlat, - { - paddingLeft, - paddingRight, - ...font, - fontSize, - lineHeight, - fontWeight, - color: inputTextColor, - opacity: disabledOpacity, - textAlignVertical: multiline ? 'top' : 'center', - textAlign: textAlign ? textAlign : isRTL ? 'right' : 'left', - minWidth: Math.min( - parentState.labelTextLayout.width + 2 * FLAT_INPUT_OFFSET, - MIN_WIDTH - ), - }, - Platform.OS === 'web' ? { outline: 'none' } : undefined, - adornmentStyleAdjustmentForNativeInput, - contentStyle, - ], - testID, - })} - - - - ); -}; - -export default TextInputFlat; - -const styles = StyleSheet.create({ - placeholder: { - position: 'absolute', - left: 0, - }, - labelContainer: { - paddingTop: 0, - paddingBottom: 0, - flexGrow: 1, - }, - input: { - margin: 0, - flexGrow: 1, - }, - inputFlat: { - paddingTop: 24, - paddingBottom: 4, - }, - inputFlatDense: { - paddingTop: 22, - paddingBottom: 2, - }, - patchContainer: { - height: 24, - zIndex: 2, - }, - densePatchContainer: { - height: 22, - zIndex: 2, - }, -}); diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextInput/TextInputIcon.tsx similarity index 74% rename from src/components/TextField/TextFieldIcon.tsx rename to src/components/TextInput/TextInputIcon.tsx index 86d3a5f2ed..e76528324f 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextInput/TextInputIcon.tsx @@ -8,39 +8,39 @@ import { useInternalTheme } from '../../core/theming'; import type { $Omit } from '../../types'; import IconButton from '../IconButton/IconButton'; -export type TextFieldAccessoryProps = { +export type TextInputAccessoryProps = { style: StyleProp; multiline: boolean; disabled: boolean; error: boolean; }; -export type TextFieldIconProps = TextFieldAccessoryProps & - $Omit, keyof TextFieldAccessoryProps>; +export type TextInputIconProps = TextInputAccessoryProps & + $Omit, keyof TextInputAccessoryProps>; /** - * A component to render a leading / trailing icon in the TextField + * A component to render a leading / trailing icon in the TextInput * (return it from `startAccessory` or `endAccessory`). Accepts icon-specific props as well as - * `TextFieldAccessoryProps`, which TextField passes into those render props. + * `TextInputAccessoryProps`, which TextInput passes into those render props. * * ## Usage * ```js * import * as React from 'react'; - * import { TextField } from 'react-native-paper'; + * import { TextInput } from 'react-native-paper'; * * const MyComponent = () => { * const [text, setText] = React.useState(''); * * const searchAccessory = (props) => ( - * + * * ); * * const clearAccessory = (props) => ( - * setText('')} /> + * setText('')} /> * ); * * return ( - * { +}: TextInputIconProps) => { const theme = useInternalTheme(themeOverride); const iconSize = size ?? ACCESSORY_SIZE; @@ -93,6 +93,6 @@ const TextFieldIcon = ({ ); }; -TextFieldIcon.displayName = 'TextField.Icon'; +TextInputIcon.displayName = 'TextInput.Icon'; -export default TextFieldIcon; +export default TextInputIcon; diff --git a/src/components/TextInput/TextInputOutlined.tsx b/src/components/TextInput/TextInputOutlined.tsx deleted file mode 100644 index be4fae7487..0000000000 --- a/src/components/TextInput/TextInputOutlined.tsx +++ /dev/null @@ -1,447 +0,0 @@ -import * as React from 'react'; -import { - Animated, - View, - TextInput as NativeTextInput, - StyleSheet, - Platform, - TextStyle, - ColorValue, - LayoutChangeEvent, -} from 'react-native'; - -import { Outline } from './Addons/Outline'; -import { AdornmentType, AdornmentSide } from './Adornment/enums'; -import TextInputAdornment, { - getAdornmentConfig, - getAdornmentStyleAdjustmentForNativeInput, - TextInputAdornmentProps, -} from './Adornment/TextInputAdornment'; -import { - MAXIMIZED_LABEL_FONT_SIZE, - MINIMIZED_LABEL_FONT_SIZE, - LABEL_WIGGLE_X_OFFSET, - ADORNMENT_SIZE, - OUTLINE_MINIMIZED_LABEL_Y_OFFSET, - LABEL_PADDING_TOP, - MIN_DENSE_HEIGHT_OUTLINED, - LABEL_PADDING_TOP_DENSE, -} from './constants'; -import { - calculateLabelTopPosition, - calculateInputHeight, - calculatePadding, - adjustPaddingOut, - Padding, - calculateOutlinedIconAndAffixTopPosition, - getOutlinedInputColors, - getConstants, -} from './helpers'; -import InputLabel from './Label/InputLabel'; -import LabelBackground from './Label/LabelBackground'; -import type { RenderProps, ChildTextInputProps } from './types'; -import { useLocale } from '../../core/locale'; - -const TextInputOutlined = ({ - disabled = false, - editable = true, - label, - error = false, - selectionColor: customSelectionColor, - cursorColor, - underlineColor: _underlineColor, - outlineColor: customOutlineColor, - activeOutlineColor, - outlineStyle, - textColor, - dense, - style, - theme, - render = (props: RenderProps) => , - multiline = false, - parentState, - innerRef, - onFocus, - forceFocus, - onBlur, - onChangeText, - onLayoutAnimatedText, - onLabelTextLayout, - onLeftAffixLayoutChange, - onRightAffixLayoutChange, - onInputLayout, - onLayout, - left, - right, - placeholderTextColor, - testID = 'text-input-outlined', - contentStyle, - scaledLabel, - ...rest -}: ChildTextInputProps) => { - const adornmentConfig = getAdornmentConfig({ left, right }); - const { direction } = useLocale(); - const isRTL = direction === 'rtl'; - - const { colors } = theme; - const roundness = theme.shapes.corner.extraSmall; - const font = theme.fonts.bodyLarge; - const hasActiveOutline = parentState.focused || error; - - const { INPUT_PADDING_HORIZONTAL, MIN_HEIGHT, ADORNMENT_OFFSET, MIN_WIDTH } = - getConstants(); - - const { - fontSize: fontSizeStyle, - fontWeight, - lineHeight: lineHeightStyle, - height, - backgroundColor = colors?.background, - textAlign, - ...viewStyle - } = (StyleSheet.flatten(style) || {}) as TextStyle; - const fontSize = fontSizeStyle || MAXIMIZED_LABEL_FONT_SIZE; - const lineHeight = - lineHeightStyle || (Platform.OS === 'web' ? fontSize * 1.2 : undefined); - - const { - inputTextColor, - activeColor, - disabledOpacity, - outlineColor, - placeholderColor, - errorColor, - selectionColor, - } = getOutlinedInputColors({ - activeOutlineColor, - customOutlineColor, - customSelectionColor, - textColor, - disabled, - error, - theme, - }); - - const densePaddingTop = label ? LABEL_PADDING_TOP_DENSE : 0; - const paddingTop = label ? LABEL_PADDING_TOP : 0; - const yOffset = label ? OUTLINE_MINIMIZED_LABEL_Y_OFFSET : 0; - - const labelScale = MINIMIZED_LABEL_FONT_SIZE / fontSize; - const fontScale = MAXIMIZED_LABEL_FONT_SIZE / fontSize; - - const labelWidth = parentState.labelLayout.width; - const labelHeight = parentState.labelLayout.height; - const labelHalfWidth = labelWidth / 2; - const labelHalfHeight = labelHeight / 2; - - const baseLabelTranslateX = - (isRTL ? 1 : -1) * - (labelHalfWidth - - (labelScale * labelWidth) / 2 - - (fontSize - MINIMIZED_LABEL_FONT_SIZE) * labelScale); - - let labelTranslationXOffset = 0; - const isAdornmentLeftIcon = adornmentConfig.some( - ({ side, type }) => - side === AdornmentSide.Left && type === AdornmentType.Icon - ); - const isAdornmentRightIcon = adornmentConfig.some( - ({ side, type }) => - side === AdornmentSide.Right && type === AdornmentType.Icon - ); - - if (isAdornmentLeftIcon) { - labelTranslationXOffset = - (isRTL ? -1 : 1) * ADORNMENT_SIZE + ADORNMENT_OFFSET; - } - - const minInputHeight = - (dense ? MIN_DENSE_HEIGHT_OUTLINED : MIN_HEIGHT) - paddingTop; - - const inputHeight = calculateInputHeight(labelHeight, height, minInputHeight); - - const topPosition = calculateLabelTopPosition( - labelHeight, - inputHeight, - paddingTop - ); - - if (height && typeof height !== 'number') { - // eslint-disable-next-line - console.warn('Currently we support only numbers in height prop'); - } - - const paddingSettings = { - height: height ? +height : null, - labelHalfHeight, - offset: paddingTop, - multiline: multiline ? multiline : null, - dense: dense ? dense : null, - topPosition, - fontSize, - lineHeight, - label, - scale: fontScale, - isAndroid: Platform.OS === 'android', - styles: StyleSheet.flatten( - dense ? styles.inputOutlinedDense : styles.inputOutlined - ) as Padding, - }; - - const pad = calculatePadding(paddingSettings); - - const paddingOut = adjustPaddingOut({ ...paddingSettings, pad }); - - const baseLabelTranslateY = -labelHalfHeight - (topPosition + yOffset); - - const { current: placeholderOpacityAnims } = React.useRef([ - new Animated.Value(0), - new Animated.Value(1), - ]); - - const placeholderOpacity = hasActiveOutline - ? parentState.labeled - : placeholderOpacityAnims[parentState.labelLayout.measured ? 1 : 0]; - - const placeholderStyle = { - position: 'absolute', - left: 0, - paddingHorizontal: INPUT_PADDING_HORIZONTAL, - }; - - const placeholderTextColorBasedOnState = parentState.displayPlaceholder - ? placeholderTextColor ?? placeholderColor - : 'transparent'; - - const labelBackgroundColor: ColorValue = - backgroundColor === 'transparent' - ? theme.colors.background - : backgroundColor; - - const labelProps = { - label, - onLayoutAnimatedText, - onLabelTextLayout, - placeholderOpacity, - labelError: error, - placeholderStyle, - baseLabelTranslateY, - baseLabelTranslateX, - font, - fontSize, - lineHeight, - fontWeight, - labelScale, - wiggleOffsetX: LABEL_WIGGLE_X_OFFSET, - topPosition, - hasActiveOutline, - activeColor, - placeholderColor, - disabledOpacity, - backgroundColor: labelBackgroundColor, - errorColor, - labelTranslationXOffset, - roundness, - maxFontSizeMultiplier: rest.maxFontSizeMultiplier, - testID, - contentStyle, - inputContainerLayout: { - width: - parentState.inputContainerLayout.width + - (isAdornmentRightIcon || isAdornmentLeftIcon - ? INPUT_PADDING_HORIZONTAL - : 0), - }, - opacity: - parentState.value || parentState.focused - ? parentState.labelLayout.measured - ? 1 - : 0 - : 1, - }; - - const onLayoutChange = React.useCallback( - (e: LayoutChangeEvent) => { - onInputLayout(e); - onLayout?.(e); - }, - [onLayout, onInputLayout] - ); - - const minHeight = (height || - (dense ? MIN_DENSE_HEIGHT_OUTLINED : MIN_HEIGHT)) as number; - - const outlinedHeight = - inputHeight + (dense ? densePaddingTop / 2 : paddingTop); - const { leftLayout, rightLayout } = parentState; - - const leftAffixTopPosition = calculateOutlinedIconAndAffixTopPosition({ - height: outlinedHeight, - affixHeight: leftLayout.height || 0, - labelYOffset: -yOffset, - }); - - const rightAffixTopPosition = calculateOutlinedIconAndAffixTopPosition({ - height: outlinedHeight, - affixHeight: rightLayout.height || 0, - labelYOffset: -yOffset, - }); - const iconTopPosition = calculateOutlinedIconAndAffixTopPosition({ - height: outlinedHeight, - affixHeight: ADORNMENT_SIZE, - labelYOffset: -yOffset, - }); - - const rightAffixWidth = right - ? rightLayout.width || ADORNMENT_SIZE - : ADORNMENT_SIZE; - - const leftAffixWidth = left - ? leftLayout.width || ADORNMENT_SIZE - : ADORNMENT_SIZE; - - const adornmentStyleAdjustmentForNativeInput = - getAdornmentStyleAdjustmentForNativeInput({ - adornmentConfig, - rightAffixWidth, - leftAffixWidth, - mode: 'outlined', - }); - const affixTopPosition = { - [AdornmentSide.Left]: leftAffixTopPosition, - [AdornmentSide.Right]: rightAffixTopPosition, - }; - const onAffixChange = { - [AdornmentSide.Left]: onLeftAffixLayoutChange, - [AdornmentSide.Right]: onRightAffixLayoutChange, - }; - - let adornmentProps: TextInputAdornmentProps = { - adornmentConfig, - forceFocus, - topPosition: { - [AdornmentType.Icon]: iconTopPosition, - [AdornmentType.Affix]: affixTopPosition, - }, - onAffixChange, - isTextInputFocused: parentState.focused, - maxFontSizeMultiplier: rest.maxFontSizeMultiplier, - disabled, - }; - if (adornmentConfig.length) { - adornmentProps = { - ...adornmentProps, - left, - right, - textStyle: { ...font, fontSize, lineHeight, fontWeight }, - visible: parentState.labeled, - }; - } - - return ( - - {/* - Render the outline separately from the container - This is so that the label can overlap the outline - Otherwise the border will cut off the label on Android - */} - - - {label ? ( - - ) : null} - {render?.({ - ...rest, - ref: innerRef, - onLayout: onLayoutChange, - onChangeText, - placeholder: rest.placeholder, - editable: !disabled && editable, - selectionColor, - cursorColor: - typeof cursorColor === 'undefined' ? activeColor : cursorColor, - placeholderTextColor: placeholderTextColorBasedOnState, - onFocus, - onBlur, - underlineColorAndroid: 'transparent', - multiline, - style: [ - styles.input, - !multiline || (multiline && height) ? { height: inputHeight } : {}, - paddingOut, - { - ...font, - fontSize, - lineHeight, - fontWeight, - color: inputTextColor, - opacity: disabledOpacity, - textAlignVertical: multiline ? 'top' : 'center', - textAlign: textAlign ? textAlign : isRTL ? 'right' : 'left', - paddingHorizontal: INPUT_PADDING_HORIZONTAL, - minWidth: Math.min( - parentState.labelTextLayout.width + - 2 * INPUT_PADDING_HORIZONTAL, - MIN_WIDTH - ), - }, - Platform.OS === 'web' ? { outline: 'none' } : undefined, - adornmentStyleAdjustmentForNativeInput, - contentStyle, - ], - testID, - } as RenderProps)} - - - - ); -}; - -export default TextInputOutlined; - -const styles = StyleSheet.create({ - labelContainer: { - paddingBottom: 0, - flexGrow: 1, - }, - input: { - margin: 0, - flexGrow: 1, - }, - inputOutlined: { - paddingTop: 8, - paddingBottom: 8, - }, - inputOutlinedDense: { - paddingTop: 4, - paddingBottom: 4, - }, -}); diff --git a/src/components/TextField/constants.ts b/src/components/TextInput/constants.ts similarity index 66% rename from src/components/TextField/constants.ts rename to src/components/TextInput/constants.ts index 320b5d0f16..524593278f 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextInput/constants.ts @@ -9,26 +9,26 @@ import { defaultShapes } from '../../theme/tokens/sys/shape'; export const fontScale = PixelRatio.getFontScale(); /** - * Common constants for the text field component. + * Common constants for the text input component. */ -export const BASELINE_TEXT_FIELD_HEIGHT = 56; -export const BASELINE_TEXT_FIELD_PADDING_VERTICAL = 8; +export const BASELINE_TEXT_INPUT_HEIGHT = 56; +export const BASELINE_TEXT_INPUT_PADDING_VERTICAL = 8; -export const TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL = 16; -export const TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL = 12; +export const TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL = 16; +export const TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL = 12; -export const TEXT_FIELD_HEIGHT = Math.ceil( - BASELINE_TEXT_FIELD_HEIGHT * fontScale +export const TEXT_INPUT_HEIGHT = Math.ceil( + BASELINE_TEXT_INPUT_HEIGHT * fontScale ); -export const TEXT_FIELD_PADDING_VERTICAL = Math.ceil( - BASELINE_TEXT_FIELD_PADDING_VERTICAL * fontScale +export const TEXT_INPUT_PADDING_VERTICAL = Math.ceil( + BASELINE_TEXT_INPUT_PADDING_VERTICAL * fontScale ); -export const TEXT_FIELD_BORDER_RADIUS = defaultShapes.corner.extraSmall; +export const TEXT_INPUT_BORDER_RADIUS = defaultShapes.corner.extraSmall; export const LABEL_START_OFFSET_WITHOUT_ACCESSORY = - TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL; export const ACCESSORY_SIZE = 24; @@ -64,27 +64,27 @@ const FILLED_LINE_HEIGHT_DELTA = 3; export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = ACCESSORY_SIZE + - TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + - TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL; -export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; +export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_INPUT_PADDING_VERTICAL; export const FILLED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( - ((BASELINE_TEXT_FIELD_HEIGHT - - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + ((BASELINE_TEXT_INPUT_HEIGHT - + 2 * BASELINE_TEXT_INPUT_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 + - BASELINE_TEXT_FIELD_PADDING_VERTICAL) * + BASELINE_TEXT_INPUT_PADDING_VERTICAL) * fontScale ); export const FILLED_MULTILINE_PADDING_TOP = - Math.ceil(ACTIVE_LABEL_FONT_SIZE * fontScale) + TEXT_FIELD_PADDING_VERTICAL; + Math.ceil(ACTIVE_LABEL_FONT_SIZE * fontScale) + TEXT_INPUT_PADDING_VERTICAL; export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; export const FILLED_PADDING_BOTTOM = - TEXT_FIELD_PADDING_VERTICAL + FILLED_LINE_HEIGHT_DELTA; + TEXT_INPUT_PADDING_VERTICAL + FILLED_LINE_HEIGHT_DELTA; /** * Constants for the outlined variant. @@ -95,8 +95,8 @@ const OUTLINED_LINE_HEIGHT_DELTA = 2; export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( - ((BASELINE_TEXT_FIELD_HEIGHT - - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + ((BASELINE_TEXT_INPUT_HEIGHT - + 2 * BASELINE_TEXT_INPUT_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 - OUTLINED_LINE_HEIGHT_DELTA) * @@ -107,21 +107,21 @@ 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 - + TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL - OUTLINED_LABEL_PADDING_HORIZONTAL; export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = Math.ceil( - (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + OUTLINED_LINE_HEIGHT_DELTA) * + (-BASELINE_TEXT_INPUT_PADDING_VERTICAL + OUTLINED_LINE_HEIGHT_DELTA) * fontScale ); export const OUTLINED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( - ((BASELINE_TEXT_FIELD_HEIGHT - - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + ((BASELINE_TEXT_INPUT_HEIGHT - + 2 * BASELINE_TEXT_INPUT_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 + - BASELINE_TEXT_FIELD_PADDING_VERTICAL - + BASELINE_TEXT_INPUT_PADDING_VERTICAL - OUTLINED_LINE_HEIGHT_DELTA) * fontScale ); @@ -129,7 +129,7 @@ export const OUTLINED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( /** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY = ACCESSORY_SIZE + - TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL - OUTLINED_LABEL_PADDING_HORIZONTAL; /** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ diff --git a/src/components/TextInput/constants.tsx b/src/components/TextInput/constants.tsx deleted file mode 100644 index d85d28e542..0000000000 --- a/src/components/TextInput/constants.tsx +++ /dev/null @@ -1,39 +0,0 @@ -export const MAXIMIZED_LABEL_FONT_SIZE = 16; -export const MINIMIZED_LABEL_FONT_SIZE = 12; -export const LABEL_WIGGLE_X_OFFSET = 4; - -export const ADORNMENT_SIZE = 24; -export const MIN_WIDTH = 100; - -//Text input affix offset -export const MD3_AFFIX_OFFSET = 16; - -// Text input icon -export const ICON_SIZE = 24; -export const MD3_ICON_OFFSET = 16; - -// Text input common -export const MD3_MIN_HEIGHT = 56; -export const MD3_ADORNMENT_OFFSET = 16; -export const LABEL_PADDING_TOP_DENSE = 24; -export const LABEL_PADDING_TOP = 8; - -// Text input flat -export const MD3_LABEL_PADDING_TOP = 26; - -export const MD3_LABEL_PADDING_HORIZONTAL = 16; - -export const MD3_FLAT_INPUT_OFFSET = 16; - -export const MINIMIZED_LABEL_Y_OFFSET = -18; -export const MIN_DENSE_HEIGHT_WL = 52; -export const MIN_DENSE_HEIGHT = 40; - -// Text input outlined -export const MD3_INPUT_PADDING_HORIZONTAL = 16; - -// extra space to avoid overlapping input's text and icon -export const MD3_OUTLINED_INPUT_OFFSET = 16; - -export const OUTLINE_MINIMIZED_LABEL_Y_OFFSET = -6; -export const MIN_DENSE_HEIGHT_OUTLINED = 48; diff --git a/src/components/TextInput/helpers.tsx b/src/components/TextInput/helpers.tsx deleted file mode 100644 index 633c03f279..0000000000 --- a/src/components/TextInput/helpers.tsx +++ /dev/null @@ -1,501 +0,0 @@ -import { AdornmentSide, AdornmentType } from './Adornment/enums'; -import type { AdornmentConfig } from './Adornment/types'; -import { - MIN_WIDTH, - ADORNMENT_SIZE, - MD3_ADORNMENT_OFFSET, - MD3_AFFIX_OFFSET, - MD3_FLAT_INPUT_OFFSET, - MD3_ICON_OFFSET, - MD3_INPUT_PADDING_HORIZONTAL, - MD3_LABEL_PADDING_HORIZONTAL, - MD3_LABEL_PADDING_TOP, - MD3_MIN_HEIGHT, - MD3_OUTLINED_INPUT_OFFSET, -} from './constants'; -import type { TextInputLabelProp } from './types'; -import { tokens } from '../../theme/tokens'; -import type { InternalTheme } from '../../types'; - -const { stateOpacity } = tokens.md.ref; - -type PaddingProps = { - height: number | null; - labelHalfHeight: number; - multiline: boolean | null; - dense: boolean | null; - topPosition: number; - fontSize: number; - lineHeight?: number; - label?: TextInputLabelProp | null; - scale: number; - offset: number; - isAndroid: boolean; - styles: { paddingTop: number; paddingBottom: number }; -}; - -type AdjProps = PaddingProps & { - pad: number; -}; - -export type Padding = { paddingTop: number; paddingBottom: number }; - -export const calculateLabelTopPosition = ( - labelHeight: number, - height: number = 0, - optionalPadding: number = 0 -): number => { - const customHeight = height > 0 ? height : 0; - - return Math.floor((customHeight - labelHeight) / 2 + optionalPadding); -}; - -export const calculateInputHeight = ( - labelHeight: number, - height: any = 0, - minHeight: number -): number => { - const finalHeight = height > 0 ? height : labelHeight; - - if (height > 0) return height; - return finalHeight < minHeight ? minHeight : finalHeight; -}; - -export const calculatePadding = (props: PaddingProps): number => { - const { height, multiline = false } = props; - - let result = 0; - - if (multiline) { - if (height && multiline) { - result = calculateTextAreaPadding(props); - } else { - result = calculateInputPadding(props); - } - } - - return Math.max(0, result); -}; - -const calculateTextAreaPadding = (props: PaddingProps) => { - const { dense } = props; - - return dense ? 10 : 20; -}; - -const calculateInputPadding = ({ - topPosition, - fontSize, - multiline, - scale, - dense, - offset, - isAndroid, -}: PaddingProps): number => { - const refFontSize = scale * fontSize; - let result = Math.floor(topPosition / 2); - - result = - result + - Math.floor((refFontSize - fontSize) / 2) - - (scale < 1 ? offset / 2 : 0); - - if (multiline && isAndroid) - result = Math.min(dense ? offset / 2 : offset, result); - - return result; -}; - -export const adjustPaddingOut = ({ - pad, - multiline, - label, - scale, - height, - fontSize, - lineHeight, - dense, - offset, - isAndroid, -}: AdjProps): Padding => { - const fontHeight = lineHeight ?? fontSize; - const refFontHeight = scale * fontSize; - let result = pad; - - if (!isAndroid && height && !multiline) { - return { - paddingTop: Math.max(0, (height - fontHeight) / 2), - paddingBottom: Math.max(0, (height - fontHeight) / 2), - }; - } - if (!isAndroid && multiline) { - if (dense) { - if (label) { - result += scale < 1 ? Math.min(offset, (refFontHeight / 2) * scale) : 0; - } else { - result += 0; - } - } - if (!dense) { - if (label) { - result += - scale < 1 - ? Math.min(offset, refFontHeight * scale) - : Math.min(offset / 2, refFontHeight * scale); - } else { - result += scale < 1 ? Math.min(offset / 2, refFontHeight * scale) : 0; - } - } - result = Math.floor(result); - } - return { paddingTop: result, paddingBottom: result }; -}; - -export const adjustPaddingFlat = ({ - pad, - scale, - multiline, - label, - height, - offset, - dense, - fontSize, - isAndroid, - styles, -}: AdjProps): Padding => { - let result = pad; - let topResult = result; - let bottomResult = result; - const { paddingTop, paddingBottom } = styles; - const refFontSize = scale * fontSize; - - if (!multiline) { - // do not modify padding if input is not multiline - if (label) { - // return const style for flat input with label - return { paddingTop, paddingBottom }; - } - // return pad for flat input without label - return { paddingTop: result, paddingBottom: result }; - } - - if (label) { - // add paddings passed from styles - topResult = paddingTop; - bottomResult = paddingBottom; - - // adjust top padding for iOS - if (!isAndroid) { - if (dense) { - topResult += - scale < 1 - ? Math.min(result, refFontSize * scale) - result / 2 - : Math.min(result, refFontSize * scale) - result / 2; - } - if (!dense) { - topResult += - scale < 1 - ? Math.min(offset / 2, refFontSize * scale) - : Math.min(result, refFontSize * scale) - offset / 2; - } - } - topResult = Math.floor(topResult); - } else { - if (height) { - // center text when height is passed - return { - paddingTop: Math.max(0, (height - fontSize) / 2), - paddingBottom: Math.max(0, (height - fontSize) / 2), - }; - } - // adjust paddings for iOS if no label - if (!isAndroid) { - if (dense) { - result += - scale < 1 - ? Math.min(offset / 2, (fontSize / 2) * scale) - : Math.min(offset / 2, scale); - } - if (!dense) { - result += - scale < 1 - ? Math.min(offset, fontSize * scale) - : Math.min(fontSize, (offset / 2) * scale); - } - - result = Math.floor(result); - topResult = result; - bottomResult = result; - } - } - - return { - paddingTop: Math.max(0, topResult), - paddingBottom: Math.max(0, bottomResult), - }; -}; - -export function calculateFlatAffixTopPosition({ - height, - paddingTop, - paddingBottom, - affixHeight, -}: { - height: number; - paddingTop: number; - paddingBottom: number; - affixHeight: number; -}): number { - const inputHeightWithoutPadding = height - paddingTop - paddingBottom; - - const halfOfTheInputHeightDecreasedByAffixHeight = - (inputHeightWithoutPadding - affixHeight) / 2; - - return paddingTop + halfOfTheInputHeightDecreasedByAffixHeight; -} - -export function calculateOutlinedIconAndAffixTopPosition({ - height, - affixHeight, - labelYOffset, -}: { - height: number; - affixHeight: number; - labelYOffset: number; -}): number { - return (height - affixHeight + labelYOffset) / 2; -} - -export const calculateFlatInputHorizontalPadding = ({ - adornmentConfig, -}: { - adornmentConfig: AdornmentConfig[]; -}) => { - const { LABEL_PADDING_HORIZONTAL, ADORNMENT_OFFSET, FLAT_INPUT_OFFSET } = - getConstants(); - - let paddingLeft = LABEL_PADDING_HORIZONTAL; - let paddingRight = LABEL_PADDING_HORIZONTAL; - - adornmentConfig.forEach(({ type, side }) => { - if (type === AdornmentType.Icon && side === AdornmentSide.Left) { - paddingLeft = ADORNMENT_SIZE + ADORNMENT_OFFSET + FLAT_INPUT_OFFSET; - } else if (side === AdornmentSide.Right) { - if (type === AdornmentType.Affix) { - paddingRight = ADORNMENT_SIZE + ADORNMENT_OFFSET + FLAT_INPUT_OFFSET; - } else if (type === AdornmentType.Icon) { - paddingRight = ADORNMENT_SIZE + ADORNMENT_OFFSET + FLAT_INPUT_OFFSET; - } - } - }); - - return { paddingLeft, paddingRight }; -}; - -type BaseProps = { - theme: InternalTheme; - disabled?: boolean; -}; - -type Mode = 'flat' | 'outlined'; - -const getInputTextColor = ({ - theme, - textColor, -}: BaseProps & { textColor?: string }) => { - if (textColor) { - return textColor; - } - - return theme.colors.onSurface; -}; - -const getActiveColor = ({ - theme, - error, - activeUnderlineColor, - activeOutlineColor, - mode, -}: BaseProps & { - error?: boolean; - activeUnderlineColor?: string; - activeOutlineColor?: string; - mode?: Mode; -}) => { - const isFlat = mode === 'flat'; - const modeColor = isFlat ? activeUnderlineColor : activeOutlineColor; - - if (error) { - return theme.colors.error; - } - - if (modeColor) { - return modeColor; - } - - return theme.colors.primary; -}; - -const getPlaceholderColor = ({ theme }: BaseProps) => { - return theme.colors.onSurfaceVariant; -}; - -const getSelectionColor = ({ - activeColor, - customSelectionColor, -}: { - activeColor: string; - customSelectionColor?: string; -}) => { - if (typeof customSelectionColor !== 'undefined') { - return customSelectionColor; - } - return activeColor; -}; - -const getFlatBackgroundColor = ({ theme, disabled }: BaseProps) => { - if (disabled) { - return theme.colors.surfaceContainerHighest; - } - - return theme.colors.surfaceVariant; -}; - -const getFlatUnderlineColor = ({ - theme, - disabled, - underlineColor, -}: BaseProps & { underlineColor?: string }) => { - if (!disabled && underlineColor) { - return underlineColor; - } - - return theme.colors.onSurfaceVariant; -}; - -const getOutlinedOutlineInputColor = ({ - theme, - disabled, - customOutlineColor, -}: BaseProps & { customOutlineColor?: string }) => { - if (!disabled && customOutlineColor) { - return customOutlineColor; - } - - if (disabled) { - if (theme.dark) { - return 'transparent'; - } - return theme.colors.outlineVariant; - } - - return theme.colors.outline; -}; - -export const getFlatInputColors = ({ - underlineColor, - activeUnderlineColor, - customSelectionColor, - textColor, - disabled, - error, - theme, -}: { - underlineColor?: string; - activeUnderlineColor?: string; - customSelectionColor?: string; - textColor?: string; - disabled?: boolean; - error?: boolean; - theme: InternalTheme; -}) => { - const baseFlatColorProps = { theme, disabled }; - const activeColor = getActiveColor({ - ...baseFlatColorProps, - error, - activeUnderlineColor, - mode: 'flat', - }); - - const disabledOpacity = disabled - ? stateOpacity.disabled - : stateOpacity.enabled; - - return { - inputTextColor: getInputTextColor({ - ...baseFlatColorProps, - textColor, - }), - activeColor, - disabledOpacity, - underlineColorCustom: getFlatUnderlineColor({ - ...baseFlatColorProps, - underlineColor, - }), - placeholderColor: getPlaceholderColor(baseFlatColorProps), - selectionColor: getSelectionColor({ activeColor, customSelectionColor }), - errorColor: theme.colors.error, - backgroundColor: getFlatBackgroundColor(baseFlatColorProps), - }; -}; - -export const getOutlinedInputColors = ({ - activeOutlineColor, - customOutlineColor, - customSelectionColor, - textColor, - disabled, - error, - theme, -}: { - activeOutlineColor?: string; - customOutlineColor?: string; - customSelectionColor?: string; - textColor?: string; - disabled?: boolean; - error?: boolean; - theme: InternalTheme; -}) => { - const baseOutlinedColorProps = { theme, disabled }; - const activeColor = getActiveColor({ - ...baseOutlinedColorProps, - error, - activeOutlineColor, - mode: 'outlined', - }); - - const disabledOpacity = disabled - ? stateOpacity.disabled - : stateOpacity.enabled; - - return { - inputTextColor: getInputTextColor({ - ...baseOutlinedColorProps, - textColor, - }), - activeColor, - disabledOpacity, - outlineColor: getOutlinedOutlineInputColor({ - ...baseOutlinedColorProps, - customOutlineColor, - }), - placeholderColor: getPlaceholderColor(baseOutlinedColorProps), - selectionColor: getSelectionColor({ activeColor, customSelectionColor }), - errorColor: theme.colors.error, - }; -}; - -export const getConstants = () => { - return { - AFFIX_OFFSET: MD3_AFFIX_OFFSET, - ICON_OFFSET: MD3_ICON_OFFSET, - LABEL_PADDING_TOP: MD3_LABEL_PADDING_TOP, - LABEL_PADDING_HORIZONTAL: MD3_LABEL_PADDING_HORIZONTAL, - FLAT_INPUT_OFFSET: MD3_FLAT_INPUT_OFFSET, - MIN_HEIGHT: MD3_MIN_HEIGHT, - INPUT_PADDING_HORIZONTAL: MD3_INPUT_PADDING_HORIZONTAL, - ADORNMENT_OFFSET: MD3_ADORNMENT_OFFSET, - OUTLINED_INPUT_OFFSET: MD3_OUTLINED_INPUT_OFFSET, - MIN_WIDTH, - }; -}; diff --git a/src/components/TextField/hooks.ts b/src/components/TextInput/hooks.ts similarity index 86% rename from src/components/TextField/hooks.ts rename to src/components/TextInput/hooks.ts index 5883e81f0b..9cc5fc4232 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextInput/hooks.ts @@ -5,7 +5,11 @@ import { useState, type RefObject, } from 'react'; -import { BlurEvent, FocusEvent, TextInput } from 'react-native'; +import { + BlurEvent, + FocusEvent, + TextInput as NativeTextInput, +} from 'react-native'; import { interpolate, @@ -20,44 +24,44 @@ import { TIMING_CONFIG, } from './constants'; import type { - TextFieldAnimationState, - TextFieldFlags, - TextFieldAnimationHandlers, - TextFieldHookReturn, - TextFieldLayoutState, - TextFieldProps, - TextFieldVariant, -} from './TextField'; + TextInputAnimationState, + TextInputFlags, + TextInputAnimationHandlers, + TextInputHookReturn, + TextInputLayoutState, + TextInputProps, + TextInputVariant, +} from './TextInput'; import { getAccentColors, getAccessibilityData, - getFilledTextFieldData, - getOutlinedTextFieldData, - getTextFieldAnimationLayout, + getFilledTextInputData, + getOutlinedTextInputData, + getTextInputAnimationLayout, } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { InternalTheme } from '../../types'; -const useTextFieldAnimation = ({ +const useTextInputAnimation = ({ variant, isRTL, hasAccessory, value, defaultValue, }: { - variant: TextFieldVariant; + variant: TextInputVariant; isRTL: boolean; hasAccessory: boolean; value: string | undefined; defaultValue: string | undefined; -}): TextFieldAnimationState & TextFieldAnimationHandlers => { +}): TextInputAnimationState & TextInputAnimationHandlers => { const initialText = value ?? defaultValue ?? ''; const focusSV = useSharedValue(0); const floatSV = useSharedValue(initialText.length > 0 ? 1 : 0); - const { activeTop, inactiveTop, translateXEnd } = getTextFieldAnimationLayout( + const { activeTop, inactiveTop, translateXEnd } = getTextInputAnimationLayout( { variant, hasAccessory, @@ -121,9 +125,9 @@ const useTextFieldAnimation = ({ }; }; -const useTextFieldInput = ( +const useTextInputInput = ( props: Pick< - TextFieldProps, + TextInputProps, 'value' | 'defaultValue' | 'onChangeText' | 'counter' | 'maxLength' > ) => { @@ -179,11 +183,11 @@ const useTextFieldInput = ( }; }; -const useTextFieldFocus = ( - props: Pick, - input: RefObject, +const useTextInputFocus = ( + props: Pick, + input: RefObject, isDisabled: boolean, - { runFocusAnimation, runBlurAnimation }: TextFieldAnimationHandlers, + { runFocusAnimation, runBlurAnimation }: TextInputAnimationHandlers, getHasText: () => boolean ) => { const [isFocused, setIsFocused] = useState(false); @@ -218,13 +222,13 @@ const useTextFieldFocus = ( }; }; -const useTextFieldFlags = ( - props: TextFieldProps, +const useTextInputFlags = ( + props: TextInputProps, isFocused: boolean, hasValue: boolean, isRTL: boolean, hasAccessory: boolean -): TextFieldFlags => { +): TextInputFlags => { const isFloating = isFocused || hasValue; return { @@ -240,7 +244,7 @@ const useTextFieldFlags = ( }; }; -const useTextFieldLayout = ({ +const useTextInputLayout = ({ variant, props, input, @@ -249,14 +253,14 @@ const useTextFieldLayout = ({ isFocused, animation, }: { - variant: TextFieldVariant; - props: TextFieldProps; - input: RefObject; + variant: TextInputVariant; + props: TextInputProps; + input: RefObject; theme: InternalTheme; - flags: TextFieldFlags; + flags: TextInputFlags; isFocused: boolean; - animation: TextFieldAnimationState; -}): TextFieldLayoutState => { + animation: TextInputAnimationState; +}): TextInputLayoutState => { const { isRTL, isDisabled, hasError, hasAccessory, hasSuffix } = flags; const { multiline } = props; @@ -276,7 +280,7 @@ const useTextFieldLayout = ({ hasSuffix: _hasSuffix, ...layout } = variant === 'filled' - ? getFilledTextFieldData( + ? getFilledTextInputData( { input, theme, @@ -292,7 +296,7 @@ const useTextFieldLayout = ({ }, props ) - : getOutlinedTextFieldData( + : getOutlinedTextInputData( { input, theme, @@ -337,10 +341,10 @@ const useTextFieldLayout = ({ ); }; -export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { +export const useTextInput = (props: TextInputProps): TextInputHookReturn => { const { ref, variant = 'filled', theme: themeOverride } = props; - const input = useRef(null); + const input = useRef(null); const theme = useInternalTheme(themeOverride); @@ -350,9 +354,9 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; const { hasValue, inputLength, getHasText, onChangeText } = - useTextFieldInput(props); + useTextInputInput(props); - const animation = useTextFieldAnimation({ + const animation = useTextInputAnimation({ variant, isRTL, hasAccessory, @@ -360,7 +364,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { defaultValue: props.defaultValue, }); - const { isFocused, onFocus, onBlur, focusInput } = useTextFieldFocus( + const { isFocused, onFocus, onBlur, focusInput } = useTextInputFocus( props, input, !!props.disabled, @@ -368,7 +372,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { getHasText ); - const flags = useTextFieldFlags( + const flags = useTextInputFlags( props, isFocused, hasValue, @@ -402,7 +406,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const placeholderTextColor = props.placeholderTextColor ?? theme.colors.onSurfaceVariant; - const layout = useTextFieldLayout({ + const layout = useTextInputLayout({ variant, props, input, diff --git a/src/components/TextInput/index.ts b/src/components/TextInput/index.ts new file mode 100644 index 0000000000..b6d400c68c --- /dev/null +++ b/src/components/TextInput/index.ts @@ -0,0 +1,13 @@ +import TextInputComponent from './TextInput'; +import TextInputIcon from './TextInputIcon'; + +const TextInput = Object.assign( + // @component ./TextInput.tsx + TextInputComponent, + { + // @component ./TextInputIcon.tsx + Icon: TextInputIcon, + } +); + +export default TextInput; diff --git a/src/components/TextField/styles.ts b/src/components/TextInput/styles.ts similarity index 78% rename from src/components/TextField/styles.ts rename to src/components/TextInput/styles.ts index c7cc6d3c2c..bd4258dc64 100644 --- a/src/components/TextField/styles.ts +++ b/src/components/TextInput/styles.ts @@ -6,11 +6,11 @@ import { 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, + TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_INPUT_BORDER_RADIUS, + TEXT_INPUT_HEIGHT, + TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_INPUT_PADDING_VERTICAL, } from './constants'; import { tokens } from '../../theme/tokens'; @@ -25,8 +25,8 @@ export const styles = StyleSheet.create({ }, field: { flexDirection: 'row', - minHeight: TEXT_FIELD_HEIGHT, - paddingVertical: TEXT_FIELD_PADDING_VERTICAL, + minHeight: TEXT_INPUT_HEIGHT, + paddingVertical: TEXT_INPUT_PADDING_VERTICAL, }, addendum: { flexDirection: 'row', @@ -34,7 +34,7 @@ export const styles = StyleSheet.create({ supportingText: { flex: 1, marginTop: SUPPORTING_TEXT_MARGIN_TOP, - paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + paddingHorizontal: TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL, fontSize: SUPPORTING_TEXT_FONT_SIZE, fontWeight: bodySmall.fontWeight, textAlign: 'left', @@ -42,7 +42,7 @@ export const styles = StyleSheet.create({ counter: { marginTop: SUPPORTING_TEXT_MARGIN_TOP, marginStart: 'auto', - paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + paddingHorizontal: TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL, fontSize: SUPPORTING_TEXT_FONT_SIZE, fontWeight: bodySmall.fontWeight, textAlign: 'right', @@ -53,7 +53,7 @@ export const styles = StyleSheet.create({ alignSelf: 'center', justifyContent: 'center', alignItems: 'center', - marginEnd: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + marginEnd: TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL, }, leadingAccessory: { width: ACCESSORY_SIZE, @@ -61,7 +61,7 @@ export const styles = StyleSheet.create({ alignSelf: 'center', justifyContent: 'center', alignItems: 'center', - marginStart: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + marginStart: TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL, }, disabled: { opacity: tokens.md.ref.stateOpacity.disabled, @@ -87,7 +87,7 @@ export const filledStyles = StyleSheet.create({ flex: 1, flexDirection: 'row', alignItems: 'flex-end', - paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + paddingHorizontal: TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL, }, labelWrapper: { position: 'absolute', @@ -111,14 +111,14 @@ export const outlinedStyles = StyleSheet.create({ right: 0, top: 0, bottom: 0, - borderRadius: TEXT_FIELD_BORDER_RADIUS, + borderRadius: TEXT_INPUT_BORDER_RADIUS, pointerEvents: 'none', }, container: { flex: 1, flexDirection: 'row', alignItems: 'center', - paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + paddingHorizontal: TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL, }, labelWrapper: { position: 'absolute', diff --git a/src/components/TextInput/types.tsx b/src/components/TextInput/types.tsx deleted file mode 100644 index 49bc46e121..0000000000 --- a/src/components/TextInput/types.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import * as React from 'react'; -import type { - TextInput as NativeTextInput, - Animated, - TextStyle, - LayoutChangeEvent, - ColorValue, - StyleProp, - ViewProps, - ViewStyle, - NativeSyntheticEvent, - TextLayoutEventData, -} from 'react-native'; - -import type { $Omit, InternalTheme, ThemeProp } from './../../types'; - -export type TextInputLabelProp = string | React.ReactElement; - -type TextInputProps = React.ComponentPropsWithRef & { - mode?: 'flat' | 'outlined'; - left?: React.ReactNode; - right?: React.ReactNode; - disabled?: boolean; - label?: TextInputLabelProp; - placeholder?: string; - error?: boolean; - onChangeText?: Function; - selectionColor?: string; - cursorColor?: string; - underlineColor?: string; - activeUnderlineColor?: string; - outlineColor?: string; - activeOutlineColor?: string; - textColor?: string; - dense?: boolean; - multiline?: boolean; - numberOfLines?: number; - onFocus?: (args: any) => void; - onBlur?: (args: any) => void; - render?: (props: RenderProps) => React.ReactNode; - value?: string; - style?: StyleProp; - theme?: ThemeProp; - testID?: string; - contentStyle?: StyleProp; - outlineStyle?: StyleProp; - underlineStyle?: StyleProp; - scaledLabel?: boolean; -}; - -export type RenderProps = { - ref: (a?: NativeTextInput | null) => void; - onChangeText?: (a: string) => void; - placeholder?: string; - placeholderTextColor?: ColorValue; - editable?: boolean; - selectionColor?: string; - cursorColor?: string; - onFocus?: (args: any) => void; - onBlur?: (args: any) => void; - underlineColorAndroid?: string; - onLayout?: (args: any) => void; - style: any; - multiline?: boolean; - numberOfLines?: number; - value?: string; - adjustsFontSizeToFit?: boolean; - testID?: string; -}; -type TextInputTypesWithoutMode = $Omit; -export type State = { - labeled: Animated.Value; - error: Animated.Value; - focused: boolean; - displayPlaceholder: boolean; - value?: string; - labelTextLayout: { width: number }; - labelLayout: { measured: boolean; width: number; height: number }; - leftLayout: { height: number | null; width: number | null }; - rightLayout: { height: number | null; width: number | null }; - inputContainerLayout: { width: number }; - contentStyle?: StyleProp; -}; -export type ChildTextInputProps = { - parentState: State; - innerRef: (ref?: NativeTextInput | null) => void; - onFocus?: (args: any) => void; - onBlur?: (args: any) => void; - forceFocus: () => void; - onChangeText?: (value: string) => void; - onInputLayout: (event: LayoutChangeEvent) => void; - onLayoutAnimatedText: (args: any) => void; - onLabelTextLayout: (event: NativeSyntheticEvent) => void; - onLeftAffixLayoutChange: (event: LayoutChangeEvent) => void; - onRightAffixLayoutChange: (event: LayoutChangeEvent) => void; -} & $Omit & { theme: InternalTheme }; - -export type LabelProps = { - mode?: 'flat' | 'outlined'; - placeholderStyle: any; - placeholderOpacity: - | number - | Animated.Value - | Animated.AnimatedInterpolation; - baseLabelTranslateX: number; - baseLabelTranslateY: number; - wiggleOffsetX: number; - labelScale: number; - fontSize: number; - lineHeight?: number | undefined; - fontWeight: TextStyle['fontWeight']; - font: any; - topPosition: number; - paddingLeft?: number; - paddingRight?: number; - labelTranslationXOffset?: number; - placeholderColor: string | null; - disabledOpacity?: number; - backgroundColor?: ColorValue; - label?: TextInputLabelProp | null; - hasActiveOutline?: boolean | null; - activeColor: string; - errorColor?: string; - labelError?: boolean | null; - onLayoutAnimatedText: (args: any) => void; - onLabelTextLayout: (event: NativeSyntheticEvent) => void; - roundness: number; - maxFontSizeMultiplier?: number | undefined | null; - testID?: string; - contentStyle?: StyleProp; - theme?: ThemeProp; -}; -export type InputLabelProps = { - labeled: Animated.Value; - error: Animated.Value; - focused: boolean; - wiggle: boolean; - opacity: number; - labelLayoutMeasured: boolean; - labelLayoutWidth: number; - labelLayoutHeight: number; - inputContainerLayout: { width: number }; - labelBackground?: any; - maxFontSizeMultiplier?: number | undefined | null; - scaledLabel?: boolean; -} & LabelProps; - -export type LabelBackgroundProps = { - labelStyle: any; - labeled: Animated.Value; - labelLayoutWidth: number; - labelLayoutHeight: number; - maxFontSizeMultiplier?: number | undefined | null; - theme?: ThemeProp; -} & LabelProps; diff --git a/src/components/TextField/utils.ts b/src/components/TextInput/utils.ts similarity index 94% rename from src/components/TextField/utils.ts rename to src/components/TextInput/utils.ts index 86b0ac8816..fe979c38b8 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextInput/utils.ts @@ -20,28 +20,28 @@ import { OUTLINED_MULTILINE_PADDING_TOP, PREFIX_END_PADDING, SUFFIX_START_PADDING, - TEXT_FIELD_BORDER_RADIUS, + TEXT_INPUT_BORDER_RADIUS, FILLED_PADDING_BOTTOM, } from './constants'; import { filledStyles, outlinedStyles, styles } from './styles'; import type { - FilledTextFieldHookData, - OutlinedTextFieldHookData, - TextFieldProps, - TextFieldSharedApi, - TextFieldVariant, - SharedTextFieldStyleData, + FilledTextInputHookData, + OutlinedTextInputHookData, + TextInputProps, + TextInputSharedApi, + TextInputVariant, + SharedTextInputStyleData, GetAccessibilityDataProps, GetAccessibilityDataReturn, -} from './TextField'; +} from './TextInput'; import type { InternalTheme } from '../../types'; -export const getTextFieldAnimationLayout = ({ +export const getTextInputAnimationLayout = ({ variant, hasAccessory, isRTL, }: { - variant: TextFieldVariant; + variant: TextInputVariant; hasAccessory: boolean; isRTL: boolean; }) => { @@ -208,9 +208,9 @@ export const getOutlineColor = ({ * Returns `isRTL` as well so callers can use it when building `inputStyles`, * which is variant-specific (filled adds `MULTILINE_PADDING_TOP`). */ -export const getSharedTextFieldStyleData = ( - api: TextFieldSharedApi -): SharedTextFieldStyleData => { +export const getSharedTextInputStyleData = ( + api: TextInputSharedApi +): SharedTextInputStyleData => { const { theme, isDisabled, @@ -300,10 +300,10 @@ export const getSharedTextFieldStyleData = ( }; }; -export const getFilledTextFieldData = ( - api: TextFieldSharedApi, - props: TextFieldProps -): FilledTextFieldHookData => { +export const getFilledTextInputData = ( + api: TextInputSharedApi, + props: TextInputProps +): FilledTextInputHookData => { const { style: inputStyleOverride, ...textInputProps } = props; const { @@ -344,7 +344,7 @@ export const getFilledTextFieldData = ( * Shared styles */ - const shared = getSharedTextFieldStyleData(api); + const shared = getSharedTextInputStyleData(api); /** * Variant-specific styles @@ -372,8 +372,8 @@ export const getFilledTextFieldData = ( { paddingBottom: FILLED_PADDING_BOTTOM, backgroundColor: fieldBackgroundColor, - borderTopStartRadius: TEXT_FIELD_BORDER_RADIUS, - borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, + borderTopStartRadius: TEXT_INPUT_BORDER_RADIUS, + borderTopEndRadius: TEXT_INPUT_BORDER_RADIUS, overflow: 'hidden', }, ]; @@ -448,10 +448,10 @@ export const getFilledTextFieldData = ( }; }; -export const getOutlinedTextFieldData = ( - api: TextFieldSharedApi, - props: TextFieldProps -): OutlinedTextFieldHookData => { +export const getOutlinedTextInputData = ( + api: TextInputSharedApi, + props: TextInputProps +): OutlinedTextInputHookData => { const { style: inputStyleOverride, ...textInputProps } = props; const { @@ -484,7 +484,7 @@ export const getOutlinedTextFieldData = ( * Shared styles */ - const shared = getSharedTextFieldStyleData(api); + const shared = getSharedTextInputStyleData(api); /** * Variant-specific styles @@ -498,7 +498,7 @@ export const getOutlinedTextFieldData = ( const fieldStyles: StyleProp = [ styles.field, { - borderRadius: TEXT_FIELD_BORDER_RADIUS, + borderRadius: TEXT_INPUT_BORDER_RADIUS, }, textInputProps.multiline && { alignItems: 'flex-start' }, ]; diff --git a/src/components/__tests__/HelperText.test.tsx b/src/components/__tests__/HelperText.test.tsx deleted file mode 100644 index 22ffc025b9..0000000000 --- a/src/components/__tests__/HelperText.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { getTheme } from '../../core/theming'; -import { render } from '../../test-utils'; -import HelperText from '../HelperText/HelperText'; - -describe('HelperText', () => { - it('should have correct text color for info type', () => { - const { getByTestId } = render( - - Info: Maximum length is 100 characters - - ); - - expect(getByTestId('helper-text')).toHaveStyle({ - color: getTheme().colors.onSurfaceVariant, - }); - }); - - it('should have correct text color for error type', () => { - const { getByTestId } = render( - - Error: Only letters are allowed - - ); - - expect(getByTestId('helper-text')).toHaveStyle({ - color: getTheme().colors.error, - }); - }); -}); diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx deleted file mode 100644 index fd4e7997fe..0000000000 --- a/src/components/__tests__/TextField.test.tsx +++ /dev/null @@ -1,1269 +0,0 @@ -import * as React from 'react'; -import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; - -import { act, fireEvent, render } from '../../test-utils'; -import { tokens } from '../../theme/tokens'; -import TextField from '../TextField'; -import type { - TextFieldRenderProps, - TextFieldHandles, -} from '../TextField/TextField'; -import type { TextFieldAccessoryProps } from '../TextField/TextFieldIcon'; - -const { stateOpacity } = tokens.md.ref; - -const defaultI18nIsRTL = I18nManager.isRTL; - -const getConstantsOriginal = I18nManager.getConstants.bind(I18nManager); - -beforeAll(() => { - jest.spyOn(I18nManager, 'getConstants').mockImplementation(() => ({ - ...getConstantsOriginal(), - isRTL: I18nManager.isRTL, - })); -}); - -afterAll(() => { - jest.restoreAllMocks(); -}); - -afterEach(() => { - I18nManager.isRTL = defaultI18nIsRTL; -}); - -function firstIndexOfTestIdInTree(tree: unknown, testID: string): number { - const serialized = JSON.stringify(tree); - const match = new RegExp(`"testID":\\s*"${testID}"`).exec(serialized); - return match ? match.index : -1; -} - -/** Locates a Text node whose children are serialized as a one-element JSON string array. */ -function firstIndexOfTextChildArrayInTree(tree: unknown, text: string): number { - return JSON.stringify(tree).indexOf(JSON.stringify([text])); -} - -it('renders filled TextField with label and value', () => { - const tree = render( - {}} /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders outlined TextField with label and value', () => { - const tree = render( - {}} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders filled TextField with TextField.Icon accessories', () => { - const tree = render( - {}} - startAccessory={(props: TextFieldAccessoryProps) => ( - - )} - endAccessory={(props: TextFieldAccessoryProps) => ( - - )} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders outlined TextField with TextField.Icon accessories', () => { - const tree = render( - {}} - startAccessory={(props: TextFieldAccessoryProps) => ( - - )} - endAccessory={(props: TextFieldAccessoryProps) => ( - - )} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders filled TextField with TextField.Icon accessories when error is true', () => { - const tree = render( - {}} - error - startAccessory={(props: TextFieldAccessoryProps) => ( - - )} - endAccessory={(props: TextFieldAccessoryProps) => ( - {}} /> - )} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders outlined TextField with TextField.Icon accessories when error is true', () => { - const tree = render( - {}} - error - startAccessory={(props: TextFieldAccessoryProps) => ( - - )} - endAccessory={(props: TextFieldAccessoryProps) => ( - {}} /> - )} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('fires onPress on TextField.Icon end accessory', () => { - const onClear = jest.fn(); - const { getAllByTestId } = render( - {}} - startAccessory={(props: TextFieldAccessoryProps) => ( - - )} - endAccessory={(props: TextFieldAccessoryProps) => ( - - )} - /> - ); - - fireEvent.press(getAllByTestId('icon-button')[1]); - - expect(onClear).toHaveBeenCalledTimes(1); -}); - -it('disables TextField.Icon when the field is disabled', () => { - const { getAllByTestId } = render( - {}} - disabled - startAccessory={(props: TextFieldAccessoryProps) => ( - {}} /> - )} - endAccessory={(props: TextFieldAccessoryProps) => ( - {}} /> - )} - /> - ); - - const buttons = getAllByTestId('icon-button'); - expect(buttons[0].props.accessibilityState?.disabled).toBe(true); - expect(buttons[1].props.accessibilityState?.disabled).toBe(true); -}); - -it('does not disable TextField.Icon when the field is read-only (editable false)', () => { - const { getAllByTestId } = render( - {}} - editable={false} - startAccessory={(props: TextFieldAccessoryProps) => ( - {}} /> - )} - endAccessory={(props: TextFieldAccessoryProps) => ( - {}} /> - )} - /> - ); - - const buttons = getAllByTestId('icon-button'); - expect(buttons[0].props.accessibilityState?.disabled).not.toBe(true); - expect(buttons[1].props.accessibilityState?.disabled).not.toBe(true); -}); - -it('renders supporting text below the field', () => { - const { getByText } = render( - {}} - supportingText="Use a valid address" - /> - ); - - expect(getByText('Use a valid address')).toBeTruthy(); -}); - -it('uses polite aria-live on error supporting text', () => { - const { getByText, getByTestId } = render( - {}} - supportingText="Invalid" - error - testID="tf-input" - /> - ); - - expect(getByText('Invalid').props['aria-live']).toBe('polite'); - expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); -}); - -it('marks the input invalid when error is true without supporting text', () => { - const { getByTestId } = render( - {}} - error - testID="tf-input" - /> - ); - - expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); - expect(getByTestId('tf-input').props.accessibilityHint).toBeUndefined(); -}); - -it('hides helper supporting text from the accessibility tree and omits aria-live', () => { - const { getByText, getByTestId } = render( - {}} - supportingText="Optional" - testID="tf-input" - /> - ); - - expect(getByText('Optional').props['aria-hidden']).toBe(true); - expect(getByText('Optional').props['aria-live']).toBeUndefined(); - expect(getByTestId('tf-input').props['aria-label']).toBe('Email, Optional'); -}); - -it('includes supporting text in aria-label when label is omitted', () => { - const { getByTestId } = render( - {}} - supportingText="Helper only" - testID="tf-input" - /> - ); - - expect(getByTestId('tf-input').props['aria-label']).toBe('Helper only'); -}); - -it('does not mark the input as aria-disabled when editable is false (read-only)', () => { - const { getByTestId } = render( - {}} - editable={false} - testID="tf-input" - /> - ); - - expect(getByTestId('tf-input').props.accessibilityState?.disabled).not.toBe( - true - ); -}); - -it('marks the input as disabled in accessibilityState when disabled is true', () => { - const { getByTestId } = render( - {}} - disabled - testID="tf-input" - /> - ); - - expect(getByTestId('tf-input').props.accessibilityState?.disabled).toBe(true); -}); - -it('renders the input via render with merged props', () => { - const renderInput = jest.fn((props: TextFieldRenderProps) => ( - - )); - - const { getByTestId } = render( - {}} - render={renderInput} - /> - ); - - expect(getByTestId('custom-input')).toBeTruthy(); - expect(renderInput).toHaveBeenCalled(); - const merged = renderInput.mock.calls[0]?.[0] as TextFieldRenderProps; - expect(merged['aria-label']).toBe('Pin'); - expect(merged.value).toBe('12'); -}); - -it('does not apply disabled opacity to the TextInput when editable is false (filled)', () => { - const { getByTestId } = render( - {}} - editable={false} - testID="tf-input-ro" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-input-ro').props.style) - ).not.toMatchObject({ opacity: stateOpacity.disabled }); -}); - -it('does not apply disabled opacity to the TextInput when editable is false (outlined)', () => { - const { getByTestId } = render( - {}} - editable={false} - testID="tf-input-ro-out" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-input-ro-out').props.style) - ).not.toMatchObject({ opacity: stateOpacity.disabled }); -}); - -it('applies disabled opacity to the TextInput when disabled is true (filled)', () => { - const { getByTestId } = render( - {}} - disabled - testID="tf-input-dis" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-input-dis').props.style) - ).toMatchObject({ opacity: stateOpacity.disabled }); -}); - -it('applies disabled opacity to the TextInput when disabled is true (outlined)', () => { - const { getByTestId } = render( - {}} - disabled - testID="tf-input-dis-out" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-input-dis-out').props.style) - ).toMatchObject({ opacity: stateOpacity.disabled }); -}); - -it('forwards TextInput props such as testID', () => { - const { getByTestId } = render( - {}} - testID="email-input" - /> - ); - - expect(getByTestId('email-input')).toBeTruthy(); -}); - -/* TextField peels these before spreading onto TextInput (see TextField.tsx). - * Custom layout / sub-component styling props are intentionally not supported. */ -it('does not pass TextField-only props through to TextInput', () => { - const { getByTestId } = render( - {}} - error - disabled - testID="tf-native" - /> - ); - - const input = getByTestId('tf-native'); - expect(input.props.variant).toBeUndefined(); - expect(input.props.theme).toBeUndefined(); - expect(input.props.startAccessory).toBeUndefined(); - expect(input.props.endAccessory).toBeUndefined(); - expect(input.props.label).toBeUndefined(); - expect(input.props.supportingText).toBeUndefined(); - expect(input.props.prefix).toBeUndefined(); - expect(input.props.suffix).toBeUndefined(); - expect(input.props.counter).toBeUndefined(); - expect(input.props.error).toBeUndefined(); - expect(input.props.disabled).toBeUndefined(); -}); - -it('shows a character counter when counter is true and maxLength is set (filled)', () => { - const { getByText, queryByText } = render( - {}} - counter - maxLength={100} - /> - ); - - expect(getByText('5/100')).toBeTruthy(); - expect(queryByText('0/100')).toBeNull(); -}); - -it('shows a character counter when counter is true and maxLength is set (outlined)', () => { - const { getByText } = render( - {}} - counter - maxLength={50} - /> - ); - - expect(getByText('0/50')).toBeTruthy(); -}); - -it('updates the character counter when the value changes', () => { - const { getByText, rerender } = render( - {}} - counter - maxLength={10} - /> - ); - - expect(getByText('1/10')).toBeTruthy(); - - rerender( - {}} - counter - maxLength={10} - /> - ); - - expect(getByText('4/10')).toBeTruthy(); -}); - -it('does not show a character counter when counter is false', () => { - const { queryByText } = render( - {}} - maxLength={100} - /> - ); - - expect(queryByText('5/100')).toBeNull(); -}); - -it('does not show a character counter when maxLength is missing', () => { - const { queryByText } = render( - {}} counter /> - ); - - expect(queryByText('5/100')).toBeNull(); - expect(queryByText(/\//)).toBeNull(); -}); - -it('invokes onFocus and onBlur on the TextInput', () => { - const onFocus = jest.fn(); - const onBlur = jest.fn(); - const { getByTestId } = render( - {}} - onFocus={onFocus} - onBlur={onBlur} - testID="tf-input" - /> - ); - - const input = getByTestId('tf-input'); - fireEvent(input, 'focus'); - fireEvent(input, 'blur'); - - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onBlur).toHaveBeenCalledTimes(1); -}); - -it('focuses the TextInput when the outer Pressable is pressed', () => { - const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); - - const { UNSAFE_getByProps, getByTestId } = render( - {}} - testID="tf-input" - /> - ); - - expect(getByTestId('tf-input')).toBeTruthy(); - - /* Pressable is not exposed as a distinct type in the test renderer; match its props. */ - const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); - fireEvent.press(pressable); - - expect(focusSpy).toHaveBeenCalled(); - focusSpy.mockRestore(); -}); - -it('does not focus the TextInput when disabled and the Pressable is pressed', () => { - const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); - - const { UNSAFE_getByProps } = render( - {}} disabled /> - ); - - const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); - fireEvent.press(pressable); - - expect(focusSpy).not.toHaveBeenCalled(); - focusSpy.mockRestore(); -}); - -it('focuses the TextInput when read-only and the Pressable is pressed', () => { - const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); - - const { UNSAFE_getByProps } = render( - {}} - editable={false} - /> - ); - - const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); - fireEvent.press(pressable); - - expect(focusSpy).toHaveBeenCalled(); - focusSpy.mockRestore(); -}); - -it('exposes the TextInput instance via ref prop', () => { - const ref = React.createRef(); - - render( - {}} - testID="tf-input" - /> - ); - - expect(ref.current).toBeTruthy(); - expect(typeof ref.current?.focus).toBe('function'); - expect(typeof ref.current?.clear).toBe('function'); - expect(typeof ref.current?.blur).toBe('function'); - expect(typeof ref.current?.isFocused).toBe('function'); - expect(typeof ref.current?.setNativeProps).toBe('function'); - expect(typeof ref.current?.setSelection).toBe('function'); -}); - -it('passes error, disabled, and multiline to accessories', () => { - const startAccessoryProps: TextFieldAccessoryProps[] = []; - const endAccessoryProps: TextFieldAccessoryProps[] = []; - - function StartAccessory(props: TextFieldAccessoryProps) { - startAccessoryProps.push(props); - return ; - } - - function EndAccessory(props: TextFieldAccessoryProps) { - endAccessoryProps.push(props); - return ; - } - - const { getByTestId } = render( - {}} - multiline - error - disabled - startAccessory={StartAccessory} - endAccessory={EndAccessory} - /> - ); - - expect(getByTestId('start-accessory')).toBeTruthy(); - expect(getByTestId('end-accessory')).toBeTruthy(); - expect(startAccessoryProps[0]).toMatchObject({ - error: true, - disabled: true, - multiline: true, - }); - expect(endAccessoryProps[0]).toMatchObject({ - error: true, - disabled: true, - multiline: true, - }); -}); - -it('passes error to accessories when the field is disabled', () => { - const startAccessoryProps: TextFieldAccessoryProps[] = []; - - function StartAccessory(props: TextFieldAccessoryProps) { - startAccessoryProps.push(props); - return ; - } - - const { getByTestId } = render( - {}} - error - disabled - startAccessory={StartAccessory} - /> - ); - - expect(getByTestId('start-acc-error-disabled')).toBeTruthy(); - expect(startAccessoryProps[0].error).toBe(true); - expect(startAccessoryProps[0].disabled).toBe(true); -}); - -it('renders supporting text as a Text child', () => { - const { getByText } = render( - {}} - supportingText="Hint" - /> - ); - - expect(getByText('Hint')).toBeTruthy(); -}); - -it('renders the counter as a Text child', () => { - const { getByText } = render( - {}} - counter - maxLength={80} - /> - ); - - expect(getByText('2/80')).toBeTruthy(); -}); - -it('renders supporting text and counter separately when both are shown', () => { - const { getByText } = render( - {}} - supportingText="Help text" - counter - maxLength={10} - /> - ); - - expect(getByText('Help text')).toBeTruthy(); - expect(getByText('1/10')).toBeTruthy(); -}); - -it('applies RTL text alignment and writing direction to the TextInput (filled)', () => { - I18nManager.isRTL = true; - - const { getByTestId } = render( - {}} - testID="tf-input-rtl" - /> - ); - - expect(StyleSheet.flatten(getByTestId('tf-input-rtl').props.style)).toEqual( - expect.objectContaining({ - textAlign: 'right', - writingDirection: 'rtl', - }) - ); -}); - -it('applies RTL text alignment and writing direction to the TextInput (outlined)', () => { - I18nManager.isRTL = true; - - const { getByTestId } = render( - {}} - testID="tf-input-rtl-outlined" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-input-rtl-outlined').props.style) - ).toEqual( - expect.objectContaining({ - textAlign: 'right', - writingDirection: 'rtl', - }) - ); -}); - -it('applies RTL writing direction to supporting text', () => { - I18nManager.isRTL = true; - - const { getByText } = render( - {}} - supportingText="Hint" - /> - ); - - expect(StyleSheet.flatten(getByText('Hint').props.style)).toEqual( - expect.objectContaining({ - writingDirection: 'rtl', - }) - ); -}); - -it('places EndAccessory before StartAccessory in the tree when RTL', () => { - I18nManager.isRTL = true; - - function StartAccessory() { - return ; - } - - function EndAccessory() { - return ; - } - - const { toJSON } = render( - {}} - startAccessory={StartAccessory} - endAccessory={EndAccessory} - testID="tf-input-rtl-order" - /> - ); - - const tree = toJSON(); - expect(firstIndexOfTestIdInTree(tree, 'rtl-acc-from-end-prop')).toBeLessThan( - firstIndexOfTestIdInTree(tree, 'rtl-acc-from-start-prop') - ); -}); - -it('places StartAccessory before EndAccessory in the tree when LTR', () => { - I18nManager.isRTL = false; - - function StartAccessory() { - return ; - } - - function EndAccessory() { - return ; - } - - const { toJSON } = render( - {}} - startAccessory={StartAccessory} - endAccessory={EndAccessory} - testID="tf-input-ltr-order" - /> - ); - - const tree = toJSON(); - expect( - firstIndexOfTestIdInTree(tree, 'ltr-acc-from-start-prop') - ).toBeLessThan(firstIndexOfTestIdInTree(tree, 'ltr-acc-from-end-prop')); -}); - -it('does not expose the placeholder string when the TextField is not focused', () => { - const { getByTestId } = render( - {}} - placeholder="e.g. user@example.com" - testID="tf-input" - /> - ); - - /* Sentinel space avoids iOS multiline UITextView not updating placeholder from nil (react-native#31573). */ - expect(getByTestId('tf-input').props.placeholder).toBe(' '); -}); - -it('shows placeholder when the TextField is focused', () => { - const { getByTestId } = render( - {}} - placeholder="e.g. user@example.com" - testID="tf-input" - /> - ); - - fireEvent(getByTestId('tf-input'), 'focus'); - - expect(getByTestId('tf-input').props.placeholder).toBe( - 'e.g. user@example.com' - ); -}); - -it('shows placeholder on multiline TextField when focused', () => { - const { getByTestId } = render( - {}} - placeholder="Add a note…" - multiline - testID="tf-multiline" - /> - ); - - expect(getByTestId('tf-multiline').props.placeholder).toBe(' '); - - fireEvent(getByTestId('tf-multiline'), 'focus'); - - expect(getByTestId('tf-multiline').props.placeholder).toBe('Add a note…'); -}); - -it('does not expose the placeholder string again after the TextField loses focus', () => { - const { getByTestId } = render( - {}} - placeholder="e.g. user@example.com" - testID="tf-input" - /> - ); - - fireEvent(getByTestId('tf-input'), 'focus'); - fireEvent(getByTestId('tf-input'), 'blur'); - - expect(getByTestId('tf-input').props.placeholder).toBe(' '); -}); - -it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order)', () => { - function LoneStartAccessory() { - return ; - } - - I18nManager.isRTL = false; - - const { toJSON: toJsonLtr } = render( - {}} - startAccessory={LoneStartAccessory} - testID="tf-lone-ltr" - /> - ); - - I18nManager.isRTL = true; - - const { toJSON: toJsonRtl } = render( - {}} - startAccessory={LoneStartAccessory} - testID="tf-lone-rtl" - /> - ); - - const ltrTree = toJsonLtr(); - expect(firstIndexOfTestIdInTree(ltrTree, 'lone-start-acc')).toBeLessThan( - firstIndexOfTestIdInTree(ltrTree, 'tf-lone-ltr') - ); - - const rtlTree = toJsonRtl(); - expect(firstIndexOfTestIdInTree(rtlTree, 'tf-lone-rtl')).toBeLessThan( - firstIndexOfTestIdInTree(rtlTree, 'lone-start-acc') - ); -}); - -it('shows prefix and suffix when the field is floating and hides them after value is cleared while blurred', () => { - const { getByTestId, getByText, queryByText, rerender } = render( - {}} - prefix="$" - suffix="/100" - testID="tf-ps" - /> - ); - - expect(getByText('$')).toBeTruthy(); - expect(getByText('/100')).toBeTruthy(); - - rerender( - {}} - prefix="$" - suffix="/100" - testID="tf-ps" - /> - ); - - expect(queryByText('$')).toBeNull(); - expect(queryByText('/100')).toBeNull(); - expect(getByTestId('tf-ps')).toBeTruthy(); -}); - -it('renders prefix and suffix while focused even when value is empty', () => { - const { getByTestId, getByText, queryByText } = render( - {}} - prefix="$" - suffix=" kg" - testID="tf-ps-focus" - /> - ); - - expect(queryByText('$')).toBeNull(); - expect(queryByText(' kg')).toBeNull(); - - fireEvent(getByTestId('tf-ps-focus'), 'focus'); - - expect(getByText('$')).toBeTruthy(); - expect(getByText(' kg')).toBeTruthy(); -}); - -it('places prefix Text before the TextInput and suffix Text after it', () => { - const { toJSON } = render( - {}} - prefix="$" - suffix="/100" - testID="tf-order" - /> - ); - - const tree = toJSON(); - expect(firstIndexOfTextChildArrayInTree(tree, '$')).toBeLessThan( - firstIndexOfTestIdInTree(tree, 'tf-order') - ); - expect(firstIndexOfTestIdInTree(tree, 'tf-order')).toBeLessThan( - firstIndexOfTextChildArrayInTree(tree, '/100') - ); -}); - -it('aligns input text toward the suffix when suffix is active (LTR)', () => { - const { getByTestId } = render( - {}} - suffix="/100" - testID="tf-suffix-align-ltr" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-suffix-align-ltr').props.style) - ).toEqual( - expect.objectContaining({ - textAlign: 'right', - writingDirection: 'ltr', - }) - ); -}); - -it('aligns input text toward the suffix when suffix is active (RTL)', () => { - I18nManager.isRTL = true; - - const { getByTestId } = render( - {}} - suffix="/100" - testID="tf-suffix-align-rtl" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-suffix-align-rtl').props.style) - ).toEqual( - expect.objectContaining({ - textAlign: 'left', - writingDirection: 'rtl', - }) - ); -}); - -it('uses default horizontal alignment when suffix prop exists but suffix is not shown yet (LTR)', () => { - const { getByTestId } = render( - {}} - suffix="/100" - testID="tf-no-suffix-yet" - /> - ); - - expect( - StyleSheet.flatten(getByTestId('tf-no-suffix-yet').props.style) - ).toEqual( - expect.objectContaining({ - textAlign: 'left', - writingDirection: 'ltr', - }) - ); -}); - -it('does not apply the TextInput style prop to prefix or suffix Text', () => { - const { getByTestId, getByText } = render( - {}} - prefix="$" - suffix="]" - style={{ fontSize: 40, letterSpacing: 9 }} - testID="tf-input-style" - /> - ); - - const inputFlat = StyleSheet.flatten( - getByTestId('tf-input-style').props.style - ); - expect(inputFlat).toEqual( - expect.objectContaining({ fontSize: 40, letterSpacing: 9 }) - ); - - const prefixFlat = StyleSheet.flatten(getByText('$').props.style); - const suffixFlat = StyleSheet.flatten(getByText(']').props.style); - - expect(prefixFlat.fontSize).not.toBe(40); - expect(prefixFlat.letterSpacing).toBeUndefined(); - expect(suffixFlat.fontSize).not.toBe(40); - expect(suffixFlat.letterSpacing).toBeUndefined(); -}); - -it('passes defaultValue to the native input when uncontrolled without counter', () => { - const { getByTestId } = render( - {}} - testID="tf-uncontrolled" - /> - ); - - const input = getByTestId('tf-uncontrolled'); - expect(input.props.defaultValue).toBe('hello'); - expect(input.props.value).toBeUndefined(); -}); - -it('updates the character counter for an uncontrolled field with counter enabled', () => { - const onChangeText = jest.fn(); - const { getByTestId, getByText } = render( - - ); - - expect(getByText('1/10')).toBeTruthy(); - - fireEvent.changeText(getByTestId('tf-uncontrolled-counter'), 'abcd'); - - expect(onChangeText).toHaveBeenCalledWith('abcd'); - expect(getByText('4/10')).toBeTruthy(); -}); - -it('resets counter and hides prefix/suffix when clear() is called on uncontrolled field while blurred', () => { - const ref = React.createRef(); - const { getByText, queryByText } = render( - - ); - - expect(getByText('3/200')).toBeTruthy(); - expect(getByText('$')).toBeTruthy(); - expect(getByText('/100')).toBeTruthy(); - - act(() => { - ref.current?.clear(); - }); - - expect(getByText('0/200')).toBeTruthy(); - expect(queryByText('$')).toBeNull(); - expect(queryByText('/100')).toBeNull(); -}); - -it('resets counter but keeps prefix/suffix visible when clear() is called on uncontrolled field while focused', () => { - const ref = React.createRef(); - const { getByTestId, getByText } = render( - - ); - - expect(getByText('2/100')).toBeTruthy(); - expect(getByText('$')).toBeTruthy(); - expect(getByText(' kg')).toBeTruthy(); - - fireEvent(getByTestId('tf-clear-focused'), 'focus'); - - act(() => { - ref.current?.clear(); - }); - - expect(getByText('0/100')).toBeTruthy(); - expect(getByText('$')).toBeTruthy(); - expect(getByText(' kg')).toBeTruthy(); -}); - -it('notifies the parent via onChangeText when clear() is called on a controlled field', () => { - const ref = React.createRef(); - const onChangeText = jest.fn(); - const { getByTestId } = render( - - ); - - const input = getByTestId('tf-controlled'); - expect(input.props.value).toBe('test@example.com'); - - act(() => { - ref.current?.clear(); - }); - - expect(onChangeText).toHaveBeenCalledWith(''); - expect(onChangeText).toHaveBeenCalledTimes(1); -}); - -it('hides prefix/suffix when blurring after clear() was called while focused', () => { - const ref = React.createRef(); - const { getByTestId, getByText, queryByText } = render( - - ); - - expect(getByText('$')).toBeTruthy(); - expect(getByText('/100')).toBeTruthy(); - - fireEvent(getByTestId('tf-clear-then-blur'), 'focus'); - - act(() => { - ref.current?.clear(); - }); - - // While focused, prefix/suffix stay visible - expect(getByText('$')).toBeTruthy(); - expect(getByText('/100')).toBeTruthy(); - - fireEvent(getByTestId('tf-clear-then-blur'), 'blur'); - - // After blur with no text, prefix/suffix should be hidden - expect(queryByText('$')).toBeNull(); - expect(queryByText('/100')).toBeNull(); -}); diff --git a/src/components/__tests__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx index 3b4c860e1f..88982ede1c 100644 --- a/src/components/__tests__/TextInput.test.tsx +++ b/src/components/__tests__/TextInput.test.tsx @@ -1,989 +1,1274 @@ -/* eslint-disable react-native/no-inline-styles */ import * as React from 'react'; -import { Platform, StyleSheet, Text, View } from 'react-native'; - -import { fireEvent } from '@testing-library/react-native'; +import { + I18nManager, + StyleSheet, + TextInput as NativeTextInput, + View, +} from 'react-native'; -import PaperProvider from '../../core/PaperProvider'; -import { DefaultTheme, getTheme, ThemeProvider } from '../../core/theming'; -import { render } from '../../test-utils'; -import { red500 } from '../../theme/colors'; +import { act, fireEvent, render } from '../../test-utils'; import { tokens } from '../../theme/tokens'; -import { - getFlatInputColors, - getOutlinedInputColors, -} from '../TextInput/helpers'; -import TextInput, { Props } from '../TextInput/TextInput'; +import TextInput from '../TextInput'; +import type { + TextInputRenderProps, + TextInputHandles, +} from '../TextInput/TextInput'; +import type { TextInputAccessoryProps } from '../TextInput/TextInputIcon'; const { stateOpacity } = tokens.md.ref; -const style = StyleSheet.create({ - inputStyle: { - color: red500, - }, - centered: { - textAlign: 'center', - }, - height: { - height: 100, - }, - lineHeight: { - lineHeight: 22, - }, - contentStyle: { - paddingLeft: 20, - }, -}); - -// Revert changes to Platform.OS automatically -const defaultPlatform = Platform.OS; -beforeEach(() => { - Platform.OS = defaultPlatform; -}); - -const affixTextValue = '/100'; -it('correctly renders left-side icon adornment, and right-side affix adornment', () => { - const { getByText, getByTestId, toJSON } = render( - { - console.log('!@# press left'); - }} - /> - } - right={ - - } +const defaultI18nIsRTL = I18nManager.isRTL; + +const getConstantsOriginal = I18nManager.getConstants.bind(I18nManager); + +beforeAll(() => { + jest.spyOn(I18nManager, 'getConstants').mockImplementation(() => ({ + ...getConstantsOriginal(), + isRTL: I18nManager.isRTL, + })); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +afterEach(() => { + I18nManager.isRTL = defaultI18nIsRTL; +}); + +function firstIndexOfTestIdInTree(tree: unknown, testID: string): number { + const serialized = JSON.stringify(tree); + const match = new RegExp(`"testID":\\s*"${testID}"`).exec(serialized); + return match ? match.index : -1; +} + +/** Locates a Text node whose children are serialized as a one-element JSON string array. */ +function firstIndexOfTextChildArrayInTree(tree: unknown, text: string): number { + return JSON.stringify(tree).indexOf(JSON.stringify([text])); +} + +it('renders filled TextInput with label and value', () => { + const tree = render( + {}} /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextInput with label and value', () => { + const tree = render( + {}} /> - ); - expect(getByText(affixTextValue)).toBeTruthy(); - expect(getByTestId('left-icon-adornment')).toBeTruthy(); - expect(getByTestId('right-affix-adornment')).toBeTruthy(); - expect(toJSON()).toMatchSnapshot(); + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders filled TextInput with TextInput.Icon accessories', () => { + const tree = render( + {}} + startAccessory={(props: TextInputAccessoryProps) => ( + + )} + endAccessory={(props: TextInputAccessoryProps) => ( + + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextInput with TextInput.Icon accessories', () => { + const tree = render( + {}} + startAccessory={(props: TextInputAccessoryProps) => ( + + )} + endAccessory={(props: TextInputAccessoryProps) => ( + + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); }); -it('correctly renders left-side affix adornment, and right-side icon adornment', () => { - const { getByText, getByTestId, toJSON } = render( +it('renders filled TextInput with TextInput.Icon accessories when error is true', () => { + const tree = render( - } - right={ + label="Search" + value="q" + onChangeText={() => {}} + error + startAccessory={(props: TextInputAccessoryProps) => ( + + )} + endAccessory={(props: TextInputAccessoryProps) => ( + {}} /> + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextInput with TextInput.Icon accessories when error is true', () => { + const tree = render( + {}} + error + startAccessory={(props: TextInputAccessoryProps) => ( + + )} + endAccessory={(props: TextInputAccessoryProps) => ( + {}} /> + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('fires onPress on TextInput.Icon end accessory', () => { + const onClear = jest.fn(); + const { getAllByTestId } = render( + {}} + startAccessory={(props: TextInputAccessoryProps) => ( + + )} + endAccessory={(props: TextInputAccessoryProps) => ( { - console.log('!@# press left'); - }} + {...props} + icon="close" + onPress={onClear} + accessibilityLabel="Clear" /> - } + )} /> ); - expect(getByText(affixTextValue)).toBeTruthy(); - expect(getByTestId('right-icon-adornment')).toBeTruthy(); - expect(getByTestId('left-affix-adornment')).toBeTruthy(); - expect(toJSON()).toMatchSnapshot(); + + fireEvent.press(getAllByTestId('icon-button')[1]); + + expect(onClear).toHaveBeenCalledTimes(1); }); -it('correctly applies default textAlign based on default RTL', () => { - const { toJSON } = render( +it('disables TextInput.Icon when the field is disabled', () => { + const { getAllByTestId } = render( {}} + disabled + startAccessory={(props: TextInputAccessoryProps) => ( + {}} /> + )} + endAccessory={(props: TextInputAccessoryProps) => ( + {}} /> + )} /> ); - expect(toJSON()).toMatchSnapshot(); + const buttons = getAllByTestId('icon-button'); + expect(buttons[0].props.accessibilityState?.disabled).toBe(true); + expect(buttons[1].props.accessibilityState?.disabled).toBe(true); }); -it('correctly applies textAlign center', () => { - const { toJSON } = render( +it('does not disable TextInput.Icon when the field is read-only (editable false)', () => { + const { getAllByTestId } = render( {}} + editable={false} + startAccessory={(props: TextInputAccessoryProps) => ( + {}} /> + )} + endAccessory={(props: TextInputAccessoryProps) => ( + {}} /> + )} /> ); - expect(toJSON()).toMatchSnapshot(); + const buttons = getAllByTestId('icon-button'); + expect(buttons[0].props.accessibilityState?.disabled).not.toBe(true); + expect(buttons[1].props.accessibilityState?.disabled).not.toBe(true); }); -it('correctly applies cursorColor prop', () => { - const { toJSON } = render( +it('renders supporting text below the field', () => { + const { getByText } = render( {}} + supportingText="Use a valid address" /> ); - expect(toJSON()).toMatchSnapshot(); + expect(getByText('Use a valid address')).toBeTruthy(); }); -it('correctly applies height to multiline Outline TextInput', () => { - const { toJSON } = render( +it('uses polite aria-live on error supporting text', () => { + const { getByText, getByTestId } = render( {}} + supportingText="Invalid" + error + testID="tf-input" /> ); - expect(toJSON()).toMatchSnapshot(); + expect(getByText('Invalid').props['aria-live']).toBe('polite'); + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); }); -it('correctly applies error state Outline TextInput', () => { +it('marks the input invalid when error is true without supporting text', () => { const { getByTestId } = render( {}} error + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); + expect(getByTestId('tf-input').props.accessibilityHint).toBeUndefined(); +}); + +it('hides helper supporting text from the accessibility tree and omits aria-live', () => { + const { getByText, getByTestId } = render( + {}} + supportingText="Optional" + testID="tf-input" /> ); - const outline = getByTestId('text-input-outline'); - expect(outline).toHaveStyle({ borderWidth: 2 }); + expect(getByText('Optional').props['aria-hidden']).toBe(true); + expect(getByText('Optional').props['aria-live']).toBeUndefined(); + expect(getByTestId('tf-input').props['aria-label']).toBe('Email, Optional'); }); -it('correctly applies focused state Outline TextInput', () => { +it('includes supporting text in aria-label when label is omitted', () => { const { getByTestId } = render( {}} + supportingText="Helper only" + testID="tf-input" /> ); - const outline = getByTestId('text-input-outline'); - expect(outline).toHaveStyle({ borderWidth: 2 }); + expect(getByTestId('tf-input').props['aria-label']).toBe('Helper only'); +}); - fireEvent(getByTestId('text-input-outlined'), 'focus'); +it('does not mark the input as aria-disabled when editable is false (read-only)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input" + /> + ); - expect(outline).toHaveStyle({ borderWidth: 2 }); + expect(getByTestId('tf-input').props.accessibilityState?.disabled).not.toBe( + true + ); }); -it('contains patch spacing for flat input when ios, multiline and disabled', () => { - Platform.OS = 'ios'; +it('marks the input as disabled in accessibilityState when disabled is true', () => { const { getByTestId } = render( {}} + disabled + testID="tf-input" /> ); - expect(getByTestId('patch-container')).toBeTruthy(); + + expect(getByTestId('tf-input').props.accessibilityState?.disabled).toBe(true); }); -it('correctly applies a component as the text label', () => { - const { toJSON } = render( +it('renders the input via render with merged props', () => { + const renderInput = jest.fn((props: TextInputRenderProps) => ( + + )); + + const { getByTestId } = render( Flat input} - placeholder="Type something" - value={'Some test value'} + label="Pin" + value="12" + onChangeText={() => {}} + render={renderInput} /> ); - expect(toJSON()).toMatchSnapshot(); + expect(getByTestId('custom-input')).toBeTruthy(); + expect(renderInput).toHaveBeenCalled(); + const merged = renderInput.mock.calls[0]?.[0] as TextInputRenderProps; + expect(merged['aria-label']).toBe('Pin'); + expect(merged.value).toBe('12'); }); -it('correctly applies paddingLeft from contentStyleProp', () => { - const { toJSON } = render( +it('does not apply disabled opacity to the TextInput when editable is false (filled)', () => { + const { getByTestId } = render( {}} + editable={false} + testID="tf-input-ro" /> ); - expect(toJSON()).toMatchSnapshot(); + expect( + StyleSheet.flatten(getByTestId('tf-input-ro').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); }); -it('renders label with correct color when active', () => { +it('does not apply disabled opacity to the TextInput when editable is false (outlined)', () => { const { getByTestId } = render( {}} + editable={false} + testID="tf-input-ro-out" /> ); - fireEvent(getByTestId('text-input-flat'), 'focus'); + expect( + StyleSheet.flatten(getByTestId('tf-input-ro-out').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); +}); - expect(getByTestId('text-input-flat-label-active')).toHaveStyle({ - color: getTheme().colors.primary, - }); +it('applies disabled opacity to the TextInput when disabled is true (filled)', () => { + const { getByTestId } = render( + {}} + disabled + testID="tf-input-dis" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-dis').props.style) + ).toMatchObject({ opacity: stateOpacity.disabled }); }); -it('renders label with correct color when inactive', () => { +it('applies disabled opacity to the TextInput when disabled is true (outlined)', () => { const { getByTestId } = render( {}} + disabled + testID="tf-input-dis-out" /> ); - expect(getByTestId('text-input-label-inactive')).toHaveStyle({ - color: getTheme().colors.onSurfaceVariant, - }); + 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('renders input placeholder initially with transparent placeholderTextColor', () => { +/* TextInput peels these before spreading onto TextInput (see TextInput.tsx). + * Custom layout / sub-component styling props are intentionally not supported. */ +it('does not pass TextInput-only props through to TextInput', () => { const { getByTestId } = render( - + {}} + error + disabled + testID="tf-native" + /> + ); + + const input = getByTestId('tf-native'); + expect(input.props.variant).toBeUndefined(); + expect(input.props.theme).toBeUndefined(); + expect(input.props.startAccessory).toBeUndefined(); + expect(input.props.endAccessory).toBeUndefined(); + expect(input.props.label).toBeUndefined(); + expect(input.props.supportingText).toBeUndefined(); + expect(input.props.prefix).toBeUndefined(); + expect(input.props.suffix).toBeUndefined(); + expect(input.props.counter).toBeUndefined(); + expect(input.props.error).toBeUndefined(); + expect(input.props.disabled).toBeUndefined(); +}); + +it('shows a character counter when counter is true and maxLength is set (filled)', () => { + const { getByText, queryByText } = render( + {}} + counter + maxLength={100} + /> + ); + + expect(getByText('5/100')).toBeTruthy(); + expect(queryByText('0/100')).toBeNull(); +}); + +it('shows a character counter when counter is true and maxLength is set (outlined)', () => { + const { getByText } = render( + {}} + counter + maxLength={50} + /> ); - expect(getByTestId('text-input').props.placeholderTextColor).toBe( - 'transparent' + 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('correctly applies padding offset to input label on Android when RTL', () => { - Platform.OS = 'android'; +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( - - - } - right={ - - } - /> - - ); - - expect(getByTestId('text-input-flat-label-active')).toHaveStyle({ - paddingLeft: 56, - paddingRight: 16, - }); + {}} + 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('correctly applies padding offset to input label on Android when LTR', () => { - Platform.OS = 'android'; +it('focuses the TextInput when the outer Pressable is pressed', () => { + const focusSpy = jest.spyOn(NativeTextInput.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(NativeTextInput.prototype, 'focus'); + + const { UNSAFE_getByProps } = render( + {}} disabled /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).not.toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('focuses the TextInput when read-only and the Pressable is pressed', () => { + const focusSpy = jest.spyOn(NativeTextInput.prototype, 'focus'); + + const { UNSAFE_getByProps } = render( + {}} + editable={false} + /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('exposes the TextInput instance via ref prop', () => { + const ref = React.createRef(); + + render( + {}} + testID="tf-input" + /> + ); + + expect(ref.current).toBeTruthy(); + expect(typeof ref.current?.focus).toBe('function'); + expect(typeof ref.current?.clear).toBe('function'); + expect(typeof ref.current?.blur).toBe('function'); + expect(typeof ref.current?.isFocused).toBe('function'); + expect(typeof ref.current?.setNativeProps).toBe('function'); + expect(typeof ref.current?.setSelection).toBe('function'); +}); + +it('passes error, disabled, and multiline to accessories', () => { + const startAccessoryProps: TextInputAccessoryProps[] = []; + const endAccessoryProps: TextInputAccessoryProps[] = []; + + function StartAccessory(props: TextInputAccessoryProps) { + startAccessoryProps.push(props); + return ; + } + + function EndAccessory(props: TextInputAccessoryProps) { + endAccessoryProps.push(props); + return ; + } const { getByTestId } = render( - } - right={ - - } + label="Search" + value="" + onChangeText={() => {}} + multiline + error + disabled + startAccessory={StartAccessory} + endAccessory={EndAccessory} /> ); - expect(getByTestId('text-input-flat-label-active')).toHaveStyle({ - paddingLeft: 16, - paddingRight: 56, + 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('calls onLayout on right-side affix adornment', () => { - const onLayoutMock = jest.fn(); - const nativeEventMock = { - nativeEvent: { layout: { height: 100 } }, - }; +it('passes error to accessories when the field is disabled', () => { + const startAccessoryProps: TextInputAccessoryProps[] = []; + + function StartAccessory(props: TextInputAccessoryProps) { + startAccessoryProps.push(props); + return ; + } const { getByTestId } = render( } - /> - ); - fireEvent( - getByTestId('right-affix-adornment-text'), - 'onLayout', - nativeEventMock - ); - expect(onLayoutMock).toHaveBeenCalledWith(nativeEventMock); -}); - -(['outlined', 'flat'] as const).forEach((mode) => - it(`renders ${mode} input with correct line height`, () => { - const input = render( - - ); - - expect(input.getByTestId(`text-input-${mode}`)).toHaveStyle({ - lineHeight: 22, - }); - }) -); - -(['outlined', 'flat'] as const).forEach((mode) => - it(`renders ${mode} input with passed textColor`, () => { - const input = render( - - ); - - expect(input.getByTestId(`text-input-${mode}`)).toHaveStyle({ - color: 'purple', - }); - }) -); - -it("correctly applies theme background to label when input's background is transparent", () => { - const backgroundColor = 'transparent'; - const theme = { - ...DefaultTheme, - colors: { - ...DefaultTheme.colors, - background: 'pink', - }, - }; + label="Search" + value="" + onChangeText={() => {}} + error + disabled + startAccessory={StartAccessory} + /> + ); - const { getByTestId } = render( - - - - ); - - expect(getByTestId('transparent-example-label-background')).toHaveStyle({ - backgroundColor: 'pink', - }); + expect(getByTestId('start-acc-error-disabled')).toBeTruthy(); + expect(startAccessoryProps[0].error).toBe(true); + expect(startAccessoryProps[0].disabled).toBe(true); +}); + +it('renders supporting text as a Text child', () => { + const { getByText } = render( + {}} + supportingText="Hint" + /> + ); + + expect(getByText('Hint')).toBeTruthy(); }); -it('always applies line height for web, even if not specified', () => { - Platform.OS = 'web'; +it('renders the counter as a Text child', () => { + const { getByText } = render( + {}} + counter + maxLength={80} + /> + ); + + expect(getByText('2/80')).toBeTruthy(); +}); + +it('renders supporting text and counter separately when both are shown', () => { + const { getByText } = render( + {}} + supportingText="Help text" + counter + maxLength={10} + /> + ); + + expect(getByText('Help text')).toBeTruthy(); + expect(getByText('1/10')).toBeTruthy(); +}); + +it('applies RTL text alignment and writing direction to the TextInput (filled)', () => { + I18nManager.isRTL = true; + const { getByTestId } = render( - - - - - - - - - ); - - expect(getByTestId('default-font')).toHaveStyle({ lineHeight: 16 * 1.2 }); - expect(getByTestId('default-font-flat')).toHaveStyle({ - lineHeight: 16 * 1.2, - }); + {}} + testID="tf-input-rtl" + /> + ); - expect(getByTestId('large-font')).toHaveStyle({ lineHeight: 30 * 1.2 }); - expect(getByTestId('large-font-flat')).toHaveStyle({ lineHeight: 30 * 1.2 }); + expect(StyleSheet.flatten(getByTestId('tf-input-rtl').props.style)).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'rtl', + }) + ); +}); - expect(getByTestId('custom-line-height')).toHaveStyle({ - lineHeight: 29, - }); - expect(getByTestId('custom-line-height-flat')).toHaveStyle({ - lineHeight: 29, - }); +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('call onPress when affix adornment pressed', () => { - const affixOnPress = jest.fn(); - const affixTextValue = '+39'; - const { getByText, toJSON } = render( +it('applies RTL writing direction to supporting text', () => { + I18nManager.isRTL = true; + + const { getByText } = render( } + label="Email" + value="" + onChangeText={() => {}} + supportingText="Hint" /> ); - fireEvent.press(getByText(affixTextValue)); + expect(StyleSheet.flatten(getByText('Hint').props.style)).toEqual( + expect.objectContaining({ + writingDirection: 'rtl', + }) + ); +}); + +it('places EndAccessory before StartAccessory in the tree when RTL', () => { + I18nManager.isRTL = true; - expect(getByText(affixTextValue)).toBeTruthy(); - expect(toJSON()).toMatchSnapshot(); - expect(affixOnPress).toHaveBeenCalledTimes(1); + 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') + ); }); -describe('maxFontSizeMultiplier', () => { - const createInput = ( - type: Exclude, - maxFontSizeMultiplier?: Props['maxFontSizeMultiplier'] - ) => { - return ( - - ); - }; +it('places StartAccessory before EndAccessory in the tree when LTR', () => { + I18nManager.isRTL = false; - it('should have default value in flat input', () => { - const { getByTestId } = render(createInput('flat')); + function StartAccessory() { + return ; + } - expect(getByTestId('text-input-flat').props.maxFontSizeMultiplier).toBe( - 1.5 - ); - }); + function EndAccessory() { + return ; + } - it('should have default value in outlined input', () => { - const { getByTestId } = render(createInput('outlined')); + const { toJSON } = render( + {}} + startAccessory={StartAccessory} + endAccessory={EndAccessory} + testID="tf-input-ltr-order" + /> + ); - expect(getByTestId('text-input-outlined').props.maxFontSizeMultiplier).toBe( - 1.5 - ); - }); + const tree = toJSON(); + expect( + firstIndexOfTestIdInTree(tree, 'ltr-acc-from-start-prop') + ).toBeLessThan(firstIndexOfTestIdInTree(tree, 'ltr-acc-from-end-prop')); +}); - it('should have correct passed value in flat input', () => { - const { getByTestId } = render(createInput('flat', 2)); +it('does not expose the placeholder string when the TextInput is not focused', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); - expect(getByTestId('text-input-flat').props.maxFontSizeMultiplier).toBe(2); - }); + /* Sentinel space avoids iOS multiline UITextView not updating placeholder from nil (react-native#31573). */ + expect(getByTestId('tf-input').props.placeholder).toBe(' '); +}); - it('should have correct passed value in outlined input', () => { - const { getByTestId } = render(createInput('outlined', 2)); +it('shows placeholder when the TextInput is focused', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); - expect(getByTestId('text-input-outlined').props.maxFontSizeMultiplier).toBe( - 2 - ); - }); + fireEvent(getByTestId('tf-input'), 'focus'); - it('should have passed null value in flat input', () => { - const { getByTestId } = render(createInput('flat', null)); + expect(getByTestId('tf-input').props.placeholder).toBe( + 'e.g. user@example.com' + ); +}); - expect(getByTestId('text-input-flat').props.maxFontSizeMultiplier).toBe( - null - ); - }); +it('shows placeholder on multiline TextInput when focused', () => { + const { getByTestId } = render( + {}} + placeholder="Add a note…" + multiline + testID="tf-multiline" + /> + ); - it('should have passed null value in outlined input', () => { - const { getByTestId } = render(createInput('outlined', null)); + expect(getByTestId('tf-multiline').props.placeholder).toBe(' '); - expect(getByTestId('text-input-outlined').props.maxFontSizeMultiplier).toBe( - null - ); - }); + fireEvent(getByTestId('tf-multiline'), 'focus'); + + expect(getByTestId('tf-multiline').props.placeholder).toBe('Add a note…'); }); -describe('getFlatInputColor - underline color', () => { - it('should return correct disabled color, for theme version 3', () => { - expect( - getFlatInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - underlineColorCustom: getTheme().colors.onSurfaceVariant, - }); - }); +it('does not expose the placeholder string again after the TextInput loses focus', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); - it('should return correct theme color, for theme version 3', () => { - expect( - getFlatInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - underlineColorCustom: getTheme().colors.onSurfaceVariant, - }); - }); + fireEvent(getByTestId('tf-input'), 'focus'); + fireEvent(getByTestId('tf-input'), 'blur'); - it('should return custom color, no matter what the theme is', () => { - expect( - getFlatInputColors({ - underlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - underlineColorCustom: 'beige', - }); - - expect( - getFlatInputColors({ - underlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - underlineColorCustom: 'beige', - }); - }); + expect(getByTestId('tf-input').props.placeholder).toBe(' '); }); -describe('getFlatInputColor - input text color', () => { - it('should return custom color, if not disabled, no matter what the theme is', () => { - expect( - getOutlinedInputColors({ - textColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - inputTextColor: 'beige', - }); - - expect( - getOutlinedInputColors({ - textColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - inputTextColor: 'beige', - }); - }); +it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order)', () => { + function LoneStartAccessory() { + return ; + } - it('should return correct disabled color, for theme version 3', () => { - expect( - getFlatInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - inputTextColor: getTheme().colors.onSurface, - disabledOpacity: stateOpacity.disabled, - }); - }); + I18nManager.isRTL = false; - it('should return correct theme color, for theme version 3', () => { - expect( - getFlatInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - inputTextColor: getTheme().colors.onSurface, - }); - }); + 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') + ); }); -describe('getFlatInputColor - placeholder color', () => { - it('should return correct disabled color', () => { - expect( - getFlatInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - placeholderColor: getTheme().colors.onSurfaceVariant, - disabledOpacity: stateOpacity.disabled, - }); - }); +it('shows prefix and suffix when the field is floating and hides them after value is cleared while blurred', () => { + const { getByTestId, getByText, queryByText, rerender } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + /> + ); - it('should return correct theme color', () => { - expect( - getFlatInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - placeholderColor: getTheme().colors.onSurfaceVariant, - }); - }); + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + rerender( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + /> + ); + + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); + expect(getByTestId('tf-ps')).toBeTruthy(); }); -describe('getFlatInputColor - background color', () => { - it('should return correct disabled color, for theme version 3', () => { - expect( - getFlatInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - backgroundColor: getTheme().colors.surfaceContainerHighest, - }); - expect( - getFlatInputColors({ - disabled: true, - theme: getTheme(true), - }) - ).toMatchObject({ - backgroundColor: getTheme(true).colors.surfaceContainerHighest, - }); - }); +it('renders prefix and suffix while focused even when value is empty', () => { + const { getByTestId, getByText, queryByText } = render( + {}} + prefix="$" + suffix=" kg" + testID="tf-ps-focus" + /> + ); - it('should return correct theme color, for theme version 3', () => { - expect( - getFlatInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - backgroundColor: getTheme().colors.surfaceVariant, - }); - }); + expect(queryByText('$')).toBeNull(); + expect(queryByText(' kg')).toBeNull(); + + fireEvent(getByTestId('tf-ps-focus'), 'focus'); + + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); }); -describe('getFlatInputColor - error color', () => { - it('should return correct error color, no matter what the theme is', () => { - expect( - getFlatInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - errorColor: getTheme().colors.error, - }); - - expect( - getFlatInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - errorColor: getTheme().colors.error, - }); - }); +it('places prefix Text before the TextInput and suffix Text after it', () => { + const { toJSON } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-order" + /> + ); + + const tree = toJSON(); + expect(firstIndexOfTextChildArrayInTree(tree, '$')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'tf-order') + ); + expect(firstIndexOfTestIdInTree(tree, 'tf-order')).toBeLessThan( + firstIndexOfTextChildArrayInTree(tree, '/100') + ); }); -describe('getFlatInputColor - active color', () => { - it('should return disabled color, for theme version 3', () => { - expect( - getFlatInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.primary, - disabledOpacity: stateOpacity.disabled, - }); - }); +it('aligns input text toward the suffix when suffix is active (LTR)', () => { + const { getByTestId } = render( + {}} + suffix="/100" + testID="tf-suffix-align-ltr" + /> + ); - it('should return correct active color, if error, no matter what the theme is', () => { - expect( - getFlatInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.error, - }); - - expect( - getFlatInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.error, - }); - }); + expect( + StyleSheet.flatten(getByTestId('tf-suffix-align-ltr').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'ltr', + }) + ); +}); - it('should return custom active color, no matter what the theme is', () => { - expect( - getFlatInputColors({ - activeUnderlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: 'beige', - }); - - expect( - getFlatInputColors({ - activeUnderlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: 'beige', - }); - }); +it('aligns input text toward the suffix when suffix is active (RTL)', () => { + I18nManager.isRTL = true; - it('should return theme active color, for theme version 3', () => { - expect( - getFlatInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.primary, - }); - }); + 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', + }) + ); }); -describe('getOutlinedInputColors - outline color', () => { - it('should return correct disabled color, for theme version 3, light theme', () => { - expect( - getOutlinedInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - outlineColor: getTheme().colors.outlineVariant, - }); - }); +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" + /> + ); - it('should return correct disabled color, for theme version 3, dark theme', () => { - expect( - getOutlinedInputColors({ - disabled: true, - theme: getTheme(true), - }) - ).toMatchObject({ - outlineColor: 'transparent', - }); - }); + expect( + StyleSheet.flatten(getByTestId('tf-no-suffix-yet').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'left', + writingDirection: 'ltr', + }) + ); +}); - it('should return custom color, if not disabled, no matter what the theme is', () => { - expect( - getOutlinedInputColors({ - customOutlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - outlineColor: 'beige', - }); - - expect( - getOutlinedInputColors({ - customOutlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - outlineColor: 'beige', - }); - }); +it('does not apply the TextInput style prop to prefix or suffix Text', () => { + const { getByTestId, getByText } = render( + {}} + prefix="$" + suffix="]" + style={{ fontSize: 40, letterSpacing: 9 }} + testID="tf-input-style" + /> + ); - it('should return theme color, for theme version 3', () => { - expect( - getOutlinedInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - outlineColor: getTheme().colors.outline, - }); - }); + const inputFlat = StyleSheet.flatten( + getByTestId('tf-input-style').props.style + ); + expect(inputFlat).toEqual( + expect.objectContaining({ fontSize: 40, letterSpacing: 9 }) + ); + + const prefixFlat = StyleSheet.flatten(getByText('$').props.style); + const suffixFlat = StyleSheet.flatten(getByText(']').props.style); + + expect(prefixFlat.fontSize).not.toBe(40); + expect(prefixFlat.letterSpacing).toBeUndefined(); + expect(suffixFlat.fontSize).not.toBe(40); + expect(suffixFlat.letterSpacing).toBeUndefined(); }); -describe('getOutlinedInputColors - input text color', () => { - it('should return correct disabled color, for theme version 3', () => { - expect( - getOutlinedInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - inputTextColor: getTheme().colors.onSurface, - disabledOpacity: stateOpacity.disabled, - }); - }); +it('passes defaultValue to the native input when uncontrolled without counter', () => { + const { getByTestId } = render( + {}} + testID="tf-uncontrolled" + /> + ); - it('should return correct theme color, for theme version 3', () => { - expect( - getOutlinedInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - inputTextColor: getTheme().colors.onSurface, - }); - }); + const input = getByTestId('tf-uncontrolled'); + expect(input.props.defaultValue).toBe('hello'); + expect(input.props.value).toBeUndefined(); }); -describe('getOutlinedInputColors - placeholder color', () => { - it('should return correct disabled color, for theme version 3', () => { - expect( - getOutlinedInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - placeholderColor: getTheme().colors.onSurfaceVariant, - disabledOpacity: stateOpacity.disabled, - }); - }); +it('updates the character counter for an uncontrolled field with counter enabled', () => { + const onChangeText = jest.fn(); + const { getByTestId, getByText } = render( + + ); - it('should return correct theme color, for theme version 3', () => { - expect( - getOutlinedInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - placeholderColor: getTheme().colors.onSurfaceVariant, - }); - }); + expect(getByText('1/10')).toBeTruthy(); + + fireEvent.changeText(getByTestId('tf-uncontrolled-counter'), 'abcd'); + + expect(onChangeText).toHaveBeenCalledWith('abcd'); + expect(getByText('4/10')).toBeTruthy(); }); -describe('getOutlinedInputColors - error color', () => { - it('should return correct error color, no matter what the theme is', () => { - expect( - getOutlinedInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - errorColor: getTheme().colors.error, - }); - - expect( - getOutlinedInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - errorColor: getTheme().colors.error, - }); +it('resets counter and hides prefix/suffix when clear() is called on uncontrolled field while blurred', () => { + const ref = React.createRef(); + const { getByText, queryByText } = render( + + ); + + expect(getByText('3/200')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + act(() => { + ref.current?.clear(); }); + + expect(getByText('0/200')).toBeTruthy(); + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); }); -describe('getOutlinedInputColors - active color', () => { - it('should return disabled color, for theme version 3', () => { - expect( - getOutlinedInputColors({ - disabled: true, - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.primary, - disabledOpacity: stateOpacity.disabled, - }); - }); +it('resets counter but keeps prefix/suffix visible when clear() is called on uncontrolled field while focused', () => { + const ref = React.createRef(); + const { getByTestId, getByText } = render( + + ); - it('should return correct active color, if error, no matter what the theme is', () => { - expect( - getOutlinedInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.error, - }); - - expect( - getOutlinedInputColors({ - error: true, - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.error, - }); - }); + expect(getByText('2/100')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); - it('should return custom active color, no matter what the theme is', () => { - expect( - getOutlinedInputColors({ - activeOutlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: 'beige', - }); - - expect( - getOutlinedInputColors({ - activeOutlineColor: 'beige', - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: 'beige', - }); - }); + fireEvent(getByTestId('tf-clear-focused'), 'focus'); - it('should return theme active color, for theme version 3', () => { - expect( - getOutlinedInputColors({ - theme: getTheme(), - }) - ).toMatchObject({ - activeColor: getTheme().colors.primary, - }); + act(() => { + ref.current?.clear(); }); + + expect(getByText('0/100')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); }); -describe('outlineStyle - underlineStyle', () => { - it('correctly applies outline style', () => { - const { getByTestId } = render( - - ); +it('notifies the parent via onChangeText when clear() is called on a controlled field', () => { + const ref = React.createRef(); + const onChangeText = jest.fn(); + const { getByTestId } = render( + + ); + + const input = getByTestId('tf-controlled'); + expect(input.props.value).toBe('test@example.com'); - expect(getByTestId('text-input-outline')).toHaveStyle({ - borderRadius: 16, - borderWidth: 6, - }); + act(() => { + ref.current?.clear(); }); - it('correctly applies underline style', () => { - const { getByTestId } = render( - - ); - - expect(getByTestId('text-input-underline')).toHaveStyle({ - borderRadius: 16, - borderWidth: 6, - }); + expect(onChangeText).toHaveBeenCalledWith(''); + expect(onChangeText).toHaveBeenCalledTimes(1); +}); + +it('hides prefix/suffix when blurring after clear() was called while focused', () => { + const ref = React.createRef(); + const { getByTestId, getByText, queryByText } = render( + + ); + + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + fireEvent(getByTestId('tf-clear-then-blur'), 'focus'); + + act(() => { + ref.current?.clear(); }); + + // While focused, prefix/suffix stay visible + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); + + fireEvent(getByTestId('tf-clear-then-blur'), 'blur'); + + // After blur with no text, prefix/suffix should be hidden + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); }); diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap deleted file mode 100644 index 8bfb78d564..0000000000 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ /dev/null @@ -1,2960 +0,0 @@ -// 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/components/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap index facd344c1e..9c05912827 100644 --- a/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -1,2067 +1,2552 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`call onPress when affix adornment pressed 1`] = ` +exports[`renders filled TextInput with TextInput.Icon accessories 1`] = ` - + } + accessible={false} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + role="none" +> + - + - - - Flat input - - - Flat input - - - - - - - - - +39 + Search - - -`; - -exports[`correctly applies a component as the text label 1`] = ` - - - - - - Flat input - - - - - Flat input - - + + magnify + + + - - - -`; - -exports[`correctly applies cursorColor prop 1`] = ` - - - - + + + - - Flat input - - - Flat input - + + + close + + + - - -`; - -exports[`correctly applies default textAlign based on default RTL 1`] = ` - + +`; + +exports[`renders filled TextInput with TextInput.Icon accessories when error is true 1`] = ` + + - + - - - Flat input - - - Flat input - - - - - - - -`; - -exports[`correctly applies height to multiline Outline TextInput 1`] = ` - - - - + + Search + + + - - - Outline Input - - + + magnify + + + + + + + + + + + + + - Outline Input - + + + close + + + - + + +`; + +exports[`renders filled TextInput with label and value 1`] = ` + + + + + + + Email + + + + style={ + [ + { + "alignItems": "flex-end", + "flex": 1, + "flexDirection": "row", + "paddingHorizontal": 16, + }, + false, + { + "opacity": 1, + }, + ] + } + > + + - -`; - -exports[`correctly applies paddingLeft from contentStyleProp 1`] = ` - + +`; + +exports[`renders outlined TextInput with TextInput.Icon accessories 1`] = ` + + + + Search + + + - - With padding - - - With padding - + + + magnify + + + - - - -`; - -exports[`correctly applies textAlign center 1`] = ` - - - - + + + - - Flat input - - - Flat input - - - - - + + + close + + + + + + - -`; - -exports[`correctly renders left-side affix adornment, and right-side icon adornment 1`] = ` - + +`; + +exports[`renders outlined TextInput with TextInput.Icon accessories when error is true 1`] = ` + + - + + Search + + + + - - Flat input - - - Flat input - + + + magnify + + + - - - - - /100 - - - + + - + + - heart - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + close + + - -`; - -exports[`correctly renders left-side icon adornment, and right-side affix adornment 1`] = ` - + +`; + +exports[`renders outlined TextInput with label and value 1`] = ` + - + - - - Flat input - - - Flat input - - - - - - - - - - - - - heart - - - - + "fontWeight": "400", + "includeFontPadding": false, + "paddingHorizontal": 0, + "paddingVertical": 0, + }, + { + "color": "rgba(73, 69, 79, 1)", + }, + { + "fontSize": 12, + }, + false, + ] + } + > + Password + - - - - /100 - + + + `; diff --git a/src/index.tsx b/src/index.tsx index c643523308..c44134cda4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -37,7 +37,6 @@ export { default as Dialog } from './components/Dialog/Dialog'; export { default as Divider } from './components/Divider'; export { default as FAB } from './components/FAB'; export { default as AnimatedFAB } from './components/FAB/AnimatedFAB'; -export { default as HelperText } from './components/HelperText/HelperText'; export { default as Icon } from './components/Icon'; export { default as IconButton } from './components/IconButton/IconButton'; export { default as Menu } from './components/Menu/Menu'; @@ -51,8 +50,7 @@ export { default as Surface } from './components/Surface'; export { default as Switch } from './components/Switch/Switch'; export { default as Appbar } from './components/Appbar'; export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple'; -export { default as TextInput } from './components/TextInput/TextInput'; -export { default as TextField } from './components/TextField'; +export { default as TextInput } from './components/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; export { default as Tooltip } from './components/Tooltip/Tooltip'; @@ -105,7 +103,6 @@ export type { Props as DrawerItemProps } from './components/Drawer/DrawerItem'; export type { Props as DrawerSectionProps } from './components/Drawer/DrawerSection'; export type { Props as FABProps } from './components/FAB/FAB'; export type { Props as FABGroupProps } from './components/FAB/FABGroup'; -export type { Props as HelperTextProps } from './components/HelperText/HelperText'; export type { Props as IconButtonProps } from './components/IconButton/IconButton'; export type { Props as ListAccordionProps } from './components/List/ListAccordion'; export type { Props as ListAccordionGroupProps } from './components/List/ListAccordionGroup'; @@ -129,19 +126,16 @@ export type { Props as SearchbarProps } from './components/Searchbar'; export type { Props as SnackbarProps } from './components/Snackbar'; export type { Props as SurfaceProps } from './components/Surface'; export type { Props as SwitchProps } from './components/Switch/Switch'; -export type { 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, - TextFieldRenderProps, - TextFieldVariant, - TextFieldHandles, -} from './components/TextField/TextField'; + TextInputProps, + TextInputRenderProps, + TextInputVariant, + TextInputHandles, +} from './components/TextInput/TextInput'; export type { - TextFieldAccessoryProps, - TextFieldIconProps, -} from './components/TextField/TextFieldIcon'; + TextInputAccessoryProps, + TextInputIconProps, +} from './components/TextInput/TextInputIcon'; 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/yarn.lock b/yarn.lock index 5ae8c0517a..6626782615 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10271,6 +10271,7 @@ __metadata: marked: "npm:^4.1.1" patch-package: "npm:^6.5.0" prism-react-renderer: "npm:^1.3.5" + process: "npm:^0.11.10" react: "npm:17.0.2" react-color: "npm:^2.19.3" react-dom: "npm:17.0.2" @@ -19420,6 +19421,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "progress@npm:^2.0.3": version: 2.0.3 resolution: "progress@npm:2.0.3" From c425f48ba0bf15701ff09c799a772816ed7293b4 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 27 May 2026 12:24:47 +0200 Subject: [PATCH 25/26] fix: docs building --- docs/babel.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/babel.config.js b/docs/babel.config.js index b34f4c9f0f..1a907e925f 100644 --- a/docs/babel.config.js +++ b/docs/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ['@docusaurus/core/lib/babel/preset'], + plugins: ['react-native-reanimated/plugin'], }; From 60bd2095c3b9be5826054246425eb0f2c7f46566 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 27 May 2026 12:33:50 +0200 Subject: [PATCH 26/26] chore: docs migration guide --- docs/docs/guides/12-migration.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/docs/guides/12-migration.md b/docs/docs/guides/12-migration.md index 6d9a35424a..1fcd32bd25 100644 --- a/docs/docs/guides/12-migration.md +++ b/docs/docs/guides/12-migration.md @@ -4,15 +4,19 @@ title: Migration from Paper 5.x to 6.x TBC -## Component and types +## Components + +### TextInput The Paper 6.x `TextInput` is a complete rewrite with a new API. Import the component the same way, but note that the props and behavior have changed significantly. +#### Types + ```tsx import { TextInput, type TextInputProps } from 'react-native-paper'; ``` -## Visual / variant +#### Variant - **`mode="flat"`** → **`variant="filled"`** - **`mode="outlined"`** → **`variant="outlined"`** @@ -27,10 +31,9 @@ import { TextInput, type TextInputProps } from 'react-native-paper'; ``` -## Icons and adornments +#### Adornments - **`left` / `right`** → **`startAccessory` / `endAccessory`** -- **`TextInput.Icon`** → **`TextInput.Icon`** - **`TextInput.Affix`** → **`prefix` / `suffix`**, or **`TextInput.Icon`**, or **`startAccessory` / `endAccessory`** ```tsx @@ -51,10 +54,9 @@ import { TextInput, type TextInputProps } from 'react-native-paper'; /> ``` -## Label, helper, error, disabled +#### Label and supporting text - **`label: React.Element | string`** → **`string`** -- **`error` / `disabled`** → **`error`** and **`disabled`** - **`HelperText`** was removed; use **`supportingText`**. ```tsx @@ -79,13 +81,12 @@ import { TextInput, type TextInputProps } from 'react-native-paper'; /> ``` -## Styling / behavior removed +#### Removed props No direct `TextInput` equivalents for: - **`dense`**, **`contentStyle`**, **`underlineStyle`** - **`underlineColor`**, **`activeUnderlineColor`**, **`outlineColor`**, **`activeOutlineColor`**, **`textColor`** -- **`render`** Use **`style`** on the inner input and the **`theme`** for colors.