@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.
Files changed (40) hide show
  1. package/Application/BottomSheet.hooks.ts +27 -17
  2. package/Application/BottomSheet.tsx +35 -27
  3. package/Application/BottomTab/BottomTabBar.tsx +6 -6
  4. package/Application/BottomTab/DuoRailTabBar.tsx +5 -5
  5. package/Application/BottomTab/TabScreen.tsx +11 -10
  6. package/Application/BottomTab/index.tsx +5 -5
  7. package/Application/BottomTab/usePressLift.ts +3 -3
  8. package/Application/Components/HeaderToolkitAction.tsx +16 -13
  9. package/Application/Components/useToolkitActions.tsx +22 -21
  10. package/Application/ModalScreen.tsx +19 -15
  11. package/Application/StackScreen.tsx +13 -9
  12. package/Application/modalLayout.ts +1 -0
  13. package/Context/index.ts +3 -1
  14. package/Image/index.tsx +6 -6
  15. package/Input/ErrorView.tsx +59 -0
  16. package/Input/FloatingView.tsx +101 -0
  17. package/Input/Input.tsx +40 -37
  18. package/Input/InputDropDown.tsx +13 -10
  19. package/Input/InputMoney.tsx +17 -17
  20. package/Input/InputMoneyView.tsx +12 -12
  21. package/Input/InputOTP.tsx +46 -34
  22. package/Input/InputPhoneNumber.tsx +48 -43
  23. package/Input/InputSearch.tsx +24 -23
  24. package/Input/InputTextArea.tsx +29 -23
  25. package/Input/RenderTrailing.tsx +79 -0
  26. package/Input/constants.ts +8 -0
  27. package/Input/utils.ts +126 -7
  28. package/Layout/FloatingButton.tsx +38 -24
  29. package/Layout/layoutMetrics.ts +43 -34
  30. package/Layout/screenTypes.ts +2 -3
  31. package/Pagination/PaginationScroll.tsx +5 -3
  32. package/Pagination/types.ts +3 -5
  33. package/Popup/PopupNotify.tsx +17 -14
  34. package/Popup/PopupPromotion.tsx +5 -4
  35. package/Popup/types.ts +2 -4
  36. package/Popup/utils.ts +7 -4
  37. package/Skeleton/index.tsx +15 -12
  38. package/package.json +1 -1
  39. package/Input/common.tsx +0 -257
  40. package/Input/moneyUtils.ts +0 -23
@@ -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(14);
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 [focused, setFocused] = useState(false);
75
+ const [isFocused, setIsFocused] = useState(false);
75
76
  const [internalText, setInternalText] = useState(defaultValue || '');
76
77
  const currentValue = value !== undefined ? value : internalText;
77
- const haveValue = !!currentValue;
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 _onFocus = (e: TextInputFocusEvent) => {
88
- setFocused(true);
88
+ const handleFocus = (e: TextInputFocusEvent) => {
89
+ setIsFocused(true);
89
90
  onFocus?.(e);
90
91
  };
91
92
 
92
- const _onBlur = (e: TextInputFocusEvent) => {
93
- setFocused(false);
93
+ const handleBlur = (e: TextInputFocusEvent) => {
94
+ setIsFocused(false);
94
95
  onBlur?.(e);
95
96
  };
96
97
 
97
- const onClearText = () => {
98
+ const handleClearText = () => {
98
99
  inputRef?.current?.clear();
99
100
  inputRef.current?.setNativeProps({ text: '' });
100
- _onChangeText('');
101
+ handleChangeText('');
101
102
  };
102
103
 
103
- const _onChangeText = (text: string) => {
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 _setText = (text: string) => {
111
+ const handleSetText = (text: string) => {
111
112
  inputRef?.current?.setNativeProps({ text });
112
- _onChangeText(text);
113
+ handleChangeText(text);
113
114
  };
114
115
 
115
116
  useImperativeHandle(ref, () => {
116
117
  return {
117
- clear: onClearText,
118
+ clear: handleClearText,
118
119
  focus: () => inputRef.current?.focus(),
119
120
  blur: () => inputRef.current?.blur(),
120
- setText: _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 ? 0.6 : 1}
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={_onChangeText}
159
+ onChangeText={handleChangeText}
159
160
  defaultValue={defaultValue}
160
- onFocus={_onFocus}
161
- onBlur={_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
- {haveValue && (
173
+ {hasValue && (
173
174
  <TouchableOpacity
174
175
  style={styles.iconWrapper}
175
- onPress={onClearText}
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={16}
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 (focused) {
212
+ if (isFocused) {
212
213
  inputState = 'focus';
213
214
  }
214
215
  if (value && value?.length > 0) {
@@ -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 { ErrorView, FloatingView, getBorderColor, MAX_LENGTH } from './common';
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 { setAutomationID, useComponentId } from '../Application';
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 = MAX_LENGTH,
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 DEFAULT_HEIGHT = useScaleSize(104);
66
+ const defaultHeight = useScaleSize(DEFAULT_HEIGHT);
61
67
 
62
68
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
63
- const scaledFontSize = useScaleSize(14);
69
+ const scaledFontSize = useScaleSize(INPUT_FONT_SIZE);
64
70
  const inputFontFamily = exportFontFamily('regular');
65
- const [focused, setFocused] = useState(false);
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 onClearText = () => {
79
+ const handleClearText = () => {
74
80
  inputRef?.current?.clear();
75
81
  inputRef.current?.setNativeProps({ text: '' });
76
- _onChangeText('');
82
+ handleChangeText('');
77
83
  };
78
84
 
79
- const _onChangeText = (text: string) => {
85
+ const handleChangeText = (text: string) => {
80
86
  onChangeText?.(text);
81
87
  setInputValue(text);
82
88
  };
83
89
 
84
- const _onFocus = (e: TextInputFocusEvent) => {
85
- setFocused(true);
90
+ const handleFocus = (e: TextInputFocusEvent) => {
91
+ setIsFocused(true);
86
92
  onFocus?.(e);
87
93
  };
88
94
 
89
- const _onBlur = (e: TextInputFocusEvent) => {
90
- setFocused(false);
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: onClearText,
102
+ clear: handleClearText,
97
103
  focus: () => inputRef.current?.focus(),
98
104
  blur: () => inputRef.current?.blur(),
99
- setText: (text: string) => _onChangeText(text),
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 || DEFAULT_HEIGHT,
138
+ height: height || defaultHeight,
133
139
  backgroundColor: theme.colors.background.surface,
134
140
  },
135
- getBorderColor(theme, focused, errorMessage, disabled),
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={_onChangeText}
179
- onFocus={_onFocus}
180
- onBlur={_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
- {focused && inputValue.length > 0 && (
192
+ {isFocused && inputValue.length > 0 && (
187
193
  <TouchableOpacity
188
194
  style={styles.iconWrapper}
189
- onPress={onClearText}
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={16}
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 { Localize } from '../Application';
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 < 3) {
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 === 3 && i >= 1) {
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 isMissingLeadingZero = phone.length === 9 && /^[1-9]/.test(phone);
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 (phoneFormatted.length === 0) {
198
+ if (digitCount === 0) {
90
199
  error = localize.translate('enterPhoneNumber');
91
- } else if (phoneFormatted.length < 10 || phoneFormatted.length > 11) {
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 { formatMoneyToNumber, formatNumberToMoney, checkValidPhoneNumber };
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 = 12,
57
+ bottom = DEFAULT_BOTTOM,
48
58
  }: FloatingButtonProps) => {
49
59
  const { theme } = useContext(ApplicationContext);
50
- const enabled = animatedValue != null;
60
+ const hasScrollValue = animatedValue != null;
51
61
  const sharedValue = useNormalizedSharedValue(animatedValue);
52
- const scaledFontSize = useScaleSize(16);
53
- const scaledLineHeight = useScaleSize(22);
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' ? 36 : 48;
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 [showText, setShowText] = useState(true);
71
+ const [isLabelShown, setIsLabelShown] = useState(true);
62
72
 
63
73
  useEffect(() => {
64
74
  if (!label) return;
65
- opacityAnimated.value = withTiming(1, { duration: 100 });
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 || !enabled) return;
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, { duration: 100 });
89
+ opacityAnimated.value = withTiming(0, {
90
+ duration: ANIMATION_DURATION,
91
+ });
80
92
  widthAnimated.value = withTiming(
81
93
  minWidth,
82
- { duration: 100 },
94
+ { duration: ANIMATION_DURATION },
83
95
  finished => {
84
- if (finished) scheduleOnRN(setShowText, false);
96
+ if (finished) scheduleOnRN(setIsLabelShown, false);
85
97
  },
86
98
  );
87
99
  } else {
88
- opacityAnimated.value = withTiming(1, { duration: 100 });
100
+ opacityAnimated.value = withTiming(1, {
101
+ duration: ANIMATION_DURATION,
102
+ });
89
103
  widthAnimated.value = withTiming(
90
104
  maxWidth.value,
91
- { duration: 100 },
105
+ { duration: ANIMATION_DURATION },
92
106
  finished => {
93
- if (finished) scheduleOnRN(setShowText, true);
107
+ if (finished) scheduleOnRN(setIsLabelShown, true);
94
108
  },
95
109
  );
96
110
  }
97
111
  }
98
112
  }
99
113
  },
100
- [label, enabled, minWidth],
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' ? 12 : 24}
163
+ size={size === 'small' ? SMALL_ICON_SIZE : LARGE_ICON_SIZE}
150
164
  color={iconColor}
151
165
  />
152
- {showText && !!label && (
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: 100,
182
- paddingHorizontal: 12,
195
+ borderRadius: PILL_RADIUS,
196
+ paddingHorizontal: Spacing.M,
183
197
  flexWrap: 'wrap',
184
198
  alignSelf: 'flex-end',
185
199
  },
186
200
  label: {
187
- marginLeft: 8,
201
+ marginLeft: Spacing.S,
188
202
  },
189
- alignRight: { right: 12 },
203
+ alignRight: { right: Spacing.M },
190
204
  alignCenter: { alignSelf: 'center' },
191
205
  alignEnd: { alignSelf: 'flex-end' },
192
- small: { height: 36 },
193
- large: { height: 48 },
206
+ small: { height: SMALL_SIZE },
207
+ large: { height: LARGE_SIZE },
194
208
  content: {
195
209
  flexDirection: 'row',
196
210
  alignItems: 'center',