133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
import { useEffect, useMemo, useRef } from "react"
|
||
|
||
const OTP_LENGTH = 5
|
||
|
||
const normalizeDigits = (value: string) =>
|
||
value
|
||
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
|
||
|
||
const sanitizeOtp = (value: string) => normalizeDigits(value).replace(/\D/g, "").slice(0, OTP_LENGTH)
|
||
|
||
export function AuthOtpInput({
|
||
id,
|
||
value,
|
||
disabled,
|
||
onChange,
|
||
onComplete,
|
||
}: {
|
||
id: string
|
||
value: string
|
||
disabled?: boolean
|
||
onChange: (value: string) => void
|
||
onComplete?: (value: string) => void
|
||
}) {
|
||
const inputRefs = useRef<Array<HTMLInputElement | null>>([])
|
||
const lastCompletedValueRef = useRef("")
|
||
const normalizedValue = useMemo(() => sanitizeOtp(value), [value])
|
||
const digits = useMemo(
|
||
() => Array.from({ length: OTP_LENGTH }, (_, index) => normalizedValue[index] ?? ""),
|
||
[normalizedValue],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (normalizedValue.length !== OTP_LENGTH) {
|
||
lastCompletedValueRef.current = ""
|
||
return
|
||
}
|
||
|
||
if (normalizedValue !== lastCompletedValueRef.current) {
|
||
lastCompletedValueRef.current = normalizedValue
|
||
onComplete?.(normalizedValue)
|
||
}
|
||
}, [normalizedValue, onComplete])
|
||
|
||
const focusIndex = (index: number) => {
|
||
inputRefs.current[index]?.focus()
|
||
inputRefs.current[index]?.select()
|
||
}
|
||
|
||
const handleSlotChange = (index: number, nextRawValue: string) => {
|
||
const nextValue = sanitizeOtp(nextRawValue)
|
||
if (!nextValue) {
|
||
const updated = digits.slice()
|
||
updated[index] = ""
|
||
onChange(updated.join(""))
|
||
return
|
||
}
|
||
|
||
if (nextValue.length > 1) {
|
||
onChange(nextValue)
|
||
const focusTarget = Math.min(nextValue.length, OTP_LENGTH - 1)
|
||
focusIndex(focusTarget)
|
||
return
|
||
}
|
||
|
||
const updated = digits.slice()
|
||
updated[index] = nextValue
|
||
const combined = updated.join("")
|
||
onChange(combined)
|
||
|
||
if (index < OTP_LENGTH - 1) {
|
||
focusIndex(index + 1)
|
||
}
|
||
}
|
||
|
||
const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (event.key === "Backspace" && !digits[index] && index > 0) {
|
||
const updated = digits.slice()
|
||
updated[index - 1] = ""
|
||
onChange(updated.join(""))
|
||
focusIndex(index - 1)
|
||
event.preventDefault()
|
||
return
|
||
}
|
||
|
||
if (event.key === "ArrowLeft" && index > 0) {
|
||
focusIndex(index - 1)
|
||
event.preventDefault()
|
||
return
|
||
}
|
||
|
||
if (event.key === "ArrowRight" && index < OTP_LENGTH - 1) {
|
||
focusIndex(index + 1)
|
||
event.preventDefault()
|
||
}
|
||
}
|
||
|
||
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
|
||
event.preventDefault()
|
||
const pasted = sanitizeOtp(event.clipboardData.getData("text"))
|
||
if (!pasted) {
|
||
return
|
||
}
|
||
onChange(pasted)
|
||
focusIndex(Math.min(pasted.length, OTP_LENGTH - 1))
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center justify-center gap-2 sm:gap-3" dir="ltr">
|
||
{digits.map((digit, index) => (
|
||
<input
|
||
key={`${id}-${index}`}
|
||
ref={(element) => {
|
||
inputRefs.current[index] = element
|
||
}}
|
||
id={index === 0 ? id : undefined}
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
pattern="[0-9]*"
|
||
maxLength={OTP_LENGTH}
|
||
disabled={disabled}
|
||
value={digit}
|
||
onChange={(event) => handleSlotChange(index, event.target.value)}
|
||
onKeyDown={(event) => handleKeyDown(index, event)}
|
||
onPaste={handlePaste}
|
||
className="h-12 w-12 rounded-2xl border border-slate-200 bg-white text-center text-lg font-semibold tracking-[0.18em] text-slate-900 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:ring-sky-500/20 sm:h-14 sm:w-14"
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|