Today PM raised a requirement to implement a 6 digit OTP input box, each input box is a separate grid, so I opened vscode…
The initial idea was to use a hidden input to store the actual react controlled data, and then use six separate inputs or divs to display six grids
The general structure is as follows
import React from "react";
const PIN_LENGTH = 6;
export default function InputOTP() {
return (
<div>
<input type="number" pattern="\d*" maxLength={PIN_LENGTH} />
{Array.from({ length: PIN_LENGTH }).map((_, index) => {
return <input key={index} />;
})}
</div>
);
}
Copy the code
When only type=’number’ and pattern= ‘\d{6}’ are set, the keyboard will still display symbols. Only the numeric keyboard will be displayed if pattern= ‘\d*’ is set
Next we deal with styles and data by adding some class names to the elements and using value to store the input data:
export default function InputOTP() {
const [value, setValue] = React.useState("");
return (
<div className={"container"} >
<input
className={"hiddenInput"}
type="number"
pattern="\d*"
maxLength={PIN_LENGTH}
/>
{Array.from({ length: PIN_LENGTH }).map((_, index) => {
const focus =
index === value.length ||
(index === PIN_LENGTH - 1 && value.length === PIN_LENGTH);
return (
<input
className={`pinInputThe ${focus ? "fucos" :""} `}key={index}
value={value[index]| | ""}readOnly={true}
/>
);
})}
</div>
);
}
Copy the code
The style is as follows:
.container {
display: flex;
width: 100%;
flex-wrap: nowrap;
justify-content: center;
}
.hiddenInput {
width: 0;
height: 0;
outline: "none";
padding: 0;
border-width: 0;
box-shadow: "none";
position: "absolute";
}
.pinInput {
box-sizing: border-box;
padding: 0;
outline: none;
background-color: transparent;
width: 36px;
height: 36px;
margin: 10px 10px 20px;
text-align: center;
border: 1px solid rgb(189.189.189);
border-radius: 3px;
font-size: 25px;
font-weight: 500;
}
.fucos {
border-color: orangered;
border-width: 2px;
}
Copy the code
You can see the initial results:
But now we can’t enter anything because the readOnly for input has been set to true. We now have six small input click events to process. When clicked we focus the hidden input and then listen for the hidden input onChange event to store the changed value to value. This is the controlled component. You also need to deal with special keys like left and right arrows and Spaces to clear their default behavior.
The code for the final lite version is as follows:
const PIN_LENGTH = 6;
const KEYCODE = Object.freeze({
LEFT_ARROW: 37.RIGHT_ARROW: 39.END: 35.HOME: 36.SPACE: 32});export default function InputOTP() {
const [value, setValue] = React.useState("");
const inputRef = React.useRef();
function handleClick(e) {
e.preventDefault();
if(inputRef.current) { inputRef.current.focus(); }}function handleChange(e) {
const val = e.target.value || "";
setValue(val);
}
// Handle some special keyboard keys, clear the default behavior
function handleOnKeyDown(e) {
switch (e.keyCode) {
case KEYCODE.LEFT_ARROW:
case KEYCODE.RIGHT_ARROW:
case KEYCODE.HOME:
case KEYCODE.END:
case KEYCODE.SPACE:
e.preventDefault();
break;
default:
break; }}return (
<div className={"container"} >
<input
ref={inputRef}
className={"hiddenInput"}
type="number"
pattern="\d*"
onChange={handleChange}
onKeyDown={handleOnKeyDown}
maxLength={PIN_LENGTH}
/>
{Array.from({ length: PIN_LENGTH }).map((_, index) => {
const focus =
index === value.length ||
(index === PIN_LENGTH - 1 && value.length === PIN_LENGTH);
return (
<input
className={`pinInputThe ${focus ? "fucos" :""} `}key={index}
value={value[index]| | ""}onClick={handleClick}
readOnly={true}
/>
);
})}
</div>
);
}
Copy the code
The effect is as follows:
I thought it was done, but who knows when testing, PM said that for the convenience of users, it can implement the paste function? Who let us be humble cuttoson, dare not resist, can only silently open vscode again…
If we want to implement the copy-paste function, it seems that the previous design will not work, because the outer six small input is only used to display the data, it is read-only, the real control of the data is the hidden input, but we can not pass the small input long press event to the hidden input. Setting readOnly for small input to false doesn’t seem to work either…
So why do we need to use hidden inputs for data control? We can use six inputs for data control, and the key question is when to focus on each input. We store an index in the component that represents the input subscript of the current focus, and all subsequent operations are based on this index
The final code is as follows:
export default function InputOTP() {
const [value, setValue] = React.useState("");
// Store 6 input references
const inputsRef = React.useRef([]);
// The input subscript of the current focus
const curFocusIndexRef = React.useRef(0);
// Verify that the value is valid only if a number exists
const isInputValueValid = React.useCallback((value) = > {
return /^\d+$/.test(value); } []);// Focus the input of the specified subscript
const focusInput = React.useCallback((i) = > {
const inputs = inputsRef.current;
if (i >= inputs.length) return;
const input = inputs[i];
if(! input)return; input.focus(); curFocusIndexRef.current = i; } []);// Focus on the last input
const focusNextInput = React.useCallback(() = > {
const curFoncusIndex = curFocusIndexRef.current;
const nextIndex =
curFoncusIndex + 1 >= pinLength ? pinLength - 1 : curFoncusIndex + 1;
focusInput(nextIndex);
}, [focusInput]);
// Focus on the previous input
const focusPrevInput = React.useCallback(() = > {
const curFoncusIndex = curFocusIndexRef.current;
let prevIndex;
if (curFoncusIndex === pinLength - 1 && value.length === pinLength) {
prevIndex = pinLength - 1;
} else {
prevIndex = curFoncusIndex - 1< =0 ? 0 : curFoncusIndex - 1;
}
focusInput(prevIndex);
}, [focusInput, value]);
// Handle the delete button
const handleOnDelete = React.useCallback(() = > {
const curIndex = curFocusIndexRef.current;
if (curIndex === 0) {
if(! value)return;
setValue("");
} else if (curIndex === pinLength - 1 && value.length === pinLength) {
setValue(value.slice(0, curIndex));
} else {
setValue(value.slice(0, value.length - 1));
}
focusPrevInput();
}, [focusPrevInput, value]);
const handleOnKeyDown = React.useCallback(
(e) = > {
switch (e.keyCode) {
case KEYCODE.LEFT_ARROW:
case KEYCODE.RIGHT_ARROW:
case KEYCODE.HOME:
case KEYCODE.END:
case KEYCODE.SPACE:
e.preventDefault();
break;
// When the delete button is clicked
case KEYCODE.BACK_SPACE:
handleOnDelete();
break;
default:
break;
}
},
[handleOnDelete]
);
// When clicking input, refocus the current input to pop up the keyboard
const handleClick = React.useCallback(() = > {
focusInput(curFocusIndexRef.current);
}, [focusInput]);
const handleChange = React.useCallback(
(e) = > {
const val = e.target.value || "";
if(! isInputValueValid(val))return;
if (val.length === 1) {
focusNextInput();
setValue(`${value}${val}`);
}
},
[focusNextInput, isInputValueValid, value]
);
const handlePaste = React.useCallback(
(e) = > {
// Be sure to clear the default behavior
e.preventDefault();
const val = e.clipboardData.getData("text/plain").slice(0, pinLength);
if(! isInputValueValid(val))return;
const len = val.length;
const index = len === pinLength ? pinLength - 1 : len;
// If there is input before, it can be overwritten directly, or it can be implemented without overwriting
setValue(val);
focusInput(index);
},
[focusInput, isInputValueValid]
);
return (
<div className={"container"} >
{Array.from({ length: pinLength }).map((_, index) => {
const focus = index === curFocusIndexRef.current;
return (
<input
key={index}
ref={(ref)= > (inputsRef.current[index] = ref)}
className={`pinInput ${focus ? "focus" : ""}`}
maxLength={1}
type="number"
pattern="\d*"
autoComplete="false"
value={value[index] || ""}
onClick={handleClick}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleOnKeyDown}
/>
);
})}
</div>
);
}
Copy the code
One small detail to note here is that since the input is not read-only, there will be a cursor when typing, and since the cursor is not required for this requirement, we just need to add some CSS to the input
color: transparent;
caret-color: transparent;
text-shadow: 0 0 0 # 000;
Copy the code
Here’s the result: