@momo-kits/foundation 0.168.1-rn862.1 → 0.168.2-rn.88.3

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.
@@ -1,5 +1,5 @@
1
1
  import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react';
2
- import { Alert, InteractionManager } from 'react-native';
2
+ import { Alert } from 'react-native';
3
3
  import { useHeaderHeight } from '@react-navigation/elements';
4
4
  import { ScreenTrackingParams } from './types';
5
5
  import Navigation from './Navigation';
@@ -8,7 +8,12 @@ import { version } from '../package.json';
8
8
  import { SwipeBackHaptic, useAppState } from './utils';
9
9
  import { TooltipPortalHost, TooltipPortalProvider } from './TooltipPortal';
10
10
 
11
- const runAfterInteractions = InteractionManager.runAfterInteractions;
11
+ // RN 0.87+: InteractionManager đã bị xoá khỏi react-native → thay bằng requestIdleCallback.
12
+ // Giữ nguyên shape trả về { cancel } để code bên dưới (timeoutInteraction?.cancel?.()) không đổi.
13
+ const runAfterInteractions = (task: () => void) => {
14
+ const handle = requestIdleCallback(() => task());
15
+ return { cancel: () => cancelIdleCallback(handle) };
16
+ };
12
17
 
13
18
  /**
14
19
  * container for stack screen
@@ -4,15 +4,14 @@ import React, {
4
4
  useContext,
5
5
  useEffect,
6
6
  useImperativeHandle,
7
+ useLayoutEffect,
7
8
  useRef,
8
9
  useState,
9
10
  } from 'react';
10
11
  import {
11
- Platform,
12
- Pressable,
13
- Text,
14
12
  TextInput,
15
13
  TextInputFocusEvent,
14
+ TextInputSelectionChangeEvent,
16
15
  TouchableOpacity,
17
16
  View,
18
17
  } from 'react-native';
@@ -24,6 +23,7 @@ import {
24
23
  } from '../Context';
25
24
  import { Icon } from '../Icon';
26
25
  import { useScaleSize, exportFontFamily } from '../Text';
26
+ import { Spacing } from '../Consts';
27
27
  import {
28
28
  ErrorView,
29
29
  FloatingView,
@@ -35,24 +35,97 @@ import { InputMoneyProps } from './index';
35
35
  import styles from './styles';
36
36
  import SystemTextInput from './SystemTextInput';
37
37
 
38
- const moneyFormatter = new Intl.NumberFormat('vi-VN');
38
+ type Selection = { start: number; end: number };
39
+ type Edit = { start: number; end: number; inserted: string };
39
40
 
40
- // Finds the common prefix and suffix of `old` and `text`; `size` counts the
41
- // characters removed plus inserted between them.
42
- const findEdit = (old: string, text: string) => {
43
- let start = 0;
44
- while (start < text.length && old[start] === text[start]) {
45
- start += 1;
41
+ const countDigits = (text: string) => text.replace(/\D/g, '').length;
42
+
43
+ const groupDigits = (digits: string) =>
44
+ digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
45
+
46
+ // Caret position right after the `digitIndex`-th digit of `display`.
47
+ const caretFromDigitIndex = (display: string, digitIndex: number) => {
48
+ let count = 0;
49
+ for (let i = 0; i < display.length && count < digitIndex; i++) {
50
+ if (/\d/.test(display[i])) {
51
+ count += 1;
52
+ if (count === digitIndex) return i + 1;
53
+ }
54
+ }
55
+ return 0;
56
+ };
57
+
58
+ const detectEdit = (old: string, next: string, selection: Selection): Edit => {
59
+ const { start, end } = selection;
60
+ const removed = old.length - next.length;
61
+ const candidates: Edit[] = [
62
+ {
63
+ start,
64
+ end,
65
+ inserted: next.slice(start, next.length - (old.length - end)),
66
+ },
67
+ { start: start - removed, end: start, inserted: '' },
68
+ { start, end: start + removed, inserted: '' },
69
+ ];
70
+ const match = candidates.find(
71
+ e =>
72
+ e.start >= 0 &&
73
+ e.end >= e.start &&
74
+ e.end <= old.length &&
75
+ old.slice(0, e.start) + e.inserted + old.slice(e.end) === next,
76
+ );
77
+ if (match) return match;
78
+
79
+ let prefix = 0;
80
+ while (
81
+ prefix < Math.min(old.length, next.length) &&
82
+ old[prefix] === next[prefix]
83
+ ) {
84
+ prefix += 1;
46
85
  }
47
86
  let suffix = 0;
48
87
  while (
49
- suffix < Math.min(text.length, old.length) - start &&
50
- old[old.length - 1 - suffix] === text[text.length - 1 - suffix]
88
+ suffix < Math.min(old.length, next.length) - prefix &&
89
+ old[old.length - 1 - suffix] === next[next.length - 1 - suffix]
51
90
  ) {
52
91
  suffix += 1;
53
92
  }
54
- const size = old.length + text.length - 2 * (start + suffix);
55
- return { old, start, suffix, size };
93
+ return {
94
+ start: prefix,
95
+ end: old.length - suffix,
96
+ inserted: next.slice(prefix, next.length - suffix),
97
+ };
98
+ };
99
+
100
+ // Applies `edit` to the digits of `display`. Separators and currency are never
101
+ // deleted: removing a separator deletes the digit before it instead, removing
102
+ // only the currency leaves the digits untouched.
103
+ const applyEdit = (display: string, edit: Edit, digitLimit: number) => {
104
+ const digits = display.replace(/\D/g, '');
105
+ let left = countDigits(display.slice(0, edit.start));
106
+ let removed = countDigits(display.slice(edit.start, edit.end));
107
+ const inserted = edit.inserted.replace(/\D/g, '');
108
+ if (
109
+ !removed &&
110
+ !inserted &&
111
+ left > 0 &&
112
+ display.slice(edit.start, edit.end).includes('.')
113
+ ) {
114
+ left -= 1;
115
+ removed = 1;
116
+ }
117
+ const next = digits.slice(0, left) + inserted + digits.slice(left + removed);
118
+ const trimmed = next.replace(/^0+(?=\d)/, '');
119
+ if ((!removed && !inserted) || trimmed.length > digitLimit) {
120
+ return { digits, caretDigit: left };
121
+ }
122
+ return {
123
+ digits: trimmed,
124
+ caretDigit: Math.max(
125
+ 0,
126
+ left + inserted.length - (next.length - trimmed.length),
127
+ ),
128
+ };
56
129
  };
57
130
 
58
131
  const InputMoney = forwardRef(
@@ -93,14 +166,14 @@ const InputMoney = forwardRef(
93
166
  const [focused, setFocused] = useState(false);
94
167
  const [displayValue, setDisplayValue] = useState('');
95
168
  const [numericValue, setNumericValue] = useState('');
96
- const [lastDisplayValue, setLastDisplayValue] = useState('');
169
+ const [commitCount, setCommitCount] = useState(0);
170
+ const displayRef = useRef('');
171
+ const selectionRef = useRef<Selection>({ start: 0, end: 0 });
172
+ // Caret to push to native once the committed text is rendered.
173
+ const pendingCaretRef = useRef<number | null>(null);
174
+ const pendingSelectionRef = useRef<Selection | null>(null);
175
+ const editingRef = useRef(false);
97
176
  const inputRef = useRef<TextInput>(null);
98
- const caretDigitsAfter = useRef<number | undefined>(undefined);
99
- const lastRawText = useRef('');
100
- const [caretSelection, setCaretSelection] = useState<{
101
- start: number;
102
- end: number;
103
- }>();
104
177
  const componentName = 'InputMoney';
105
178
 
106
179
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
@@ -118,96 +191,92 @@ const InputMoney = forwardRef(
118
191
  if (value !== undefined && typeof value === 'string') {
119
192
  const cleanValue = value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
120
193
  if (cleanValue !== numericValue) {
121
- caretDigitsAfter.current = undefined;
122
- setNumericValue(cleanValue);
123
- updateDisplayValue(cleanValue);
194
+ commit(cleanValue, cleanValue.length);
124
195
  }
125
196
  }
126
197
  }, [value]);
127
198
 
128
- useEffect(() => {
129
- setLastDisplayValue(displayValue);
130
- }, [displayValue]);
131
-
132
- // The selection only rides along with the render that reformats the text,
133
- // then it is released so the user can move the caret freely.
134
- useEffect(() => {
135
- if (!caretSelection) return;
136
- // A newer keystroke may already have queued its own selection; keep it.
137
- setCaretSelection(current =>
138
- current === caretSelection ? undefined : current,
139
- );
140
- }, [caretSelection]);
141
-
142
- const updateDisplayValue = (numeric: string) => {
143
- const next = numeric ? moneyFormatter.format(Number(numeric)) : '';
144
- setDisplayValue(next);
145
-
146
- // Android moves the caret proportionally when the text is replaced, so
147
- // it lands before the typed digit once a separator is added. Send the
148
- // caret together with the text, keeping the same digits after it.
149
- const digitsAfter = caretDigitsAfter.current;
150
- caretDigitsAfter.current = undefined;
151
- if (Platform.OS !== 'android' || digitsAfter === undefined) return;
152
- let before = parseInputText(next).length - digitsAfter;
153
- let caret = 0;
154
- while (before > 0 && caret < next.length) {
155
- if (/\d/.test(next[caret])) before -= 1;
156
- caret += 1;
199
+ // Always set the caret imperatively: a controlled `selection` prop is only
200
+ // sent to native when it differs from the last native selection, so it is
201
+ // skipped whenever a stale selection event already reported the same value.
202
+ useLayoutEffect(() => {
203
+ const caret = pendingCaretRef.current;
204
+ if (caret === null) return;
205
+ pendingCaretRef.current = null;
206
+ if (inputRef.current?.isFocused()) {
207
+ inputRef.current.setSelection(caret, caret);
157
208
  }
158
- setCaretSelection({ start: caret, end: caret });
159
- };
209
+ }, [commitCount]);
160
210
 
161
- const parseInputText = (text: string): string => text.replace(/\D/g, '');
211
+ const commit = (digits: string, caretDigit: number) => {
212
+ const display = digits ? `${groupDigits(digits)}${currency}` : '';
213
+ const caret = caretFromDigitIndex(display, caretDigit);
214
+ const changed = digits !== numericValue;
215
+ displayRef.current = display;
216
+ selectionRef.current = { start: caret, end: caret };
217
+ pendingCaretRef.current = caret;
218
+ setCommitCount(count => count + 1);
219
+ setDisplayValue(display);
220
+ setNumericValue(digits);
221
+ return changed;
222
+ };
162
223
 
163
- const updateNumericValue = (val: string) => {
164
- let next = val.replace(/[^\d]/g, '').replace(/^0+(?=\d)/, '');
165
- if (next.length > digitLimit) next = next.slice(0, digitLimit);
166
- if (/^\d*$/.test(next)) {
167
- setNumericValue(next);
168
- updateDisplayValue(next);
169
- onChangeText?.(next);
170
- }
224
+ const parseInputText = (text: string): string => {
225
+ if (!text) return '';
226
+ const currencyRegex = new RegExp(` ?${currency}$`, 'g');
227
+ const textWithoutCurrency = text.replace(currencyRegex, '').trim();
228
+ return textWithoutCurrency.replace(/[^\d]/g, '');
171
229
  };
172
230
 
173
231
  const onClearText = () => {
174
232
  inputRef?.current?.clear();
175
233
  inputRef.current?.setNativeProps({ text: '' });
176
- setDisplayValue('');
177
- setNumericValue('');
234
+ commit('', 0);
178
235
  onChangeText?.('');
179
236
  };
180
237
 
181
238
  const _onChangeText = (text: string) => {
182
- if (!text) {
183
- lastRawText.current = '';
184
- setDisplayValue('');
185
- setNumericValue('');
186
- onChangeText?.('');
187
- return;
239
+ let digits = '';
240
+ let caretDigit = 0;
241
+ pendingSelectionRef.current = null;
242
+ editingRef.current = true;
243
+ setTimeout(() => {
244
+ editingRef.current = false;
245
+ });
246
+ if (text) {
247
+ const edit = detectEdit(displayRef.current, text, selectionRef.current);
248
+ ({ digits, caretDigit } = applyEdit(
249
+ displayRef.current,
250
+ edit,
251
+ digitLimit,
252
+ ));
188
253
  }
189
- const digits = parseInputText(text);
190
- // When typing outpaces JS (slow devices) native rejects the stale
191
- // formatted text and keeps the raw one, so the edit may apply to either.
192
- // Pick the one that differs from the new text by a single edit.
193
- const raw = lastRawText.current;
194
- lastRawText.current = text;
195
- const formattedEdit = findEdit(lastDisplayValue, text);
196
- const rawEdit = findEdit(raw, text);
197
- const { old, start, suffix } =
198
- raw && rawEdit.size < formattedEdit.size ? rawEdit : formattedEdit;
199
- caretDigitsAfter.current = parseInputText(
200
- text.slice(text.length - suffix),
201
- ).length;
202
- // Deleting a separator removes the digit before it.
203
- if (text.length < old.length && digits === numericValue) {
204
- const index = parseInputText(text.slice(0, start)).length - 1;
205
- if (index >= 0) {
206
- updateNumericValue(digits.slice(0, index) + digits.slice(index + 1));
207
- return;
208
- }
254
+ if (commit(digits, caretDigit)) {
255
+ onChangeText?.(digits);
209
256
  }
210
- updateNumericValue(digits);
257
+ };
258
+
259
+ // Keeps the caret out of the currency suffix.
260
+ const _onSelectionChange = (e: TextInputSelectionChangeEvent) => {
261
+ props.onSelectionChange?.(e);
262
+ if (editingRef.current) return;
263
+ const selection = e.nativeEvent.selection;
264
+ pendingSelectionRef.current = selection;
265
+ setTimeout(() => {
266
+ if (pendingSelectionRef.current !== selection) return;
267
+ pendingSelectionRef.current = null;
268
+ const { start, end } = selection;
269
+ const display = displayRef.current;
270
+ const max = display ? display.length - currency.length : 0;
271
+ const clamped = {
272
+ start: Math.min(start, max),
273
+ end: Math.min(end, max),
274
+ };
275
+ selectionRef.current = clamped;
276
+ if (clamped.start !== start || clamped.end !== end) {
277
+ inputRef.current?.setSelection(clamped.start, clamped.end);
278
+ }
279
+ });
211
280
  };
212
281
 
213
282
  const _onFocus = (e: TextInputFocusEvent) => {
@@ -221,15 +290,17 @@ const InputMoney = forwardRef(
221
290
  };
222
291
 
223
292
  const _setText = (text: string) => {
224
- caretDigitsAfter.current = undefined;
225
293
  if (!text) {
226
- setDisplayValue('');
227
- setNumericValue('');
294
+ commit('', 0);
228
295
  onChangeText?.('');
229
296
  return;
230
297
  }
231
- const newNumericValue = parseInputText(text);
232
- updateNumericValue(newNumericValue);
298
+ const digits = parseInputText(text)
299
+ .replace(/^0+(?=\d)/, '')
300
+ .slice(0, digitLimit);
301
+ if (commit(digits, digits.length)) {
302
+ onChangeText?.(digits);
303
+ }
233
304
  };
234
305
 
235
306
  useImperativeHandle(ref, () => {
@@ -251,14 +322,6 @@ const InputMoney = forwardRef(
251
322
  placeholderColor = disabledColor;
252
323
  iconTintColor = disabledColor;
253
324
  }
254
- const textStyle = {
255
- fontSize: scaledFontSize,
256
- fontFamily: inputFontFamily,
257
- };
258
- const placeholderHasCurrency = placeholder.endsWith(currency);
259
- const inputPlaceholder = placeholderHasCurrency
260
- ? placeholder.slice(0, -currency.length)
261
- : placeholder;
262
325
  return (
263
326
  <View
264
327
  style={[
@@ -277,11 +340,7 @@ const InputMoney = forwardRef(
277
340
  onPress={onPressFloatingIcon}
278
341
  componentId={componentId}
279
342
  />
280
- <Pressable
281
- style={[styles.inputView, styles.moneyInputView]}
282
- onPress={() => inputRef.current?.focus()}
283
- disabled={disabled}
284
- >
343
+ <View style={styles.inputView}>
285
344
  <SystemTextInput
286
345
  {...props}
287
346
  accessibilityLabel={`${componentId}`}
@@ -291,34 +350,33 @@ const InputMoney = forwardRef(
291
350
  disabled: disabled,
292
351
  ...props.accessibilityState,
293
352
  }}
294
- // No width: the input sizes to its text natively, so the
295
- // currency follows it without waiting for JS.
296
- style={[textStyle, styles.moneyTextInput, { color: textColor }]}
353
+ style={[
354
+ {
355
+ width: '100%',
356
+ paddingLeft: Spacing.M,
357
+ height: '100%',
358
+ fontSize: scaledFontSize,
359
+ paddingVertical: Spacing.S,
360
+ fontFamily: inputFontFamily,
361
+ },
362
+ {
363
+ color: textColor,
364
+ },
365
+ ]}
297
366
  allowFontScaling={false}
298
367
  textBreakStrategy="highQuality"
299
368
  value={displayValue}
300
- selection={caretSelection ?? props.selection}
301
369
  onChangeText={_onChangeText}
370
+ onSelectionChange={_onSelectionChange}
302
371
  onFocus={_onFocus}
303
372
  onBlur={_onBlur}
304
- placeholder={inputPlaceholder}
373
+ placeholder={placeholder}
305
374
  selectionColor={theme.colors.primary}
306
375
  placeholderTextColor={placeholderColor}
307
376
  keyboardType={'numeric'}
308
377
  maxLength={undefined}
309
378
  />
310
- {displayValue || placeholderHasCurrency ? (
311
- <Text
312
- style={[
313
- textStyle,
314
- { color: displayValue ? textColor : placeholderColor },
315
- ]}
316
- allowFontScaling={false}
317
- >
318
- {currency}
319
- </Text>
320
- ) : null}
321
- </Pressable>
379
+ </View>
322
380
  <View style={styles.iconView}>
323
381
  {showClearIcon && focused && displayValue ? (
324
382
  <TouchableOpacity
package/Input/styles.ts CHANGED
@@ -218,14 +218,4 @@ export default StyleSheet.create({
218
218
  marginLeft: Spacing.M,
219
219
  },
220
220
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
221
- moneyInputView: {
222
- paddingLeft: Spacing.M,
223
- },
224
- moneyTextInput: {
225
- flexShrink: 1,
226
- height: '100%',
227
- paddingLeft: 0,
228
- paddingRight: Spacing.XXS,
229
- paddingVertical: Spacing.S,
230
- },
231
221
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.168.1-rn862.1",
3
+ "version": "0.168.2-rn.88.3",
4
4
  "description": "React Native Component Kits",
5
5
  "keywords": [
6
6
  "@momo-kits/foundation"
@@ -12,29 +12,29 @@
12
12
  },
13
13
  "scripts": {},
14
14
  "dependencies": {
15
- "@react-navigation/bottom-tabs": "7.4.2",
16
- "@react-navigation/core": "7.12.1",
17
- "@react-navigation/elements": "2.5.2",
18
- "@react-navigation/native": "7.1.14",
19
- "@react-navigation/routers": "7.4.1",
20
- "@react-navigation/stack": "7.4.2",
15
+ "@react-navigation/bottom-tabs": "7.21.0",
16
+ "@react-navigation/core": "7.24.0",
17
+ "@react-navigation/elements": "2.9.45",
18
+ "@react-navigation/native": "7.5.1",
19
+ "@react-navigation/routers": "7.6.5",
20
+ "@react-navigation/stack": "7.12.1",
21
21
  "@shopify/flash-list": "2.1.0",
22
22
  "lottie-react-native": "7.3.6",
23
23
  "react-native-fast-image": "git+https://oauth2:k_r5y_gV6sX9-TqQxgcd@gitlab.mservice.com.vn/momo-platform/public/react-native-fast-image.git#v8.11.0",
24
- "react-native-gesture-handler": "2.31.0",
24
+ "react-native-gesture-handler": "3.4.0",
25
25
  "react-native-linear-gradient": "git+https://oauth2:k_r5y_gV6sX9-TqQxgcd@gitlab.mservice.com.vn/momo-platform/public/react-native-linear-gradient#v3.0.0",
26
- "react-native-reanimated": "4.5.3",
27
- "react-native-reserved-regions": "0.2.0",
28
- "react-native-safe-area-context": "5.7.0"
26
+ "react-native-reanimated": "4.7.1",
27
+ "react-native-safe-area-context": "5.7.0",
28
+ "react-native-reserved-regions": "0.2.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/color": "3.0.6",
32
- "react-native-reanimated": "4.5.3",
33
- "react-native-worklets": "0.11.3"
32
+ "react-native-reanimated": "4.7.1",
33
+ "react-native-worklets": "0.13.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react-native": "*",
37
- "react-native-reanimated": ">=4.5.0",
38
- "react-native-worklets": ">=0.11.0"
37
+ "react-native-reanimated": ">=4.7.0",
38
+ "react-native-worklets": ">=0.13.0"
39
39
  }
40
40
  }