132 lines
4.0 KiB
TypeScript
132 lines
4.0 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useEffect } from 'react';
|
|
|
|
interface SegmentedOtpInputProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
length?: number;
|
|
disabled?: boolean;
|
|
autoFocus?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function SegmentedOtpInput({
|
|
value,
|
|
onChange,
|
|
length = 6,
|
|
disabled = false,
|
|
autoFocus = true,
|
|
className = '',
|
|
}: SegmentedOtpInputProps) {
|
|
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
|
|
|
// Split value into characters array padded to length
|
|
const digits = Array.from({ length }, (_, i) => value[i] ?? '');
|
|
|
|
useEffect(() => {
|
|
if (autoFocus && inputsRef.current[0]) {
|
|
inputsRef.current[0]?.focus();
|
|
}
|
|
}, [autoFocus]);
|
|
|
|
const updateDigit = (index: number, char: string) => {
|
|
const chars = [...digits];
|
|
chars[index] = char;
|
|
// Build new value
|
|
const nextVal = chars.join('').trimEnd();
|
|
onChange(nextVal);
|
|
};
|
|
|
|
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Backspace') {
|
|
e.preventDefault();
|
|
if (digits[index]) {
|
|
// Clear current cell
|
|
updateDigit(index, '');
|
|
} else if (index > 0) {
|
|
// Move to previous and clear
|
|
updateDigit(index - 1, '');
|
|
inputsRef.current[index - 1]?.focus();
|
|
}
|
|
} else if (e.key === 'ArrowLeft' && index > 0) {
|
|
e.preventDefault();
|
|
inputsRef.current[index - 1]?.focus();
|
|
} else if (e.key === 'ArrowRight' && index < length - 1) {
|
|
e.preventDefault();
|
|
inputsRef.current[index + 1]?.focus();
|
|
}
|
|
};
|
|
|
|
const handleChange = (index: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const inputVal = e.target.value;
|
|
if (!inputVal) {
|
|
updateDigit(index, '');
|
|
return;
|
|
}
|
|
|
|
// Take the last character typed (in case multiple chars were entered)
|
|
const char = inputVal.slice(-1);
|
|
updateDigit(index, char);
|
|
|
|
// Auto advance to next input if available
|
|
if (index < length - 1) {
|
|
inputsRef.current[index + 1]?.focus();
|
|
inputsRef.current[index + 1]?.select();
|
|
}
|
|
};
|
|
|
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
e.preventDefault();
|
|
const pasted = e.clipboardData.getData('text').replace(/\s|-/g, '');
|
|
if (!pasted) return;
|
|
|
|
const trimmed = pasted.slice(0, length);
|
|
onChange(trimmed);
|
|
|
|
// Focus either the cell after the pasted length or the last cell
|
|
const targetIdx = Math.min(trimmed.length, length - 1);
|
|
inputsRef.current[targetIdx]?.focus();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`flex items-center justify-center gap-2 sm:gap-3 ${className}`}
|
|
onPaste={handlePaste}
|
|
role="group"
|
|
aria-label="6-digit verification code"
|
|
>
|
|
{Array.from({ length }).map((_, index) => {
|
|
const isFilled = Boolean(digits[index]);
|
|
return (
|
|
<input
|
|
key={index}
|
|
ref={(el) => {
|
|
inputsRef.current[index] = el;
|
|
}}
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete={index === 0 ? 'one-time-code' : 'off'}
|
|
maxLength={1}
|
|
disabled={disabled}
|
|
value={digits[index]}
|
|
aria-label={`Digit ${index + 1}`}
|
|
data-testid={`otp-input-${index}`}
|
|
onChange={(e) => handleChange(index, e)}
|
|
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
onFocus={(e) => e.target.select()}
|
|
className={`w-11 h-13 sm:w-13 sm:h-16 text-center text-xl sm:text-2xl font-mono font-black tracking-widest rounded-xl transition-all duration-150 outline-none
|
|
${
|
|
isFilled
|
|
? 'border-2 border-[#00f2fe] text-white bg-[#0e1726] shadow-[0_0_12px_rgba(0,242,254,0.35)]'
|
|
: 'border border-[#262a33] text-white/90 bg-[#070a0f] hover:border-[#3a494b]'
|
|
}
|
|
focus:border-[#00f2fe] focus:bg-[#0f1d30] focus:shadow-[0_0_16px_rgba(0,242,254,0.5)] focus:scale-105
|
|
disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|