'use client'; import { ChangeEvent, ClipboardEvent, FunctionComponent, KeyboardEvent, useEffect, useMemo, useRef } from 'react'; import { Stack, TextField } from '@mui/material'; import { digitsOnly } from '@/utils'; export interface OtpInputProps { /** Number of digit boxes. */ length?: number; /** Controlled value — the digits entered so far. */ value: string; /** Emits the full concatenated value on every change. */ onChange: (value: string) => void; /** Fired once the value reaches `length` digits. */ onComplete?: (value: string) => void; disabled?: boolean; autoFocus?: boolean; error?: boolean; /** Accessible label for the group. */ 'aria-label'?: string; } const DEFAULT_LENGTH = 5; /** * The box is sized in `ch`-free absolute terms but must survive 6 boxes + gaps inside the * ~480px app frame minus the card padding, so it shrinks rather than overflowing. */ const BOX_MIN_SIZE = 40; const BOX_MAX_SIZE = 52; const BOX_GAP = 1; /** * One-time-code input: `length` single-digit boxes with auto-advance, backspace-to-previous, * and paste distribution. Digits are normalized (Persian/Arabic → ASCII) and the group is * forced LTR so codes read left-to-right even inside the RTL (`fa`) layout. * @component OtpInput */ const OtpInput: FunctionComponent = ({ length = DEFAULT_LENGTH, value, onChange, onComplete, disabled, autoFocus, error, 'aria-label': ariaLabel, }) => { const inputRefs = useRef>([]); const chars = useMemo( () => Array.from({ length }, (_, index) => value[index] ?? ''), [value, length] ); useEffect(() => { if (autoFocus) inputRefs.current[0]?.focus(); }, [autoFocus]); const focusBox = (index: number) => { const target = inputRefs.current[index]; if (target) { target.focus(); target.select(); } }; const emit = (next: string[]) => { const joined = next.join(''); onChange(joined); if (next.every((char) => char !== '')) onComplete?.(joined); }; const handleChange = (index: number, event: ChangeEvent) => { const incoming = digitsOnly(event.target.value); const next = [...chars]; if (!incoming) { next[index] = ''; emit(next); return; } if (incoming.length === 1) { next[index] = incoming; focusBox(index + 1); } else { // Paste / multi-char: fill sequentially from the current box. for (let offset = 0; offset < incoming.length && index + offset < length; offset += 1) { next[index + offset] = incoming[offset]; } focusBox(Math.min(index + incoming.length, length - 1)); } emit(next); }; const handleKeyDown = (index: number, event: KeyboardEvent) => { if (event.key === 'Backspace' && !chars[index] && index > 0) { // Empty box + backspace clears the previous digit too, so one keypress erases one digit // instead of the first press only moving focus and the second doing the clearing. const next = [...chars]; next[index - 1] = ''; focusBox(index - 1); emit(next); } }; const handlePaste = (index: number, event: ClipboardEvent) => { event.preventDefault(); const pasted = digitsOnly(event.clipboardData.getData('text')); if (!pasted) return; const next = [...chars]; for (let offset = 0; offset < pasted.length && index + offset < length; offset += 1) { next[index + offset] = pasted[offset]; } focusBox(Math.min(index + pasted.length, length - 1)); emit(next); }; return ( // `gap`, never Stack's `spacing`: spacing compiles to a directional margin that the RTL Emotion // cache mirrors, while this group is force-`dir="ltr"` so the code reads left-to-right. The two // disagreed on /fa and collapsed the gap between the first two boxes while doubling it at the end. {chars.map((char, index) => ( handleChange(index, event as ChangeEvent)} onKeyDown={(event) => handleKeyDown(index, event as KeyboardEvent)} onPaste={(event) => handlePaste(index, event as ClipboardEvent)} inputRef={(el: HTMLInputElement | null) => { inputRefs.current[index] = el; }} slotProps={{ htmlInput: { inputMode: 'numeric', maxLength: 1, // Lets iOS/Android offer the SMS code as a keyboard suggestion even without WebOTP. autoComplete: 'one-time-code', 'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`, style: { textAlign: 'center', fontSize: '1.25rem', padding: 12, width: '100%' }, }, }} /> ))} ); }; export default OtpInput;