diff --git a/example/src/Examples/TextInputExample.tsx b/example/src/Examples/TextInputExample.tsx index c39c19021d..4b9db43abc 100644 --- a/example/src/Examples/TextInputExample.tsx +++ b/example/src/Examples/TextInputExample.tsx @@ -214,6 +214,19 @@ const TextInputExample = () => { /> } /> + inputActionHandler('text', text)} + maxLength={100} + right={ + + } + /> { maxLength={100} right={} /> + inputActionHandler('text', text)} + maxLength={100} + right={ + + } + /> & { + /** + * When true, the loading indicator will be the React Native default ActivityIndicator. + */ + useNativeActivityIndicator?: boolean; +}; + +type StyleContextType = { + style: StyleProp; + isTextInputFocused: boolean; + testID: string; + disabled?: boolean; +}; + +const StyleContext = React.createContext({ + style: {}, + isTextInputFocused: false, + testID: '', +}); + +const ActivityIndicatorAdornment: React.FunctionComponent< + { + testID: string; + indicator: React.ReactNode; + topPosition: number; + side: 'left' | 'right'; + theme?: ThemeProp; + disabled?: boolean; + useNativeActivityIndicator?: boolean; + } & Omit +> = ({ + indicator, + topPosition, + side, + isTextInputFocused, + testID, + theme: themeOverrides, + disabled, +}) => { + const { isV3 } = useInternalTheme(themeOverrides); + const { ICON_OFFSET } = getConstants(isV3); + + const style: StyleProp = { + top: topPosition, + [side]: ICON_OFFSET, + }; + const contextState = { + style, + isTextInputFocused, + side, + testID, + disabled, + }; + + return ( + + {indicator} + + ); +}; + +const TextInputActivityIndicator = ({ + useNativeActivityIndicator, + color: customColor, + theme: themeOverrides, + ...rest +}: Props) => { + const { style, isTextInputFocused, testID, disabled } = + React.useContext(StyleContext); + + const theme = useInternalTheme(themeOverrides); + + const indicatorColor = getIconColor({ + theme, + disabled, + isTextInputFocused, + customColor, + }); + + return ( + + {useNativeActivityIndicator ? ( + + ) : ( + + )} + + ); +}; + +TextInputActivityIndicator.displayName = 'TextInput.ActivityIndicator'; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + width: ICON_SIZE, + height: ICON_SIZE, + justifyContent: 'center', + alignItems: 'center', + }, +}); + +export default TextInputActivityIndicator; + +// @component-docs ignore-next-line +export { ActivityIndicatorAdornment }; diff --git a/src/components/TextInput/Adornment/TextInputAdornment.tsx b/src/components/TextInput/Adornment/TextInputAdornment.tsx index 9f03eb2472..7feb3028ce 100644 --- a/src/components/TextInput/Adornment/TextInputAdornment.tsx +++ b/src/components/TextInput/Adornment/TextInputAdornment.tsx @@ -1,14 +1,17 @@ -import React from 'react'; -import type { - LayoutChangeEvent, - TextStyle, - StyleProp, - Animated, +import React, { isValidElement } from 'react'; +import { + type LayoutChangeEvent, + type TextStyle, + type StyleProp, + type Animated, } from 'react-native'; import type { ThemeProp } from 'src/types'; import { AdornmentSide, AdornmentType, InputMode } from './enums'; +import TextInputActivityIndicator, { + ActivityIndicatorAdornment, +} from './TextInputActivityIndicator'; import TextInputAffix, { AffixAdornment } from './TextInputAffix'; import TextInputIcon, { IconAdornment } from './TextInputIcon'; import type { @@ -36,6 +39,8 @@ export function getAdornmentConfig({ type = AdornmentType.Affix; } else if (adornment.type === TextInputIcon) { type = AdornmentType.Icon; + } else if (adornment.type === TextInputActivityIndicator) { + type = AdornmentType.ActivityIndicator; } adornmentConfig.push({ side, @@ -120,6 +125,7 @@ export interface TextInputAdornmentProps { [AdornmentSide.Right]: number | null; }; [AdornmentType.Icon]: number; + [AdornmentType.ActivityIndicator]: number; }; onAffixChange: { [AdornmentSide.Left]: (event: LayoutChangeEvent) => void; @@ -193,6 +199,21 @@ const TextInputAdornment: React.FunctionComponent = ({ maxFontSizeMultiplier={maxFontSizeMultiplier} /> ); + } else if (type === AdornmentType.ActivityIndicator) { + const { useNativeActivityIndicator } = + isValidElement(inputAdornmentComponent) && + inputAdornmentComponent.props; + return ( + + ); } else { return null; } diff --git a/src/components/TextInput/Adornment/enums.tsx b/src/components/TextInput/Adornment/enums.tsx index 9a364f7215..4deefe8f24 100644 --- a/src/components/TextInput/Adornment/enums.tsx +++ b/src/components/TextInput/Adornment/enums.tsx @@ -1,6 +1,7 @@ export enum AdornmentType { Icon = 'icon', Affix = 'affix', + ActivityIndicator = 'activityIndicator', } export enum AdornmentSide { Right = 'right', diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index 6b0083ae70..fddbf637a2 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -10,6 +10,9 @@ import { TextLayoutEventData, } from 'react-native'; +import TextInputActivityIndicator, { + Props as TextInputActivityIndicatorProps, +} from './Adornment/TextInputActivityIndicator'; import TextInputAffix, { Props as TextInputAffixProps, } from './Adornment/TextInputAffix'; @@ -180,6 +183,7 @@ interface CompoundedComponent > { Icon: React.FunctionComponent; Affix: React.FunctionComponent>; + ActivityIndicator: React.FunctionComponent; } type TextInputHandles = Pick< @@ -572,4 +576,7 @@ TextInput.Icon = TextInputIcon; // @ts-ignore Types of property 'theme' are incompatible. TextInput.Affix = TextInputAffix; +// @component ./Adornment/TextInputActivityIndicator.tsx +TextInput.ActivityIndicator = TextInputActivityIndicator; + export default TextInput; diff --git a/src/components/TextInput/TextInputFlat.tsx b/src/components/TextInput/TextInputFlat.tsx index c9dabcc228..eb99ac3a38 100644 --- a/src/components/TextInput/TextInputFlat.tsx +++ b/src/components/TextInput/TextInputFlat.tsx @@ -238,6 +238,8 @@ const TextInputFlat = ({ const iconTopPosition = (flatHeight - ADORNMENT_SIZE) / 2; + const loadingTopPosition = iconTopPosition; + const leftAffixTopPosition = leftLayout.height ? calculateFlatAffixTopPosition({ height: flatHeight, @@ -315,6 +317,7 @@ const TextInputFlat = ({ topPosition: { [AdornmentType.Affix]: affixTopPosition, [AdornmentType.Icon]: iconTopPosition, + [AdornmentType.ActivityIndicator]: loadingTopPosition, }, onAffixChange, isTextInputFocused: parentState.focused, diff --git a/src/components/TextInput/TextInputOutlined.tsx b/src/components/TextInput/TextInputOutlined.tsx index bdecd73569..36f906d9aa 100644 --- a/src/components/TextInput/TextInputOutlined.tsx +++ b/src/components/TextInput/TextInputOutlined.tsx @@ -285,6 +285,12 @@ const TextInputOutlined = ({ labelYOffset: -yOffset, }); + const loadingTopPosition = calculateOutlinedIconAndAffixTopPosition({ + height: outlinedHeight, + affixHeight: ADORNMENT_SIZE, + labelYOffset: -yOffset, + }); + const rightAffixWidth = right ? rightLayout.width || ADORNMENT_SIZE : ADORNMENT_SIZE; @@ -316,6 +322,7 @@ const TextInputOutlined = ({ topPosition: { [AdornmentType.Icon]: iconTopPosition, [AdornmentType.Affix]: affixTopPosition, + [AdornmentType.ActivityIndicator]: loadingTopPosition, }, onAffixChange, isTextInputFocused: parentState.focused, diff --git a/src/components/TextInput/types.tsx b/src/components/TextInput/types.tsx index 50863082ed..7593244b7a 100644 --- a/src/components/TextInput/types.tsx +++ b/src/components/TextInput/types.tsx @@ -21,6 +21,8 @@ type TextInputProps = React.ComponentPropsWithRef & { left?: React.ReactNode; right?: React.ReactNode; disabled?: boolean; + useNativeActivityIndicator?: boolean; + loading?: boolean; label?: TextInputLabelProp; placeholder?: string; error?: boolean; @@ -45,6 +47,7 @@ type TextInputProps = React.ComponentPropsWithRef & { contentStyle?: StyleProp; outlineStyle?: StyleProp; underlineStyle?: StyleProp; + loadingStyle?: StyleProp; }; export type RenderProps = {