feat: create OtpInput component

This commit is contained in:
Nicolas Meienberger 2023-04-07 15:06:15 +02:00
parent 1cc8d3f868
commit 6712ac4608
6 changed files with 453 additions and 17 deletions

View file

@ -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
View file

@ -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==}

View 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;
}

View 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();
});
});

View 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>
);
};

View file

@ -0,0 +1 @@
export { OtpInput } from './OtpInput';