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

fix: no jumpy input in amount filter #53695

Merged
merged 16 commits into from
Feb 3, 2025
Merged
29 changes: 29 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -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
@@ -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
@@ -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`)
@@ -2968,6 +2992,7 @@ SPEC REPOS:
- FirebaseInstallations
- FirebasePerformance
- FirebaseRemoteConfig
- ForkInputMask
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleSignIn
@@ -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:
@@ -3270,6 +3297,7 @@ SPEC CHECKSUMS:
FirebasePerformance: 0c01a7a496657d7cea86d40c0b1725259d164c6c
FirebaseRemoteConfig: 2d6e2cfdb49af79535c8af8a80a4a5009038ec2b
fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be
ForkInputMask: 55e3fbab504b22da98483e9f9a6514b98fdd2f3c
FullStory: c8a10b2358c0d33c57be84d16e4c440b0434b33d
fullstory_react-native: 63a803cca04b0447a71daa73e4df3f7b56e1919d
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
@@ -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
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
@@ -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",
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
@@ -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(() => {
1 change: 1 addition & 0 deletions src/components/Form/types.ts
Original file line number Diff line number Diff line change
@@ -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>;
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
@@ -138,7 +138,7 @@ function SearchAutocompleteInput(
isLoading={!!isSearchingForReports}
ref={ref}
onKeyPress={handleKeyPress(onSubmit)}
isMarkdownEnabled
type="markdown"
multiline={false}
parser={(input: string) => {
'worklet';
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
@@ -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';
@@ -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(
@@ -62,7 +62,7 @@ function BaseTextInput(
prefixCharacter = '',
suffixCharacter = '',
inputID,
isMarkdownEnabled = false,
type = 'default',
excludedMarkdownStyles = [],
shouldShowClearButton = false,
prefixContainerStyle = [],
@@ -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};
@@ -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}
10 changes: 6 additions & 4 deletions src/components/TextInput/BaseTextInput/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
@@ -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(
@@ -65,7 +65,7 @@ function BaseTextInput(
prefixCharacter = '',
suffixCharacter = '',
inputID,
isMarkdownEnabled = false,
type = 'default',
excludedMarkdownStyles = [],
shouldShowClearButton = false,
shouldUseDisabledStyles = true,
@@ -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();
@@ -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}
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;
@@ -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[];

@@ -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
Loading