@momo-kits/foundation 0.167.1-beta.1 → 0.167.1-beta.11

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 +176 -51
  2. package/package.json +1 -1
@@ -4,6 +4,7 @@ import React, {
4
4
  useContext,
5
5
  useEffect,
6
6
  useImperativeHandle,
7
+ useLayoutEffect,
7
8
  useRef,
8
9
  useState,
9
10
  } from 'react';
@@ -11,6 +12,7 @@ import {
11
12
  NativeSyntheticEvent,
12
13
  TextInput,
13
14
  TextInputFocusEventData,
15
+ TextInputSelectionChangeEventData,
14
16
  TouchableOpacity,
15
17
  View,
16
18
  } from 'react-native';
@@ -32,9 +34,101 @@ import {
32
34
  } from './common';
33
35
  import { InputMoneyProps } from './index';
34
36
  import styles from './styles';
35
- import { formatNumberToMoney } from './utils';
36
37
  import SystemTextInput from './SystemTextInput';
37
38
 
39
+ type Selection = { start: number; end: number };
40
+ type Edit = { start: number; end: number; inserted: string };
41
+
42
+ const countDigits = (text: string) => text.replace(/\D/g, '').length;
43
+
44
+ const groupDigits = (digits: string) =>
45
+ digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
46
+
47
+ // Caret position right after the `digitIndex`-th digit of `display`.
48
+ const caretFromDigitIndex = (display: string, digitIndex: number) => {
49
+ let count = 0;
50
+ for (let i = 0; i < display.length && count < digitIndex; i++) {
51
+ if (/\d/.test(display[i])) {
52
+ count += 1;
53
+ if (count === digitIndex) return i + 1;
54
+ }
55
+ }
56
+ return 0;
57
+ };
58
+
59
+ const detectEdit = (old: string, next: string, selection: Selection): Edit => {
60
+ const { start, end } = selection;
61
+ const removed = old.length - next.length;
62
+ const candidates: Edit[] = [
63
+ {
64
+ start,
65
+ end,
66
+ inserted: next.slice(start, next.length - (old.length - end)),
67
+ },
68
+ { start: start - removed, end: start, inserted: '' },
69
+ { start, end: start + removed, inserted: '' },
70
+ ];
71
+ const match = candidates.find(
72
+ e =>
73
+ e.start >= 0 &&
74
+ e.end >= e.start &&
75
+ e.end <= old.length &&
76
+ old.slice(0, e.start) + e.inserted + old.slice(e.end) === next,
77
+ );
78
+ if (match) return match;
79
+
80
+ let prefix = 0;
81
+ while (
82
+ prefix < Math.min(old.length, next.length) &&
83
+ old[prefix] === next[prefix]
84
+ ) {
85
+ prefix += 1;
86
+ }
87
+ let suffix = 0;
88
+ while (
89
+ suffix < Math.min(old.length, next.length) - prefix &&
90
+ old[old.length - 1 - suffix] === next[next.length - 1 - suffix]
91
+ ) {
92
+ suffix += 1;
93
+ }
94
+ return {
95
+ start: prefix,
96
+ end: old.length - suffix,
97
+ inserted: next.slice(prefix, next.length - suffix),
98
+ };
99
+ };
100
+
101
+ // Applies `edit` to the digits of `display`. Separators and currency are never
102
+ // deleted: removing a separator deletes the digit before it instead, removing
103
+ // only the currency leaves the digits untouched.
104
+ const applyEdit = (display: string, edit: Edit, digitLimit: number) => {
105
+ const digits = display.replace(/\D/g, '');
106
+ let left = countDigits(display.slice(0, edit.start));
107
+ let removed = countDigits(display.slice(edit.start, edit.end));
108
+ const inserted = edit.inserted.replace(/\D/g, '');
109
+ if (
110
+ !removed &&
111
+ !inserted &&
112
+ left > 0 &&
113
+ display.slice(edit.start, edit.end).includes('.')
114
+ ) {
115
+ left -= 1;
116
+ removed = 1;
117
+ }
118
+ const next = digits.slice(0, left) + inserted + digits.slice(left + removed);
119
+ const trimmed = next.replace(/^0+(?=\d)/, '');
120
+ if ((!removed && !inserted) || trimmed.length > digitLimit) {
121
+ return { digits, caretDigit: left };
122
+ }
123
+ return {
124
+ digits: trimmed,
125
+ caretDigit: Math.max(
126
+ 0,
127
+ left + inserted.length - (next.length - trimmed.length),
128
+ ),
129
+ };
130
+ };
131
+
38
132
  const InputMoney = forwardRef(
39
133
  (
40
134
  {
@@ -73,7 +167,13 @@ const InputMoney = forwardRef(
73
167
  const [focused, setFocused] = useState(false);
74
168
  const [displayValue, setDisplayValue] = useState('');
75
169
  const [numericValue, setNumericValue] = useState('');
76
- const [lastDisplayValue, setLastDisplayValue] = useState('');
170
+ const [commitCount, setCommitCount] = useState(0);
171
+ const displayRef = useRef('');
172
+ const selectionRef = useRef<Selection>({ start: 0, end: 0 });
173
+ // Caret to push to native once the committed text is rendered.
174
+ const pendingCaretRef = useRef<number | null>(null);
175
+ const pendingSelectionRef = useRef<Selection | null>(null);
176
+ const editingRef = useRef(false);
77
177
  const inputRef = useRef<TextInput>(null);
78
178
  const componentName = 'InputMoney';
79
179
 
@@ -91,25 +191,36 @@ const InputMoney = forwardRef(
91
191
 
92
192
  useEffect(() => {
93
193
  if (value !== undefined && typeof value === 'string') {
94
- const cleanValue = value.replace(/\D/g, '');
194
+ const cleanValue = value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
95
195
  if (cleanValue !== numericValue) {
96
- setNumericValue(cleanValue);
97
- updateDisplayValue(cleanValue);
196
+ commit(cleanValue, cleanValue.length);
98
197
  }
99
198
  }
100
199
  }, [value]);
101
200
 
102
- useEffect(() => {
103
- setLastDisplayValue(displayValue);
104
- }, [displayValue]);
105
-
106
- const updateDisplayValue = (numeric: string) => {
107
- if (!numeric) {
108
- setDisplayValue('');
109
- return;
201
+ // Always set the caret imperatively: a controlled `selection` prop is only
202
+ // sent to native when it differs from the last native selection, so it is
203
+ // skipped whenever a stale selection event already reported the same value.
204
+ useLayoutEffect(() => {
205
+ const caret = pendingCaretRef.current;
206
+ if (caret === null) return;
207
+ pendingCaretRef.current = null;
208
+ if (inputRef.current?.isFocused()) {
209
+ inputRef.current.setSelection(caret, caret);
110
210
  }
111
- const formatted = formatNumberToMoney(Number(numeric));
112
- setDisplayValue(formatted ? `${formatted}${currency}` : '');
211
+ }, [commitCount]);
212
+
213
+ const commit = (digits: string, caretDigit: number) => {
214
+ const display = digits ? `${groupDigits(digits)}${currency}` : '';
215
+ const caret = caretFromDigitIndex(display, caretDigit);
216
+ const changed = digits !== numericValue;
217
+ displayRef.current = display;
218
+ selectionRef.current = { start: caret, end: caret };
219
+ pendingCaretRef.current = caret;
220
+ setCommitCount(count => count + 1);
221
+ setDisplayValue(display);
222
+ setNumericValue(digits);
223
+ return changed;
113
224
  };
114
225
 
115
226
  const parseInputText = (text: string): string => {
@@ -119,47 +230,57 @@ const InputMoney = forwardRef(
119
230
  return textWithoutCurrency.replace(/[^\d]/g, '');
120
231
  };
121
232
 
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
233
  const onClearText = () => {
133
234
  inputRef?.current?.clear();
134
235
  inputRef.current?.setNativeProps({ text: '' });
135
- setDisplayValue('');
136
- setNumericValue('');
236
+ commit('', 0);
137
237
  onChangeText?.('');
138
238
  };
139
239
 
140
240
  const _onChangeText = (text: string) => {
141
- if (!text) {
142
- setDisplayValue('');
143
- setNumericValue('');
144
- onChangeText?.('');
145
- return;
241
+ let digits = '';
242
+ let caretDigit = 0;
243
+ pendingSelectionRef.current = null;
244
+ editingRef.current = true;
245
+ setTimeout(() => {
246
+ editingRef.current = false;
247
+ });
248
+ if (text) {
249
+ const edit = detectEdit(displayRef.current, text, selectionRef.current);
250
+ ({ digits, caretDigit } = applyEdit(
251
+ displayRef.current,
252
+ edit,
253
+ digitLimit,
254
+ ));
146
255
  }
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
- }
256
+ if (commit(digits, caretDigit)) {
257
+ onChangeText?.(digits);
160
258
  }
161
- const newNumericValue = parseInputText(text);
162
- updateNumericValue(newNumericValue);
259
+ };
260
+
261
+ // Keeps the caret out of the currency suffix.
262
+ const _onSelectionChange = (
263
+ e: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
264
+ ) => {
265
+ props.onSelectionChange?.(e);
266
+ if (editingRef.current) return;
267
+ const selection = e.nativeEvent.selection;
268
+ pendingSelectionRef.current = selection;
269
+ setTimeout(() => {
270
+ if (pendingSelectionRef.current !== selection) return;
271
+ pendingSelectionRef.current = null;
272
+ const { start, end } = selection;
273
+ const display = displayRef.current;
274
+ const max = display ? display.length - currency.length : 0;
275
+ const clamped = {
276
+ start: Math.min(start, max),
277
+ end: Math.min(end, max),
278
+ };
279
+ selectionRef.current = clamped;
280
+ if (clamped.start !== start || clamped.end !== end) {
281
+ inputRef.current?.setSelection(clamped.start, clamped.end);
282
+ }
283
+ });
163
284
  };
164
285
 
165
286
  const _onFocus = (e: NativeSyntheticEvent<TextInputFocusEventData>) => {
@@ -174,13 +295,16 @@ const InputMoney = forwardRef(
174
295
 
175
296
  const _setText = (text: string) => {
176
297
  if (!text) {
177
- setDisplayValue('');
178
- setNumericValue('');
298
+ commit('', 0);
179
299
  onChangeText?.('');
180
300
  return;
181
301
  }
182
- const newNumericValue = parseInputText(text);
183
- updateNumericValue(newNumericValue);
302
+ const digits = parseInputText(text)
303
+ .replace(/^0+(?=\d)/, '')
304
+ .slice(0, digitLimit);
305
+ if (commit(digits, digits.length)) {
306
+ onChangeText?.(digits);
307
+ }
184
308
  };
185
309
 
186
310
  useImperativeHandle(ref, () => {
@@ -247,6 +371,7 @@ const InputMoney = forwardRef(
247
371
  textBreakStrategy="highQuality"
248
372
  value={displayValue}
249
373
  onChangeText={_onChangeText}
374
+ onSelectionChange={_onSelectionChange}
250
375
  onFocus={_onFocus}
251
376
  onBlur={_onBlur}
252
377
  placeholder={placeholder}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.167.1-beta.1",
3
+ "version": "0.167.1-beta.11",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},