159 lines
5.2 KiB
TypeScript
159 lines
5.2 KiB
TypeScript
'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<OtpInputProps> = ({
|
|
length = DEFAULT_LENGTH,
|
|
value,
|
|
onChange,
|
|
onComplete,
|
|
disabled,
|
|
autoFocus,
|
|
error,
|
|
'aria-label': ariaLabel,
|
|
}) => {
|
|
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
|
|
|
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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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.
|
|
<Stack
|
|
direction="row"
|
|
dir="ltr"
|
|
role="group"
|
|
aria-label={ariaLabel}
|
|
sx={{ justifyContent: 'center', gap: BOX_GAP, width: '100%' }}
|
|
>
|
|
{chars.map((char, index) => (
|
|
<TextField
|
|
key={index}
|
|
sx={{ flex: `1 1 ${BOX_MIN_SIZE}px`, minWidth: BOX_MIN_SIZE, maxWidth: BOX_MAX_SIZE }}
|
|
value={char}
|
|
disabled={disabled}
|
|
error={error}
|
|
onChange={(event) => handleChange(index, event as ChangeEvent<HTMLInputElement>)}
|
|
onKeyDown={(event) => handleKeyDown(index, event as KeyboardEvent<HTMLInputElement>)}
|
|
onPaste={(event) => handlePaste(index, event as ClipboardEvent<HTMLInputElement>)}
|
|
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%' },
|
|
},
|
|
}}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default OtpInput;
|