Browse Source

feat: create OtpInput component

Nicolas Meienberger 2 years ago
parent
commit
866bee4491

+ 2 - 2
package.json

@@ -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",

+ 15 - 15
pnpm-lock.yaml

@@ -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 - 0
src/client/components/ui/OtpInput/OptInput.module.scss

@@ -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 - 0
src/client/components/ui/OtpInput/OtpInput.test.tsx

@@ -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 - 0
src/client/components/ui/OtpInput/OtpInput.tsx

@@ -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 - 0
src/client/components/ui/OtpInput/index.ts

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