MotorSignal-App/website/components/segmented-otp-input.tsx

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>
);
}