use-otp-input
Version:
🚀 Awesome React Hook for One-time password (OTP) Input
222 lines (219 loc) • 8.35 kB
JavaScript
import { useState, useRef, useCallback } from 'react';
import { selectNode } from 'selection-extra/dist/helpers/nodes';
function useOTPInput(options = {}) {
const { length = 6, normalizeValue, value, onChange } = options;
const [internalValue, setInternalValue] = useState("");
const inputRefs = useRef([]);
const focusEventLockRef = useRef(false);
const keyDownDataRef = useRef(null);
const mergedValue = (value ?? internalValue).toString().split("");
const mergedOnChange = useCallback(
(nextValue) => {
if (typeof value !== "undefined") {
if (!onChange) {
throw new Error(`Invalid onChange property`);
} else {
onChange(nextValue.slice(0, length));
}
return;
}
setInternalValue(nextValue.slice(0, length));
},
[length, onChange, value]
);
const selectInput = (index, caretIndex) => {
const targetInput = inputRefs.current[index];
if (targetInput) {
if (typeof caretIndex === "number") {
selectNode(targetInput, [caretIndex, caretIndex]);
} else {
selectNode(targetInput);
}
}
};
const selectInputAfter = (index) => {
selectInput(index);
};
const selectInputStart = (index) => {
selectInput(index, 0);
};
const removePrevInput = (index) => {
const nextMergedValue = [...mergedValue];
nextMergedValue.splice(index - 1, 1);
mergedOnChange(nextMergedValue.join(""));
setTimeout(() => {
selectInputStart(index - 1);
});
};
const removeAfterInput = (index) => {
const nextMergedValue = [...mergedValue];
nextMergedValue.splice(index + 1, 1);
mergedOnChange(nextMergedValue.join(""));
};
const getInputProps = (index) => {
if (index < 0 || index >= length) {
throw new Error(`Unexpected input index of [${index}]`);
}
const result = {
ref: (ref) => {
inputRefs.current[index] = ref;
},
value: mergedValue[index] ?? "",
onChange: (event) => {
const eventValue = event.target.value.trim();
let nextMergedValue = [...mergedValue];
if (eventValue.length >= 2) {
nextMergedValue.splice(index, 1, ...eventValue.split(""));
nextMergedValue = nextMergedValue.slice(0, length);
} else {
nextMergedValue[index] = eventValue;
}
const nextValue = nextMergedValue.join("");
const normalizedValue = nextValue && normalizeValue ? normalizeValue(nextValue, mergedValue.join("")) : nextValue;
mergedOnChange(normalizedValue);
if (normalizedValue === mergedValue.join("")) {
setTimeout(() => {
if (keyDownDataRef.current) {
selectInput(
keyDownDataRef.current.inputIndex,
keyDownDataRef.current.caretIndex
);
}
});
return;
}
focusEventLockRef.current = true;
if (event.target.value.length === 1) {
if (mergedValue.length === nextMergedValue.length && mergedValue.length === length) {
return;
}
selectInputAfter(index + 1);
}
if (event.target.value.length >= 2) {
if (keyDownDataRef.current && keyDownDataRef.current.caretIndex === 0) {
const currentValueLength = event.target.value.length;
setTimeout(() => {
const extraLength = currentValueLength - 1;
if (index + extraLength > length - 1) {
selectInputAfter(length - 1);
} else {
selectInputStart(index + extraLength);
}
});
} else {
selectInputAfter(index + event.target.value.length - 1);
}
}
focusEventLockRef.current = false;
},
onFocus: (event) => {
if (focusEventLockRef.current) {
return;
}
if (event.currentTarget === inputRefs.current[index] && mergedValue.length < index) {
selectInputAfter(mergedValue.length);
}
},
onKeyDown: (event) => {
const selectionStart = event.currentTarget.selectionStart;
if (event.key === "Backspace") {
if (event.ctrlKey && event.shiftKey && event.altKey || event.ctrlKey && event.shiftKey || event.ctrlKey && event.altKey || event.shiftKey && event.altKey) {
return;
}
if (event.ctrlKey && typeof selectionStart === "number") {
event.preventDefault();
const nextMergedValue = [...mergedValue];
nextMergedValue.splice(0, index + selectionStart);
mergedOnChange(nextMergedValue.join(""));
setTimeout(() => {
selectInputStart(0);
});
return;
}
setTimeout(() => {
selectInputStart(index);
});
}
if (event.key === "Delete") {
if (event.ctrlKey && event.shiftKey && event.altKey || event.ctrlKey && event.shiftKey || event.ctrlKey && event.altKey || event.shiftKey && event.altKey) {
return;
}
if (event.ctrlKey && typeof selectionStart === "number") {
event.preventDefault();
let nextMergedValue = [...mergedValue];
nextMergedValue = nextMergedValue.slice(0, index + selectionStart);
mergedOnChange(nextMergedValue.join(""));
return;
}
}
if (event.key === "Home" && !event.ctrlKey && !event.shiftKey && !event.altKey) {
event.preventDefault();
selectInputStart(0);
}
if (event.key === "End" && !event.ctrlKey && !event.shiftKey && !event.altKey) {
event.preventDefault();
const targetIndex = mergedValue.length - 1;
selectInputAfter(targetIndex);
}
if (
// 暂时仅处理 Caret 的情况
window.getSelection()?.type === "Caret" && typeof selectionStart === "number"
) {
keyDownDataRef.current = {
inputIndex: index,
caretIndex: selectionStart
};
}
},
/**
* 实测发现 onKeyDown 时 selectionStart 是按下前的位置,
* onKeyUp 时 selectionStart 是按下后的位置
*/
onKeyUp: (event) => {
const selectionStart = event.currentTarget.selectionStart;
if (event.key === "ArrowLeft" && keyDownDataRef.current?.inputIndex === index && keyDownDataRef.current.caretIndex === 0 && selectionStart === 0 && index - 1 >= 0) {
selectInputAfter(index - 1);
return;
}
if (event.key === "ArrowRight" && keyDownDataRef.current?.inputIndex === index && keyDownDataRef.current.caretIndex === 1 && selectionStart === 1 && index + 1 <= length) {
selectInputStart(index + 1);
return;
}
if (event.key === "Backspace") {
if (event.ctrlKey && event.shiftKey && event.altKey || event.ctrlKey && event.shiftKey || event.ctrlKey && event.altKey || event.shiftKey && event.altKey || event.ctrlKey) {
return;
}
if (keyDownDataRef.current?.inputIndex === index && keyDownDataRef.current.caretIndex === 0) {
removePrevInput(index);
}
}
if (event.key === "Delete") {
if (event.ctrlKey && event.shiftKey && event.altKey || event.ctrlKey && event.shiftKey || event.ctrlKey && event.altKey || event.shiftKey && event.altKey || event.ctrlKey) {
return;
}
if (keyDownDataRef.current?.inputIndex === index && keyDownDataRef.current.caretIndex === 1) {
removeAfterInput(index);
}
}
}
};
return result;
};
return {
inputProps: Array.from({ length }, (_, index) => index).map((index) => {
return getInputProps(index);
}),
clearInput: () => {
mergedOnChange("");
},
setInput: (nextValue) => {
const internalValue2 = nextValue.split("").map((item) => item.trim()).join("");
const normalizedValue = internalValue2 && normalizeValue ? normalizeValue(internalValue2, mergedValue.join("")) : internalValue2;
mergedOnChange(normalizedValue);
},
getInput: () => {
return mergedValue.join("");
}
};
}
export { useOTPInput as default };