Last active
December 13, 2023 07:43
-
-
Save imkarimkarim/fdc7ae01b84161719c30ca1942b3e647 to your computer and use it in GitHub Desktop.
Pure React OTP input
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
export default function Example() { | |
return <OtpInput value={otp} onChange={(value: string) => setOtp(value)} />; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// copied from https://dominicarrojado.com/posts/how-to-create-your-own-otp-input-in-react-and-typescript-with-tests-part-1/ | |
// this is just a result of the following article in one file so you don't need to go step by step and copy the whole component at once | |
import React, { useMemo } from "react"; | |
export const RE_DIGIT = new RegExp(/^\d+$/); | |
export const focusOnFirstOtpInput = () => { | |
document.getElementById("otp-input-0")?.focus(); | |
}; | |
export default function OtpInput({ | |
value, | |
valueLength = 6, | |
onChange, | |
}: { | |
value: string; | |
valueLength?: number; | |
onChange: (value: string) => void; | |
}) { | |
const focusToNextInput = (target: HTMLElement) => { | |
const nextElementSibling = | |
target.nextElementSibling as HTMLInputElement | null; | |
if (nextElementSibling) { | |
nextElementSibling.focus(); | |
} | |
}; | |
const focusToPrevInput = (target: HTMLElement) => { | |
const previousElementSibling = | |
target.previousElementSibling as HTMLInputElement | null; | |
if (previousElementSibling) { | |
previousElementSibling.focus(); | |
} | |
}; | |
const valueItems = useMemo(() => { | |
const valueArray = value.split(""); | |
const items: Array<string> = []; | |
for (let i = 0; i < valueLength; i++) { | |
const char = valueArray[i]; | |
if (RE_DIGIT.test(char)) { | |
items.push(char); | |
} else { | |
items.push(""); | |
} | |
} | |
return items; | |
}, [value, valueLength]); | |
const inputOnChange = ( | |
e: React.ChangeEvent<HTMLInputElement>, | |
idx: number | |
) => { | |
const target = e.target; | |
let targetValue = target.value.trim(); | |
targetValue = toEnglishNumber(targetValue); | |
const isTargetValueDigit = RE_DIGIT.test(targetValue); | |
if (!isTargetValueDigit && targetValue !== "") { | |
return; | |
} | |
const nextInputEl = | |
target.nextElementSibling as HTMLInputElement | null; | |
// only delete digit if next input element has no value | |
if (!isTargetValueDigit && nextInputEl && nextInputEl.value !== "") { | |
return; | |
} | |
targetValue = isTargetValueDigit ? targetValue : " "; | |
const targetValueLength = targetValue.length; | |
if (targetValueLength === 1) { | |
const newValue = | |
value.substring(0, idx) + | |
targetValue + | |
value.substring(idx + 1); | |
onChange(newValue); | |
if (targetValue.length === 1) { | |
if (!isTargetValueDigit) { | |
return; | |
} | |
focusToNextInput(target); | |
} else { | |
const nextElementSibling = | |
target.nextElementSibling as HTMLInputElement | null; | |
if (nextElementSibling) { | |
nextElementSibling.focus(); | |
} | |
} | |
} else if (targetValueLength === valueLength) { | |
onChange(targetValue); | |
target.blur(); | |
} | |
}; | |
const inputOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |
const { key } = e; | |
const target = e.target as HTMLInputElement; | |
if (key === "ArrowRight" || key === "ArrowDown") { | |
e.preventDefault(); | |
return focusToNextInput(target); | |
} | |
if (key === "ArrowLeft" || key === "ArrowUp") { | |
e.preventDefault(); | |
return focusToPrevInput(target); | |
} | |
const targetValue = target.value; | |
// keep the selection range position | |
// if the same digit was typed | |
target.setSelectionRange(0, targetValue.length); | |
if (e.key !== "Backspace" || targetValue !== "") { | |
return; | |
} | |
focusToPrevInput(target); | |
const previousElementSibling = | |
target.previousElementSibling as HTMLInputElement | null; | |
if (previousElementSibling) { | |
previousElementSibling.focus(); | |
} | |
}; | |
const inputOnFocus = (e: React.FocusEvent<HTMLInputElement>) => { | |
const { target } = e; | |
// keep focusing back until previous input | |
// element has value | |
const prevInputEl = | |
target.previousElementSibling as HTMLInputElement | null; | |
if (prevInputEl && prevInputEl.value === "") { | |
return prevInputEl.focus(); | |
} | |
target.setSelectionRange(0, target.value.length); | |
}; | |
return ( | |
<div | |
className={classNames( | |
bootstrapStyle.activationCode, | |
bootstrapStyle.inForm | |
)} | |
id="activationCode" | |
> | |
{valueItems.map((digit, idx) => ( | |
<input | |
key={idx} | |
autoFocus={idx == 0} | |
id={`otp-input-${idx}`} | |
placeholder="-" | |
type="text" | |
inputMode="numeric" | |
autoComplete="one-time-code" | |
pattern="\d{1}" | |
maxLength={valueLength} | |
className={classNames( | |
"otp-input", | |
bootstrapStyle.persianDigits | |
)} | |
value={digit} | |
onChange={(e) => inputOnChange(e, idx)} | |
onKeyDown={inputOnKeyDown} | |
onFocus={inputOnFocus} | |
/> | |
))} | |
</div> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment