@cleartrip/ct-design-field 4.0.0 → 5.0.0
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/README.md +87 -0
- package/dist/Field.d.ts +4 -4
- package/dist/Field.d.ts.map +1 -1
- package/dist/FieldAction/index.d.ts +0 -1
- package/dist/FieldAction/index.d.ts.map +1 -1
- package/dist/FieldAction/type.d.ts +1 -1
- package/dist/FieldAction/type.d.ts.map +1 -1
- package/dist/FieldIcon/index.d.ts +0 -1
- package/dist/FieldIcon/index.d.ts.map +1 -1
- package/dist/FieldIcon/type.d.ts +4 -2
- package/dist/FieldIcon/type.d.ts.map +1 -1
- package/dist/Input.d.ts +8 -0
- package/dist/Input.d.ts.map +1 -0
- package/dist/Input.native.d.ts +7 -0
- package/dist/Input.native.d.ts.map +1 -0
- package/dist/InputField.d.ts +4 -0
- package/dist/InputField.d.ts.map +1 -0
- package/dist/Label.d.ts +6 -0
- package/dist/Label.d.ts.map +1 -0
- package/dist/Label.native.d.ts +6 -0
- package/dist/Label.native.d.ts.map +1 -0
- package/dist/TextArea.d.ts +7 -0
- package/dist/TextArea.d.ts.map +1 -0
- package/dist/TextAreaInput.d.ts +7 -0
- package/dist/TextAreaInput.d.ts.map +1 -0
- package/dist/TextAreaInput.native.d.ts +3 -0
- package/dist/TextAreaInput.native.d.ts.map +1 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/ct-design-field.browser.cjs.js +11 -1
- package/dist/ct-design-field.browser.cjs.js.map +1 -1
- package/dist/ct-design-field.browser.esm.js +11 -1
- package/dist/ct-design-field.browser.esm.js.map +1 -1
- package/dist/ct-design-field.cjs.js +1003 -396
- package/dist/ct-design-field.cjs.js.map +1 -1
- package/dist/ct-design-field.esm.js +997 -387
- package/dist/ct-design-field.esm.js.map +1 -1
- package/dist/ct-design-field.umd.js +2721 -537
- package/dist/ct-design-field.umd.js.map +1 -1
- package/dist/index.d.ts +6 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/style.d.ts +146 -38
- package/dist/style.d.ts.map +1 -1
- package/dist/type.d.ts +114 -25
- package/dist/type.d.ts.map +1 -1
- package/dist/variants/Card/index.d.ts +13 -0
- package/dist/variants/Card/index.d.ts.map +1 -0
- package/dist/variants/Card/index.native.d.ts +13 -0
- package/dist/variants/Card/index.native.d.ts.map +1 -0
- package/dist/variants/Card/type.d.ts +5 -0
- package/dist/variants/Card/type.d.ts.map +1 -0
- package/dist/variants/OTP/index.d.ts +4 -0
- package/dist/variants/OTP/index.d.ts.map +1 -0
- package/dist/variants/OTP/type.d.ts +25 -0
- package/dist/variants/OTP/type.d.ts.map +1 -0
- package/dist/variants/Phone/Prefix/index.d.ts +4 -0
- package/dist/variants/Phone/Prefix/index.d.ts.map +1 -0
- package/dist/variants/Phone/Prefix/type.d.ts.map +1 -0
- package/dist/variants/Phone/index.d.ts +5 -0
- package/dist/variants/Phone/index.d.ts.map +1 -0
- package/dist/variants/Phone/index.native.d.ts +5 -0
- package/dist/variants/Phone/index.native.d.ts.map +1 -0
- package/dist/variants/Phone/type.d.ts +7 -0
- package/dist/variants/Phone/type.d.ts.map +1 -0
- package/package.json +33 -19
- package/src/Field.tsx +201 -0
- package/src/FieldAction/index.tsx +47 -0
- package/src/FieldAction/type.ts +15 -0
- package/src/FieldIcon/index.tsx +48 -0
- package/src/FieldIcon/type.ts +52 -0
- package/src/Input.native.tsx +284 -0
- package/src/Input.tsx +242 -0
- package/src/InputField.tsx +22 -0
- package/src/Label.native.tsx +83 -0
- package/src/Label.tsx +91 -0
- package/src/TextArea.tsx +14 -0
- package/src/TextAreaInput.native.tsx +4 -0
- package/src/TextAreaInput.tsx +243 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +8 -0
- package/src/style.ts +353 -0
- package/src/type.ts +243 -0
- package/src/variants/Card/index.native.tsx +46 -0
- package/src/variants/Card/index.tsx +89 -0
- package/src/variants/Card/type.ts +5 -0
- package/src/variants/OTP/index.tsx +343 -0
- package/src/variants/OTP/type.ts +34 -0
- package/src/variants/Phone/Prefix/index.tsx +87 -0
- package/src/variants/Phone/Prefix/type.ts +24 -0
- package/src/variants/Phone/index.native.tsx +84 -0
- package/src/variants/Phone/index.tsx +79 -0
- package/src/variants/Phone/type.ts +13 -0
- package/dist/CardField/CardField.d.ts +0 -6
- package/dist/CardField/CardField.d.ts.map +0 -1
- package/dist/CardField/index.d.ts +0 -3
- package/dist/CardField/index.d.ts.map +0 -1
- package/dist/CardField/type.d.ts +0 -16
- package/dist/CardField/type.d.ts.map +0 -1
- package/dist/OTPField/OTPField.d.ts +0 -6
- package/dist/OTPField/OTPField.d.ts.map +0 -1
- package/dist/OTPField/SingleOTPInput.d.ts +0 -6
- package/dist/OTPField/SingleOTPInput.d.ts.map +0 -1
- package/dist/OTPField/index.d.ts +0 -3
- package/dist/OTPField/index.d.ts.map +0 -1
- package/dist/OTPField/type.d.ts +0 -23
- package/dist/OTPField/type.d.ts.map +0 -1
- package/dist/PhoneField/PhoneField.d.ts +0 -6
- package/dist/PhoneField/PhoneField.d.ts.map +0 -1
- package/dist/PhoneField/index.d.ts +0 -3
- package/dist/PhoneField/index.d.ts.map +0 -1
- package/dist/PhoneField/type.d.ts +0 -11
- package/dist/PhoneField/type.d.ts.map +0 -1
- package/dist/PhoneFieldPrefix/index.d.ts +0 -5
- package/dist/PhoneFieldPrefix/index.d.ts.map +0 -1
- package/dist/PhoneFieldPrefix/type.d.ts.map +0 -1
- package/dist/StyledField/StyledField.d.ts +0 -7
- package/dist/StyledField/StyledField.d.ts.map +0 -1
- package/dist/StyledField/index.d.ts +0 -2
- package/dist/StyledField/index.d.ts.map +0 -1
- package/dist/StyledField/type.d.ts +0 -12
- package/dist/StyledField/type.d.ts.map +0 -1
- package/dist/StyledFieldContainer/StyledFieldContainer.d.ts +0 -7
- package/dist/StyledFieldContainer/StyledFieldContainer.d.ts.map +0 -1
- package/dist/StyledFieldContainer/index.d.ts +0 -2
- package/dist/StyledFieldContainer/index.d.ts.map +0 -1
- package/dist/StyledFieldContainer/type.d.ts +0 -8
- package/dist/StyledFieldContainer/type.d.ts.map +0 -1
- package/dist/StyledFieldPlaceholder/StyledFieldPlaceholder.d.ts +0 -7
- package/dist/StyledFieldPlaceholder/StyledFieldPlaceholder.d.ts.map +0 -1
- package/dist/StyledFieldPlaceholder/index.d.ts +0 -2
- package/dist/StyledFieldPlaceholder/index.d.ts.map +0 -1
- package/dist/StyledFieldPlaceholder/type.d.ts +0 -7
- package/dist/StyledFieldPlaceholder/type.d.ts.map +0 -1
- /package/dist/{PhoneFieldPrefix → variants/Phone/Prefix}/type.d.ts +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { forwardRef, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { FieldChangeEvent, IFieldProps, IFieldRef } from '../../type';
|
|
4
|
+
import { FieldType } from '../../constants';
|
|
5
|
+
import InputField from '../../Field';
|
|
6
|
+
import useMergeRefs from '@cleartrip/ct-design-use-merge-refs';
|
|
7
|
+
|
|
8
|
+
export interface ICursorRef {
|
|
9
|
+
cursorPos: number;
|
|
10
|
+
operation: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export enum CURSOR_OPERATION {
|
|
14
|
+
NONE = 'NONE',
|
|
15
|
+
ADD = 'ADD',
|
|
16
|
+
SUBSTRACT = 'SUBSTRACT',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CardField is a wrapper component which is used add a space after a specific character limit
|
|
21
|
+
* For Eg: 1234 5678 9012 3456
|
|
22
|
+
*
|
|
23
|
+
* Use cases
|
|
24
|
+
*
|
|
25
|
+
* 1.) Gift Voucher
|
|
26
|
+
* 2.) Credit/Debit Card
|
|
27
|
+
*/
|
|
28
|
+
const CardField = forwardRef<IFieldRef, IFieldProps>(({ onChange, value, ...rest }, forwardedRef) => {
|
|
29
|
+
const ref = useRef<IFieldRef | null>(null);
|
|
30
|
+
const mergedRef = useMergeRefs(forwardedRef, ref);
|
|
31
|
+
const cursorRef = useRef<ICursorRef>({
|
|
32
|
+
cursorPos: 0,
|
|
33
|
+
operation: CURSOR_OPERATION.NONE,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Update the cursor position for on each re-render.
|
|
38
|
+
*/
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
ref.current?.setSelection(cursorRef.current.cursorPos, cursorRef.current.cursorPos);
|
|
41
|
+
|
|
42
|
+
cursorRef.current = {
|
|
43
|
+
cursorPos: cursorRef.current.cursorPos,
|
|
44
|
+
operation: CURSOR_OPERATION.NONE,
|
|
45
|
+
};
|
|
46
|
+
}, [value]);
|
|
47
|
+
|
|
48
|
+
const handleChange = (e: FieldChangeEvent) => {
|
|
49
|
+
onChange?.(e);
|
|
50
|
+
let selectionStart = e.target.selectionStart || 0;
|
|
51
|
+
|
|
52
|
+
//Update the cursor on the basis of operations.
|
|
53
|
+
switch (cursorRef.current.operation) {
|
|
54
|
+
case CURSOR_OPERATION.SUBSTRACT: {
|
|
55
|
+
--selectionStart;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case CURSOR_OPERATION.ADD: {
|
|
60
|
+
++selectionStart;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
//Update the ref
|
|
69
|
+
cursorRef.current = {
|
|
70
|
+
cursorPos: selectionStart,
|
|
71
|
+
operation: CURSOR_OPERATION.NONE,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<InputField
|
|
77
|
+
ref={mergedRef}
|
|
78
|
+
onChange={handleChange}
|
|
79
|
+
{...rest}
|
|
80
|
+
type={FieldType.NUMBER}
|
|
81
|
+
value={value}
|
|
82
|
+
inputMode='numeric'
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
CardField.displayName = 'CardField';
|
|
88
|
+
|
|
89
|
+
export default CardField;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { IOtpFieldProps } from './type';
|
|
3
|
+
import { Container } from '@cleartrip/ct-design-container';
|
|
4
|
+
import { Typography, TypographyColor, TypographyVariant } from '@cleartrip/ct-design-typography';
|
|
5
|
+
import { IFieldRef, FieldChangeEvent, FieldKeyDownEvent, FieldFocusEvent } from '../../type';
|
|
6
|
+
import { useTheme } from '@cleartrip/ct-design-theme';
|
|
7
|
+
import Field from '../../Input';
|
|
8
|
+
import { isIOS } from '@cleartrip/ct-design-common-utils';
|
|
9
|
+
import { makeStyles, useStyles } from '@cleartrip/ct-design-style-manager';
|
|
10
|
+
|
|
11
|
+
const styles = makeStyles((theme) => ({
|
|
12
|
+
rootContainer: {
|
|
13
|
+
justifyContent: 'center',
|
|
14
|
+
},
|
|
15
|
+
otpContainer: {
|
|
16
|
+
display: 'flex',
|
|
17
|
+
flexDirection: 'row',
|
|
18
|
+
gap: theme.spacing[3],
|
|
19
|
+
},
|
|
20
|
+
otpBox: {
|
|
21
|
+
width: theme.size[15],
|
|
22
|
+
height: theme.size[15],
|
|
23
|
+
borderRadius: theme?.border.radius[8],
|
|
24
|
+
justifyContent: 'center',
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
borderStyle: theme.border.style.solid,
|
|
27
|
+
},
|
|
28
|
+
otpInput: {
|
|
29
|
+
width: '100%',
|
|
30
|
+
height: '100%',
|
|
31
|
+
fontSize: theme?.typography.size[20],
|
|
32
|
+
fontWeight: theme?.typography.weight.semibold,
|
|
33
|
+
color: theme.color.text.primary,
|
|
34
|
+
textAlign: 'center',
|
|
35
|
+
},
|
|
36
|
+
errorText: {
|
|
37
|
+
marginVertical: theme?.spacing[2],
|
|
38
|
+
fontSize: theme?.typography.size[14],
|
|
39
|
+
fontWeight: theme?.typography.weight.semibold,
|
|
40
|
+
color: theme?.color.text.warning,
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const OtpBox: React.FC<Omit<IOtpFieldProps, 'onFocus'> & { focus: boolean; onFocus: (e: FieldFocusEvent) => void }> = ({
|
|
45
|
+
value,
|
|
46
|
+
focus,
|
|
47
|
+
inputMode,
|
|
48
|
+
onChange,
|
|
49
|
+
onFocus,
|
|
50
|
+
readOnly,
|
|
51
|
+
onBlur,
|
|
52
|
+
onPressIn,
|
|
53
|
+
onKeyDown,
|
|
54
|
+
onSelect,
|
|
55
|
+
maxLength,
|
|
56
|
+
prompt,
|
|
57
|
+
styleConfig,
|
|
58
|
+
}) => {
|
|
59
|
+
const theme = useTheme();
|
|
60
|
+
const ref = useRef<IFieldRef>(null);
|
|
61
|
+
const { hasError = false } = prompt || {};
|
|
62
|
+
const { otpBox = [], otpRootInput = [] } = styleConfig || {};
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (focus && ref.current) {
|
|
65
|
+
ref.current.focus();
|
|
66
|
+
}
|
|
67
|
+
}, [focus]);
|
|
68
|
+
|
|
69
|
+
const dynamicStyles = useStyles(
|
|
70
|
+
(theme) => {
|
|
71
|
+
if (hasError) {
|
|
72
|
+
return {
|
|
73
|
+
otpBox: {
|
|
74
|
+
borderColor: theme.color.border.warning,
|
|
75
|
+
borderWidth: theme.border.width.lg,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
otpBox: {
|
|
81
|
+
borderColor: focus ? theme.color.border.primary : theme.color.border.disabledDark,
|
|
82
|
+
borderWidth: focus ? theme.border.width.lg : theme.border.width.sm,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
[hasError, focus],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Container
|
|
91
|
+
styleConfig={{
|
|
92
|
+
root: [styles.otpBox, dynamicStyles.otpBox, ...otpBox],
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<Field
|
|
96
|
+
id={`otp-box`}
|
|
97
|
+
disabled={readOnly}
|
|
98
|
+
ref={ref}
|
|
99
|
+
value={value}
|
|
100
|
+
maxLength={maxLength}
|
|
101
|
+
inputMode={inputMode}
|
|
102
|
+
onChange={onChange}
|
|
103
|
+
onBlur={onBlur}
|
|
104
|
+
onPressIn={onPressIn}
|
|
105
|
+
onKeyDown={onKeyDown}
|
|
106
|
+
onSelect={onSelect}
|
|
107
|
+
autoCapitalize='characters'
|
|
108
|
+
cursorColor={theme.color.text.grapetini900}
|
|
109
|
+
onFocus={onFocus}
|
|
110
|
+
focus={focus}
|
|
111
|
+
styleConfig={{ root: [styles.otpInput, ...otpRootInput] }}
|
|
112
|
+
/>
|
|
113
|
+
</Container>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const OtpField: React.FC<IOtpFieldProps> = ({
|
|
118
|
+
value = '',
|
|
119
|
+
numberOfBoxes = 4,
|
|
120
|
+
inputMode = 'numeric',
|
|
121
|
+
autoFocus = true,
|
|
122
|
+
refocus = false,
|
|
123
|
+
onChange,
|
|
124
|
+
readOnly = false,
|
|
125
|
+
onComplete,
|
|
126
|
+
onKeyDown,
|
|
127
|
+
onSelect,
|
|
128
|
+
onBlur,
|
|
129
|
+
onPressIn,
|
|
130
|
+
onFocus,
|
|
131
|
+
prompt,
|
|
132
|
+
styleConfig = {},
|
|
133
|
+
}) => {
|
|
134
|
+
const [focusedIndex, setFocusedIndex] = useState<number>(autoFocus ? 0 : -1);
|
|
135
|
+
const { hasError = false, message: errorMessage } = prompt || {};
|
|
136
|
+
const { promptStyles, otpRootContainer = [], otpRootInput = [] } = styleConfig || {};
|
|
137
|
+
const { promptMessageTypography } = promptStyles || {};
|
|
138
|
+
|
|
139
|
+
// Convert value string to array for easy access
|
|
140
|
+
const otpValues = Array(numberOfBoxes)
|
|
141
|
+
.fill('')
|
|
142
|
+
.map((_, index) => {
|
|
143
|
+
const char = value[index];
|
|
144
|
+
return char && char !== ' ' ? char : '';
|
|
145
|
+
});
|
|
146
|
+
// Handle auto focus on mount
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (autoFocus && !readOnly) {
|
|
149
|
+
setFocusedIndex(0);
|
|
150
|
+
}
|
|
151
|
+
}, [autoFocus, readOnly]);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (refocus) {
|
|
155
|
+
setFocusedIndex(0);
|
|
156
|
+
}
|
|
157
|
+
}, [refocus]);
|
|
158
|
+
|
|
159
|
+
const validateInput = useCallback(
|
|
160
|
+
(text: string): string => {
|
|
161
|
+
if (inputMode === 'numeric') {
|
|
162
|
+
return text.replace(/[^0-9]/g, '');
|
|
163
|
+
}
|
|
164
|
+
return text.replace(/[^0-9A-Za-z]/g, '').toUpperCase();
|
|
165
|
+
},
|
|
166
|
+
[inputMode],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* handleInputChange - Memoized event handler for OTP box input change.
|
|
171
|
+
*
|
|
172
|
+
* This function is memoized as much as possible; however, since it relies on otpValues, which is derived
|
|
173
|
+
* from component props/state and changes as the value prop changes, it's not possible to avoid recalculating
|
|
174
|
+
* otpValues within this function. To optimize, recompute otpValues inside the handler,
|
|
175
|
+
* use useCallback only for the core handler, and avoid including otpValues directly in the dependency array.
|
|
176
|
+
*/
|
|
177
|
+
const handleInputChange = useCallback(
|
|
178
|
+
(e: FieldChangeEvent, index: number) => {
|
|
179
|
+
const validatedText = validateInput(e.target.value);
|
|
180
|
+
|
|
181
|
+
// Recompute otpValues - treat space as empty placeholder
|
|
182
|
+
const otpString = value || '';
|
|
183
|
+
const currentOtpValues = Array(numberOfBoxes)
|
|
184
|
+
.fill('')
|
|
185
|
+
.map((_, idx) => {
|
|
186
|
+
const char = otpString[idx];
|
|
187
|
+
return char && char !== ' ' ? char : '';
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Handle iOS auto-fill (when multiple characters are pasted)
|
|
191
|
+
if (validatedText.length > 1) {
|
|
192
|
+
const newValues = [...currentOtpValues];
|
|
193
|
+
|
|
194
|
+
// Start filling from the current focused index
|
|
195
|
+
for (let i = 0; i < validatedText.length && index + i < numberOfBoxes; i++) {
|
|
196
|
+
newValues[index + i] = validatedText[i];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const newValue = newValues.join('');
|
|
200
|
+
|
|
201
|
+
onChange?.({
|
|
202
|
+
target: { value: newValue, selectionStart: index },
|
|
203
|
+
currentTarget: e.currentTarget,
|
|
204
|
+
preventDefault: e.preventDefault,
|
|
205
|
+
});
|
|
206
|
+
// Move focus to next empty box or last box
|
|
207
|
+
const nextEmptyIndex = newValues.findIndex((val) => !val);
|
|
208
|
+
if (nextEmptyIndex !== -1) {
|
|
209
|
+
setFocusedIndex(nextEmptyIndex);
|
|
210
|
+
} else {
|
|
211
|
+
setFocusedIndex(numberOfBoxes - 1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check if OTP is complete (no empty boxes)
|
|
215
|
+
if (!newValues.includes('')) {
|
|
216
|
+
onComplete?.(newValue);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Single character input - fill the FOCUSED box
|
|
222
|
+
if (validatedText) {
|
|
223
|
+
const newValues = [...currentOtpValues];
|
|
224
|
+
newValues[index] = validatedText;
|
|
225
|
+
|
|
226
|
+
const newValue = newValues.join('');
|
|
227
|
+
onChange?.({
|
|
228
|
+
target: { value: newValue, selectionStart: index },
|
|
229
|
+
currentTarget: e.currentTarget,
|
|
230
|
+
preventDefault: e.preventDefault,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Move focus to next box sequentially
|
|
234
|
+
if (index < numberOfBoxes - 1) {
|
|
235
|
+
setFocusedIndex(index + 1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check if OTP is complete (no empty boxes)
|
|
239
|
+
if (!newValues.includes('')) {
|
|
240
|
+
onComplete?.(newValue);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
[validateInput, value, numberOfBoxes, onChange, onComplete],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const handleKeyPress = useCallback(
|
|
248
|
+
(e: FieldKeyDownEvent, index: number) => {
|
|
249
|
+
const key = e.target.key;
|
|
250
|
+
|
|
251
|
+
if (key === 'Backspace') {
|
|
252
|
+
// Recompute to avoid stale closure - treat space as empty
|
|
253
|
+
const otpString = value || '';
|
|
254
|
+
const currentOtpValues = Array(numberOfBoxes)
|
|
255
|
+
.fill('')
|
|
256
|
+
.map((_, idx) => {
|
|
257
|
+
const char = otpString[idx];
|
|
258
|
+
return char && char !== ' ' ? char : '';
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (currentOtpValues[index]) {
|
|
262
|
+
// Clear current box - use SPACE as placeholder to preserve position
|
|
263
|
+
const newValues = [...currentOtpValues];
|
|
264
|
+
newValues[index] = ' ';
|
|
265
|
+
onChange?.({
|
|
266
|
+
target: { value: newValues.join(''), selectionStart: index },
|
|
267
|
+
currentTarget: e.currentTarget,
|
|
268
|
+
preventDefault: e.preventDefault,
|
|
269
|
+
});
|
|
270
|
+
} else if (index > 0) {
|
|
271
|
+
// Move to previous box if current is empty
|
|
272
|
+
setFocusedIndex(index - 1);
|
|
273
|
+
}
|
|
274
|
+
} else if (key === 'ArrowLeft' && index > 0) {
|
|
275
|
+
e.preventDefault?.();
|
|
276
|
+
setFocusedIndex(index - 1);
|
|
277
|
+
} else if (key === 'ArrowRight' && index < numberOfBoxes - 1) {
|
|
278
|
+
e.preventDefault?.();
|
|
279
|
+
setFocusedIndex(index + 1);
|
|
280
|
+
}
|
|
281
|
+
onKeyDown?.({
|
|
282
|
+
target: { value, key },
|
|
283
|
+
currentTarget: e.currentTarget,
|
|
284
|
+
preventDefault: e.preventDefault,
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
[numberOfBoxes, onKeyDown, value, onChange],
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const handleFocus = useCallback(
|
|
291
|
+
(e: FieldFocusEvent, index: number) => {
|
|
292
|
+
onFocus?.({
|
|
293
|
+
target: { value, index },
|
|
294
|
+
currentTarget: e.currentTarget,
|
|
295
|
+
preventDefault: e.preventDefault,
|
|
296
|
+
});
|
|
297
|
+
setFocusedIndex(index);
|
|
298
|
+
},
|
|
299
|
+
[onFocus, value],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<Container styleConfig={{ root: [styles.rootContainer, ...otpRootContainer] }}>
|
|
304
|
+
<Container styleConfig={{ root: [styles.otpContainer, ...otpRootInput] }}>
|
|
305
|
+
{Array(numberOfBoxes)
|
|
306
|
+
.fill(null)
|
|
307
|
+
.map((_, index) => (
|
|
308
|
+
<OtpBox
|
|
309
|
+
key={index}
|
|
310
|
+
value={otpValues[index]}
|
|
311
|
+
focus={focusedIndex === index}
|
|
312
|
+
prompt={{ hasError }}
|
|
313
|
+
inputMode={inputMode}
|
|
314
|
+
maxLength={isIOS() ? numberOfBoxes : 1}
|
|
315
|
+
onFocus={(e) => handleFocus(e, index)}
|
|
316
|
+
onChange={(e) => handleInputChange(e, index)}
|
|
317
|
+
readOnly={readOnly}
|
|
318
|
+
onPressIn={onPressIn}
|
|
319
|
+
onKeyDown={(e) => handleKeyPress(e, index)}
|
|
320
|
+
onSelect={onSelect}
|
|
321
|
+
onBlur={onBlur}
|
|
322
|
+
styleConfig={styleConfig}
|
|
323
|
+
/>
|
|
324
|
+
))}
|
|
325
|
+
</Container>
|
|
326
|
+
|
|
327
|
+
{hasError && !!errorMessage && (
|
|
328
|
+
<Typography
|
|
329
|
+
variant={TypographyVariant.P3}
|
|
330
|
+
color={TypographyColor.WARNING}
|
|
331
|
+
styleConfig={{
|
|
332
|
+
root: [styles.errorText],
|
|
333
|
+
...promptMessageTypography,
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
{errorMessage}
|
|
337
|
+
</Typography>
|
|
338
|
+
)}
|
|
339
|
+
</Container>
|
|
340
|
+
);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export default OtpField;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Styles } from '@cleartrip/ct-design-types';
|
|
2
|
+
import { FieldFocusEvent, IFieldProps } from '../../type';
|
|
3
|
+
|
|
4
|
+
type OtpFieldStyleConfig = {
|
|
5
|
+
otpBox?: Styles[];
|
|
6
|
+
otpRootInput?: Styles[];
|
|
7
|
+
otpRootContainer?: Styles[];
|
|
8
|
+
} & IFieldProps['styleConfig'];
|
|
9
|
+
|
|
10
|
+
export interface OtpFieldFocusEvent extends FieldFocusEvent {
|
|
11
|
+
target: {
|
|
12
|
+
value: string;
|
|
13
|
+
index: number;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
interface OwnOtpFieldProps extends IFieldProps {
|
|
17
|
+
/** Number of OTP input boxes to display (default: 4) */
|
|
18
|
+
numberOfBoxes?: number;
|
|
19
|
+
|
|
20
|
+
/** Callback fired when all OTP boxes are filled */
|
|
21
|
+
onComplete?: (otp: string) => void;
|
|
22
|
+
|
|
23
|
+
/** Whether to refocus the first otp input box (default: false) */
|
|
24
|
+
refocus?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Max length of the OTP input box
|
|
27
|
+
*/
|
|
28
|
+
maxLength?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type IOtpFieldProps = Omit<OwnOtpFieldProps, 'onFocus' | 'styleConfig'> & {
|
|
32
|
+
onFocus?: (e: OtpFieldFocusEvent) => void;
|
|
33
|
+
styleConfig?: OtpFieldStyleConfig;
|
|
34
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { makeStyles } from '@cleartrip/ct-design-style-manager';
|
|
2
|
+
import { IPhoneFieldPrefix } from './type';
|
|
3
|
+
import { Container } from '@cleartrip/ct-design-container';
|
|
4
|
+
import { Divider } from '@cleartrip/ct-design-divider';
|
|
5
|
+
import { Box } from '@cleartrip/ct-design-box';
|
|
6
|
+
import { Typography } from '@cleartrip/ct-design-typography';
|
|
7
|
+
|
|
8
|
+
const styles = makeStyles((theme) => {
|
|
9
|
+
return {
|
|
10
|
+
wrapper: {
|
|
11
|
+
alignItems: 'center',
|
|
12
|
+
justifyContent: 'center',
|
|
13
|
+
flexDirection: 'row',
|
|
14
|
+
paddingLeft: 16,
|
|
15
|
+
},
|
|
16
|
+
flagContainer: {
|
|
17
|
+
display: 'flex',
|
|
18
|
+
alignItems: 'center',
|
|
19
|
+
justifyContent: 'center',
|
|
20
|
+
flexDirection: 'row',
|
|
21
|
+
height: theme.size[6],
|
|
22
|
+
width: theme.size[6],
|
|
23
|
+
},
|
|
24
|
+
countryCode: {
|
|
25
|
+
alignSelf: 'flex-start',
|
|
26
|
+
},
|
|
27
|
+
divider: {
|
|
28
|
+
borderRightWidth: 1,
|
|
29
|
+
height: '100%',
|
|
30
|
+
borderRightColor: theme.color.background.disabledDark,
|
|
31
|
+
},
|
|
32
|
+
disabledColor: {
|
|
33
|
+
color: theme.color.text.disabled,
|
|
34
|
+
},
|
|
35
|
+
enabledColor: {
|
|
36
|
+
color: theme.color.text.primary,
|
|
37
|
+
},
|
|
38
|
+
container: {
|
|
39
|
+
flexDirection: 'row',
|
|
40
|
+
alignItems: 'center',
|
|
41
|
+
gap: theme.spacing[2],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const DownChevronV2 = ({ color = '#1A1A1A', ...rest }) => {
|
|
47
|
+
return (
|
|
48
|
+
<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' viewBox='0 0 12 12' {...rest}>
|
|
49
|
+
<path stroke={color} strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M2 4l4 4 4-4'></path>
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const PhoneFieldPrefix: React.FC<IPhoneFieldPrefix> = ({
|
|
55
|
+
countryCode,
|
|
56
|
+
disabled = false,
|
|
57
|
+
flagIcon,
|
|
58
|
+
onDropdownClick,
|
|
59
|
+
showDropdownIcon,
|
|
60
|
+
}) => {
|
|
61
|
+
const onClick = () => {
|
|
62
|
+
if (disabled) return;
|
|
63
|
+
onDropdownClick?.();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Box boxSize='micro' styleConfig={{ root: [styles.wrapper] }} onClick={onClick}>
|
|
68
|
+
<Container
|
|
69
|
+
styleConfig={{
|
|
70
|
+
root: [styles.container],
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{flagIcon && <Container styleConfig={{ root: [styles.flagContainer] }}>{flagIcon}</Container>}
|
|
74
|
+
<Typography variant='B1' color={disabled ? 'disabled' : 'primary'} styleConfig={{ root: [styles.countryCode] }}>
|
|
75
|
+
{countryCode}
|
|
76
|
+
</Typography>
|
|
77
|
+
|
|
78
|
+
{showDropdownIcon && (
|
|
79
|
+
<DownChevronV2 color={disabled ? styles.disabledColor.color : styles.enabledColor.color} />
|
|
80
|
+
)}
|
|
81
|
+
</Container>
|
|
82
|
+
<Divider dividerWidth={1} dividerLength={0} orientation='vertical' styleConfig={{ root: [styles.divider] }} />
|
|
83
|
+
</Box>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default PhoneFieldPrefix;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface IPhoneFieldPrefix {
|
|
4
|
+
/**
|
|
5
|
+
* The icon to display for the country flag.
|
|
6
|
+
*/
|
|
7
|
+
flagIcon?: ReactNode;
|
|
8
|
+
/**
|
|
9
|
+
* The country code to display.
|
|
10
|
+
*/
|
|
11
|
+
countryCode: string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether to show the dropdown icon.
|
|
14
|
+
*/
|
|
15
|
+
showDropdownIcon?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The function to call when the dropdown icon is clicked.
|
|
18
|
+
*/
|
|
19
|
+
onDropdownClick?: () => void;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the field is disabled.
|
|
22
|
+
*/
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { forwardRef, useState } from 'react';
|
|
2
|
+
import { FieldChangeEvent, IFieldRef } from '../../type';
|
|
3
|
+
import { FieldType } from '../../constants';
|
|
4
|
+
import { IPhoneField } from './type';
|
|
5
|
+
import Field from '../../Field';
|
|
6
|
+
import PhoneFieldPrefix from './Prefix';
|
|
7
|
+
import { ONLY_NUMERIC } from '@cleartrip/ct-design-common-constants';
|
|
8
|
+
import { View } from 'react-native';
|
|
9
|
+
import { makeStyles, useStyles } from '@cleartrip/ct-design-style-manager';
|
|
10
|
+
|
|
11
|
+
const styles = makeStyles((theme) => {
|
|
12
|
+
return {
|
|
13
|
+
fieldConfig: {
|
|
14
|
+
flex: 1,
|
|
15
|
+
paddingTop: 0,
|
|
16
|
+
paddingBottom: 0,
|
|
17
|
+
paddingVertical: 0,
|
|
18
|
+
lineHeight: 18,
|
|
19
|
+
paddingLeft: theme.spacing[4],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const PhoneField = forwardRef<IFieldRef, IPhoneField>(
|
|
25
|
+
(
|
|
26
|
+
{ flagIcon, countryCode, showDropdownIcon, onDropdownClick, disabled, max, onChange, styleConfig, ...rest },
|
|
27
|
+
forwardedRef,
|
|
28
|
+
) => {
|
|
29
|
+
const [prefixWidth, setPrefixWidth] = useState(0);
|
|
30
|
+
const handleChange = (event: FieldChangeEvent) => {
|
|
31
|
+
const value = event.target.value;
|
|
32
|
+
const isValidValue = value ? new RegExp(ONLY_NUMERIC).test(value) : true;
|
|
33
|
+
if (value.length <= max && isValidValue) {
|
|
34
|
+
onChange?.(event);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const customPlaceHolderStyling = useStyles(() => {
|
|
38
|
+
return {
|
|
39
|
+
placeholderStyles: {
|
|
40
|
+
marginLeft: prefixWidth,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}, [prefixWidth]);
|
|
44
|
+
return (
|
|
45
|
+
<Field
|
|
46
|
+
{...rest}
|
|
47
|
+
type={FieldType.PHONE}
|
|
48
|
+
ref={forwardedRef}
|
|
49
|
+
disabled={disabled}
|
|
50
|
+
onChange={handleChange}
|
|
51
|
+
prefix={
|
|
52
|
+
<View
|
|
53
|
+
onLayout={(e) => {
|
|
54
|
+
const layout = e.nativeEvent.layout.width;
|
|
55
|
+
setPrefixWidth(layout);
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<PhoneFieldPrefix
|
|
59
|
+
flagIcon={flagIcon}
|
|
60
|
+
countryCode={countryCode}
|
|
61
|
+
showDropdownIcon={showDropdownIcon}
|
|
62
|
+
onDropdownClick={onDropdownClick}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
/>
|
|
65
|
+
</View>
|
|
66
|
+
}
|
|
67
|
+
inputMode='numeric'
|
|
68
|
+
styleConfig={{
|
|
69
|
+
field: [styles.fieldConfig, ...(styleConfig?.field || [])],
|
|
70
|
+
placeholder: {
|
|
71
|
+
placeholderLabel: [
|
|
72
|
+
customPlaceHolderStyling.placeholderStyles,
|
|
73
|
+
...(styleConfig?.placeholder?.placeholderLabel || []),
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
...styleConfig,
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
PhoneField.displayName = 'PhoneField';
|
|
84
|
+
export default PhoneField;
|