Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add activity indicator to TextInput (#1) #4375

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions example/src/Examples/TextInputExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,19 @@ const TextInputExample = () => {
/>
}
/>
<TextInput
style={styles.inputContainerStyle}
label="Flat input with Activity Indicator"
placeholder="Type something"
value={text}
onChangeText={(text) => inputActionHandler('text', text)}
maxLength={100}
right={
<TextInput.ActivityIndicator
useNativeActivityIndicator={true}
/>
}
/>
<TextInput
style={[styles.inputContainerStyle, styles.fontSize]}
label="Flat input large font"
Expand Down Expand Up @@ -291,6 +304,20 @@ const TextInputExample = () => {
maxLength={100}
right={<TextInput.Affix text={`${outlinedText.length}/100`} />}
/>
<TextInput
mode="outlined"
style={styles.inputContainerStyle}
label="Outlined with Activity Indicator"
placeholder="Press the icon to submit"
value={text}
onChangeText={(text) => inputActionHandler('text', text)}
maxLength={100}
right={
<TextInput.ActivityIndicator
useNativeActivityIndicator={true}
/>
}
/>
<TextInput
mode="outlined"
style={[styles.inputContainerStyle, styles.fontSize]}
Expand Down
126 changes: 126 additions & 0 deletions src/components/TextInput/Adornment/TextInputActivityIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React from 'react';
import {
StyleSheet,
StyleProp,
ViewStyle,
View,
ActivityIndicator as RNActivityIndicator,
} from 'react-native';

import { getIconColor } from './utils';
import { useInternalTheme } from '../../../core/theming';
import { $Omit, ThemeProp } from '../../../types';
import ActivityIndicator from '../../ActivityIndicator';
import { Props as ActivityIndicatorProps } from '../../ActivityIndicator';
import { ICON_SIZE } from '../constants';
import { getConstants } from '../helpers';

export type Props = $Omit<
ActivityIndicatorProps,
'size' | 'hidesWhenStopped'
> & {
/**
* When true, the loading indicator will be the React Native default ActivityIndicator.
*/
useNativeActivityIndicator?: boolean;
};

type StyleContextType = {
style: StyleProp<ViewStyle>;
isTextInputFocused: boolean;
testID: string;
disabled?: boolean;
};

const StyleContext = React.createContext<StyleContextType>({
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<StyleContextType, 'style'>
> = ({
indicator,
topPosition,
side,
isTextInputFocused,
testID,
theme: themeOverrides,
disabled,
}) => {
const { isV3 } = useInternalTheme(themeOverrides);
const { ICON_OFFSET } = getConstants(isV3);

const style: StyleProp<ViewStyle> = {
top: topPosition,
[side]: ICON_OFFSET,
};
const contextState = {
style,
isTextInputFocused,
side,
testID,
disabled,
};

return (
<StyleContext.Provider value={contextState}>
{indicator}
</StyleContext.Provider>
);
};

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 (
<View style={[styles.container, style]}>
{useNativeActivityIndicator ? (
<RNActivityIndicator color={indicatorColor} testID={testID} {...rest} />
) : (
<ActivityIndicator {...rest} color={indicatorColor} testID={testID} />
)}
</View>
);
};

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 };
33 changes: 27 additions & 6 deletions src/components/TextInput/Adornment/TextInputAdornment.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -120,6 +125,7 @@ export interface TextInputAdornmentProps {
[AdornmentSide.Right]: number | null;
};
[AdornmentType.Icon]: number;
[AdornmentType.ActivityIndicator]: number;
};
onAffixChange: {
[AdornmentSide.Left]: (event: LayoutChangeEvent) => void;
Expand Down Expand Up @@ -193,6 +199,21 @@ const TextInputAdornment: React.FunctionComponent<TextInputAdornmentProps> = ({
maxFontSizeMultiplier={maxFontSizeMultiplier}
/>
);
} else if (type === AdornmentType.ActivityIndicator) {
const { useNativeActivityIndicator } =
isValidElement(inputAdornmentComponent) &&
inputAdornmentComponent.props;
return (
<ActivityIndicatorAdornment
{...commonProps}
key={side}
indicator={inputAdornmentComponent}
topPosition={topPosition[AdornmentType.ActivityIndicator]}
theme={theme}
disabled={disabled}
useNativeActivityIndicator={useNativeActivityIndicator}
/>
);
} else {
return null;
}
Expand Down
1 change: 1 addition & 0 deletions src/components/TextInput/Adornment/enums.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum AdornmentType {
Icon = 'icon',
Affix = 'affix',
ActivityIndicator = 'activityIndicator',
}
export enum AdornmentSide {
Right = 'right',
Expand Down
7 changes: 7 additions & 0 deletions src/components/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,6 +183,7 @@ interface CompoundedComponent
> {
Icon: React.FunctionComponent<TextInputIconProps>;
Affix: React.FunctionComponent<Partial<TextInputAffixProps>>;
ActivityIndicator: React.FunctionComponent<TextInputActivityIndicatorProps>;
}

type TextInputHandles = Pick<
Expand Down Expand Up @@ -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;
3 changes: 3 additions & 0 deletions src/components/TextInput/TextInputFlat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ const TextInputFlat = ({

const iconTopPosition = (flatHeight - ADORNMENT_SIZE) / 2;

const loadingTopPosition = iconTopPosition;

const leftAffixTopPosition = leftLayout.height
? calculateFlatAffixTopPosition({
height: flatHeight,
Expand Down Expand Up @@ -315,6 +317,7 @@ const TextInputFlat = ({
topPosition: {
[AdornmentType.Affix]: affixTopPosition,
[AdornmentType.Icon]: iconTopPosition,
[AdornmentType.ActivityIndicator]: loadingTopPosition,
},
onAffixChange,
isTextInputFocused: parentState.focused,
Expand Down
7 changes: 7 additions & 0 deletions src/components/TextInput/TextInputOutlined.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -316,6 +322,7 @@ const TextInputOutlined = ({
topPosition: {
[AdornmentType.Icon]: iconTopPosition,
[AdornmentType.Affix]: affixTopPosition,
[AdornmentType.ActivityIndicator]: loadingTopPosition,
},
onAffixChange,
isTextInputFocused: parentState.focused,
Expand Down
3 changes: 3 additions & 0 deletions src/components/TextInput/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type TextInputProps = React.ComponentPropsWithRef<typeof NativeTextInput> & {
left?: React.ReactNode;
right?: React.ReactNode;
disabled?: boolean;
useNativeActivityIndicator?: boolean;
loading?: boolean;
label?: TextInputLabelProp;
placeholder?: string;
error?: boolean;
Expand All @@ -45,6 +47,7 @@ type TextInputProps = React.ComponentPropsWithRef<typeof NativeTextInput> & {
contentStyle?: StyleProp<TextStyle>;
outlineStyle?: StyleProp<ViewStyle>;
underlineStyle?: StyleProp<ViewStyle>;
loadingStyle?: StyleProp<ViewStyle>;
};

export type RenderProps = {
Expand Down