@momo-kits/foundation 0.168.1-beta.2 → 0.168.1-beta.20

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,16 +1,18 @@
1
+ /* eslint-disable react-hooks/exhaustive-deps */
1
2
  import React, {
2
3
  forwardRef,
3
4
  useContext,
4
5
  useEffect,
5
6
  useImperativeHandle,
6
- useLayoutEffect,
7
7
  useRef,
8
8
  useState,
9
9
  } from 'react';
10
10
  import {
11
+ Platform,
12
+ Pressable,
13
+ Text,
11
14
  TextInput,
12
15
  TextInputFocusEvent,
13
- TextInputSelectionChangeEvent,
14
16
  TouchableOpacity,
15
17
  View,
16
18
  } from 'react-native';
@@ -22,7 +24,6 @@ import {
22
24
  } from '../Context';
23
25
  import { Icon } from '../Icon';
24
26
  import { useScaleSize, exportFontFamily } from '../Text';
25
- import { Spacing } from '../Consts';
26
27
  import {
27
28
  ErrorView,
28
29
  FloatingView,
@@ -34,93 +35,22 @@ import { InputMoneyProps } from './index';
34
35
  import styles from './styles';
35
36
  import SystemTextInput from './SystemTextInput';
36
37
 
37
- type Selection = { start: number; end: number };
38
- type Edit = { start: number; end: number; inserted: string };
38
+ const moneyFormatter = new Intl.NumberFormat('vi-VN');
39
39
 
40
- const countDigits = (text: string) => text.replace(/\D/g, '').length;
41
-
42
- const groupDigits = (digits: string) =>
43
- digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
44
-
45
- const caretFromDigitIndex = (display: string, digitIndex: number) => {
46
- let count = 0;
47
- for (let i = 0; i < display.length && count < digitIndex; i++) {
48
- if (/\d/.test(display[i])) {
49
- count += 1;
50
- if (count === digitIndex) return i + 1;
51
- }
52
- }
53
- return 0;
54
- };
55
-
56
- const detectEdit = (old: string, next: string, selection: Selection): Edit => {
57
- const { start, end } = selection;
58
- const removed = old.length - next.length;
59
- const candidates: Edit[] = [
60
- {
61
- start,
62
- end,
63
- inserted: next.slice(start, next.length - (old.length - end)),
64
- },
65
- { start: start - removed, end: start, inserted: '' },
66
- { start, end: start + removed, inserted: '' },
67
- ];
68
- const match = candidates.find(
69
- e =>
70
- e.start >= 0 &&
71
- e.end >= e.start &&
72
- e.end <= old.length &&
73
- old.slice(0, e.start) + e.inserted + old.slice(e.end) === next,
74
- );
75
- if (match) return match;
76
-
77
- let prefix = 0;
78
- while (
79
- prefix < Math.min(old.length, next.length) &&
80
- old[prefix] === next[prefix]
81
- ) {
82
- prefix += 1;
40
+ const findEdit = (old: string, text: string) => {
41
+ let start = 0;
42
+ while (start < text.length && old[start] === text[start]) {
43
+ start += 1;
83
44
  }
84
45
  let suffix = 0;
85
46
  while (
86
- suffix < Math.min(old.length, next.length) - prefix &&
87
- old[old.length - 1 - suffix] === next[next.length - 1 - suffix]
47
+ suffix < Math.min(text.length, old.length) - start &&
48
+ old[old.length - 1 - suffix] === text[text.length - 1 - suffix]
88
49
  ) {
89
50
  suffix += 1;
90
51
  }
91
- return {
92
- start: prefix,
93
- end: old.length - suffix,
94
- inserted: next.slice(prefix, next.length - suffix),
95
- };
96
- };
97
-
98
- const applyEdit = (display: string, edit: Edit, digitLimit: number) => {
99
- const digits = display.replace(/\D/g, '');
100
- let left = countDigits(display.slice(0, edit.start));
101
- let removed = countDigits(display.slice(edit.start, edit.end));
102
- const inserted = edit.inserted.replace(/\D/g, '');
103
- if (
104
- !removed &&
105
- !inserted &&
106
- left > 0 &&
107
- display.slice(edit.start, edit.end).includes('.')
108
- ) {
109
- left -= 1;
110
- removed = 1;
111
- }
112
- const next = digits.slice(0, left) + inserted + digits.slice(left + removed);
113
- const trimmed = next.replace(/^0+(?=\d)/, '');
114
- if ((!removed && !inserted) || trimmed.length > digitLimit) {
115
- return { digits, caretDigit: left };
116
- }
117
- return {
118
- digits: trimmed,
119
- caretDigit: Math.max(
120
- 0,
121
- left + inserted.length - (next.length - trimmed.length),
122
- ),
123
- };
52
+ const size = old.length + text.length - 2 * (start + suffix);
53
+ return { old, start, suffix, size };
124
54
  };
125
55
 
126
56
  const InputMoney = forwardRef(
@@ -160,14 +90,15 @@ const InputMoney = forwardRef(
160
90
  const inputFontFamily = exportFontFamily('bold');
161
91
  const [focused, setFocused] = useState(false);
162
92
  const [displayValue, setDisplayValue] = useState('');
163
- const [commitCount, setCommitCount] = useState(0);
164
- const displayRef = useRef('');
165
- const digitsRef = useRef('');
166
- const selectionRef = useRef<Selection>({ start: 0, end: 0 });
167
- const pendingCaretRef = useRef<number | null>(null);
168
- const pendingSelectionRef = useRef<Selection | null>(null);
169
- const editingRef = useRef(false);
93
+ const [numericValue, setNumericValue] = useState('');
94
+ const [lastDisplayValue, setLastDisplayValue] = useState('');
170
95
  const inputRef = useRef<TextInput>(null);
96
+ const caretDigitsAfter = useRef<number | undefined>(undefined);
97
+ const lastRawText = useRef('');
98
+ const [caretSelection, setCaretSelection] = useState<{
99
+ start: number;
100
+ end: number;
101
+ }>();
171
102
  const componentName = 'InputMoney';
172
103
 
173
104
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
@@ -177,94 +108,103 @@ const InputMoney = forwardRef(
177
108
  props.accessibilityLabel,
178
109
  );
179
110
 
180
- const digitLimit = props.maxLength ?? Number.POSITIVE_INFINITY;
111
+ const maxLength = (props as any)?.maxLength;
112
+ const hasMaxLength = typeof maxLength === 'number';
113
+ const digitLimit = hasMaxLength ? maxLength : Number.POSITIVE_INFINITY;
181
114
 
182
115
  useEffect(() => {
183
116
  if (value !== undefined && typeof value === 'string') {
184
117
  const cleanValue = value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
185
- if (cleanValue !== digitsRef.current) {
186
- commit(cleanValue, cleanValue.length);
118
+ if (cleanValue !== numericValue) {
119
+ caretDigitsAfter.current = undefined;
120
+ setNumericValue(cleanValue);
121
+ updateDisplayValue(cleanValue);
187
122
  }
188
123
  }
189
- // eslint-disable-next-line react-hooks/exhaustive-deps
190
124
  }, [value]);
191
125
 
192
- useLayoutEffect(() => {
193
- const caret = pendingCaretRef.current;
194
- if (caret === null || !inputRef.current?.isFocused()) return;
195
- pendingCaretRef.current = null;
196
- inputRef.current.setSelection(caret, caret);
197
- }, [commitCount]);
126
+ useEffect(() => {
127
+ setLastDisplayValue(displayValue);
128
+ }, [displayValue]);
198
129
 
199
- const commit = (digits: string, caretDigit: number) => {
200
- const display = digits ? `${groupDigits(digits)}${currency}` : '';
201
- const caret = caretFromDigitIndex(display, caretDigit);
202
- const changed = digits !== digitsRef.current;
203
- displayRef.current = display;
204
- digitsRef.current = digits;
205
- selectionRef.current = { start: caret, end: caret };
206
- pendingCaretRef.current = caret;
207
- setCommitCount(count => count + 1);
208
- setDisplayValue(display);
209
- return changed;
130
+ useEffect(() => {
131
+ if (!caretSelection) return;
132
+ setCaretSelection(current =>
133
+ current === caretSelection ? undefined : current,
134
+ );
135
+ }, [caretSelection]);
136
+
137
+ const updateDisplayValue = (numeric: string) => {
138
+ const next = numeric ? moneyFormatter.format(Number(numeric)) : '';
139
+ setDisplayValue(next);
140
+
141
+ const digitsAfter = caretDigitsAfter.current;
142
+ caretDigitsAfter.current = undefined;
143
+ if (Platform.OS !== 'android' || digitsAfter === undefined) return;
144
+ let before = parseInputText(next).length - digitsAfter;
145
+ let caret = 0;
146
+ while (before > 0 && caret < next.length) {
147
+ if (/\d/.test(next[caret])) before -= 1;
148
+ caret += 1;
149
+ }
150
+ setCaretSelection({ start: caret, end: caret });
151
+ };
152
+
153
+ const parseInputText = (text: string): string => text.replace(/\D/g, '');
154
+
155
+ const updateNumericValue = (val: string) => {
156
+ let next = val.replace(/[^\d]/g, '').replace(/^0+(?=\d)/, '');
157
+ if (next.length > digitLimit) next = next.slice(0, digitLimit);
158
+ if (/^\d*$/.test(next)) {
159
+ setNumericValue(next);
160
+ updateDisplayValue(next);
161
+ onChangeText?.(next);
162
+ }
210
163
  };
211
164
 
212
165
  const onClearText = () => {
213
- commit('', 0);
166
+ inputRef?.current?.clear();
167
+ inputRef.current?.setNativeProps({ text: '' });
168
+ setDisplayValue('');
169
+ setNumericValue('');
214
170
  onChangeText?.('');
215
171
  };
216
172
 
217
173
  const _onChangeText = (text: string) => {
218
- let digits = '';
219
- let caretDigit = 0;
220
- pendingSelectionRef.current = null;
221
- editingRef.current = true;
222
- setTimeout(() => {
223
- editingRef.current = false;
224
- });
225
- if (text) {
226
- const edit = detectEdit(displayRef.current, text, selectionRef.current);
227
- ({ digits, caretDigit } = applyEdit(
228
- displayRef.current,
229
- edit,
230
- digitLimit,
231
- ));
174
+ if (!text) {
175
+ lastRawText.current = '';
176
+ setDisplayValue('');
177
+ setNumericValue('');
178
+ onChangeText?.('');
179
+ return;
232
180
  }
233
- if (commit(digits, caretDigit)) {
234
- onChangeText?.(digits);
181
+ const digits = parseInputText(text);
182
+ const raw = lastRawText.current;
183
+ lastRawText.current = text;
184
+ const formattedEdit = findEdit(lastDisplayValue, text);
185
+ const rawEdit = findEdit(raw, text);
186
+ const { old, start, suffix } =
187
+ raw && rawEdit.size < formattedEdit.size ? rawEdit : formattedEdit;
188
+ caretDigitsAfter.current = parseInputText(
189
+ text.slice(text.length - suffix),
190
+ ).length;
191
+ if (text.length < old.length && digits === numericValue) {
192
+ const index = parseInputText(text.slice(0, start)).length - 1;
193
+ if (index >= 0) {
194
+ updateNumericValue(digits.slice(0, index) + digits.slice(index + 1));
195
+ return;
196
+ }
235
197
  }
198
+ updateNumericValue(digits);
236
199
  };
237
200
 
238
- const _onSelectionChange = (e: TextInputSelectionChangeEvent) => {
239
- props.onSelectionChange?.(e);
240
- if (editingRef.current) return;
241
- const selection = e.nativeEvent.selection;
242
- pendingSelectionRef.current = selection;
243
- setTimeout(() => {
244
- if (pendingSelectionRef.current !== selection) return;
245
- pendingSelectionRef.current = null;
246
- const { start, end } = selection;
247
- const display = displayRef.current;
248
- const max = display ? display.length - currency.length : 0;
249
- const clamped = {
250
- start: Math.min(start, max),
251
- end: Math.min(end, max),
252
- };
253
- selectionRef.current = clamped;
254
- if (clamped.start !== start || clamped.end !== end) {
255
- inputRef.current?.setSelection(clamped.start, clamped.end);
256
- }
257
- });
201
+ const focusAtEnd = () => {
202
+ inputRef.current?.focus();
203
+ inputRef.current?.setSelection(displayValue.length, displayValue.length);
258
204
  };
259
205
 
260
206
  const _onFocus = (e: TextInputFocusEvent) => {
261
207
  setFocused(true);
262
- const caret = pendingCaretRef.current;
263
- pendingCaretRef.current = null;
264
- if (caret !== null && !props.selectTextOnFocus) {
265
- selectionRef.current = { start: caret, end: caret };
266
- inputRef.current?.setSelection(caret, caret);
267
- }
268
208
  onFocus?.(e);
269
209
  };
270
210
 
@@ -274,13 +214,15 @@ const InputMoney = forwardRef(
274
214
  };
275
215
 
276
216
  const _setText = (text: string) => {
277
- const digits = text
278
- .replace(/\D/g, '')
279
- .replace(/^0+(?=\d)/, '')
280
- .slice(0, digitLimit);
281
- if (commit(digits, digits.length)) {
282
- onChangeText?.(digits);
217
+ caretDigitsAfter.current = undefined;
218
+ if (!text) {
219
+ setDisplayValue('');
220
+ setNumericValue('');
221
+ onChangeText?.('');
222
+ return;
283
223
  }
224
+ const newNumericValue = parseInputText(text);
225
+ updateNumericValue(newNumericValue);
284
226
  };
285
227
 
286
228
  useImperativeHandle(ref, () => {
@@ -302,6 +244,14 @@ const InputMoney = forwardRef(
302
244
  placeholderColor = disabledColor;
303
245
  iconTintColor = disabledColor;
304
246
  }
247
+ const textStyle = {
248
+ fontSize: scaledFontSize,
249
+ fontFamily: inputFontFamily,
250
+ };
251
+ const placeholderHasCurrency = placeholder.endsWith(currency);
252
+ const inputPlaceholder = placeholderHasCurrency
253
+ ? placeholder.slice(0, -currency.length)
254
+ : placeholder;
305
255
  return (
306
256
  <View
307
257
  style={[
@@ -320,44 +270,46 @@ const InputMoney = forwardRef(
320
270
  onPress={onPressFloatingIcon}
321
271
  componentId={componentId}
322
272
  />
323
- <View style={styles.inputView}>
273
+ <Pressable
274
+ style={[styles.inputView, styles.moneyInputView]}
275
+ onPress={focusAtEnd}
276
+ disabled={disabled}
277
+ >
324
278
  <SystemTextInput
325
279
  {...props}
326
280
  accessibilityLabel={`${componentId}`}
327
281
  textAlignVertical="center"
328
282
  ref={inputRef}
329
- editable={props.editable !== false && !disabled}
330
283
  accessibilityState={{
331
284
  disabled: disabled,
332
285
  ...props.accessibilityState,
333
286
  }}
334
- style={[
335
- {
336
- width: '100%',
337
- paddingLeft: Spacing.M,
338
- height: '100%',
339
- fontSize: scaledFontSize,
340
- paddingVertical: Spacing.S,
341
- fontFamily: inputFontFamily,
342
- },
343
- {
344
- color: textColor,
345
- },
346
- ]}
287
+ style={[textStyle, styles.moneyTextInput, { color: textColor }]}
347
288
  allowFontScaling={false}
348
289
  textBreakStrategy="highQuality"
349
290
  value={displayValue}
291
+ selection={caretSelection ?? props.selection}
350
292
  onChangeText={_onChangeText}
351
- onSelectionChange={_onSelectionChange}
352
293
  onFocus={_onFocus}
353
294
  onBlur={_onBlur}
354
- placeholder={placeholder}
295
+ placeholder={inputPlaceholder}
355
296
  selectionColor={theme.colors.primary}
356
297
  placeholderTextColor={placeholderColor}
357
298
  keyboardType={'numeric'}
358
299
  maxLength={undefined}
359
300
  />
360
- </View>
301
+ {displayValue || placeholderHasCurrency ? (
302
+ <Text
303
+ style={[
304
+ textStyle,
305
+ { color: displayValue ? textColor : placeholderColor },
306
+ ]}
307
+ allowFontScaling={false}
308
+ >
309
+ {currency}
310
+ </Text>
311
+ ) : null}
312
+ </Pressable>
361
313
  <View style={styles.iconView}>
362
314
  {showClearIcon && focused && displayValue ? (
363
315
  <TouchableOpacity
package/Input/styles.ts CHANGED
@@ -218,4 +218,13 @@ 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
+ paddingLeft: 0,
227
+ paddingRight: Spacing.XXS,
228
+ paddingVertical: 0,
229
+ },
221
230
  });
package/Layout/utils.ts CHANGED
@@ -510,6 +510,7 @@ const useGridSystem = () => {
510
510
  export {
511
511
  DuoMetrics,
512
512
  HeaderMetrics,
513
+ InnerEdgeStyles,
513
514
  getHeaderMargin,
514
515
  getHeaderRowOptions,
515
516
  useGridSystem,
@@ -8,7 +8,7 @@ import { Image } from '../Image';
8
8
  import { Text, useScaleSize } from '../Text';
9
9
  import { PopupNotifyProps } from './types';
10
10
  import { scheduleOnRN } from 'react-native-worklets';
11
- import { usePopupMaxHeight, usePopupWidth } from './utils';
11
+ import { getPopupRadius, usePopupMaxHeight, usePopupWidth } from './utils';
12
12
 
13
13
  const PopupNotify: React.FC<PopupNotifyProps> = ({
14
14
  image,
@@ -26,6 +26,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
26
26
  const [scrollContent, setScrollContent] = useState(false);
27
27
  const popupWidth = usePopupWidth();
28
28
  const maxHeight = usePopupMaxHeight();
29
+ const radius = getPopupRadius();
29
30
  const scaledMaxHeight = useScaleSize(172);
30
31
 
31
32
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
@@ -170,7 +171,15 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
170
171
 
171
172
  const body = (
172
173
  <>
173
- {!!image && <Image source={{ uri: image }} style={styles.image} />}
174
+ {!!image && (
175
+ <Image
176
+ source={{ uri: image }}
177
+ style={[
178
+ styles.image,
179
+ { borderTopLeftRadius: radius, borderTopRightRadius: radius },
180
+ ]}
181
+ />
182
+ )}
174
183
  <View style={styles.content}>
175
184
  <Text
176
185
  typography={'header_default_bold'}
@@ -218,9 +227,9 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
218
227
  <View
219
228
  accessibilityLabel={'popup_notify'}
220
229
  style={[
221
- styles.container,
222
230
  {
223
231
  backgroundColor: theme.colors.background.surface,
232
+ borderRadius: radius,
224
233
  width: popupWidth,
225
234
  maxHeight,
226
235
  },
@@ -231,7 +240,13 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
231
240
  body
232
241
  ) : (
233
242
  // scrolls when iPhone Duo landscape is too short; actions stay visible
234
- <ScrollView style={styles.body} bounces={false}>
243
+ <ScrollView
244
+ style={[
245
+ styles.body,
246
+ { borderTopLeftRadius: radius, borderTopRightRadius: radius },
247
+ ]}
248
+ bounces={false}
249
+ >
235
250
  {body}
236
251
  </ScrollView>
237
252
  )}
@@ -242,17 +257,12 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
242
257
  };
243
258
 
244
259
  const styles = StyleSheet.create({
245
- container: {
246
- borderRadius: Radius.L,
247
- },
248
260
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
249
261
  image: {
250
- borderTopLeftRadius: Radius.L,
251
- borderTopRightRadius: Radius.L,
252
262
  aspectRatio: 1.7777,
253
263
  overflow: 'hidden',
254
264
  },
255
- body: { flexShrink: 1 },
265
+ body: { flexShrink: 1, overflow: 'hidden' },
256
266
  content: { padding: Spacing.XL },
257
267
  description: {
258
268
  marginTop: Spacing.S,
package/Popup/utils.ts CHANGED
@@ -1,32 +1,26 @@
1
1
  import { Dimensions, useWindowDimensions } from 'react-native';
2
2
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
- import { Spacing } from '../Consts';
4
- import { getDuoTopInset, isIPhoneDuo } from '../Application/device';
5
-
6
- // width of a system alert on the iPhone Duo inner display, in both orientations
7
- const DUO_POPUP_MAX_WIDTH = 440;
3
+ import { Radius, Spacing } from '../Consts';
4
+ import { getDuoModalBounds, isIPhoneDuo } from '../Application/device';
8
5
 
9
6
  const usePopupWidth = () => {
10
- const windowSize = useWindowDimensions();
7
+ const insets = useSafeAreaInsets();
8
+ const { width, height } = useWindowDimensions();
11
9
  if (!isIPhoneDuo()) {
12
10
  return Dimensions.get('window').width - Spacing.XL * 2;
13
11
  }
14
- return Math.min(windowSize.width - Spacing.XL * 2, DUO_POPUP_MAX_WIDTH);
12
+ return getDuoModalBounds(insets, width, height).maxWidth;
15
13
  };
16
14
 
17
- // room left for a popup on iPhone Duo, where landscape is too short for it
18
15
  const usePopupMaxHeight = () => {
19
16
  const insets = useSafeAreaInsets();
20
17
  const { width, height } = useWindowDimensions();
21
18
  if (!isIPhoneDuo()) {
22
19
  return undefined;
23
20
  }
24
- return (
25
- height -
26
- getDuoTopInset(insets, width, height) -
27
- insets.bottom -
28
- Spacing.XL * 2
29
- );
21
+ return getDuoModalBounds(insets, width, height).maxHeight;
30
22
  };
31
23
 
32
- export { usePopupWidth, usePopupMaxHeight };
24
+ const getPopupRadius = () => (isIPhoneDuo() ? Radius.XL : Radius.L);
25
+
26
+ export { usePopupWidth, usePopupMaxHeight, getPopupRadius };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.168.1-beta.2",
3
+ "version": "0.168.1-beta.20",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},