-
Notifications
You must be signed in to change notification settings - Fork 426
/
Copy pathindex.tsx
287 lines (253 loc) · 9.18 KB
/
index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
import React from 'react';
type AllowedInputTypes = 'password' | 'text' | 'number' | 'tel';
type InputProps = Required<
Pick<
React.InputHTMLAttributes<HTMLInputElement>,
| 'value'
| 'onChange'
| 'onFocus'
| 'onBlur'
| 'onKeyDown'
| 'onPaste'
| 'aria-label'
| 'autoComplete'
| 'style'
| 'inputMode'
| 'onInput'
> & {
ref: React.RefCallback<HTMLInputElement>;
placeholder: string | undefined;
className: string | undefined;
type: AllowedInputTypes;
}
>;
interface OTPInputProps {
/** Value of the OTP input */
value?: string;
/** Number of OTP inputs to be rendered */
numInputs?: number;
/** Callback to be called when the OTP has received focued */
onFocus?: (index: number) => void;
/** Callback to be called when the OTP value changes */
onChange: (otp: string) => void;
/** Callback to be called when pasting content into the component */
onPaste?: (event: React.ClipboardEvent<HTMLDivElement>) => void;
/** Callback to be called when the OTP has lost focus */
onBlur?: (index: number) => void;
/** Function to render the input */
renderInput: (inputProps: InputProps, index: number) => React.ReactNode;
/** Whether the first input should be auto focused */
shouldAutoFocus?: boolean;
/** Placeholder for the inputs */
placeholder?: string;
/** Function to render the separator */
renderSeparator?: ((index: number) => React.ReactNode) | React.ReactNode;
/** Style for the container */
containerStyle?: React.CSSProperties | string;
/** Style for the input */
inputStyle?: React.CSSProperties | string;
/** The type that will be passed to the input being rendered */
inputType?: AllowedInputTypes;
/** Do not apply the default styles to the inputs, will be removed in future versions */
skipDefaultStyles?: boolean; // TODO: Remove in next major release
}
const isStyleObject = (obj: unknown) => typeof obj === 'object' && obj !== null;
const OTPInput = ({
value = '',
numInputs = 4,
onFocus,
onChange,
onPaste,
onBlur,
renderInput,
shouldAutoFocus = false,
inputType = 'text',
renderSeparator,
placeholder,
containerStyle,
inputStyle,
skipDefaultStyles = false,
}: OTPInputProps) => {
const [activeInput, setActiveInput] = React.useState(0);
const inputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
const getOTPValue = () => (value ? value.toString().split('') : []);
const isInputNum = inputType === 'number' || inputType === 'tel';
React.useEffect(() => {
inputRefs.current = inputRefs.current.slice(0, numInputs);
}, [numInputs]);
React.useEffect(() => {
if (shouldAutoFocus) {
inputRefs.current[0]?.focus();
}
}, [shouldAutoFocus]);
const getPlaceholderValue = () => {
if (typeof placeholder === 'string') {
if (placeholder.length === numInputs) {
return placeholder;
}
if (placeholder.length > 0) {
console.error('Length of the placeholder should be equal to the number of inputs.');
}
}
return undefined;
};
const isInputValueValid = (value: string) => {
const isTypeValid = isInputNum ? !isNaN(Number(value)) : typeof value === 'string';
return isTypeValid && value.trim().length === 1;
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
if (isInputValueValid(value)) {
changeCodeAtFocus(value);
focusInput(activeInput + 1);
}
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { nativeEvent } = event;
const value = event.target.value;
if (!isInputValueValid(value)) {
// Pasting from the native autofill suggestion on a mobile device can pass
// the pasted string as one long input to one of the cells. This ensures
// that we handle the full input and not just the first character.
if (value.length === numInputs) {
const hasInvalidInput = value.split('').some((cellInput) => !isInputValueValid(cellInput));
if (!hasInvalidInput) {
handleOTPChange(value.split(''));
focusInput(numInputs - 1);
}
}
// @ts-expect-error - This was added previously to handle and edge case
// for dealing with keyCode "229 Unidentified" on Android. Check if this is
// still needed.
if (nativeEvent.data === null && nativeEvent.inputType === 'deleteContentBackward') {
event.preventDefault();
changeCodeAtFocus('');
focusInput(activeInput - 1);
}
// Clear the input if it's not valid value because firefox allows
// pasting non-numeric characters in a number type input
event.target.value = '';
}
};
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => (index: number) => {
setActiveInput(index);
event.target.select();
if (!inputRefs.current.includes(event.relatedTarget as HTMLInputElement)) {
onFocus?.(index);
}
};
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => (index: number) => {
setActiveInput(activeInput - 1);
if (!inputRefs.current.includes(event.relatedTarget as HTMLInputElement)) {
onBlur?.(index);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const otp = getOTPValue();
if ([event.code, event.key].includes('Backspace')) {
event.preventDefault();
changeCodeAtFocus('');
focusInput(activeInput - 1);
} else if (event.code === 'Delete') {
event.preventDefault();
changeCodeAtFocus('');
} else if (event.code === 'ArrowLeft') {
event.preventDefault();
focusInput(activeInput - 1);
} else if (event.code === 'ArrowRight') {
event.preventDefault();
focusInput(activeInput + 1);
}
// React does not trigger onChange when the same value is entered
// again. So we need to focus the next input manually in this case.
else if (event.key === otp[activeInput]) {
event.preventDefault();
focusInput(activeInput + 1);
} else if (
event.code === 'Spacebar' ||
event.code === 'Space' ||
event.code === 'ArrowUp' ||
event.code === 'ArrowDown'
) {
event.preventDefault();
}
};
const focusInput = (index: number) => {
const activeInput = Math.max(Math.min(numInputs - 1, index), 0);
if (inputRefs.current[activeInput]) {
inputRefs.current[activeInput]?.focus();
inputRefs.current[activeInput]?.select();
setActiveInput(activeInput);
}
};
const changeCodeAtFocus = (value: string) => {
const otp = getOTPValue();
otp[activeInput] = value[0];
handleOTPChange(otp);
};
const handleOTPChange = (otp: Array<string>) => {
const otpValue = otp.join('');
onChange(otpValue);
};
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
event.preventDefault();
const otp = getOTPValue();
let nextActiveInput = activeInput;
// Get pastedData in an array of max size (num of inputs - current position)
const pastedData = event.clipboardData
.getData('text/plain')
.slice(0, numInputs - activeInput)
.split('');
// Prevent pasting if the clipboard data contains non-numeric values for number inputs
if (isInputNum && pastedData.some((value) => isNaN(Number(value)))) {
return;
}
// Paste data from focused input onwards
for (let pos = 0; pos < numInputs; ++pos) {
if (pos >= activeInput && pastedData.length > 0) {
otp[pos] = pastedData.shift() ?? '';
nextActiveInput++;
}
}
focusInput(nextActiveInput);
handleOTPChange(otp);
};
return (
<div
style={Object.assign({ display: 'flex', alignItems: 'center' }, isStyleObject(containerStyle) && containerStyle)}
className={typeof containerStyle === 'string' ? containerStyle : undefined}
onPaste={onPaste}
>
{Array.from({ length: numInputs }, (_, index) => index).map((index) => (
<React.Fragment key={index}>
{renderInput(
{
value: getOTPValue()[index] ?? '',
placeholder: getPlaceholderValue()?.[index] ?? undefined,
ref: (element) => (inputRefs.current[index] = element),
onChange: handleChange,
onFocus: (event) => handleFocus(event)(index),
onBlur: (event) => handleBlur(event)(index),
onKeyDown: handleKeyDown,
onPaste: handlePaste,
autoComplete: 'off',
'aria-label': `Please enter OTP character ${index + 1}`,
style: Object.assign(
!skipDefaultStyles ? ({ width: '1em', textAlign: 'center' } as const) : {},
isStyleObject(inputStyle) ? inputStyle : {}
),
className: typeof inputStyle === 'string' ? inputStyle : undefined,
type: inputType,
inputMode: isInputNum ? 'numeric' : 'text',
onInput: handleInputChange,
},
index
)}
{index < numInputs - 1 && (typeof renderSeparator === 'function' ? renderSeparator(index) : renderSeparator)}
</React.Fragment>
))}
</div>
);
};
export type { OTPInputProps, InputProps, AllowedInputTypes };
export default OTPInput;