-
Notifications
You must be signed in to change notification settings - Fork 21
/
verification-code-control.js
225 lines (193 loc) · 6.41 KB
/
verification-code-control.js
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
/**
* External dependencies
*/
import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
import { Flex } from '@wordpress/components';
/**
* Internal dependencies
*/
import AppInputControl from '.~/components/app-input-control';
import './verification-code-control.scss';
const KEY_CODE_LEFT = 37;
const KEY_CODE_RIGHT = 39;
const KEY_CODE_BACKSPACE = 8;
const DIGIT_LENGTH = 6;
const initDigits = Array( DIGIT_LENGTH ).fill( '' );
const toCallbackData = ( digits ) => {
const code = digits.join( '' );
const isFilled = code.length === DIGIT_LENGTH;
return { code, isFilled };
};
/**
* @callback onCodeChange
* @param {Object} verification Data payload.
* @param {string} verification.code The current entered verification code.
* @param {boolean} verification.isFilled Whether all digits of validation code are filled.
*/
/**
* Renders a row of input elements for entering six-digit verification code.
*
* @param {Object} props React props.
* @param {onCodeChange} props.onCodeChange Called when the verification code are changed.
* @param {string} [props.resetNeedle=''] When the passed value changes, it will trigger internal state resetting for this component.
*/
export default function VerificationCodeControl( {
onCodeChange,
resetNeedle = '',
} ) {
const inputsRef = useRef( [] );
const cursorRef = useRef( 0 );
const [ digits, setDigits ] = useState( initDigits );
const [ focus, setFocus ] = useState( 0 );
// prevent potential undesired re-renders
const onCodeChangeRef = useRef( null );
onCodeChangeRef.current = onCodeChange;
/**
* Moves focus to the input at given input
* if it exists.
*
* @param {number} targetIdx Index of the node to move the focus to.
*/
const maybeMoveFocus = ( targetIdx ) => {
// prevent index overflow
targetIdx = Math.min( targetIdx, DIGIT_LENGTH - 1 );
const node = inputsRef.current[ targetIdx ];
if ( node ) {
node.focus();
}
};
const getEventData = ( e ) => {
const { value, dataset } = e.target;
const idx = Number( dataset.idx );
return {
idx,
value: e.clipboardData?.getData( 'text/plain' ) ?? value,
};
};
const handleKeyDown = ( e ) => {
const { dataset, selectionStart, selectionEnd, value } = e.target;
const idx = Number( dataset.idx );
switch ( e.keyCode ) {
case KEY_CODE_LEFT:
case KEY_CODE_BACKSPACE:
if ( selectionStart === 0 && selectionEnd === 0 ) {
maybeMoveFocus( idx - 1 );
}
break;
case KEY_CODE_RIGHT:
if ( selectionStart === 1 || ! value ) {
maybeMoveFocus( idx + 1 );
}
break;
}
};
const updateState = useCallback(
( nextDigits ) => {
setDigits( nextDigits );
onCodeChangeRef.current( toCallbackData( nextDigits ) );
},
[ onCodeChangeRef ]
);
// Track the cursor's position.
const handleBeforeInput = ( e ) => {
cursorRef.current = e.target.selectionStart;
};
const handleUpdate = ( e ) => {
e.preventDefault();
const { nextDigits, nextFocusIdx } = e.clipboardData
? handlePaste( e )
: handleInput( e );
setFocus( nextFocusIdx );
if ( nextDigits.toString() !== digits.toString() ) {
updateState( nextDigits );
}
};
const handleInput = ( e ) => {
const { value, idx } = getEventData( e );
// Only keep the first entered char from the starting position of key cursor.
const digit = value.substr( cursorRef.current, 1 ).replace( /\D/, '' );
// If that char is not a digit, then clear the input to empty.
if ( digit !== value ) {
e.target.value = digit;
}
const nextDigits = [ ...digits ];
nextDigits[ idx ] = digit;
// always increase focus index by one except for digit deletions
return { nextDigits, nextFocusIdx: digit ? idx + 1 : idx };
};
const handlePaste = ( e ) => {
const { idx, value } = getEventData( e );
// only allow n digits, from the current idx position until the end
const newDigits = [
...value.replace( /\D/g, '' ).substr( 0, DIGIT_LENGTH - idx ),
];
// edge case: blur when pasting on last item
if (
newDigits.length === 1 &&
newDigits[ 0 ] !== digits[ idx ] &&
idx === DIGIT_LENGTH - 1
) {
e.target.blur();
}
const nextDigits = [ ...digits ];
newDigits.forEach(
( digit, i ) => ( nextDigits[ i + idx ] = newDigits[ i ] )
);
return {
nextDigits,
nextFocusIdx: newDigits.length + idx,
};
};
/**
* Set the focus to the first input if the control's value is (back) at the initial state.
*
* Since the <InputControl> has an internal state management that always controls the actual `value` prop of the <input>,
* the <InputControl> is forced the <input> to be a controlled input.
* When using it, it's always necessary to specify `value` prop from the below <AppInputControl>
* to avoid the warning - A component is changing an uncontrolled input to be controlled.
*
* @see https://github.com/WordPress/gutenberg/blob/%40wordpress/components%4012.0.8/packages/components/src/input-control/input-field.js#L47-L68
* @see https://github.com/WordPress/gutenberg/blob/%40wordpress/components%4012.0.8/packages/components/src/input-control/input-field.js#L115-L118
*
* But after specifying the `value` prop,
* the synchronization of external and internal `value` state will depend on whether the input is focused.
* It'd sync external to internal only if the input is not focused.
* So here we await the `digits` is reset back to `initDigits` by above useEffect and sync to internal value,
* then move the focus calling after the synchronization tick finished.
*
* Note the above also impacts in the state updates for the focused element...
* That's why we need setFocus state in order to sync these internal values.
*
* @see https://github.com/WordPress/gutenberg/blob/%40wordpress/components%4012.0.8/packages/components/src/input-control/input-field.js#L73-L90
*/
useEffect( () => {
updateState( initDigits );
setFocus( 0 );
}, [ resetNeedle, updateState ] );
useEffect( () => {
maybeMoveFocus( focus );
}, [ digits, resetNeedle, focus ] );
return (
<Flex
className="gla-verification-code-control"
justify="normal"
gap={ 2 }
>
{ digits.map( ( value, idx ) => {
return (
<AppInputControl
key={ idx }
ref={ ( el ) => ( inputsRef.current[ idx ] = el ) }
data-idx={ idx }
value={ value }
onKeyDown={ handleKeyDown }
onBeforeInput={ handleBeforeInput }
onInput={ handleUpdate }
onPaste={ handleUpdate }
autoComplete="off"
/>
);
} ) }
</Flex>
);
}