@momo-kits/foundation 0.169.10-beta.1 → 0.169.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Application/BottomSheet.hooks.ts +27 -17
- package/Application/BottomSheet.tsx +35 -27
- package/Application/BottomTab/BottomTabBar.tsx +6 -6
- package/Application/BottomTab/DuoRailTabBar.tsx +5 -5
- package/Application/BottomTab/TabScreen.tsx +11 -10
- package/Application/BottomTab/index.tsx +5 -5
- package/Application/BottomTab/usePressLift.ts +3 -3
- package/Application/Components/HeaderToolkitAction.tsx +16 -13
- package/Application/Components/useToolkitActions.tsx +22 -21
- package/Application/ModalScreen.tsx +19 -15
- package/Application/StackScreen.tsx +13 -9
- package/Application/modalLayout.ts +1 -0
- package/Context/index.ts +3 -1
- package/Image/index.tsx +6 -6
- package/Input/ErrorView.tsx +59 -0
- package/Input/FloatingView.tsx +101 -0
- package/Input/Input.tsx +40 -37
- package/Input/InputDropDown.tsx +13 -10
- package/Input/InputMoney.tsx +17 -17
- package/Input/InputMoneyView.tsx +12 -12
- package/Input/InputOTP.tsx +46 -34
- package/Input/InputPhoneNumber.tsx +48 -43
- package/Input/InputSearch.tsx +24 -23
- package/Input/InputTextArea.tsx +29 -23
- package/Input/RenderTrailing.tsx +79 -0
- package/Input/constants.ts +8 -0
- package/Input/utils.ts +126 -7
- package/Layout/FloatingButton.tsx +38 -24
- package/Layout/layoutMetrics.ts +43 -34
- package/Layout/screenTypes.ts +2 -3
- package/Pagination/PaginationScroll.tsx +5 -3
- package/Pagination/types.ts +3 -5
- package/Popup/PopupNotify.tsx +17 -14
- package/Popup/PopupPromotion.tsx +5 -4
- package/Popup/types.ts +2 -4
- package/Popup/utils.ts +7 -4
- package/Skeleton/index.tsx +15 -12
- package/package.json +1 -1
- package/Input/common.tsx +0 -257
- package/Input/moneyUtils.ts +0 -23
package/Input/InputSearch.tsx
CHANGED
|
@@ -20,11 +20,12 @@ import {
|
|
|
20
20
|
MiniAppContext,
|
|
21
21
|
} from '../Context';
|
|
22
22
|
import { Icon } from '../Icon';
|
|
23
|
+
import { Styles } from '../Consts';
|
|
23
24
|
import { Text, useScaleSize, exportFontFamily } from '../Text';
|
|
25
|
+
import { CLEAR_ICON_SIZE, INPUT_FONT_SIZE, PRESSED_OPACITY } from './constants';
|
|
24
26
|
import { InputRef, InputSearchProps } from './index';
|
|
25
27
|
import { TextTyping } from './SearchTextTyping';
|
|
26
28
|
import styles from './styles';
|
|
27
|
-
import { Styles } from '../Consts';
|
|
28
29
|
import SystemTextInput from './SystemTextInput';
|
|
29
30
|
|
|
30
31
|
const SEARCH_ICON_SIZE = 24;
|
|
@@ -59,7 +60,7 @@ const InputSearch: ForwardRefRenderFunction<InputRef, InputSearchProps> = (
|
|
|
59
60
|
) => {
|
|
60
61
|
const { theme } = useContext(ApplicationContext);
|
|
61
62
|
const context = useContext<any>(MiniAppContext);
|
|
62
|
-
const scaledFontSize = useScaleSize(
|
|
63
|
+
const scaledFontSize = useScaleSize(INPUT_FONT_SIZE);
|
|
63
64
|
const searchFontFamily = exportFontFamily('regular');
|
|
64
65
|
|
|
65
66
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -71,10 +72,10 @@ const InputSearch: ForwardRefRenderFunction<InputRef, InputSearchProps> = (
|
|
|
71
72
|
? HeaderBarMetrics.iconSize
|
|
72
73
|
: SEARCH_ICON_SIZE;
|
|
73
74
|
|
|
74
|
-
const [
|
|
75
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
75
76
|
const [internalText, setInternalText] = useState(defaultValue || '');
|
|
76
77
|
const currentValue = value !== undefined ? value : internalText;
|
|
77
|
-
const
|
|
78
|
+
const hasValue = !!currentValue;
|
|
78
79
|
const inputRef = useRef<TextInput | null>(null);
|
|
79
80
|
|
|
80
81
|
const componentName = 'InputSearch';
|
|
@@ -84,40 +85,40 @@ const InputSearch: ForwardRefRenderFunction<InputRef, InputSearchProps> = (
|
|
|
84
85
|
props.accessibilityLabel,
|
|
85
86
|
);
|
|
86
87
|
|
|
87
|
-
const
|
|
88
|
-
|
|
88
|
+
const handleFocus = (e: TextInputFocusEvent) => {
|
|
89
|
+
setIsFocused(true);
|
|
89
90
|
onFocus?.(e);
|
|
90
91
|
};
|
|
91
92
|
|
|
92
|
-
const
|
|
93
|
-
|
|
93
|
+
const handleBlur = (e: TextInputFocusEvent) => {
|
|
94
|
+
setIsFocused(false);
|
|
94
95
|
onBlur?.(e);
|
|
95
96
|
};
|
|
96
97
|
|
|
97
|
-
const
|
|
98
|
+
const handleClearText = () => {
|
|
98
99
|
inputRef?.current?.clear();
|
|
99
100
|
inputRef.current?.setNativeProps({ text: '' });
|
|
100
|
-
|
|
101
|
+
handleChangeText('');
|
|
101
102
|
};
|
|
102
103
|
|
|
103
|
-
const
|
|
104
|
+
const handleChangeText = (text: string) => {
|
|
104
105
|
if (value === undefined) {
|
|
105
106
|
setInternalText(text);
|
|
106
107
|
}
|
|
107
108
|
onChangeText?.(text);
|
|
108
109
|
};
|
|
109
110
|
|
|
110
|
-
const
|
|
111
|
+
const handleSetText = (text: string) => {
|
|
111
112
|
inputRef?.current?.setNativeProps({ text });
|
|
112
|
-
|
|
113
|
+
handleChangeText(text);
|
|
113
114
|
};
|
|
114
115
|
|
|
115
116
|
useImperativeHandle(ref, () => {
|
|
116
117
|
return {
|
|
117
|
-
clear:
|
|
118
|
+
clear: handleClearText,
|
|
118
119
|
focus: () => inputRef.current?.focus(),
|
|
119
120
|
blur: () => inputRef.current?.blur(),
|
|
120
|
-
setText:
|
|
121
|
+
setText: handleSetText,
|
|
121
122
|
};
|
|
122
123
|
});
|
|
123
124
|
|
|
@@ -131,7 +132,7 @@ const InputSearch: ForwardRefRenderFunction<InputRef, InputSearchProps> = (
|
|
|
131
132
|
inputHeightStyle,
|
|
132
133
|
]}
|
|
133
134
|
onPress={onPress}
|
|
134
|
-
activeOpacity={onPress ?
|
|
135
|
+
activeOpacity={onPress ? PRESSED_OPACITY : 1}
|
|
135
136
|
>
|
|
136
137
|
<TextTyping data={typingData} />
|
|
137
138
|
</TouchableOpacity>
|
|
@@ -155,10 +156,10 @@ const InputSearch: ForwardRefRenderFunction<InputRef, InputSearchProps> = (
|
|
|
155
156
|
},
|
|
156
157
|
]}
|
|
157
158
|
value={value}
|
|
158
|
-
onChangeText={
|
|
159
|
+
onChangeText={handleChangeText}
|
|
159
160
|
defaultValue={defaultValue}
|
|
160
|
-
onFocus={
|
|
161
|
-
onBlur={
|
|
161
|
+
onFocus={handleFocus}
|
|
162
|
+
onBlur={handleBlur}
|
|
162
163
|
placeholder={placeholder}
|
|
163
164
|
selectionColor={theme.colors.primary}
|
|
164
165
|
placeholderTextColor={theme.colors.text.hint}
|
|
@@ -169,15 +170,15 @@ const InputSearch: ForwardRefRenderFunction<InputRef, InputSearchProps> = (
|
|
|
169
170
|
const renderTrailing = () => {
|
|
170
171
|
return (
|
|
171
172
|
<View style={Styles.row}>
|
|
172
|
-
{
|
|
173
|
+
{hasValue && (
|
|
173
174
|
<TouchableOpacity
|
|
174
175
|
style={styles.iconWrapper}
|
|
175
|
-
onPress={
|
|
176
|
+
onPress={handleClearText}
|
|
176
177
|
accessibilityLabel={`${componentId}|close-icon-touch`}
|
|
177
178
|
>
|
|
178
179
|
<Icon
|
|
179
180
|
source="24_navigation_close_circle_full"
|
|
180
|
-
size={
|
|
181
|
+
size={CLEAR_ICON_SIZE}
|
|
181
182
|
color={theme.colors.text.hint}
|
|
182
183
|
/>
|
|
183
184
|
</TouchableOpacity>
|
|
@@ -208,7 +209,7 @@ const InputSearch: ForwardRefRenderFunction<InputRef, InputSearchProps> = (
|
|
|
208
209
|
};
|
|
209
210
|
|
|
210
211
|
let inputState = 'empty';
|
|
211
|
-
if (
|
|
212
|
+
if (isFocused) {
|
|
212
213
|
inputState = 'focus';
|
|
213
214
|
}
|
|
214
215
|
if (value && value?.length > 0) {
|
package/Input/InputTextArea.tsx
CHANGED
|
@@ -12,14 +12,20 @@ import {
|
|
|
12
12
|
TouchableOpacity,
|
|
13
13
|
View,
|
|
14
14
|
} from 'react-native';
|
|
15
|
+
import { setAutomationID, useComponentId } from '../Application';
|
|
15
16
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
16
17
|
import { Icon } from '../Icon';
|
|
17
18
|
import { Text, useScaleSize, exportFontFamily } from '../Text';
|
|
18
|
-
import {
|
|
19
|
+
import { CLEAR_ICON_SIZE, INPUT_FONT_SIZE } from './constants';
|
|
20
|
+
import { ErrorView } from './ErrorView';
|
|
21
|
+
import { FloatingView } from './FloatingView';
|
|
19
22
|
import { InputTextAreaProps } from './index';
|
|
20
23
|
import styles from './styles';
|
|
21
24
|
import SystemTextInput from './SystemTextInput';
|
|
22
|
-
import {
|
|
25
|
+
import { getBorderColor } from './utils';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_MAX_LENGTH = 300;
|
|
28
|
+
const DEFAULT_HEIGHT = 104;
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Multiline input with floating label, clear icon and a `length/maxLength`
|
|
@@ -40,7 +46,7 @@ const InputTextArea = forwardRef(
|
|
|
40
46
|
defaultValue,
|
|
41
47
|
height,
|
|
42
48
|
placeholder,
|
|
43
|
-
maxLength =
|
|
49
|
+
maxLength = DEFAULT_MAX_LENGTH,
|
|
44
50
|
required,
|
|
45
51
|
errorSpacing,
|
|
46
52
|
style,
|
|
@@ -57,12 +63,12 @@ const InputTextArea = forwardRef(
|
|
|
57
63
|
);
|
|
58
64
|
const { theme } = useContext(ApplicationContext);
|
|
59
65
|
const context = useContext<any>(MiniAppContext);
|
|
60
|
-
const
|
|
66
|
+
const defaultHeight = useScaleSize(DEFAULT_HEIGHT);
|
|
61
67
|
|
|
62
68
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
63
|
-
const scaledFontSize = useScaleSize(
|
|
69
|
+
const scaledFontSize = useScaleSize(INPUT_FONT_SIZE);
|
|
64
70
|
const inputFontFamily = exportFontFamily('regular');
|
|
65
|
-
const [
|
|
71
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
66
72
|
const [inputValue, setInputValue] = useState(defaultValue ?? '');
|
|
67
73
|
const inputRef = useRef<TextInput | null>(null);
|
|
68
74
|
|
|
@@ -70,33 +76,33 @@ const InputTextArea = forwardRef(
|
|
|
70
76
|
setInputValue(value ?? '');
|
|
71
77
|
}, [value]);
|
|
72
78
|
|
|
73
|
-
const
|
|
79
|
+
const handleClearText = () => {
|
|
74
80
|
inputRef?.current?.clear();
|
|
75
81
|
inputRef.current?.setNativeProps({ text: '' });
|
|
76
|
-
|
|
82
|
+
handleChangeText('');
|
|
77
83
|
};
|
|
78
84
|
|
|
79
|
-
const
|
|
85
|
+
const handleChangeText = (text: string) => {
|
|
80
86
|
onChangeText?.(text);
|
|
81
87
|
setInputValue(text);
|
|
82
88
|
};
|
|
83
89
|
|
|
84
|
-
const
|
|
85
|
-
|
|
90
|
+
const handleFocus = (e: TextInputFocusEvent) => {
|
|
91
|
+
setIsFocused(true);
|
|
86
92
|
onFocus?.(e);
|
|
87
93
|
};
|
|
88
94
|
|
|
89
|
-
const
|
|
90
|
-
|
|
95
|
+
const handleBlur = (e: TextInputFocusEvent) => {
|
|
96
|
+
setIsFocused(false);
|
|
91
97
|
onBlur?.(e);
|
|
92
98
|
};
|
|
93
99
|
|
|
94
100
|
useImperativeHandle(ref, () => {
|
|
95
101
|
return {
|
|
96
|
-
clear:
|
|
102
|
+
clear: handleClearText,
|
|
97
103
|
focus: () => inputRef.current?.focus(),
|
|
98
104
|
blur: () => inputRef.current?.blur(),
|
|
99
|
-
setText: (text: string) =>
|
|
105
|
+
setText: (text: string) => handleChangeText(text),
|
|
100
106
|
};
|
|
101
107
|
});
|
|
102
108
|
|
|
@@ -129,10 +135,10 @@ const InputTextArea = forwardRef(
|
|
|
129
135
|
style={[
|
|
130
136
|
styles.textAreaWrapper,
|
|
131
137
|
{
|
|
132
|
-
height: height ||
|
|
138
|
+
height: height || defaultHeight,
|
|
133
139
|
backgroundColor: theme.colors.background.surface,
|
|
134
140
|
},
|
|
135
|
-
getBorderColor(theme,
|
|
141
|
+
getBorderColor(theme, isFocused, errorMessage, disabled),
|
|
136
142
|
]}
|
|
137
143
|
>
|
|
138
144
|
<FloatingView
|
|
@@ -175,23 +181,23 @@ const InputTextArea = forwardRef(
|
|
|
175
181
|
textBreakStrategy="highQuality"
|
|
176
182
|
multiline={true}
|
|
177
183
|
value={inputValue}
|
|
178
|
-
onChangeText={
|
|
179
|
-
onFocus={
|
|
180
|
-
onBlur={
|
|
184
|
+
onChangeText={handleChangeText}
|
|
185
|
+
onFocus={handleFocus}
|
|
186
|
+
onBlur={handleBlur}
|
|
181
187
|
placeholder={placeholder}
|
|
182
188
|
selectionColor={theme.colors.primary}
|
|
183
189
|
placeholderTextColor={placeholderColor}
|
|
184
190
|
/>
|
|
185
191
|
</View>
|
|
186
|
-
{
|
|
192
|
+
{isFocused && inputValue.length > 0 && (
|
|
187
193
|
<TouchableOpacity
|
|
188
194
|
style={styles.iconWrapper}
|
|
189
|
-
onPress={
|
|
195
|
+
onPress={handleClearText}
|
|
190
196
|
accessibilityLabel={`${componentId}|clear-icon-touch`}
|
|
191
197
|
>
|
|
192
198
|
<Icon
|
|
193
199
|
source="24_navigation_close_circle_full"
|
|
194
|
-
size={
|
|
200
|
+
size={CLEAR_ICON_SIZE}
|
|
195
201
|
color={theme.colors.text.hint}
|
|
196
202
|
accessibilityLabel={`${componentId}|clear-icon`}
|
|
197
203
|
/>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React, { type FC, type ReactNode, useContext } from 'react';
|
|
2
|
+
import { TouchableOpacity } from 'react-native';
|
|
3
|
+
import IconSources from '../Assets/icon.json';
|
|
4
|
+
import { ApplicationContext } from '../Context';
|
|
5
|
+
import { Icon } from '../Icon';
|
|
6
|
+
import { Loader } from '../Loader';
|
|
7
|
+
import { Text } from '../Text';
|
|
8
|
+
import styles from './styles';
|
|
9
|
+
|
|
10
|
+
const ICON_SIZE = 24;
|
|
11
|
+
const TEXT_MAX_LENGTH = 15;
|
|
12
|
+
|
|
13
|
+
type IconSourceMap = Record<string, { uri?: string } | undefined>;
|
|
14
|
+
|
|
15
|
+
type TrailingProps = {
|
|
16
|
+
loading?: boolean;
|
|
17
|
+
color?: string;
|
|
18
|
+
icon?: string;
|
|
19
|
+
componentId?: string;
|
|
20
|
+
onPressIcon?: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Trailing slot: spinner while `loading`, an icon when `icon` is a known icon
|
|
25
|
+
* name or URL, otherwise `icon` as a text action (first 15 chars).
|
|
26
|
+
*/
|
|
27
|
+
const RenderTrailing: FC<TrailingProps> = ({
|
|
28
|
+
loading,
|
|
29
|
+
color,
|
|
30
|
+
icon,
|
|
31
|
+
onPressIcon,
|
|
32
|
+
componentId,
|
|
33
|
+
}) => {
|
|
34
|
+
const { theme } = useContext(ApplicationContext);
|
|
35
|
+
if (loading) {
|
|
36
|
+
return <Loader type={'spinner'} color={color} style={styles.icon} />;
|
|
37
|
+
}
|
|
38
|
+
if (!icon) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const iconSources: IconSourceMap = IconSources;
|
|
43
|
+
const isIconName = !!iconSources[icon]?.uri;
|
|
44
|
+
const isIcon = isIconName || icon.includes('http');
|
|
45
|
+
const renderIconTouchable = (content: ReactNode) => {
|
|
46
|
+
return (
|
|
47
|
+
<TouchableOpacity
|
|
48
|
+
accessibilityLabel={`${componentId}|trailing-touch`}
|
|
49
|
+
onPress={onPressIcon}
|
|
50
|
+
style={styles.icon}
|
|
51
|
+
>
|
|
52
|
+
{content}
|
|
53
|
+
</TouchableOpacity>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (isIcon) {
|
|
58
|
+
return renderIconTouchable(
|
|
59
|
+
<Icon
|
|
60
|
+
color={color}
|
|
61
|
+
source={icon}
|
|
62
|
+
size={ICON_SIZE}
|
|
63
|
+
accessibilityLabel={`${componentId}|trailing-icon`}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return renderIconTouchable(
|
|
68
|
+
<Text
|
|
69
|
+
typography="action_xs_bold"
|
|
70
|
+
color={color ?? theme.colors.primary}
|
|
71
|
+
numberOfLines={1}
|
|
72
|
+
accessibilityLabel={`${componentId}|trailing-text`}
|
|
73
|
+
>
|
|
74
|
+
{icon.substring(0, TEXT_MAX_LENGTH)}
|
|
75
|
+
</Text>,
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export { RenderTrailing };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Input text size before font scaling. */
|
|
2
|
+
const INPUT_FONT_SIZE = 14;
|
|
3
|
+
/** Size of the clear (x) icon shown while a focused field has a value. */
|
|
4
|
+
const CLEAR_ICON_SIZE = 16;
|
|
5
|
+
/** Pressed opacity of the floating icon, dropdown and typing placeholder. */
|
|
6
|
+
const PRESSED_OPACITY = 0.6;
|
|
7
|
+
|
|
8
|
+
export { INPUT_FONT_SIZE, CLEAR_ICON_SIZE, PRESSED_OPACITY };
|
package/Input/utils.ts
CHANGED
|
@@ -1,4 +1,110 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ViewStyle } from 'react-native';
|
|
2
|
+
import Localize from '../Application/Localize';
|
|
3
|
+
import { type Theme } from '../Application/types';
|
|
4
|
+
import styles from './styles';
|
|
5
|
+
|
|
6
|
+
const SMALL_HEIGHT = 48;
|
|
7
|
+
const LARGE_HEIGHT = 56;
|
|
8
|
+
const SMALL_LEADING_ICON_SIZE = 24;
|
|
9
|
+
const LARGE_LEADING_ICON_SIZE = 32;
|
|
10
|
+
const THOUSANDS_GROUP_SIZE = 3;
|
|
11
|
+
const PHONE_DIGITS_WITHOUT_ZERO = 9;
|
|
12
|
+
const MIN_PHONE_DIGITS = 10;
|
|
13
|
+
const MAX_PHONE_DIGITS = 11;
|
|
14
|
+
|
|
15
|
+
/** vi-VN number format (`.` thousands) for the InputMoney display value. */
|
|
16
|
+
const moneyFormatter = new Intl.NumberFormat('vi-VN');
|
|
17
|
+
|
|
18
|
+
/** Field height for `size` before font scaling; unset means large. */
|
|
19
|
+
const getInputHeight = (size?: 'small' | 'large') => {
|
|
20
|
+
if (size === 'small') {
|
|
21
|
+
return SMALL_HEIGHT;
|
|
22
|
+
}
|
|
23
|
+
return LARGE_HEIGHT;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Leading icon size for `size`; unset means large. */
|
|
27
|
+
const getLeadingIconSize = (size?: 'small' | 'large') => {
|
|
28
|
+
if (size === 'small') {
|
|
29
|
+
return SMALL_LEADING_ICON_SIZE;
|
|
30
|
+
}
|
|
31
|
+
return LARGE_LEADING_ICON_SIZE;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Border color by state; priority is disabled > error > focused > default. */
|
|
35
|
+
const getBorderColor = (
|
|
36
|
+
theme: Theme,
|
|
37
|
+
isFocused: boolean,
|
|
38
|
+
errorMessage?: string,
|
|
39
|
+
isDisabled?: boolean,
|
|
40
|
+
) => {
|
|
41
|
+
let borderColor = theme.colors.border.default;
|
|
42
|
+
|
|
43
|
+
if (isFocused) {
|
|
44
|
+
borderColor = theme.colors.primary;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (errorMessage) {
|
|
48
|
+
borderColor = theme.colors.error.primary;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (isDisabled) {
|
|
52
|
+
borderColor = theme.colors.border.disable;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { borderColor };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Container style for `size`; multiline uses `height` as minHeight so the
|
|
60
|
+
* field can grow with its content.
|
|
61
|
+
*/
|
|
62
|
+
const getSizeStyle = (
|
|
63
|
+
size?: 'small' | 'large',
|
|
64
|
+
isMultiline: boolean = false,
|
|
65
|
+
height?: number,
|
|
66
|
+
) => {
|
|
67
|
+
let scaleStyle: ViewStyle = {};
|
|
68
|
+
if (height) {
|
|
69
|
+
scaleStyle = { height };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isMultiline) {
|
|
73
|
+
return [
|
|
74
|
+
styles.multilineContainer,
|
|
75
|
+
{
|
|
76
|
+
minHeight: height,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (size === 'small') {
|
|
82
|
+
return [styles.smallContainer, scaleStyle];
|
|
83
|
+
}
|
|
84
|
+
return [styles.container, scaleStyle];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Locates the single edited span between `old` and `text`: common prefix
|
|
89
|
+
* length (`start`), common suffix length (`suffix`) and removed + inserted
|
|
90
|
+
* char count (`size`).
|
|
91
|
+
*/
|
|
92
|
+
const findEdit = (old: string, text: string) => {
|
|
93
|
+
let start = 0;
|
|
94
|
+
while (start < text.length && old[start] === text[start]) {
|
|
95
|
+
start += 1;
|
|
96
|
+
}
|
|
97
|
+
const maxSuffix = Math.min(text.length, old.length) - start;
|
|
98
|
+
let suffix = 0;
|
|
99
|
+
while (suffix < maxSuffix) {
|
|
100
|
+
if (old[old.length - 1 - suffix] !== text[text.length - 1 - suffix]) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
suffix += 1;
|
|
104
|
+
}
|
|
105
|
+
const size = old.length + text.length - 2 * (start + suffix);
|
|
106
|
+
return { old, start, suffix, size };
|
|
107
|
+
};
|
|
2
108
|
|
|
3
109
|
/**
|
|
4
110
|
* Formats a number Vietnamese-style (`.` thousands, `,` decimals) with
|
|
@@ -18,7 +124,7 @@ const formatNumberToMoney = (number: number, currency = '') => {
|
|
|
18
124
|
}
|
|
19
125
|
|
|
20
126
|
const numberString = Math.abs(number).toString();
|
|
21
|
-
if (numberString.length <
|
|
127
|
+
if (numberString.length < THOUSANDS_GROUP_SIZE) {
|
|
22
128
|
const sign = isNegative ? '-' : '';
|
|
23
129
|
return `${sign}${numberString}${currency}`;
|
|
24
130
|
}
|
|
@@ -32,7 +138,7 @@ const formatNumberToMoney = (number: number, currency = '') => {
|
|
|
32
138
|
} else {
|
|
33
139
|
array.push(numberString[i] as string);
|
|
34
140
|
}
|
|
35
|
-
if (count ===
|
|
141
|
+
if (count === THOUSANDS_GROUP_SIZE && i >= 1) {
|
|
36
142
|
array.push('.');
|
|
37
143
|
count = 0;
|
|
38
144
|
}
|
|
@@ -67,6 +173,7 @@ const formatMoneyToNumber = (money: string, currencyUnit: string) => {
|
|
|
67
173
|
return Number(money);
|
|
68
174
|
};
|
|
69
175
|
|
|
176
|
+
/** Digits of `phone`, every other char stripped. */
|
|
70
177
|
function formatPhoneNumber(phone: string): string {
|
|
71
178
|
return phone.replace(/\D/g, '');
|
|
72
179
|
}
|
|
@@ -79,20 +186,32 @@ function checkValidPhoneNumber(phone: string): {
|
|
|
79
186
|
phoneFormatted: string;
|
|
80
187
|
error?: string;
|
|
81
188
|
} {
|
|
82
|
-
const
|
|
189
|
+
const hasLengthWithoutZero = phone.length === PHONE_DIGITS_WITHOUT_ZERO;
|
|
190
|
+
const isMissingLeadingZero = hasLengthWithoutZero && /^[1-9]/.test(phone);
|
|
83
191
|
const phoneNumber = isMissingLeadingZero ? `0${phone}` : phone;
|
|
84
192
|
|
|
85
193
|
const phoneFormatted = formatPhoneNumber(phoneNumber);
|
|
194
|
+
const digitCount = phoneFormatted.length;
|
|
86
195
|
const localize = new Localize({ vi: {}, en: {} });
|
|
87
196
|
let error: string | undefined;
|
|
88
197
|
|
|
89
|
-
if (
|
|
198
|
+
if (digitCount === 0) {
|
|
90
199
|
error = localize.translate('enterPhoneNumber');
|
|
91
|
-
} else if (
|
|
200
|
+
} else if (digitCount < MIN_PHONE_DIGITS || digitCount > MAX_PHONE_DIGITS) {
|
|
92
201
|
error = localize.translate('invalidPhoneNumber');
|
|
93
202
|
}
|
|
94
203
|
|
|
95
204
|
return { phoneFormatted: phoneFormatted, error };
|
|
96
205
|
}
|
|
97
206
|
|
|
98
|
-
export {
|
|
207
|
+
export {
|
|
208
|
+
moneyFormatter,
|
|
209
|
+
getInputHeight,
|
|
210
|
+
getLeadingIconSize,
|
|
211
|
+
getBorderColor,
|
|
212
|
+
getSizeStyle,
|
|
213
|
+
findEdit,
|
|
214
|
+
formatMoneyToNumber,
|
|
215
|
+
formatNumberToMoney,
|
|
216
|
+
checkValidPhoneNumber,
|
|
217
|
+
};
|
|
@@ -13,12 +13,22 @@ import Animated, {
|
|
|
13
13
|
} from 'react-native-reanimated';
|
|
14
14
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
15
15
|
import { ApplicationContext } from '../Context';
|
|
16
|
-
import { Colors } from '../Consts';
|
|
16
|
+
import { Colors, Spacing } from '../Consts';
|
|
17
17
|
import { Icon } from '../Icon';
|
|
18
18
|
import { useScaleSize, exportFontFamily } from '../Text';
|
|
19
19
|
import { type AnimatedCompatValue } from '../Application/utils';
|
|
20
20
|
import { useNormalizedSharedValue } from '../Application/hooks';
|
|
21
21
|
|
|
22
|
+
const DEFAULT_BOTTOM = Spacing.M;
|
|
23
|
+
const PILL_RADIUS = 100;
|
|
24
|
+
const LABEL_FONT_SIZE = 16;
|
|
25
|
+
const LABEL_LINE_HEIGHT = 22;
|
|
26
|
+
const SMALL_SIZE = 36;
|
|
27
|
+
const LARGE_SIZE = 48;
|
|
28
|
+
const SMALL_ICON_SIZE = 12;
|
|
29
|
+
const LARGE_ICON_SIZE = 24;
|
|
30
|
+
const ANIMATION_DURATION = 100;
|
|
31
|
+
|
|
22
32
|
export interface FloatingButtonProps {
|
|
23
33
|
label?: string;
|
|
24
34
|
onPress?: () => void;
|
|
@@ -44,60 +54,64 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
|
|
|
44
54
|
iconColor = '',
|
|
45
55
|
size = 'small',
|
|
46
56
|
animatedValue,
|
|
47
|
-
bottom =
|
|
57
|
+
bottom = DEFAULT_BOTTOM,
|
|
48
58
|
}: FloatingButtonProps) => {
|
|
49
59
|
const { theme } = useContext(ApplicationContext);
|
|
50
|
-
const
|
|
60
|
+
const hasScrollValue = animatedValue != null;
|
|
51
61
|
const sharedValue = useNormalizedSharedValue(animatedValue);
|
|
52
|
-
const scaledFontSize = useScaleSize(
|
|
53
|
-
const scaledLineHeight = useScaleSize(
|
|
62
|
+
const scaledFontSize = useScaleSize(LABEL_FONT_SIZE);
|
|
63
|
+
const scaledLineHeight = useScaleSize(LABEL_LINE_HEIGHT);
|
|
54
64
|
const labelFontFamily = exportFontFamily('bold');
|
|
55
65
|
const maxWidth = useSharedValue(0);
|
|
56
|
-
const minWidth = size === 'small' ?
|
|
66
|
+
const minWidth = size === 'small' ? SMALL_SIZE : LARGE_SIZE;
|
|
57
67
|
const opacityAnimated = useSharedValue(0);
|
|
58
68
|
const widthAnimated = useSharedValue<number | null>(null);
|
|
59
69
|
const lastOffset = useSharedValue(0);
|
|
60
70
|
const lastDirection = useSharedValue<string | null>(null);
|
|
61
|
-
const [
|
|
71
|
+
const [isLabelShown, setIsLabelShown] = useState(true);
|
|
62
72
|
|
|
63
73
|
useEffect(() => {
|
|
64
74
|
if (!label) return;
|
|
65
|
-
opacityAnimated.value = withTiming(1, { duration:
|
|
75
|
+
opacityAnimated.value = withTiming(1, { duration: ANIMATION_DURATION });
|
|
66
76
|
}, [label, opacityAnimated]);
|
|
67
77
|
|
|
68
78
|
useAnimatedReaction(
|
|
69
79
|
() => sharedValue.value,
|
|
70
80
|
value => {
|
|
71
81
|
'worklet';
|
|
72
|
-
if (!label || !
|
|
82
|
+
if (!label || !hasScrollValue) return;
|
|
73
83
|
if (value !== lastOffset.value && value > 0) {
|
|
74
84
|
const direction = value > lastOffset.value ? 'down' : 'up';
|
|
75
85
|
lastOffset.value = value;
|
|
76
86
|
if (lastDirection.value !== direction) {
|
|
77
87
|
lastDirection.value = direction;
|
|
78
88
|
if (direction === 'down') {
|
|
79
|
-
opacityAnimated.value = withTiming(0, {
|
|
89
|
+
opacityAnimated.value = withTiming(0, {
|
|
90
|
+
duration: ANIMATION_DURATION,
|
|
91
|
+
});
|
|
80
92
|
widthAnimated.value = withTiming(
|
|
81
93
|
minWidth,
|
|
82
|
-
{ duration:
|
|
94
|
+
{ duration: ANIMATION_DURATION },
|
|
83
95
|
finished => {
|
|
84
|
-
if (finished) scheduleOnRN(
|
|
96
|
+
if (finished) scheduleOnRN(setIsLabelShown, false);
|
|
85
97
|
},
|
|
86
98
|
);
|
|
87
99
|
} else {
|
|
88
|
-
opacityAnimated.value = withTiming(1, {
|
|
100
|
+
opacityAnimated.value = withTiming(1, {
|
|
101
|
+
duration: ANIMATION_DURATION,
|
|
102
|
+
});
|
|
89
103
|
widthAnimated.value = withTiming(
|
|
90
104
|
maxWidth.value,
|
|
91
|
-
{ duration:
|
|
105
|
+
{ duration: ANIMATION_DURATION },
|
|
92
106
|
finished => {
|
|
93
|
-
if (finished) scheduleOnRN(
|
|
107
|
+
if (finished) scheduleOnRN(setIsLabelShown, true);
|
|
94
108
|
},
|
|
95
109
|
);
|
|
96
110
|
}
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
113
|
},
|
|
100
|
-
[label,
|
|
114
|
+
[label, hasScrollValue, minWidth],
|
|
101
115
|
);
|
|
102
116
|
|
|
103
117
|
const containerStyle = useAnimatedStyle(() => ({
|
|
@@ -146,10 +160,10 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
|
|
|
146
160
|
<TouchableOpacity style={styles.content} onPress={onPress}>
|
|
147
161
|
<Icon
|
|
148
162
|
source={icon}
|
|
149
|
-
size={size === 'small' ?
|
|
163
|
+
size={size === 'small' ? SMALL_ICON_SIZE : LARGE_ICON_SIZE}
|
|
150
164
|
color={iconColor}
|
|
151
165
|
/>
|
|
152
|
-
{
|
|
166
|
+
{isLabelShown && !!label && (
|
|
153
167
|
<Animated.Text
|
|
154
168
|
allowFontScaling={false}
|
|
155
169
|
style={[
|
|
@@ -178,19 +192,19 @@ const styles = StyleSheet.create({
|
|
|
178
192
|
alignContent: 'center',
|
|
179
193
|
alignItems: 'center',
|
|
180
194
|
position: 'absolute',
|
|
181
|
-
borderRadius:
|
|
182
|
-
paddingHorizontal:
|
|
195
|
+
borderRadius: PILL_RADIUS,
|
|
196
|
+
paddingHorizontal: Spacing.M,
|
|
183
197
|
flexWrap: 'wrap',
|
|
184
198
|
alignSelf: 'flex-end',
|
|
185
199
|
},
|
|
186
200
|
label: {
|
|
187
|
-
marginLeft:
|
|
201
|
+
marginLeft: Spacing.S,
|
|
188
202
|
},
|
|
189
|
-
alignRight: { right:
|
|
203
|
+
alignRight: { right: Spacing.M },
|
|
190
204
|
alignCenter: { alignSelf: 'center' },
|
|
191
205
|
alignEnd: { alignSelf: 'flex-end' },
|
|
192
|
-
small: { height:
|
|
193
|
-
large: { height:
|
|
206
|
+
small: { height: SMALL_SIZE },
|
|
207
|
+
large: { height: LARGE_SIZE },
|
|
194
208
|
content: {
|
|
195
209
|
flexDirection: 'row',
|
|
196
210
|
alignItems: 'center',
|