Skip to content

Commit

Permalink
Merge pull request #53695 from margelo/feat/masked-input
Browse files Browse the repository at this point in the history
fix: no jumpy input in amount filter
  • Loading branch information
luacmartins authored Feb 3, 2025
2 parents b5bcd0c + b2a30b8 commit d31566b
Show file tree
Hide file tree
Showing 21 changed files with 275 additions and 117 deletions.
29 changes: 29 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ PODS:
- GoogleUtilities/Environment (~> 7.7)
- "GoogleUtilities/NSData+zlib (~> 7.7)"
- fmt (9.1.0)
- ForkInputMask (7.3.3)
- FullStory (1.52.0)
- fullstory_react-native (1.7.2):
- DoubleConversion
Expand Down Expand Up @@ -1604,6 +1605,28 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-advanced-input-mask (1.2.1):
- DoubleConversion
- ForkInputMask (~> 7.3.2)
- glog
- hermes-engine
- RCT-Folly (= 2024.01.01.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-airship (19.2.1):
- AirshipFrameworkProxy (= 7.1.2)
- DoubleConversion
Expand Down Expand Up @@ -2880,6 +2903,7 @@ DEPENDENCIES:
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-advanced-input-mask (from `../node_modules/react-native-advanced-input-mask`)
- "react-native-airship (from `../node_modules/@ua/react-native-airship`)"
- react-native-app-logs (from `../node_modules/react-native-app-logs`)
- react-native-blob-util (from `../node_modules/react-native-blob-util`)
Expand Down Expand Up @@ -2968,6 +2992,7 @@ SPEC REPOS:
- FirebaseInstallations
- FirebasePerformance
- FirebaseRemoteConfig
- ForkInputMask
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleSignIn
Expand Down Expand Up @@ -3093,6 +3118,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon"
React-microtasksnativemodule:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-advanced-input-mask:
:path: "../node_modules/react-native-advanced-input-mask"
react-native-airship:
:path: "../node_modules/@ua/react-native-airship"
react-native-app-logs:
Expand Down Expand Up @@ -3270,6 +3297,7 @@ SPEC CHECKSUMS:
FirebasePerformance: 0c01a7a496657d7cea86d40c0b1725259d164c6c
FirebaseRemoteConfig: 2d6e2cfdb49af79535c8af8a80a4a5009038ec2b
fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be
ForkInputMask: 55e3fbab504b22da98483e9f9a6514b98fdd2f3c
FullStory: c8a10b2358c0d33c57be84d16e4c440b0434b33d
fullstory_react-native: 63a803cca04b0447a71daa73e4df3f7b56e1919d
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
Expand Down Expand Up @@ -3323,6 +3351,7 @@ SPEC CHECKSUMS:
React-logger: 26155dc23db5c9038794db915f80bd2044512c2e
React-Mapbuffer: ad1ba0205205a16dbff11b8ade6d1b3959451658
React-microtasksnativemodule: e771eb9eb6ace5884ee40a293a0e14a9d7a4343c
react-native-advanced-input-mask: 22e3bd2a0f38fada50b475c98bf39d39053097a3
react-native-airship: e10f6823d8da49bbcb2db4bdb16ff954188afccc
react-native-app-logs: ee32b6e80bf8d1b883dfc5ac96efa7c1bd9a06a5
react-native-blob-util: 221c61c98ae507b758472ac4d2d489119d1a6c44
Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"react-fast-pdf": "^1.0.22",
"react-map-gl": "^7.1.3",
"react-native": "0.76.3",
"react-native-advanced-input-mask": "1.2.1",
"react-native-android-location-enabler": "^2.0.1",
"react-native-app-logs": "0.3.1",
"react-native-blob-util": "0.19.4",
Expand Down
46 changes: 46 additions & 0 deletions src/components/AmountWithoutCurrencyInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import type {ForwardedRef} from 'react';
import CONST from '@src/CONST';
import TextInput from './TextInput';
import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';

type AmountFormProps = {
/** Amount supplied by the FormProvider */
value?: string;

/** Callback to update the amount in the FormProvider */
onInputChange?: (value: string) => void;

/** Should we allow negative number as valid input */
shouldAllowNegative?: boolean;
} & Partial<BaseTextInputProps>;

function AmountWithoutCurrencyInput(
{value: amount, shouldAllowNegative = false, inputID, name, defaultValue, accessibilityLabel, role, label, ...rest}: AmountFormProps,
ref: ForwardedRef<BaseTextInputRef>,
) {
return (
<TextInput
inputID={inputID}
name={name}
label={label}
defaultValue={defaultValue}
accessibilityLabel={accessibilityLabel}
role={role}
ref={ref}
keyboardType={!shouldAllowNegative ? CONST.KEYBOARD_TYPE.DECIMAL_PAD : undefined}
type="mask"
mask="[09999999].[09]"
allowedKeys="0123456789.,"
// On android autoCapitalize="words" is necessary when keyboardType="decimal-pad" or inputMode="decimal" to prevent input lag.
// See https://github.com/Expensify/App/issues/51868 for more information
autoCapitalize="words"
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
);
}

AmountWithoutCurrencyInput.displayName = 'AmountWithoutCurrencyForm';

export default React.forwardRef(AmountWithoutCurrencyInput);
3 changes: 2 additions & 1 deletion src/components/Form/FormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,8 @@ function FormProvider(
value: inputValues[inputID],
// As the text input is controlled, we never set the defaultValue prop
// as this is already happening by the value prop.
defaultValue: undefined,
// If it's uncontrolled, then we set the `defaultValue` prop to actual value
defaultValue: inputProps.uncontrolled ? inputProps.defaultValue : undefined,
onTouched: (event) => {
if (!inputProps.shouldSetTouchedOnBlurOnly) {
setTimeout(() => {
Expand Down
1 change: 1 addition & 0 deletions src/components/Form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type InputComponentBaseProps<TValue extends ValueTypeKey = ValueTypeKey> = Input
autoGrowHeight?: boolean;
blurOnSubmit?: boolean;
shouldSubmitForm?: boolean;
uncontrolled?: boolean;
};

type FormOnyxValues<TFormID extends OnyxFormKey = OnyxFormKey> = Omit<OnyxValues[TFormID], keyof BaseForm>;
Expand Down
39 changes: 39 additions & 0 deletions src/components/RNMaskedTextInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type {ForwardedRef} from 'react';
import React from 'react';
import type {TextInput} from 'react-native';
import type {MaskedTextInputProps} from 'react-native-advanced-input-mask';
import {MaskedTextInput} from 'react-native-advanced-input-mask';
import Animated from 'react-native-reanimated';
import useTheme from '@hooks/useTheme';

// Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet
const AnimatedTextInput = Animated.createAnimatedComponent(MaskedTextInput);

type AnimatedTextInputRef = typeof AnimatedTextInput & TextInput & HTMLInputElement;

function RNMaskedTextInputWithRef(props: MaskedTextInputProps, ref: ForwardedRef<AnimatedTextInputRef>) {
const theme = useTheme();

return (
<AnimatedTextInput
// disable autocomplete to prevent part of mask to be present on Android when value is empty
autocomplete={false}
allowFontScaling={false}
textBreakStrategy="simple"
keyboardAppearance={theme.colorScheme}
ref={(refHandle) => {
if (typeof ref !== 'function') {
return;
}
ref(refHandle as AnimatedTextInputRef);
}}
// eslint-disable-next-line
{...props}
/>
);
}

RNMaskedTextInputWithRef.displayName = 'RNMaskedTextInputWithRef';

export default React.forwardRef(RNMaskedTextInputWithRef);
export type {AnimatedTextInputRef};
2 changes: 1 addition & 1 deletion src/components/Search/SearchAutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function SearchAutocompleteInput(
isLoading={!!isSearchingForReports}
ref={ref}
onKeyPress={handleKeyPress(onSubmit)}
isMarkdownEnabled
type="markdown"
multiline={false}
parser={(input: string) => {
'worklet';
Expand Down
14 changes: 14 additions & 0 deletions src/components/TextInput/BaseTextInput/implementations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import RNMaskedTextInput from '@components/RNMaskedTextInput';
import RNTextInput from '@components/RNTextInput';
import type {BaseTextInputProps, InputType} from './types';

type InputComponentType = React.ComponentType<BaseTextInputProps>;

const InputComponentMap = new Map<InputType, InputComponentType>([
['default', RNTextInput],
['mask', RNMaskedTextInput as InputComponentType],
['markdown', RNMarkdownTextInput],
]);

export default InputComponentMap;
10 changes: 6 additions & 4 deletions src/components/TextInput/BaseTextInput/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
import RNTextInput from '@components/RNTextInput';
import Text from '@components/Text';
Expand All @@ -26,6 +25,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import isInputAutoFilled from '@libs/isInputAutoFilled';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import InputComponentMap from './implementations';
import type {BaseTextInputProps, BaseTextInputRef} from './types';

function BaseTextInput(
Expand Down Expand Up @@ -62,7 +62,7 @@ function BaseTextInput(
prefixCharacter = '',
suffixCharacter = '',
inputID,
isMarkdownEnabled = false,
type = 'default',
excludedMarkdownStyles = [],
shouldShowClearButton = false,
prefixContainerStyle = [],
Expand All @@ -71,11 +71,13 @@ function BaseTextInput(
suffixStyle = [],
contentWidth,
loadingSpinnerStyle,
uncontrolled,
...props
}: BaseTextInputProps,
ref: ForwardedRef<BaseTextInputRef>,
) {
const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput;
const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
const isMarkdownEnabled = type === 'markdown';
const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;

const inputProps = {shouldSaveDraft: false, shouldUseDefaultValue: false, ...props};
Expand Down Expand Up @@ -379,7 +381,7 @@ function BaseTextInput(
showSoftInputOnFocus={!disableKeyboard}
keyboardType={inputProps.keyboardType}
inputMode={!disableKeyboard ? inputProps.inputMode : CONST.INPUT_MODE.NONE}
value={value}
value={uncontrolled ? undefined : value}
selection={inputProps.selection}
readOnly={isReadOnly}
defaultValue={defaultValue}
Expand Down
10 changes: 6 additions & 4 deletions src/components/TextInput/BaseTextInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
import RNMarkdownTextInput from '@components/RNMarkdownTextInput';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
import RNTextInput from '@components/RNTextInput';
import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder';
Expand All @@ -29,6 +28,7 @@ import {scrollToRight} from '@libs/InputUtils';
import isInputAutoFilled from '@libs/isInputAutoFilled';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import InputComponentMap from './implementations';
import type {BaseTextInputProps, BaseTextInputRef} from './types';

function BaseTextInput(
Expand Down Expand Up @@ -65,7 +65,7 @@ function BaseTextInput(
prefixCharacter = '',
suffixCharacter = '',
inputID,
isMarkdownEnabled = false,
type = 'default',
excludedMarkdownStyles = [],
shouldShowClearButton = false,
shouldUseDisabledStyles = true,
Expand All @@ -75,11 +75,13 @@ function BaseTextInput(
suffixStyle = [],
contentWidth,
loadingSpinnerStyle,
uncontrolled = false,
...inputProps
}: BaseTextInputProps,
ref: ForwardedRef<BaseTextInputRef>,
) {
const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput;
const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
const isMarkdownEnabled = type === 'markdown';
const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;

const theme = useTheme();
Expand Down Expand Up @@ -382,7 +384,7 @@ function BaseTextInput(
onPressOut={inputProps.onPress}
showSoftInputOnFocus={!disableKeyboard}
inputMode={inputProps.inputMode}
value={value}
value={uncontrolled ? undefined : value}
selection={inputProps.selection}
readOnly={isReadOnly}
defaultValue={defaultValue}
Expand Down
22 changes: 18 additions & 4 deletions src/components/TextInput/BaseTextInput/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {MarkdownRange, MarkdownStyle} from '@expensify/react-native-live-markdown';
import type {GestureResponderEvent, StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native';
import type {MaskedTextInputOwnProps} from 'react-native-advanced-input-mask/lib/typescript/src/types';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
import type IconAsset from '@src/types/utils/IconAsset';

type InputType = 'markdown' | 'mask' | 'default';
type CustomBaseTextInputProps = {
/** Input label */
label?: string;
Expand Down Expand Up @@ -116,12 +118,12 @@ type CustomBaseTextInputProps = {
/** Type of autocomplete */
autoCompleteType?: string;

/** Should live markdown be enabled. Changes RNTextInput component to RNMarkdownTextInput */
isMarkdownEnabled?: boolean;

/** List of markdowns that won't be styled as a markdown */
excludedMarkdownStyles?: Array<keyof MarkdownStyle>;

/** A set of styles for markdown elements (such as link, h1, emoji etc.) */
markdownStyle?: MarkdownStyle;

/** Custom parser function for RNMarkdownTextInput */
parser?: (input: string) => MarkdownRange[];

Expand All @@ -148,10 +150,22 @@ type CustomBaseTextInputProps = {

/** The width of inner content */
contentWidth?: number;

/** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown` */
type?: InputType;

/** The mask of the masked input */
mask?: MaskedTextInputOwnProps['mask'];

/** A set of permitted characters for the input */
allowedKeys?: MaskedTextInputOwnProps['allowedKeys'];

/** Whether the input should be enforced to be uncontrolled. Default is `false` */
uncontrolled?: boolean;
};

type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef;

type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps;

export type {BaseTextInputProps, BaseTextInputRef, CustomBaseTextInputProps};
export type {BaseTextInputProps, BaseTextInputRef, CustomBaseTextInputProps, InputType};
Loading

0 comments on commit d31566b

Please sign in to comment.