feat: create OtpInput component
This commit is contained in:
parent
1cc8d3f868
commit
6712ac4608
6 changed files with 453 additions and 17 deletions
|
@ -33,7 +33,7 @@
|
|||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1",
|
||||
"@prisma/client": "^4.11.0",
|
||||
"@prisma/client": "^4.12.0",
|
||||
"@radix-ui/react-dialog": "^1.0.3",
|
||||
"@radix-ui/react-switch": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
|
@ -120,7 +120,7 @@
|
|||
"next-router-mock": "^0.9.2",
|
||||
"nodemon": "^2.0.21",
|
||||
"prettier": "^2.8.4",
|
||||
"prisma": "^4.11.0",
|
||||
"prisma": "^4.12.0",
|
||||
"ts-jest": "^29.0.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.0.2",
|
||||
|
|
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
|
@ -14,8 +14,8 @@ dependencies:
|
|||
specifier: ^12.0.1
|
||||
version: 12.0.1
|
||||
'@prisma/client':
|
||||
specifier: ^4.11.0
|
||||
version: 4.11.0(prisma@4.11.0)
|
||||
specifier: ^4.12.0
|
||||
version: 4.12.0(prisma@4.12.0)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)
|
||||
|
@ -271,8 +271,8 @@ devDependencies:
|
|||
specifier: ^2.8.4
|
||||
version: 2.8.4
|
||||
prisma:
|
||||
specifier: ^4.11.0
|
||||
version: 4.11.0
|
||||
specifier: ^4.12.0
|
||||
version: 4.12.0
|
||||
ts-jest:
|
||||
specifier: ^29.0.3
|
||||
version: 29.0.5(@babel/core@7.21.3)(esbuild@0.16.17)(jest@29.5.0)(typescript@5.0.2)
|
||||
|
@ -1529,8 +1529,8 @@ packages:
|
|||
resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==}
|
||||
dev: false
|
||||
|
||||
/@prisma/client@4.11.0(prisma@4.11.0):
|
||||
resolution: {integrity: sha512-0INHYkQIqgAjrt7NzhYpeDQi8x3Nvylc2uDngKyFDDj1tTRQ4uV1HnVmd1sQEraeVAN63SOK0dgCKQHlvjL0KA==}
|
||||
/@prisma/client@4.12.0(prisma@4.12.0):
|
||||
resolution: {integrity: sha512-j9/ighfWwux97J2dS15nqhl60tYoH8V0IuSsgZDb6bCFcQD3fXbXmxjYC8GHhIgOk3lB7Pq+8CwElz2MiDpsSg==}
|
||||
engines: {node: '>=14.17'}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
|
@ -1539,16 +1539,16 @@ packages:
|
|||
prisma:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@prisma/engines-version': 4.11.0-57.8fde8fef4033376662cad983758335009d522acb
|
||||
prisma: 4.11.0
|
||||
'@prisma/engines-version': 4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7
|
||||
prisma: 4.12.0
|
||||
dev: false
|
||||
|
||||
/@prisma/engines-version@4.11.0-57.8fde8fef4033376662cad983758335009d522acb:
|
||||
resolution: {integrity: sha512-3Vd8Qq06d5xD8Ch5WauWcUUrsVPdMC6Ge8ILji8RFfyhUpqon6qSyGM0apvr1O8n8qH8cKkEFqRPsYjuz5r83g==}
|
||||
/@prisma/engines-version@4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7:
|
||||
resolution: {integrity: sha512-JIHNj5jlXb9mcaJwakM0vpgRYJIAurxTUqM0iX0tfEQA5XLZ9ONkIckkhuAKdAzocZ+80GYg7QSsfpjg7OxbOA==}
|
||||
dev: false
|
||||
|
||||
/@prisma/engines@4.11.0:
|
||||
resolution: {integrity: sha512-0AEBi2HXGV02cf6ASsBPhfsVIbVSDC9nbQed4iiY5eHttW9ZtMxHThuKZE1pnESbr8HRdgmFSa/Kn4OSNYuibg==}
|
||||
/@prisma/engines@4.12.0:
|
||||
resolution: {integrity: sha512-0alKtnxhNB5hYU+ymESBlGI4b9XrGGSdv7Ud+8TE/fBNOEhIud0XQsAR+TrvUZgS4na5czubiMsODw0TUrgkIA==}
|
||||
requiresBuild: true
|
||||
|
||||
/@radix-ui/primitive@1.0.0:
|
||||
|
@ -7084,13 +7084,13 @@ packages:
|
|||
react-is: 18.2.0
|
||||
dev: true
|
||||
|
||||
/prisma@4.11.0:
|
||||
resolution: {integrity: sha512-4zZmBXssPUEiX+GeL0MUq/Yyie4ltiKmGu7jCJFnYMamNrrulTBc+D+QwAQSJ01tyzeGHlD13kOnqPwRipnlNw==}
|
||||
/prisma@4.12.0:
|
||||
resolution: {integrity: sha512-xqVper4mbwl32BWzLpdznHAYvYDWQQWK2tBfXjdUD397XaveRyAP7SkBZ6kFlIg8kKayF4hvuaVtYwXd9BodAg==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@prisma/engines': 4.11.0
|
||||
'@prisma/engines': 4.12.0
|
||||
|
||||
/prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
|
|
16
src/client/components/ui/OtpInput/OptInput.module.scss
Normal file
16
src/client/components/ui/OtpInput/OptInput.module.scss
Normal file
|
@ -0,0 +1,16 @@
|
|||
.otpGroup {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
.otpInput {
|
||||
width: 100%;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
262
src/client/components/ui/OtpInput/OtpInput.test.tsx
Normal file
262
src/client/components/ui/OtpInput/OtpInput.test.tsx
Normal file
|
@ -0,0 +1,262 @@
|
|||
import React from 'react';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { OtpInput } from './OtpInput';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/test-utils';
|
||||
|
||||
describe('<OtpInput />', () => {
|
||||
it('should accept value & valueLength props', () => {
|
||||
// arrange
|
||||
const value = faker.datatype.number({ min: 0, max: 999999 }).toString();
|
||||
const valueArray = value.split('');
|
||||
const valueLength = value.length;
|
||||
render(<OtpInput value={value} valueLength={valueLength} onChange={() => {}} />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
|
||||
// assert
|
||||
expect(inputEls).toHaveLength(valueLength);
|
||||
inputEls.forEach((inputEl, idx) => {
|
||||
expect(inputEl).toHaveValue(valueArray[idx]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow typing of digits', () => {
|
||||
// arrange
|
||||
const valueLength = faker.datatype.number({ min: 2, max: 6 }); // random number from 2-6 (minimum 2 so it can focus on the next input)
|
||||
const onChange = jest.fn();
|
||||
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
|
||||
// assert
|
||||
expect(inputEls).toHaveLength(valueLength);
|
||||
inputEls.forEach((inputEl, idx) => {
|
||||
const digit = faker.datatype.number({ min: 0, max: 9 }).toString(); // random number from 0-9, typing of digits is 1 by 1
|
||||
|
||||
// trigger a change event
|
||||
fireEvent.change(inputEl, {
|
||||
target: { value: digit }, // pass it as the target.value in the event data
|
||||
});
|
||||
|
||||
// custom matcher to check that "onChange" function was called with the same digit
|
||||
expect(onChange).toBeCalledTimes(1);
|
||||
expect(onChange).toBeCalledWith(digit);
|
||||
|
||||
const inputFocused = inputEls[idx + 1] || inputEl;
|
||||
expect(inputFocused).toHaveFocus();
|
||||
onChange.mockReset(); // resets the call times for the next iteration of the loop
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT allow typing of non-digits', () => {
|
||||
// arrange
|
||||
const valueLength = faker.datatype.number({ min: 2, max: 6 });
|
||||
const onChange = jest.fn();
|
||||
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
|
||||
// assert
|
||||
expect(inputEls).toHaveLength(valueLength);
|
||||
|
||||
inputEls.forEach((inputEl) => {
|
||||
const nonDigit = faker.random.alpha(1);
|
||||
|
||||
fireEvent.change(inputEl, {
|
||||
target: { value: nonDigit },
|
||||
});
|
||||
|
||||
expect(onChange).not.toBeCalled();
|
||||
|
||||
onChange.mockReset();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow deleting of digits (focus on previous element)', () => {
|
||||
const value = faker.datatype.number({ min: 10, max: 999999 }).toString(); // minimum 2-digit so it can focus on the previous input
|
||||
const valueLength = value.length;
|
||||
const lastIdx = valueLength - 1;
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
|
||||
expect(inputEls).toHaveLength(valueLength);
|
||||
|
||||
for (let idx = lastIdx; idx > -1; idx -= 1) {
|
||||
// loop backwards to simulate the focus on the previous input
|
||||
const inputEl = inputEls[idx] as HTMLInputElement;
|
||||
const target = { value: '' };
|
||||
|
||||
// trigger both change and keydown event
|
||||
fireEvent.change(inputEl, { target });
|
||||
fireEvent.keyDown(inputEl, {
|
||||
target,
|
||||
key: 'Backspace',
|
||||
});
|
||||
|
||||
const valueArray = value.split('');
|
||||
|
||||
valueArray[idx] = ' '; // the deleted digit is expected to be replaced with a space in the string
|
||||
|
||||
const expectedValue = valueArray.join('');
|
||||
|
||||
expect(onChange).toBeCalledTimes(1);
|
||||
expect(onChange).toBeCalledWith(expectedValue);
|
||||
|
||||
// custom matcher to check that the focus is on the previous input
|
||||
// OR
|
||||
// focus is on the current input if previous input doesn't exist
|
||||
const inputFocused = inputEls[idx - 1] || inputEl;
|
||||
|
||||
expect(inputFocused).toHaveFocus();
|
||||
|
||||
onChange.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow deleting of digits (do NOT focus on previous element)', () => {
|
||||
const value = faker.datatype.number({ min: 10, max: 999999 }).toString();
|
||||
const valueArray = value.split('');
|
||||
const valueLength = value.length;
|
||||
const lastIdx = valueLength - 1;
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
|
||||
expect(inputEls).toHaveLength(valueLength);
|
||||
|
||||
for (let idx = lastIdx; idx > 0; idx -= 1) {
|
||||
// idx > 0, because there's no previous input in index 0
|
||||
const inputEl = inputEls[idx] as HTMLInputElement;
|
||||
|
||||
fireEvent.keyDown(inputEl, {
|
||||
key: 'Backspace',
|
||||
target: { value: valueArray[idx] },
|
||||
});
|
||||
|
||||
const prevInputEl = inputEls[idx - 1];
|
||||
|
||||
expect(prevInputEl).not.toHaveFocus();
|
||||
|
||||
onChange.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT allow deleting of digits in the middle', () => {
|
||||
const value = faker.datatype.number({ min: 100000, max: 999999 }).toString();
|
||||
const valueLength = value.length;
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
const thirdInputEl = inputEls[2] as HTMLInputElement;
|
||||
const target = { value: '' };
|
||||
|
||||
fireEvent.change(thirdInputEl, { target: { value: '' } });
|
||||
fireEvent.keyDown(thirdInputEl, {
|
||||
target,
|
||||
key: 'Backspace',
|
||||
});
|
||||
|
||||
expect(onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should allow pasting of digits (same length as valueLength)', () => {
|
||||
const value = faker.datatype.number({ min: 10, max: 999999 }).toString(); // minimum 2-digit so it is considered as a paste event
|
||||
const valueLength = value.length;
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
|
||||
// get a random input element from the input elements to paste the digits on
|
||||
const randomIdx = faker.datatype.number({ min: 0, max: valueLength - 1 });
|
||||
const randomInputEl = inputEls[randomIdx] as HTMLInputElement;
|
||||
|
||||
fireEvent.change(randomInputEl, { target: { value } });
|
||||
|
||||
expect(onChange).toBeCalledTimes(1);
|
||||
expect(onChange).toBeCalledWith(value);
|
||||
|
||||
expect(randomInputEl).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('should NOT allow pasting of digits (less than valueLength)', () => {
|
||||
const value = faker.datatype.number({ min: 10, max: 99999 }).toString(); // random 2-5 digit code (less than "valueLength")
|
||||
const valueLength = faker.datatype.number({ min: 6, max: 10 }); // random number from 6-10
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
const randomIdx = faker.datatype.number({ min: 0, max: valueLength - 1 });
|
||||
const randomInputEl = inputEls[randomIdx] as HTMLInputElement;
|
||||
|
||||
fireEvent.change(randomInputEl, { target: { value } });
|
||||
|
||||
expect(onChange).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should focus to next element on right/down key', () => {
|
||||
render(<OtpInput valueLength={3} onChange={jest.fn} value="1234" />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
const firstInputEl = inputEls[0] as HTMLInputElement;
|
||||
|
||||
fireEvent.keyDown(firstInputEl, {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
|
||||
expect(inputEls[1]).toHaveFocus();
|
||||
|
||||
const secondInputEl = inputEls[1] as HTMLInputElement;
|
||||
|
||||
fireEvent.keyDown(secondInputEl, {
|
||||
key: 'ArrowDown',
|
||||
});
|
||||
|
||||
expect(inputEls[2]).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should focus to next element on left/up key', () => {
|
||||
render(<OtpInput valueLength={3} onChange={jest.fn} value="1234" />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
const lastInputEl = inputEls[2] as HTMLInputElement;
|
||||
|
||||
fireEvent.keyDown(lastInputEl, {
|
||||
key: 'ArrowLeft',
|
||||
});
|
||||
|
||||
expect(inputEls[1]).toHaveFocus();
|
||||
|
||||
const secondInputEl = inputEls[1] as HTMLInputElement;
|
||||
|
||||
fireEvent.keyDown(secondInputEl, {
|
||||
key: 'ArrowUp',
|
||||
});
|
||||
|
||||
expect(inputEls[0]).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should only focus to input if previous input has value', () => {
|
||||
const valueLength = 6;
|
||||
|
||||
render(<OtpInput valueLength={valueLength} onChange={jest.fn} value="" />);
|
||||
|
||||
const inputEls = screen.queryAllByRole('textbox');
|
||||
const lastInputEl = inputEls[valueLength - 1] as HTMLInputElement;
|
||||
|
||||
lastInputEl.focus();
|
||||
|
||||
const firstInputEl = inputEls[0];
|
||||
|
||||
expect(firstInputEl).toHaveFocus();
|
||||
});
|
||||
});
|
157
src/client/components/ui/OtpInput/OtpInput.tsx
Normal file
157
src/client/components/ui/OtpInput/OtpInput.tsx
Normal file
|
@ -0,0 +1,157 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import classes from './OptInput.module.scss';
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
valueLength: number;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const RE_DIGIT = /^\d+$/;
|
||||
|
||||
export const OtpInput = ({ value, valueLength, onChange, className }: Props) => {
|
||||
const valueItems = useMemo(() => {
|
||||
const valueArray = value.split('');
|
||||
const items: string[] = [];
|
||||
|
||||
for (let i = 0; i < valueLength; i += 1) {
|
||||
const char = valueArray[i];
|
||||
|
||||
if (char && RE_DIGIT.test(char)) {
|
||||
items.push(char);
|
||||
} else {
|
||||
items.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [value, valueLength]);
|
||||
|
||||
const focusToNextInput = (target: HTMLElement) => {
|
||||
const nextElementSibling = target.nextElementSibling as HTMLInputElement | null;
|
||||
|
||||
if (nextElementSibling) {
|
||||
nextElementSibling.focus();
|
||||
}
|
||||
};
|
||||
const focusToPrevInput = (target: HTMLElement) => {
|
||||
const previousElementSibling = target.previousElementSibling as HTMLInputElement | null;
|
||||
|
||||
if (previousElementSibling) {
|
||||
previousElementSibling.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const inputOnChange = (e: React.ChangeEvent<HTMLInputElement>, idx: number) => {
|
||||
const { target } = e;
|
||||
let targetValue = target.value.trim();
|
||||
const isTargetValueDigit = RE_DIGIT.test(targetValue);
|
||||
|
||||
if (!isTargetValueDigit && targetValue !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextInputEl = target.nextElementSibling as HTMLInputElement | null;
|
||||
|
||||
// only delete digit if next input element has no value
|
||||
if (!isTargetValueDigit && nextInputEl && nextInputEl.value !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
targetValue = isTargetValueDigit ? targetValue : ' ';
|
||||
|
||||
const targetValueLength = targetValue.length;
|
||||
|
||||
if (targetValueLength === 1) {
|
||||
const newValue = value.substring(0, idx) + targetValue + value.substring(idx + 1);
|
||||
|
||||
onChange(newValue);
|
||||
|
||||
if (!isTargetValueDigit) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusToNextInput(target);
|
||||
|
||||
const nextElementSibling = target.nextElementSibling as HTMLInputElement | null;
|
||||
|
||||
if (nextElementSibling) {
|
||||
nextElementSibling.focus();
|
||||
}
|
||||
} else if (targetValueLength === valueLength) {
|
||||
onChange(targetValue);
|
||||
|
||||
target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const inputOnFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const { target } = e;
|
||||
|
||||
const prevInputEl = target.previousElementSibling as HTMLInputElement | null;
|
||||
|
||||
if (prevInputEl && prevInputEl.value === '') {
|
||||
return prevInputEl.focus();
|
||||
}
|
||||
|
||||
target.setSelectionRange(0, target.value.length);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const inputOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { key } = e;
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (key === 'ArrowRight' || key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
return focusToNextInput(target);
|
||||
}
|
||||
|
||||
if (key === 'ArrowLeft' || key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
return focusToPrevInput(target);
|
||||
}
|
||||
|
||||
const targetValue = target.value;
|
||||
target.setSelectionRange(0, targetValue.length);
|
||||
|
||||
if (e.key !== 'Backspace' || target.value !== '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
focusToPrevInput(target);
|
||||
|
||||
const previousElementSibling = target.previousElementSibling as HTMLInputElement | null;
|
||||
|
||||
if (previousElementSibling) {
|
||||
previousElementSibling.focus();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.otpGroup}>
|
||||
{valueItems.map((digit, idx) => (
|
||||
<input
|
||||
aria-label={`digit-${idx}`}
|
||||
onChange={(e) => inputOnChange(e, idx)}
|
||||
onKeyDown={inputOnKeyDown}
|
||||
onFocus={inputOnFocus}
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={idx}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="\d{1}"
|
||||
maxLength={valueLength}
|
||||
className={clsx('form-control', classes.otpInput, className)}
|
||||
value={digit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
1
src/client/components/ui/OtpInput/index.ts
Normal file
1
src/client/components/ui/OtpInput/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { OtpInput } from './OtpInput';
|
Loading…
Add table
Reference in a new issue