@momo-kits/foundation 0.165.2-beta.1 → 0.165.2-beta.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.
Files changed (2) hide show
  1. package/Input/InputMoney.tsx +192 -50
  2. package/package.json +1 -1
@@ -4,13 +4,16 @@ 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
12
  NativeSyntheticEvent,
13
+ Platform,
12
14
  TextInput,
13
15
  TextInputFocusEventData,
16
+ TextInputSelectionChangeEventData,
14
17
  TouchableOpacity,
15
18
  View,
16
19
  } from 'react-native';
@@ -32,9 +35,124 @@ import {
32
35
  } from './common';
33
36
  import { InputMoneyProps } from './index';
34
37
  import styles from './styles';
35
- import { formatNumberToMoney } from './utils';
36
38
  import SystemTextInput from './SystemTextInput';
37
39
 
40
+ type Selection = { start: number; end: number };
41
+ type Edit = { start: number; end: number; inserted: string };
42
+
43
+ const countDigits = (text: string) => text.replace(/\D/g, '').length;
44
+
45
+ const groupDigits = (digits: string) =>
46
+ digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
47
+
48
+ // Caret position right after the `digitIndex`-th digit of `display`.
49
+ const caretFromDigitIndex = (display: string, digitIndex: number) => {
50
+ let count = 0;
51
+ for (let i = 0; i < display.length && count < digitIndex; i++) {
52
+ if (/\d/.test(display[i])) {
53
+ count += 1;
54
+ if (count === digitIndex) return i + 1;
55
+ }
56
+ }
57
+ return 0;
58
+ };
59
+
60
+ // Finds which range of `old` was replaced to produce `next`. `selection` is
61
+ // the pre-edit selection on iOS and already the post-edit caret on Android,
62
+ // where onSelectionChange fires before onChangeText.
63
+ const detectEdit = (old: string, next: string, selection: Selection): Edit => {
64
+ const { start, end } = selection;
65
+ const removed = old.length - next.length;
66
+ const preEdit: Edit[] = [
67
+ {
68
+ start,
69
+ end,
70
+ inserted: next.slice(start, next.length - (old.length - end)),
71
+ },
72
+ { start: start - removed, end: start, inserted: '' },
73
+ { start, end: start + removed, inserted: '' },
74
+ ];
75
+ const postEdit: Edit[] = [];
76
+ const tail = next.length - start;
77
+ if (start === end && tail <= old.length) {
78
+ let prefix = 0;
79
+ while (
80
+ prefix < Math.min(start, old.length - tail) &&
81
+ old[prefix] === next[prefix]
82
+ ) {
83
+ prefix += 1;
84
+ }
85
+ postEdit.push({
86
+ start: prefix,
87
+ end: old.length - tail,
88
+ inserted: next.slice(prefix, start),
89
+ });
90
+ }
91
+ const candidates =
92
+ Platform.OS === 'android'
93
+ ? [...postEdit, ...preEdit]
94
+ : [...preEdit, ...postEdit];
95
+ const match = candidates.find(
96
+ e =>
97
+ e.start >= 0 &&
98
+ e.end >= e.start &&
99
+ e.end <= old.length &&
100
+ old.slice(0, e.start) + e.inserted + old.slice(e.end) === next,
101
+ );
102
+ if (match) return match;
103
+
104
+ let prefix = 0;
105
+ while (
106
+ prefix < Math.min(old.length, next.length) &&
107
+ old[prefix] === next[prefix]
108
+ ) {
109
+ prefix += 1;
110
+ }
111
+ let suffix = 0;
112
+ while (
113
+ suffix < Math.min(old.length, next.length) - prefix &&
114
+ old[old.length - 1 - suffix] === next[next.length - 1 - suffix]
115
+ ) {
116
+ suffix += 1;
117
+ }
118
+ return {
119
+ start: prefix,
120
+ end: old.length - suffix,
121
+ inserted: next.slice(prefix, next.length - suffix),
122
+ };
123
+ };
124
+
125
+ // Applies `edit` to the digits of `display`. Separators and currency are never
126
+ // deleted: removing a separator deletes the digit before it instead, removing
127
+ // only the currency leaves the digits untouched.
128
+ const applyEdit = (display: string, edit: Edit, digitLimit: number) => {
129
+ const digits = display.replace(/\D/g, '');
130
+ let left = countDigits(display.slice(0, edit.start));
131
+ let removed = countDigits(display.slice(edit.start, edit.end));
132
+ const inserted = edit.inserted.replace(/\D/g, '');
133
+ if (
134
+ !removed &&
135
+ !inserted &&
136
+ left > 0 &&
137
+ display.slice(edit.start, edit.end).includes('.')
138
+ ) {
139
+ left -= 1;
140
+ removed = 1;
141
+ }
142
+ const next = digits.slice(0, left) + inserted + digits.slice(left + removed);
143
+ const trimmed = next.replace(/^0+(?=\d)/, '');
144
+ if ((!removed && !inserted) || trimmed.length > digitLimit) {
145
+ return { digits, caretDigit: left };
146
+ }
147
+ return {
148
+ digits: trimmed,
149
+ caretDigit: Math.max(
150
+ 0,
151
+ left + inserted.length - (next.length - trimmed.length),
152
+ ),
153
+ };
154
+ };
155
+
38
156
  const InputMoney = forwardRef(
39
157
  (
40
158
  {
@@ -73,7 +191,14 @@ const InputMoney = forwardRef(
73
191
  const [focused, setFocused] = useState(false);
74
192
  const [displayValue, setDisplayValue] = useState('');
75
193
  const [numericValue, setNumericValue] = useState('');
76
- const [lastDisplayValue, setLastDisplayValue] = useState('');
194
+ const [commitCount, setCommitCount] = useState(0);
195
+ const displayRef = useRef('');
196
+ const selectionRef = useRef<Selection>({ start: 0, end: 0 });
197
+ // Caret to push to native once the committed text is rendered.
198
+ const pendingCaretRef = useRef<number | null>(null);
199
+ // Caret the native input reports for the raw, unformatted text. On iOS that
200
+ // event lands after onChangeText and must not override the formatted caret.
201
+ const rawCaretRef = useRef<number | null>(null);
77
202
  const inputRef = useRef<TextInput>(null);
78
203
  const componentName = 'InputMoney';
79
204
 
@@ -91,25 +216,36 @@ const InputMoney = forwardRef(
91
216
 
92
217
  useEffect(() => {
93
218
  if (value !== undefined && typeof value === 'string') {
94
- const cleanValue = value.replace(/\D/g, '');
219
+ const cleanValue = value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
95
220
  if (cleanValue !== numericValue) {
96
- setNumericValue(cleanValue);
97
- updateDisplayValue(cleanValue);
221
+ commit(cleanValue, cleanValue.length);
98
222
  }
99
223
  }
100
224
  }, [value]);
101
225
 
102
- useEffect(() => {
103
- setLastDisplayValue(displayValue);
104
- }, [displayValue]);
105
-
106
- const updateDisplayValue = (numeric: string) => {
107
- if (!numeric) {
108
- setDisplayValue('');
109
- return;
226
+ // Always set the caret imperatively: a controlled `selection` prop is only
227
+ // sent to native when it differs from the last native selection, so it is
228
+ // skipped whenever a stale selection event already reported the same value.
229
+ useLayoutEffect(() => {
230
+ const caret = pendingCaretRef.current;
231
+ if (caret === null) return;
232
+ pendingCaretRef.current = null;
233
+ if (inputRef.current?.isFocused()) {
234
+ inputRef.current.setSelection(caret, caret);
110
235
  }
111
- const formatted = formatNumberToMoney(Number(numeric));
112
- setDisplayValue(formatted ? `${formatted}${currency}` : '');
236
+ }, [commitCount]);
237
+
238
+ const commit = (digits: string, caretDigit: number) => {
239
+ const display = digits ? `${groupDigits(digits)}${currency}` : '';
240
+ const caret = caretFromDigitIndex(display, caretDigit);
241
+ const changed = digits !== numericValue;
242
+ displayRef.current = display;
243
+ selectionRef.current = { start: caret, end: caret };
244
+ pendingCaretRef.current = caret;
245
+ setCommitCount(count => count + 1);
246
+ setDisplayValue(display);
247
+ setNumericValue(digits);
248
+ return changed;
113
249
  };
114
250
 
115
251
  const parseInputText = (text: string): string => {
@@ -119,47 +255,49 @@ const InputMoney = forwardRef(
119
255
  return textWithoutCurrency.replace(/[^\d]/g, '');
120
256
  };
121
257
 
122
- const updateNumericValue = (val: string) => {
123
- let next = val.replace(/[^\d]/g, '');
124
- if (next.length > digitLimit) next = next.slice(0, digitLimit);
125
- if (/^\d*$/.test(next)) {
126
- setNumericValue(next);
127
- updateDisplayValue(next);
128
- onChangeText?.(next);
129
- }
130
- };
131
-
132
258
  const onClearText = () => {
133
259
  inputRef?.current?.clear();
134
260
  inputRef.current?.setNativeProps({ text: '' });
135
- setDisplayValue('');
136
- setNumericValue('');
261
+ commit('', 0);
137
262
  onChangeText?.('');
138
263
  };
139
264
 
140
265
  const _onChangeText = (text: string) => {
141
- if (!text) {
142
- setDisplayValue('');
143
- setNumericValue('');
144
- onChangeText?.('');
266
+ let digits = '';
267
+ let caretDigit = 0;
268
+ rawCaretRef.current = 0;
269
+ if (text) {
270
+ const edit = detectEdit(displayRef.current, text, selectionRef.current);
271
+ rawCaretRef.current = edit.start + edit.inserted.length;
272
+ ({ digits, caretDigit } = applyEdit(
273
+ displayRef.current,
274
+ edit,
275
+ digitLimit,
276
+ ));
277
+ }
278
+ if (commit(digits, caretDigit)) {
279
+ onChangeText?.(digits);
280
+ }
281
+ };
282
+
283
+ // Keeps the caret out of the currency suffix.
284
+ const _onSelectionChange = (
285
+ e: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
286
+ ) => {
287
+ props.onSelectionChange?.(e);
288
+ const { start, end } = e.nativeEvent.selection;
289
+ const rawCaret = rawCaretRef.current;
290
+ rawCaretRef.current = null;
291
+ if (start === end && start === rawCaret) {
145
292
  return;
146
293
  }
147
- if (text.length < lastDisplayValue.length) {
148
- const lastChar = lastDisplayValue.charAt(lastDisplayValue.length - 1);
149
- const isRemovingCurrency =
150
- lastDisplayValue.endsWith(currency) || lastChar === ' ';
151
- const isRemovingDot = lastChar === '.';
152
- if (isRemovingCurrency || isRemovingDot) {
153
- const currentNumericValue = numericValue;
154
- if (currentNumericValue.length > 0) {
155
- const newNumericValue = currentNumericValue.slice(0, -1);
156
- updateNumericValue(newNumericValue);
157
- return;
158
- }
159
- }
294
+ const display = displayRef.current;
295
+ const max = display ? display.length - currency.length : 0;
296
+ const clamped = { start: Math.min(start, max), end: Math.min(end, max) };
297
+ selectionRef.current = clamped;
298
+ if (clamped.start !== start || clamped.end !== end) {
299
+ inputRef.current?.setSelection(clamped.start, clamped.end);
160
300
  }
161
- const newNumericValue = parseInputText(text);
162
- updateNumericValue(newNumericValue);
163
301
  };
164
302
 
165
303
  const _onFocus = (e: NativeSyntheticEvent<TextInputFocusEventData>) => {
@@ -174,13 +312,16 @@ const InputMoney = forwardRef(
174
312
 
175
313
  const _setText = (text: string) => {
176
314
  if (!text) {
177
- setDisplayValue('');
178
- setNumericValue('');
315
+ commit('', 0);
179
316
  onChangeText?.('');
180
317
  return;
181
318
  }
182
- const newNumericValue = parseInputText(text);
183
- updateNumericValue(newNumericValue);
319
+ const digits = parseInputText(text)
320
+ .replace(/^0+(?=\d)/, '')
321
+ .slice(0, digitLimit);
322
+ if (commit(digits, digits.length)) {
323
+ onChangeText?.(digits);
324
+ }
184
325
  };
185
326
 
186
327
  useImperativeHandle(ref, () => {
@@ -247,6 +388,7 @@ const InputMoney = forwardRef(
247
388
  textBreakStrategy="highQuality"
248
389
  value={displayValue}
249
390
  onChangeText={_onChangeText}
391
+ onSelectionChange={_onSelectionChange}
250
392
  onFocus={_onFocus}
251
393
  onBlur={_onBlur}
252
394
  placeholder={placeholder}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.165.2-beta.1",
3
+ "version": "0.165.2-beta.3",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},