UNPKG

use-otp-input

Version:

🚀 Awesome React Hook for One-time password (OTP) Input

222 lines (219 loc) 8.35 kB
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 };