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'],
};
diff --git a/docs/docs/guides/12-migration.md b/docs/docs/guides/12-migration.md
new file mode 100644
index 0000000000..1fcd32bd25
--- /dev/null
+++ b/docs/docs/guides/12-migration.md
@@ -0,0 +1,128 @@
+---
+title: Migration from Paper 5.x to 6.x
+---
+
+TBC
+
+## 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';
+```
+
+#### Variant
+
+- **`mode="flat"`** → **`variant="filled"`**
+- **`mode="outlined"`** → **`variant="outlined"`**
+
+```tsx
+// Before (v5)
+
+
+
+// After (v6)
+
+
+```
+
+#### Adornments
+
+- **`left` / `right`** → **`startAccessory` / `endAccessory`**
+- **`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 and supporting text
+
+- **`label: React.Element | string`** → **`string`**
+- **`HelperText`** was removed; use **`supportingText`**.
+
+```tsx
+// Before (v5)
+<>
+
+
+ Enter a valid email
+
+>
+
+// After (v6)
+
+```
+
+#### Removed props
+
+No direct `TextInput` equivalents for:
+
+- **`dense`**, **`contentStyle`**, **`underlineStyle`**
+- **`underlineColor`**, **`activeUnderlineColor`**, **`outlineColor`**, **`activeOutlineColor`**, **`textColor`**
+
+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 bc75f57c94..05650d7237 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -124,7 +124,6 @@ const config = {
AnimatedFAB: 'FAB/AnimatedFAB',
FABGroup: 'FAB/FABGroup',
},
- HelperText: { HelperText: 'HelperText/HelperText' },
IconButton: {
IconButton: 'IconButton/IconButton',
},
@@ -165,8 +164,7 @@ const config = {
},
TextInput: {
TextInput: 'TextInput/TextInput',
- TextInputAffix: 'TextInput/Adornment/TextInputAffix',
- TextInputIcon: 'TextInput/Adornment/TextInputIcon',
+ TextInputIcon: 'TextInput/TextInputIcon',
},
ToggleButton: {
ToggleButton: 'ToggleButton/ToggleButton',
@@ -204,10 +202,9 @@ const config = {
}
const customUrls = {
- TextInputAffix:
- 'src/components/TextInput/Adornment/TextInputAffix.tsx',
- TextInputIcon:
- 'src/components/TextInput/Adornment/TextInputIcon.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',
};
@@ -326,14 +323,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 59ec60634a..3e21a11ee4 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 35f5069433..eaf87beba2 100644
--- a/docs/src/components/PropTable.tsx
+++ b/docs/src/components/PropTable.tsx
@@ -11,17 +11,27 @@ const typeDefinitions = {
'https://github.com/callstack/react-native-paper/blob/main/src/components/Icon.tsx#L16',
ThemeProp:
'https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties',
+ '(props: 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',
'StyleProp': 'https://reactnative.dev/docs/text-style-props',
+ TextProps: 'https://reactnative.dev/docs/text#props',
+ AccessibilityProps:
+ 'https://reactnative.dev/docs/accessibility#accessibilityprops',
};
const renderBadge = (annotation: string) => {
const [annotType, ...annotLabel] = annotation.split(' ');
// eslint-disable-next-line prettier/prettier
- return `${annotLabel.join(' ')}`;
+ return `${annotLabel.join(' ')}`;
};
export default function PropTable({
@@ -56,7 +66,9 @@ export default function PropTable({
if (line.includes('@')) {
const annotIndex = line.indexOf('@');
// eslint-disable-next-line prettier/prettier
- return `${line.substr(0, annotIndex)} ${renderBadge(line.substr(annotIndex))}`;
+ return `${line.substr(0, annotIndex)} ${renderBadge(
+ line.substr(annotIndex)
+ )}`;
} else {
return line;
}
diff --git a/docs/src/data/screenshots.js b/docs/src/data/screenshots.js
index c1afa99a6a..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,13 +146,9 @@ 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',
+ filled: 'screenshots/text-field-filled.png',
+ outlined: 'screenshots/text-field-outlined.png',
},
- 'TextInput.Affix': 'screenshots/textinput-outline.affix.png',
- 'TextInput.Icon': 'screenshots/textinput-flat.icon.png',
ToggleButton: 'screenshots/toggle-button.png',
'ToggleButton.Group': 'screenshots/toggle-button-group.gif',
'ToggleButton.Row': 'screenshots/toggle-button-row.gif',
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/docs/static/screenshots/text-field-filled.png b/docs/static/screenshots/text-field-filled.png
new file mode 100644
index 0000000000..03ab10d37e
Binary files /dev/null and b/docs/static/screenshots/text-field-filled.png differ
diff --git a/docs/static/screenshots/text-field-outlined.png b/docs/static/screenshots/text-field-outlined.png
new file mode 100644
index 0000000000..1abb39e072
Binary files /dev/null and b/docs/static/screenshots/text-field-outlined.png differ
diff --git a/example/src/Examples/TextInputExample.tsx b/example/src/Examples/TextInputExample.tsx
index aa66654aaf..73341a0cb1 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 '@react-native-vector-icons/material-design-icons';
-import { useFonts } from 'expo-font';
import {
- HelperText,
+ Divider,
List,
- Palette,
+ Switch,
+ Text,
TextInput,
- configureFonts,
+ TouchableRipple,
useTheme,
+ type TextInputAccessoryProps,
+ type TextInputVariant,
} from 'react-native-paper';
-import { inputReducer, State } from '../../utils';
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 = useTheme();
- 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={Palette.primary70}
- activeUnderlineColor={Palette.tertiary50}
- />
-
- inputActionHandler('outlinedColors', outlinedColors)
- }
- outlineColor={Palette.primary70}
- activeOutlineColor={Palette.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: Palette.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/jest/testSetup.js b/jest/testSetup.js
index 5088ab5585..207c532a7b 100644
--- a/jest/testSetup.js
+++ b/jest/testSetup.js
@@ -14,17 +14,14 @@ jest.mock('@react-native-vector-icons/material-design-icons', () => {
const MockIcon = ({ name, color, size, style, ...props }) => {
return (
-
+
{name || '□'}
);
};
MockIcon.displayName = 'MockedMaterialDesignIcon';
-
+
return {
__esModule: true,
default: MockIcon,
diff --git a/src/components/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 86c39c2e45..0000000000
--- a/src/components/HelperText/utils.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { getStateLayer } from '../../theme/utils/state';
-import type { InternalTheme } from '../../types';
-
-type BaseProps = {
- theme: InternalTheme;
- disabled?: boolean;
- type?: 'error' | 'info';
-};
-
-export function getTextColor({ theme, disabled, type }: BaseProps) {
- if (type === 'error') {
- return getStateLayer(theme, 'error', 'enabled');
- }
- return getStateLayer(
- theme,
- 'onSurfaceVariant',
- disabled ? 'disabled' : 'enabled'
- );
-}
diff --git a/src/components/TextInput/Addons/Outline.tsx b/src/components/TextInput/Addons/Outline.tsx
deleted file mode 100644
index c40f5ffd62..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: ColorValue;
- backgroundColor: ColorValue;
- hasActiveOutline?: boolean;
- outlineColor?: ColorValue;
- 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 b329ef1190..0000000000
--- a/src/components/TextInput/Addons/Underline.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import * as React from 'react';
-import {
- Animated,
- ColorValue,
- StyleProp,
- StyleSheet,
- ViewStyle,
-} from 'react-native';
-
-import type { ThemeProp } from '../../../types';
-
-type UnderlineProps = {
- parentState: {
- focused: boolean;
- };
- error?: boolean;
- colors?: {
- error?: ColorValue;
- };
- activeColor: ColorValue;
- underlineColorCustom?: ColorValue;
- 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 4652c7612c..0000000000
--- a/src/components/TextInput/Adornment/TextInputIcon.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import React from 'react';
-import {
- ColorValue,
- 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?:
- | ColorValue
- | ((isTextInputFocused: boolean) => ColorValue | undefined);
- 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 3a6e819963..0000000000
--- a/src/components/TextInput/Adornment/utils.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { ColorValue } from 'react-native';
-
-import { tokens } from '../../../theme/tokens';
-import { getStateLayer } from '../../../theme/utils/state';
-import type { InternalTheme } from '../../../types';
-
-const stateOpacity = tokens.md.sys.state.opacity;
-
-type BaseProps = {
- theme: InternalTheme;
- disabled?: boolean;
-};
-
-export function getTextColor({ theme, disabled }: BaseProps) {
- return getStateLayer(
- theme,
- 'onSurfaceVariant',
- disabled ? 'disabled' : 'enabled'
- );
-}
-
-export function getIconColor({
- theme,
- isTextInputFocused,
- disabled,
- customColor,
-}: BaseProps & {
- isTextInputFocused: boolean;
- customColor?:
- | ColorValue
- | ((isTextInputFocused: boolean) => ColorValue | undefined);
-}) {
- 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/TextInput/TextInputErrorIcon.tsx b/src/components/TextInput/TextInputErrorIcon.tsx
new file mode 100644
index 0000000000..3af74ec3ad
--- /dev/null
+++ b/src/components/TextInput/TextInputErrorIcon.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 TextInputErrorIconProps {
+ style?: StyleProp;
+ theme?: ThemeProp;
+}
+
+const TextInputErrorIcon = ({
+ style: wrapperStyle,
+ theme: themeOverride,
+}: TextInputErrorIconProps) => {
+ const theme = useInternalTheme(themeOverride);
+
+ return (
+
+
+
+ );
+};
+
+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/TextInput/TextInputIcon.tsx b/src/components/TextInput/TextInputIcon.tsx
new file mode 100644
index 0000000000..e76528324f
--- /dev/null
+++ b/src/components/TextInput/TextInputIcon.tsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import { StyleProp, View, ViewStyle } from 'react-native';
+
+import { ACCESSORY_SIZE } from './constants';
+import { styles } from './styles';
+import { getIconColor } from './utils';
+import { useInternalTheme } from '../../core/theming';
+import type { $Omit } from '../../types';
+import IconButton from '../IconButton/IconButton';
+
+export type TextInputAccessoryProps = {
+ style: StyleProp;
+ multiline: boolean;
+ disabled: boolean;
+ error: boolean;
+};
+
+export type TextInputIconProps = TextInputAccessoryProps &
+ $Omit, keyof TextInputAccessoryProps>;
+
+/**
+ * A component to render a leading / trailing icon in the TextInput
+ * (return it from `startAccessory` or `endAccessory`). Accepts icon-specific props as well as
+ * `TextInputAccessoryProps`, which TextInput passes into those render props.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { TextInput } from 'react-native-paper';
+ *
+ * const MyComponent = () => {
+ * const [text, setText] = React.useState('');
+ *
+ * const searchAccessory = (props) => (
+ *
+ * );
+ *
+ * const clearAccessory = (props) => (
+ * setText('')} />
+ * );
+ *
+ * return (
+ *
+ * );
+ * };
+ *
+ * export default MyComponent;
+ * ```
+ *
+ * @extends IconButton props https://callstack.github.io/react-native-paper/docs/components/IconButton
+ */
+const TextInputIcon = ({
+ icon,
+ iconColor,
+ size,
+ style,
+ error,
+ disabled,
+ theme: themeOverride,
+ onPress,
+ ...rest
+}: TextInputIconProps) => {
+ const theme = useInternalTheme(themeOverride);
+
+ const iconSize = size ?? ACCESSORY_SIZE;
+
+ const color = getIconColor({
+ theme,
+ iconColor,
+ hasError: error,
+ isDisabled: disabled,
+ });
+
+ const onPressHandler = disabled ? undefined : onPress;
+
+ return (
+
+
+
+ );
+};
+
+TextInputIcon.displayName = 'TextInput.Icon';
+
+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/TextInput/constants.ts b/src/components/TextInput/constants.ts
new file mode 100644
index 0000000000..524593278f
--- /dev/null
+++ b/src/components/TextInput/constants.ts
@@ -0,0 +1,137 @@
+import { PixelRatio } from 'react-native';
+
+import { Easing } from 'react-native-reanimated';
+
+import { tokens } from '../../theme/tokens';
+import { motionDuration, motionEasing } from '../../theme/tokens/sys/motion';
+import { defaultShapes } from '../../theme/tokens/sys/shape';
+
+export const fontScale = PixelRatio.getFontScale();
+
+/**
+ * Common constants for the text input component.
+ */
+
+export const BASELINE_TEXT_INPUT_HEIGHT = 56;
+export const BASELINE_TEXT_INPUT_PADDING_VERTICAL = 8;
+
+export const TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL = 16;
+export const TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL = 12;
+
+export const TEXT_INPUT_HEIGHT = Math.ceil(
+ BASELINE_TEXT_INPUT_HEIGHT * fontScale
+);
+export const TEXT_INPUT_PADDING_VERTICAL = Math.ceil(
+ BASELINE_TEXT_INPUT_PADDING_VERTICAL * fontScale
+);
+
+export const TEXT_INPUT_BORDER_RADIUS = defaultShapes.corner.extraSmall;
+
+export const LABEL_START_OFFSET_WITHOUT_ACCESSORY =
+ TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL;
+
+export const ACCESSORY_SIZE = 24;
+
+export const PREFIX_END_PADDING = 2;
+export const SUFFIX_START_PADDING = 2;
+
+export const ERROR_ICON_SIZE = 16;
+
+export const INPUT_FONT_SIZE = tokens.md.sys.typescale.bodyLarge.fontSize;
+export const ACTIVE_LABEL_FONT_SIZE =
+ tokens.md.sys.typescale.bodySmall.fontSize;
+export const INACTIVE_LABEL_FONT_SIZE = INPUT_FONT_SIZE;
+export const SUPPORTING_TEXT_FONT_SIZE =
+ tokens.md.sys.typescale.bodySmall.fontSize;
+
+export const SUPPORTING_TEXT_MARGIN_TOP = 4;
+
+export const ANIMATION_DURATION_MS = motionDuration.short3;
+
+export const ACTIVE_INDICATOR_SIZE = 2;
+export const INACTIVE_INDICATOR_SIZE = 1;
+
+export const TIMING_CONFIG = {
+ duration: ANIMATION_DURATION_MS,
+ easing: Easing.bezier(...motionEasing.standard),
+} as const;
+
+/**
+ * Constants for the filled variant.
+ */
+
+const FILLED_LINE_HEIGHT_DELTA = 3;
+
+export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY =
+ ACCESSORY_SIZE +
+ TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL +
+ TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL;
+
+export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_INPUT_PADDING_VERTICAL;
+
+export const FILLED_INACTIVE_LABEL_TOP_POSITION = Math.ceil(
+ ((BASELINE_TEXT_INPUT_HEIGHT -
+ 2 * BASELINE_TEXT_INPUT_PADDING_VERTICAL -
+ INPUT_FONT_SIZE) /
+ 2 +
+ BASELINE_TEXT_INPUT_PADDING_VERTICAL) *
+ fontScale
+);
+
+export const FILLED_MULTILINE_PADDING_TOP =
+ 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_INPUT_PADDING_VERTICAL + FILLED_LINE_HEIGHT_DELTA;
+
+/**
+ * Constants for the outlined variant.
+ */
+
+const OUTLINED_LINE_HEIGHT_DELTA = 2;
+
+export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12;
+
+export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil(
+ ((BASELINE_TEXT_INPUT_HEIGHT -
+ 2 * BASELINE_TEXT_INPUT_PADDING_VERTICAL -
+ INPUT_FONT_SIZE) /
+ 2 -
+ OUTLINED_LINE_HEIGHT_DELTA) *
+ fontScale
+);
+
+export const OUTLINED_LABEL_PADDING_HORIZONTAL = 4;
+
+export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY =
+ ACCESSORY_SIZE +
+ TEXT_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_INPUT_PADDING_VERTICAL + OUTLINED_LINE_HEIGHT_DELTA) *
+ fontScale
+);
+
+export const OUTLINED_INACTIVE_LABEL_TOP_POSITION = Math.ceil(
+ ((BASELINE_TEXT_INPUT_HEIGHT -
+ 2 * BASELINE_TEXT_INPUT_PADDING_VERTICAL -
+ INPUT_FONT_SIZE) /
+ 2 +
+ BASELINE_TEXT_INPUT_PADDING_VERTICAL -
+ OUTLINED_LINE_HEIGHT_DELTA) *
+ fontScale
+);
+
+/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */
+export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY =
+ ACCESSORY_SIZE +
+ TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL -
+ OUTLINED_LABEL_PADDING_HORIZONTAL;
+
+/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */
+export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY =
+ OUTLINED_LABEL_PADDING_HORIZONTAL;
diff --git a/src/components/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 c633314991..0000000000
--- a/src/components/TextInput/helpers.tsx
+++ /dev/null
@@ -1,503 +0,0 @@
-import type { ColorValue } from 'react-native';
-
-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.sys.state.opacity;
-
-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?: ColorValue }) => {
- if (textColor) {
- return textColor;
- }
-
- return theme.colors.onSurface;
-};
-
-const getActiveColor = ({
- theme,
- error,
- activeUnderlineColor,
- activeOutlineColor,
- mode,
-}: BaseProps & {
- error?: boolean;
- activeUnderlineColor?: ColorValue;
- activeOutlineColor?: ColorValue;
- 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: ColorValue;
- customSelectionColor?: ColorValue;
-}) => {
- 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?: ColorValue }) => {
- if (!disabled && underlineColor) {
- return underlineColor;
- }
-
- return theme.colors.onSurfaceVariant;
-};
-
-const getOutlinedOutlineInputColor = ({
- theme,
- disabled,
- customOutlineColor,
-}: BaseProps & { customOutlineColor?: ColorValue }) => {
- 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?: ColorValue;
- activeUnderlineColor?: ColorValue;
- customSelectionColor?: ColorValue;
- textColor?: ColorValue;
- 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?: ColorValue;
- customOutlineColor?: ColorValue;
- customSelectionColor?: ColorValue;
- textColor?: ColorValue;
- 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/TextInput/hooks.ts b/src/components/TextInput/hooks.ts
new file mode 100644
index 0000000000..9cc5fc4232
--- /dev/null
+++ b/src/components/TextInput/hooks.ts
@@ -0,0 +1,463 @@
+import {
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+ type RefObject,
+} from 'react';
+import {
+ BlurEvent,
+ FocusEvent,
+ TextInput as NativeTextInput,
+} 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 {
+ TextInputAnimationState,
+ TextInputFlags,
+ TextInputAnimationHandlers,
+ TextInputHookReturn,
+ TextInputLayoutState,
+ TextInputProps,
+ TextInputVariant,
+} from './TextInput';
+import {
+ getAccentColors,
+ getAccessibilityData,
+ getFilledTextInputData,
+ getOutlinedTextInputData,
+ getTextInputAnimationLayout,
+} from './utils';
+import { useLocale } from '../../core/locale';
+import { useInternalTheme } from '../../core/theming';
+import type { InternalTheme } from '../../types';
+
+const useTextInputAnimation = ({
+ variant,
+ isRTL,
+ hasAccessory,
+ value,
+ defaultValue,
+}: {
+ variant: TextInputVariant;
+ isRTL: boolean;
+ hasAccessory: boolean;
+ value: string | undefined;
+ defaultValue: string | undefined;
+}): TextInputAnimationState & TextInputAnimationHandlers => {
+ const initialText = value ?? defaultValue ?? '';
+
+ const focusSV = useSharedValue(0);
+ const floatSV = useSharedValue(initialText.length > 0 ? 1 : 0);
+
+ const { activeTop, inactiveTop, translateXEnd } = getTextInputAnimationLayout(
+ {
+ 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 useTextInputInput = (
+ props: Pick<
+ TextInputProps,
+ 'value' | 'defaultValue' | 'onChangeText' | 'counter' | 'maxLength'
+ >
+) => {
+ const isControlled = props.value !== undefined;
+ const hasCounter = !!(props.counter && props.maxLength);
+
+ const initialText = isControlled ? props.value : props.defaultValue;
+
+ const ref = useRef(initialText ?? '');
+
+ const [hasValue, setHasValue] = useState(!!initialText);
+ const [charCount, setCharCount] = useState(initialText?.length ?? 0);
+
+ const inputLength = isControlled
+ ? props.value?.length ?? 0
+ : hasCounter
+ ? charCount
+ : hasValue
+ ? 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;
+
+ if (hasCounter) {
+ setCharCount(text.length);
+ }
+
+ if (next !== hasValue) {
+ setHasValue(next);
+ }
+ }
+
+ props.onChangeText?.(text);
+ };
+
+ return {
+ hasValue: isControlled ? !!props.value : hasValue,
+ inputLength,
+ getHasText,
+ onChangeText,
+ };
+};
+
+const useTextInputFocus = (
+ props: Pick,
+ input: RefObject,
+ isDisabled: boolean,
+ { runFocusAnimation, runBlurAnimation }: TextInputAnimationHandlers,
+ 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();
+ };
+
+ return {
+ isFocused,
+ onFocus,
+ onBlur,
+ focusInput,
+ };
+};
+
+const useTextInputFlags = (
+ props: TextInputProps,
+ isFocused: boolean,
+ hasValue: boolean,
+ isRTL: boolean,
+ hasAccessory: boolean
+): TextInputFlags => {
+ const isFloating = isFocused || hasValue;
+
+ return {
+ isRTL,
+ isFloating,
+ isDisabled: !!props.disabled,
+ isEditable: props.disabled ? false : props.editable,
+ hasError: !!props.error,
+ hasCounter: !!(props.counter && props.maxLength),
+ hasAccessory,
+ hasPrefix: !!props.prefix && isFloating,
+ hasSuffix: !!props.suffix && isFloating,
+ };
+};
+
+const useTextInputLayout = ({
+ variant,
+ props,
+ input,
+ theme,
+ flags,
+ isFocused,
+ animation,
+}: {
+ variant: TextInputVariant;
+ props: TextInputProps;
+ input: RefObject;
+ theme: InternalTheme;
+ flags: TextInputFlags;
+ isFocused: boolean;
+ animation: TextInputAnimationState;
+}): TextInputLayoutState => {
+ const { isRTL, isDisabled, hasError, hasAccessory, hasSuffix } = flags;
+
+ const { multiline } = props;
+
+ const {
+ animatedLabelWrapperStyle,
+ animatedLabelTextStyle,
+ animatedActiveOutlineStyle,
+ } = animation;
+
+ return useMemo(
+ () => {
+ const {
+ input: _input,
+ isDisabled: _isDisabled,
+ hasError: _hasError,
+ hasSuffix: _hasSuffix,
+ ...layout
+ } = variant === 'filled'
+ ? getFilledTextInputData(
+ {
+ input,
+ theme,
+ isFocused,
+ isRTL,
+ isDisabled,
+ hasAccessory,
+ hasError,
+ hasSuffix,
+ animatedLabelWrapperStyle,
+ animatedLabelTextStyle,
+ animatedActiveOutlineStyle,
+ },
+ props
+ )
+ : getOutlinedTextInputData(
+ {
+ 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.
+ * 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,
+ isRTL,
+ isDisabled,
+ hasAccessory,
+ hasError,
+ hasSuffix,
+ multiline,
+ ]
+ );
+};
+
+export const useTextInput = (props: TextInputProps): TextInputHookReturn => {
+ const { ref, variant = 'filled', theme: themeOverride } = props;
+
+ const input = useRef(null);
+
+ const theme = useInternalTheme(themeOverride);
+
+ const { direction } = useLocale();
+
+ const isRTL = direction === 'rtl';
+ const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory;
+
+ const { hasValue, inputLength, getHasText, onChangeText } =
+ useTextInputInput(props);
+
+ const animation = useTextInputAnimation({
+ variant,
+ isRTL,
+ hasAccessory,
+ value: props.value,
+ defaultValue: props.defaultValue,
+ });
+
+ const { isFocused, onFocus, onBlur, focusInput } = useTextInputFocus(
+ props,
+ input,
+ !!props.disabled,
+ animation,
+ getHasText
+ );
+
+ const flags = useTextInputFlags(
+ props,
+ isFocused,
+ hasValue,
+ isRTL,
+ hasAccessory
+ );
+
+ 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,
+ hasError: flags.hasError,
+ });
+
+ const placeholderTextColor =
+ props.placeholderTextColor ?? theme.colors.onSurfaceVariant;
+
+ const layout = useTextInputLayout({
+ variant,
+ props,
+ input,
+ theme,
+ flags,
+ isFocused,
+ animation,
+ });
+
+ const accessibilityProps = getAccessibilityData({
+ hasError: flags.hasError,
+ hasCounter: flags.hasCounter,
+ isDisabled: flags.isDisabled,
+ data: props,
+ inputLength,
+ });
+
+ const counterText = `${inputLength}/${props.maxLength}`;
+
+ const renderLeadingAccessory = flags.isRTL
+ ? props.endAccessory
+ : props.startAccessory;
+ const renderTrailingAccessory = flags.isRTL
+ ? props.startAccessory
+ : props.endAccessory;
+
+ // https://github.com/facebook/react-native/issues/31573
+ const placeholder = isFocused ? props.placeholder : ' ';
+
+ 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: animation.animatedContainerStyle,
+ placeholder,
+ counterText,
+ accessibilityProps,
+ ...layout,
+ renderLeadingAccessory,
+ renderTrailingAccessory,
+ onChangeText,
+ onFocus,
+ onBlur,
+ focusInput,
+ };
+};
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/TextInput/styles.ts b/src/components/TextInput/styles.ts
new file mode 100644
index 0000000000..0a65662a5a
--- /dev/null
+++ b/src/components/TextInput/styles.ts
@@ -0,0 +1,128 @@
+import { StyleSheet } from 'react-native';
+
+import {
+ ACCESSORY_SIZE,
+ FILLED_DISABLED_CONTAINER_OPACITY,
+ OUTLINED_LABEL_PADDING_HORIZONTAL,
+ SUPPORTING_TEXT_FONT_SIZE,
+ SUPPORTING_TEXT_MARGIN_TOP,
+ TEXT_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';
+
+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_INPUT_HEIGHT,
+ paddingVertical: TEXT_INPUT_PADDING_VERTICAL,
+ },
+ addendum: {
+ flexDirection: 'row',
+ },
+ supportingText: {
+ flex: 1,
+ marginTop: SUPPORTING_TEXT_MARGIN_TOP,
+ paddingHorizontal: TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL,
+ fontSize: SUPPORTING_TEXT_FONT_SIZE,
+ fontWeight: bodySmall.fontWeight,
+ textAlign: 'left',
+ },
+ counter: {
+ marginTop: SUPPORTING_TEXT_MARGIN_TOP,
+ marginStart: 'auto',
+ paddingHorizontal: TEXT_INPUT_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_INPUT_ACCESSORY_MARGIN_HORIZONTAL,
+ },
+ leadingAccessory: {
+ width: ACCESSORY_SIZE,
+ height: ACCESSORY_SIZE,
+ alignSelf: 'center',
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginStart: TEXT_INPUT_ACCESSORY_MARGIN_HORIZONTAL,
+ },
+ disabled: {
+ opacity: tokens.md.sys.state.opacity.disabled,
+ },
+ iconWrapper: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ icon: {
+ margin: 0,
+ },
+});
+
+export const filledStyles = StyleSheet.create({
+ outline: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ pointerEvents: 'none',
+ },
+ container: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ paddingHorizontal: TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL,
+ },
+ labelWrapper: {
+ position: 'absolute',
+ pointerEvents: 'none',
+ },
+ disabledBackground: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ opacity: FILLED_DISABLED_CONTAINER_OPACITY,
+ pointerEvents: 'none',
+ },
+});
+
+export const outlinedStyles = StyleSheet.create({
+ outline: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ borderRadius: TEXT_INPUT_BORDER_RADIUS,
+ pointerEvents: 'none',
+ },
+ container: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: TEXT_INPUT_INPUT_WRAPPER_PADDING_HORIZONTAL,
+ },
+ labelWrapper: {
+ position: 'absolute',
+ paddingHorizontal: OUTLINED_LABEL_PADDING_HORIZONTAL,
+ pointerEvents: 'none',
+ },
+});
diff --git a/src/components/TextInput/types.tsx b/src/components/TextInput/types.tsx
deleted file mode 100644
index ffb1f0bda0..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?: ColorValue;
- cursorColor?: ColorValue;
- underlineColor?: ColorValue;
- activeUnderlineColor?: ColorValue;
- outlineColor?: ColorValue;
- activeOutlineColor?: ColorValue;
- textColor?: ColorValue;
- 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?: ColorValue;
- cursorColor?: ColorValue;
- onFocus?: (args: any) => void;
- onBlur?: (args: any) => void;
- underlineColorAndroid?: ColorValue;
- 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: ColorValue | null;
- disabledOpacity?: number;
- backgroundColor?: ColorValue;
- label?: TextInputLabelProp | null;
- hasActiveOutline?: boolean | null;
- activeColor: ColorValue;
- errorColor?: ColorValue;
- 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/TextInput/utils.ts b/src/components/TextInput/utils.ts
new file mode 100644
index 0000000000..6ca77029e2
--- /dev/null
+++ b/src/components/TextInput/utils.ts
@@ -0,0 +1,642 @@
+import {
+ ColorValue,
+ Platform,
+ StyleProp,
+ TextStyle,
+ ViewStyle,
+} from 'react-native';
+
+import { AnimatedStyle } from 'react-native-reanimated';
+
+import {
+ ACTIVE_INDICATOR_SIZE,
+ FILLED_ACTIVE_LABEL_TOP_POSITION,
+ FILLED_INACTIVE_LABEL_TOP_POSITION,
+ FILLED_LABEL_START_OFFSET_WITH_ACCESSORY,
+ FILLED_MULTILINE_PADDING_TOP,
+ INACTIVE_INDICATOR_SIZE,
+ 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,
+ OUTLINED_MULTILINE_PADDING_TOP,
+ PREFIX_END_PADDING,
+ SUFFIX_START_PADDING,
+ TEXT_INPUT_BORDER_RADIUS,
+ FILLED_PADDING_BOTTOM,
+} from './constants';
+import { filledStyles, outlinedStyles, styles } from './styles';
+import type {
+ FilledTextInputHookData,
+ OutlinedTextInputHookData,
+ TextInputProps,
+ TextInputSharedApi,
+ TextInputVariant,
+ SharedTextInputStyleData,
+ GetAccessibilityDataProps,
+ GetAccessibilityDataReturn,
+} from './TextInput';
+import type { InternalTheme } from '../../types';
+
+export const getTextInputAnimationLayout = ({
+ variant,
+ hasAccessory,
+ isRTL,
+}: {
+ variant: TextInputVariant;
+ 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,
+}: {
+ theme: InternalTheme;
+ hasError: boolean;
+}) => {
+ const color = hasError ? theme.colors.error : theme.colors.primary;
+
+ return {
+ selectionColor: color,
+ cursorColor: color,
+ };
+};
+
+export const getLabelColor = ({
+ theme,
+ hasError,
+ isFocused,
+ isDisabled,
+}: {
+ theme: InternalTheme;
+ isFocused: boolean;
+ hasError: boolean;
+ isDisabled: boolean;
+}) => {
+ const {
+ colors: { error, primary, onSurface, onSurfaceVariant },
+ } = theme;
+
+ if (hasError) {
+ return error;
+ }
+ if (isDisabled) {
+ return onSurface;
+ }
+ if (isFocused) {
+ return primary;
+ }
+ return onSurfaceVariant;
+};
+
+export const getSupportingTextColor = ({
+ theme,
+ hasError,
+ isDisabled,
+}: {
+ theme: InternalTheme;
+ hasError: boolean;
+ isDisabled: boolean;
+}) => {
+ const {
+ colors: { error, onSurface, onSurfaceVariant },
+ } = theme;
+
+ if (hasError) {
+ return error;
+ }
+ if (isDisabled) {
+ return onSurface;
+ }
+ return onSurfaceVariant;
+};
+
+/**
+ * Returns the solid background color for the filled field container, or
+ * `undefined` when disabled. The disabled tint is rendered
+ * as a separate overlay View whose alpha is applied via the `opacity` style;
+ * keeping the alpha out of the color string is what makes the component safe
+ * to use with `PlatformColor` values on Android.
+ */
+export const getFieldBackgroundColor = ({
+ theme,
+ isDisabled,
+}: {
+ theme: InternalTheme;
+ isDisabled: boolean;
+}): ColorValue | undefined => {
+ if (isDisabled) {
+ return undefined;
+ }
+
+ return theme.colors.surfaceContainerHighest;
+};
+
+export const getIconColor = ({
+ theme,
+ iconColor,
+ hasError,
+ isDisabled,
+}: {
+ theme: InternalTheme;
+ iconColor?: ColorValue;
+ hasError: boolean;
+ isDisabled: boolean;
+}): ColorValue => {
+ if (iconColor) return iconColor;
+ if (hasError) return theme.colors.error;
+ if (isDisabled) return theme.colors.onSurface;
+ return theme.colors.onSurfaceVariant;
+};
+
+/**
+ * Returns the raw outline color for a filled field. The disabled state's
+ * alpha is intentionally NOT baked in here — it is applied via the `opacity`
+ * style on the (childless) outline View so the value can be a `PlatformColor`
+ * on Android, which the `color` library cannot parse at runtime.
+ */
+export const getOutlineColor = ({
+ theme,
+ hasError,
+ isFocused,
+ isDisabled,
+}: {
+ theme: InternalTheme;
+ isFocused: boolean;
+ hasError: boolean;
+ isDisabled: boolean;
+}) => {
+ const {
+ colors: { error, onSurface, primary, outline },
+ } = theme;
+
+ if (hasError) {
+ return error;
+ }
+ if (isDisabled) {
+ return onSurface;
+ }
+ if (isFocused) {
+ return primary;
+ }
+
+ return outline;
+};
+
+/**
+ * Computes the style arrays that are identical across the filled and outlined
+ * variants. Each variant logic function calls this and then only computes its
+ * own variant-specific styles on top.
+ *
+ * Returns `isRTL` as well so callers can use it when building `inputStyles`,
+ * which is variant-specific (filled adds `MULTILINE_PADDING_TOP`).
+ */
+export const getSharedTextInputStyleData = (
+ api: TextInputSharedApi
+): SharedTextInputStyleData => {
+ const {
+ theme,
+ isDisabled,
+ hasError,
+ isFocused,
+ isRTL,
+ animatedLabelTextStyle,
+ } = api;
+
+ const labelColor = getLabelColor({ theme, hasError, isFocused, isDisabled });
+
+ const supportingTextColor = getSupportingTextColor({
+ theme,
+ hasError,
+ isDisabled,
+ });
+ const {
+ colors: { onSurfaceVariant },
+ } = theme;
+
+ const animatedLabelTextStyles: StyleProp<
+ AnimatedStyle>
+ > = [
+ styles.input,
+ { color: labelColor },
+ animatedLabelTextStyle,
+ isDisabled && styles.disabled,
+ ];
+
+ const supportingTextStyles: StyleProp = [
+ styles.supportingText,
+ {
+ color: supportingTextColor,
+ writingDirection: isRTL ? 'rtl' : 'ltr',
+ },
+ isDisabled && styles.disabled,
+ ];
+
+ const counterStyles: StyleProp = [
+ styles.counter,
+ {
+ color: supportingTextColor,
+ writingDirection: isRTL ? 'rtl' : 'ltr',
+ },
+ isDisabled && styles.disabled,
+ ];
+
+ const prefixStyles: StyleProp = [
+ styles.input,
+ {
+ fontSize: INPUT_FONT_SIZE,
+ color: onSurfaceVariant,
+ paddingEnd: PREFIX_END_PADDING,
+ },
+ isDisabled && styles.disabled,
+ ];
+
+ const suffixStyles: StyleProp = [
+ styles.input,
+ {
+ fontSize: INPUT_FONT_SIZE,
+ color: onSurfaceVariant,
+ paddingStart: SUFFIX_START_PADDING,
+ },
+ isDisabled && styles.disabled,
+ ];
+
+ const leadingAccessoryStyles: StyleProp = [
+ styles.leadingAccessory,
+ isDisabled && styles.disabled,
+ ];
+
+ const trailingAccessoryStyles: StyleProp = [
+ styles.trailingAccessory,
+ isDisabled && styles.disabled,
+ ];
+
+ return {
+ isRTL,
+ animatedLabelTextStyles,
+ supportingTextStyles,
+ counterStyles,
+ prefixStyles,
+ suffixStyles,
+ leadingAccessoryStyles,
+ trailingAccessoryStyles,
+ };
+};
+
+export const getFilledTextInputData = (
+ api: TextInputSharedApi,
+ props: TextInputProps
+): FilledTextInputHookData => {
+ const { style: inputStyleOverride, ...textInputProps } = props;
+
+ const {
+ input,
+ theme,
+ hasSuffix,
+ isDisabled,
+ hasAccessory,
+ hasError,
+ animatedLabelWrapperStyle,
+ animatedActiveOutlineStyle,
+ } = api;
+
+ /**
+ * Theme tokens
+ */
+ const {
+ colors: { onSurface },
+ } = theme;
+
+ const outlineColor = getOutlineColor({
+ theme,
+ hasError,
+ isFocused: false,
+ isDisabled,
+ });
+
+ const activeOutlineColor = getOutlineColor({
+ theme,
+ hasError,
+ isFocused: true,
+ isDisabled,
+ });
+
+ const fieldBackgroundColor = getFieldBackgroundColor({ theme, isDisabled });
+
+ /**
+ * Shared styles
+ */
+
+ const shared = getSharedTextInputStyleData(api);
+
+ /**
+ * Variant-specific styles
+ */
+
+ const animatedLabelWrapperStyles: StyleProp<
+ AnimatedStyle>
+ > = [
+ filledStyles.labelWrapper,
+ {
+ left: hasAccessory
+ ? FILLED_LABEL_START_OFFSET_WITH_ACCESSORY
+ : LABEL_START_OFFSET_WITHOUT_ACCESSORY,
+ },
+ animatedLabelWrapperStyle,
+ ];
+
+ const containerStyles: StyleProp = [
+ filledStyles.container,
+ isDisabled && styles.disabled,
+ ];
+
+ const fieldStyles: StyleProp = [
+ styles.field,
+ {
+ paddingBottom: FILLED_PADDING_BOTTOM,
+ backgroundColor: fieldBackgroundColor,
+ borderTopStartRadius: TEXT_INPUT_BORDER_RADIUS,
+ borderTopEndRadius: TEXT_INPUT_BORDER_RADIUS,
+ overflow: 'hidden',
+ },
+ ];
+
+ /* Disabled tint (DISABLED_CONTAINER_OPACITY) is rendered as a childless overlay so its
+ alpha can be applied via the `opacity` style without leaking onto the label
+ and input. The View accepts `PlatformColor` directly. */
+ const disabledBackgroundStyles: StyleProp | undefined = isDisabled
+ ? [
+ filledStyles.disabledBackground,
+ {
+ backgroundColor: onSurface,
+ },
+ ]
+ : undefined;
+
+ const outlineStyles: StyleProp = [
+ filledStyles.outline,
+ {
+ height: INACTIVE_INDICATOR_SIZE,
+ backgroundColor: outlineColor,
+ },
+ isDisabled && styles.disabled,
+ ];
+
+ const animatedActiveOutlineStyles: StyleProp<
+ AnimatedStyle>
+ > = [
+ filledStyles.outline,
+ {
+ height: ACTIVE_INDICATOR_SIZE,
+ backgroundColor: activeOutlineColor,
+ },
+ isDisabled && styles.disabled,
+ animatedActiveOutlineStyle,
+ ];
+
+ const inputStyles: StyleProp = [
+ styles.input,
+ {
+ flex: 1,
+ color: onSurface,
+ fontSize: INPUT_FONT_SIZE,
+ textAlign: hasSuffix === shared.isRTL ? 'left' : 'right',
+ writingDirection: shared.isRTL ? 'rtl' : 'ltr',
+ },
+ textInputProps.multiline && {
+ height: 'auto',
+ paddingTop: FILLED_MULTILINE_PADDING_TOP,
+ },
+ //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that
+ Platform.OS === 'web' && {
+ outlineStyle: 'none' as const,
+ },
+ isDisabled && styles.disabled,
+ inputStyleOverride,
+ ];
+
+ return {
+ input,
+ isDisabled,
+ hasError,
+ hasSuffix,
+ animatedLabelWrapperStyles,
+ containerStyles,
+ fieldStyles,
+ disabledBackgroundStyles,
+ outlineStyles,
+ animatedActiveOutlineStyles,
+ inputStyles,
+ ...shared,
+ };
+};
+
+export const getOutlinedTextInputData = (
+ api: TextInputSharedApi,
+ props: TextInputProps
+): OutlinedTextInputHookData => {
+ const { style: inputStyleOverride, ...textInputProps } = props;
+
+ const {
+ input,
+ theme,
+ isFocused,
+ isDisabled,
+ hasAccessory,
+ hasError,
+ hasSuffix,
+ animatedLabelWrapperStyle,
+ } = api;
+
+ /**
+ * Theme tokens
+ */
+
+ const {
+ colors: { background: labelBackgroundColor, onSurface },
+ } = theme;
+
+ const outlineColor = getOutlineColor({
+ theme,
+ isDisabled,
+ isFocused,
+ hasError,
+ });
+
+ /**
+ * Shared styles
+ */
+
+ const shared = getSharedTextInputStyleData(api);
+
+ /**
+ * Variant-specific styles
+ */
+
+ const containerStyles: StyleProp = [
+ outlinedStyles.container,
+ isDisabled && styles.disabled,
+ ];
+
+ const fieldStyles: StyleProp = [
+ styles.field,
+ {
+ borderRadius: TEXT_INPUT_BORDER_RADIUS,
+ },
+ textInputProps.multiline && { alignItems: 'flex-start' },
+ ];
+
+ /* The outline is a childless absolutely-positioned View, so applying
+ `opacity` here is safe and lets us pass `outlineColor` through unchanged
+ (including PlatformColor values on Android). */
+ const outlineStyles: StyleProp = [
+ outlinedStyles.outline,
+ {
+ borderWidth: isFocused ? 2 : 1,
+ borderColor: outlineColor,
+ },
+ isDisabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY },
+ ];
+
+ const animatedLabelWrapperStyles: StyleProp<
+ AnimatedStyle>
+ > = [
+ outlinedStyles.labelWrapper,
+ {
+ left: hasAccessory
+ ? OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY
+ : LABEL_START_OFFSET_WITHOUT_ACCESSORY,
+ backgroundColor: labelBackgroundColor,
+ },
+ animatedLabelWrapperStyle,
+ ];
+
+ const inputStyles: StyleProp = [
+ styles.input,
+ {
+ flex: 1,
+ color: onSurface,
+ fontSize: INPUT_FONT_SIZE,
+ textAlign: hasSuffix === shared.isRTL ? 'left' : 'right',
+ writingDirection: shared.isRTL ? 'rtl' : 'ltr',
+ },
+ textInputProps.multiline && {
+ height: 'auto',
+ textAlignVertical: 'top',
+ paddingTop: OUTLINED_MULTILINE_PADDING_TOP,
+ },
+ //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that
+ Platform.OS === 'web' && {
+ outlineStyle: 'none' as const,
+ },
+ isDisabled && styles.disabled,
+ inputStyleOverride,
+ ];
+
+ return {
+ input,
+ isDisabled,
+ hasError,
+ hasSuffix,
+ animatedLabelWrapperStyles,
+ containerStyles,
+ fieldStyles,
+ disabledBackgroundStyles: undefined,
+ outlineStyles,
+ inputStyles,
+ ...shared,
+ };
+};
+
+export const getAccessibilityData = ({
+ data,
+ hasError,
+ hasCounter,
+ isDisabled,
+ inputLength,
+}: GetAccessibilityDataProps): GetAccessibilityDataReturn => {
+ const { label, supportingText, ...props } = data;
+
+ const maxLength = props.maxLength;
+ const shouldEvaluateCounter = !!maxLength && hasCounter;
+ const isEmptyString = inputLength === 0;
+ const isCounterExceeded = shouldEvaluateCounter && inputLength > maxLength;
+ const isCounterReached = shouldEvaluateCounter && inputLength === 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 ${inputLength} of ${maxLength}`;
+ }
+
+ const counterAccessibilityLabel = shouldEvaluateCounter
+ ? isCounterExceeded
+ ? `Character limit exceeded ${inputLength} of ${maxLength}`
+ : `Characters entered ${inputLength} 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 ? inputLength : 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__/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__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx
index 97f77833aa..3393ea7f2d 100644
--- a/src/components/__tests__/TextInput.test.tsx
+++ b/src/components/__tests__/TextInput.test.tsx
@@ -1,990 +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 { getTheme, ThemeProvider } from '../../core/theming';
-import { render } from '../../test-utils';
-import { red500 } from '../../theme/colors';
-import { LightTheme } from '../../theme/schemes';
+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.sys.state.opacity;
-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 = {
- ...LightTheme,
- colors: {
- ...LightTheme.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__/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 b5ecadea41..48af0a868b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -36,7 +36,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';
@@ -50,7 +49,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 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';
@@ -101,7 +100,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';
@@ -125,9 +123,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 {
+ TextInputProps,
+ TextInputRenderProps,
+ TextInputVariant,
+ TextInputHandles,
+} from './components/TextInput/TextInput';
+export type {
+ 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 63b967951e..07931713e5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10824,6 +10824,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"
@@ -19950,6 +19951,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"