Files
baya-monorepo/client/src/components/OtpInput/OtpInput.tsx
T
2026-07-27 22:27:04 +03:30

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;