今天pm提了一个需求,需要实现一个6位数字的OTP输入框,每个输入框都是一个单独的格子,于是我打开了vscode...
开始的思路是,用一个隐藏的input来存入实际被react受控的数据,然后再用6个独立的input或者div显示6个格子
大致的结构如下
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>
);
}
这里遇到一个小坑,当只设置type='number' 和pattern=“\d{6}”时,键盘仍然显示符号,需要设置为pattern=“\d*”才只会显示纯数字键盘
接着我们来处理样式和数据,先给元素添加一些类名,并使用value来存储输入的数据:
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={`pinInput ${focus ? "fucos" : ""}`}
key={index}
value={value[index] || ""}
readOnly={true}
/>
);
})}
</div>
);
}
样式如下:
.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;
}
可以看到初步的效果:
但是现在我们不能输入任何东西,因为input的readOnly被置为true了,我们现在要处理六个小input的点击事件,当点击时我们让隐藏的input聚焦,然后监听隐藏的input的onChange事件,把变化的值存入value,这就是受控组件。还需要处理一些比如左右箭头、空格等特殊按键,清除其默认行为.
最终简易版的代码如下:
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);
}
// 处理一些键盘特殊按键,清除默认行为
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={`pinInput ${focus ? "fucos" : ""}`}
key={index}
value={value[index] || ""}
onClick={handleClick}
readOnly={true}
/>
);
})}
</div>
);
}
效果如下:
本以为大功告成,谁知道在测试的时候,PM说为了方便用户,能不能实现粘贴功能?谁让我们是卑微的切图仔呢,不敢反抗,只能默默地又打开了vscode...
如果我们要实现复制粘贴的功能,好像之前的设计就行不通了,因为外面的6个小input仅仅只是为了展示数据用的,是只读的,真正控制数据的是隐藏的input,然而我们又不能将小input的长按事件传递给隐藏的input,如果将小input的readOnly设为false似乎也行不通...
既然这样,我们为什么还需要通过隐藏的input来进行数据控制呢,我们直接使用六个input进行数据控制不就行了吗,这样最关键的问题就是每个input的聚焦时机。我们在组件中存放一个index,表示当前聚焦的input下标,后续所有的操作都基于这个index
最终代码如下:
export default function InputOTP() {
const [value, setValue] = React.useState("");
// 用来存放6个input的引用
const inputsRef = React.useRef([]);
// 当前聚焦的input的下标
const curFocusIndexRef = React.useRef(0);
// 校验value是否有效,仅仅存在数字才有效
const isInputValueValid = React.useCallback((value) => {
return /^\d+$/.test(value);
}, []);
// 聚焦指定下标的input
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;
}, []);
// 聚焦后一个input
const focusNextInput = React.useCallback(() => {
const curFoncusIndex = curFocusIndexRef.current;
const nextIndex =
curFoncusIndex + 1 >= pinLength ? pinLength - 1 : curFoncusIndex + 1;
focusInput(nextIndex);
}, [focusInput]);
// 聚焦前一个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]);
// 处理删除按钮
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;
// 当点击删除按钮
case KEYCODE.BACK_SPACE:
handleOnDelete();
break;
default:
break;
}
},
[handleOnDelete]
);
// 点击input时,重新聚焦当前的input,弹出键盘
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) => {
// 一定要清除默认行为
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;
// 如果之前存在输入,这里直接覆盖,也可以实现不覆盖的,也很简单
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>
);
}
这里还要注意一个小细节,因为input不是只读的了,这样输入的时候会存在光标,因为该需求不需要光标,所以我们只需要在给input添加一些css就好了
color: transparent;
caret-color: transparent;
text-shadow: 0 0 0 #000;
大功告成,看一下效果: