Cleanup
This commit is contained in:
143
packages/ui/src/makerkit/otp-input.tsx
Normal file
143
packages/ui/src/makerkit/otp-input.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { FormEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { Input } from '../shadcn/input';
|
||||
|
||||
const DIGITS = 6;
|
||||
|
||||
export function OtpInput({
|
||||
onValid,
|
||||
onInvalid,
|
||||
}: React.PropsWithChildren<{
|
||||
onValid: (code: string) => void;
|
||||
onInvalid: () => void;
|
||||
}>) {
|
||||
const digitsArray = useMemo(
|
||||
() => Array.from({ length: DIGITS }, (_, i) => i),
|
||||
[],
|
||||
);
|
||||
|
||||
const { control, register, watch, setFocus, formState, setValue } = useForm({
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
defaultValues: {
|
||||
values: digitsArray.map(() => ({ value: '' })),
|
||||
},
|
||||
});
|
||||
|
||||
useFieldArray({
|
||||
control,
|
||||
name: 'values',
|
||||
shouldUnregister: true,
|
||||
});
|
||||
|
||||
const { values } = watch();
|
||||
const isFormValid = formState.isValid;
|
||||
const code = (values ?? []).map(({ value }) => value).join('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFormValid) {
|
||||
onInvalid();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.length === DIGITS) {
|
||||
onValid(code);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onInvalid();
|
||||
}, [onInvalid, onValid, code, isFormValid]);
|
||||
|
||||
useEffect(() => {
|
||||
setFocus('values.0.value');
|
||||
}, [setFocus]);
|
||||
|
||||
const onInput: FormEventHandler<HTMLInputElement> = useCallback(
|
||||
(target) => {
|
||||
const element = target.currentTarget;
|
||||
const isValid = element.reportValidity();
|
||||
|
||||
if (isValid) {
|
||||
const nextIndex = Number(element.dataset.index) + 1;
|
||||
|
||||
if (nextIndex >= DIGITS) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocus(`values.${nextIndex}.value`);
|
||||
}
|
||||
},
|
||||
[setFocus],
|
||||
);
|
||||
|
||||
const onPaste = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = event.clipboardData.getData('text/plain');
|
||||
|
||||
// check if value is numeric
|
||||
if (isNumeric(pasted)) {
|
||||
const digits = getDigits(pasted, digitsArray);
|
||||
|
||||
digits.forEach((value, index) => {
|
||||
setValue(`values.${index}.value`, value);
|
||||
setFocus(`values.${index + 1}.value`);
|
||||
});
|
||||
}
|
||||
},
|
||||
[digitsArray, setFocus, setValue],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
|
||||
const index = Number(event.currentTarget.dataset.inputIndex);
|
||||
|
||||
setValue(`values.${index}.value`, '');
|
||||
setFocus(`values.${index - 1}.value`);
|
||||
}
|
||||
},
|
||||
[setFocus, setValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex justify-center space-x-2'}>
|
||||
{digitsArray.map((digit, index) => {
|
||||
const control = { ...register(`values.${digit}.value`) };
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoComplete={'off'}
|
||||
className={'w-10 text-center'}
|
||||
data-index={digit}
|
||||
pattern="[0-9]"
|
||||
required
|
||||
key={digit}
|
||||
maxLength={1}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-input-index={index}
|
||||
{...control}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isNumeric(pasted: string) {
|
||||
const isNumericRegExp = /^-?\d+$/;
|
||||
|
||||
return isNumericRegExp.test(pasted);
|
||||
}
|
||||
|
||||
function getDigits(pasted: string, digitsArray: number[]) {
|
||||
return pasted.split('').slice(0, digitsArray.length);
|
||||
}
|
||||
Reference in New Issue
Block a user