@momo-kits/foundation 0.168.1-beta.1 → 0.168.1-beta.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.
@@ -4,14 +4,15 @@ import React, {
4
4
  useContext,
5
5
  useEffect,
6
6
  useImperativeHandle,
7
- useLayoutEffect,
8
7
  useRef,
9
8
  useState,
10
9
  } from 'react';
11
10
  import {
11
+ Platform,
12
+ Pressable,
13
+ Text,
12
14
  TextInput,
13
15
  TextInputFocusEvent,
14
- TextInputSelectionChangeEvent,
15
16
  TouchableOpacity,
16
17
  View,
17
18
  } from 'react-native';
@@ -23,7 +24,6 @@ import {
23
24
  } from '../Context';
24
25
  import { Icon } from '../Icon';
25
26
  import { useScaleSize, exportFontFamily } from '../Text';
26
- import { Spacing } from '../Consts';
27
27
  import {
28
28
  ErrorView,
29
29
  FloatingView,
@@ -35,97 +35,24 @@ import { InputMoneyProps } from './index';
35
35
  import styles from './styles';
36
36
  import SystemTextInput from './SystemTextInput';
37
37
 
38
- type Selection = { start: number; end: number };
39
- type Edit = { start: number; end: number; inserted: string };
38
+ const moneyFormatter = new Intl.NumberFormat('vi-VN');
40
39
 
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;
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;
85
46
  }
86
47
  let suffix = 0;
87
48
  while (
88
- suffix < Math.min(old.length, next.length) - prefix &&
89
- old[old.length - 1 - suffix] === next[next.length - 1 - suffix]
49
+ suffix < Math.min(text.length, old.length) - start &&
50
+ old[old.length - 1 - suffix] === text[text.length - 1 - suffix]
90
51
  ) {
91
52
  suffix += 1;
92
53
  }
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
- };
54
+ const size = old.length + text.length - 2 * (start + suffix);
55
+ return { old, start, suffix, size };
129
56
  };
130
57
 
131
58
  const InputMoney = forwardRef(
@@ -166,14 +93,14 @@ const InputMoney = forwardRef(
166
93
  const [focused, setFocused] = useState(false);
167
94
  const [displayValue, setDisplayValue] = useState('');
168
95
  const [numericValue, setNumericValue] = 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);
96
+ const [lastDisplayValue, setLastDisplayValue] = useState('');
176
97
  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
+ }>();
177
104
  const componentName = 'InputMoney';
178
105
 
179
106
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
@@ -191,92 +118,96 @@ const InputMoney = forwardRef(
191
118
  if (value !== undefined && typeof value === 'string') {
192
119
  const cleanValue = value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
193
120
  if (cleanValue !== numericValue) {
194
- commit(cleanValue, cleanValue.length);
121
+ caretDigitsAfter.current = undefined;
122
+ setNumericValue(cleanValue);
123
+ updateDisplayValue(cleanValue);
195
124
  }
196
125
  }
197
126
  }, [value]);
198
127
 
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);
208
- }
209
- }, [commitCount]);
128
+ useEffect(() => {
129
+ setLastDisplayValue(displayValue);
130
+ }, [displayValue]);
210
131
 
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;
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;
157
+ }
158
+ setCaretSelection({ start: caret, end: caret });
222
159
  };
223
160
 
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, '');
161
+ const parseInputText = (text: string): string => text.replace(/\D/g, '');
162
+
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
+ }
229
171
  };
230
172
 
231
173
  const onClearText = () => {
232
174
  inputRef?.current?.clear();
233
175
  inputRef.current?.setNativeProps({ text: '' });
234
- commit('', 0);
176
+ setDisplayValue('');
177
+ setNumericValue('');
235
178
  onChangeText?.('');
236
179
  };
237
180
 
238
181
  const _onChangeText = (text: string) => {
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
- ));
253
- }
254
- if (commit(digits, caretDigit)) {
255
- onChangeText?.(digits);
182
+ if (!text) {
183
+ lastRawText.current = '';
184
+ setDisplayValue('');
185
+ setNumericValue('');
186
+ onChangeText?.('');
187
+ return;
256
188
  }
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);
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;
278
208
  }
279
- });
209
+ }
210
+ updateNumericValue(digits);
280
211
  };
281
212
 
282
213
  const _onFocus = (e: TextInputFocusEvent) => {
@@ -290,17 +221,15 @@ const InputMoney = forwardRef(
290
221
  };
291
222
 
292
223
  const _setText = (text: string) => {
224
+ caretDigitsAfter.current = undefined;
293
225
  if (!text) {
294
- commit('', 0);
226
+ setDisplayValue('');
227
+ setNumericValue('');
295
228
  onChangeText?.('');
296
229
  return;
297
230
  }
298
- const digits = parseInputText(text)
299
- .replace(/^0+(?=\d)/, '')
300
- .slice(0, digitLimit);
301
- if (commit(digits, digits.length)) {
302
- onChangeText?.(digits);
303
- }
231
+ const newNumericValue = parseInputText(text);
232
+ updateNumericValue(newNumericValue);
304
233
  };
305
234
 
306
235
  useImperativeHandle(ref, () => {
@@ -322,6 +251,14 @@ const InputMoney = forwardRef(
322
251
  placeholderColor = disabledColor;
323
252
  iconTintColor = disabledColor;
324
253
  }
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;
325
262
  return (
326
263
  <View
327
264
  style={[
@@ -340,7 +277,11 @@ const InputMoney = forwardRef(
340
277
  onPress={onPressFloatingIcon}
341
278
  componentId={componentId}
342
279
  />
343
- <View style={styles.inputView}>
280
+ <Pressable
281
+ style={[styles.inputView, styles.moneyInputView]}
282
+ onPress={() => inputRef.current?.focus()}
283
+ disabled={disabled}
284
+ >
344
285
  <SystemTextInput
345
286
  {...props}
346
287
  accessibilityLabel={`${componentId}`}
@@ -350,33 +291,34 @@ const InputMoney = forwardRef(
350
291
  disabled: disabled,
351
292
  ...props.accessibilityState,
352
293
  }}
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
- ]}
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 }]}
366
297
  allowFontScaling={false}
367
298
  textBreakStrategy="highQuality"
368
299
  value={displayValue}
300
+ selection={caretSelection ?? props.selection}
369
301
  onChangeText={_onChangeText}
370
- onSelectionChange={_onSelectionChange}
371
302
  onFocus={_onFocus}
372
303
  onBlur={_onBlur}
373
- placeholder={placeholder}
304
+ placeholder={inputPlaceholder}
374
305
  selectionColor={theme.colors.primary}
375
306
  placeholderTextColor={placeholderColor}
376
307
  keyboardType={'numeric'}
377
308
  maxLength={undefined}
378
309
  />
379
- </View>
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>
380
322
  <View style={styles.iconView}>
381
323
  {showClearIcon && focused && displayValue ? (
382
324
  <TouchableOpacity
package/Input/styles.ts CHANGED
@@ -218,4 +218,14 @@ 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
+ },
221
231
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.168.1-beta.1",
3
+ "version": "0.168.1-beta.10",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},