@momo-kits/foundation 0.168.1-beta.1 → 0.168.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.
- package/Input/InputMoney.tsx +128 -186
- package/Input/styles.ts +10 -0
- package/package.json +1 -1
package/Input/InputMoney.tsx
CHANGED
|
@@ -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
|
-
|
|
39
|
-
type Edit = { start: number; end: number; inserted: string };
|
|
38
|
+
const moneyFormatter = new Intl.NumberFormat('vi-VN');
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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(
|
|
89
|
-
old[old.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
|
-
|
|
94
|
-
|
|
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 [
|
|
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
|
-
|
|
121
|
+
caretDigitsAfter.current = undefined;
|
|
122
|
+
setNumericValue(cleanValue);
|
|
123
|
+
updateDisplayValue(cleanValue);
|
|
195
124
|
}
|
|
196
125
|
}
|
|
197
126
|
}, [value]);
|
|
198
127
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
176
|
+
setDisplayValue('');
|
|
177
|
+
setNumericValue('');
|
|
235
178
|
onChangeText?.('');
|
|
236
179
|
};
|
|
237
180
|
|
|
238
181
|
const _onChangeText = (text: string) => {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
226
|
+
setDisplayValue('');
|
|
227
|
+
setNumericValue('');
|
|
295
228
|
onChangeText?.('');
|
|
296
229
|
return;
|
|
297
230
|
}
|
|
298
|
-
const
|
|
299
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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={
|
|
304
|
+
placeholder={inputPlaceholder}
|
|
374
305
|
selectionColor={theme.colors.primary}
|
|
375
306
|
placeholderTextColor={placeholderColor}
|
|
376
307
|
keyboardType={'numeric'}
|
|
377
308
|
maxLength={undefined}
|
|
378
309
|
/>
|
|
379
|
-
|
|
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
|
});
|