diff --git a/README.md b/README.md index 238e29b..5379543 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ A headless UI for building easy to use passcode component. -*What is an passcode component?* +_What is an passcode component?_ It is a group of input elements with each element only accepting one character. This component is generally used in authentication flows. -* [Installation](#installation) -* [Usage](#usage) -* [Features](#features) -* [API](#api) -* [License](#license) +- [Installation](#installation) +- [Usage](#usage) +- [Features](#features) +- [API](#api) +- [License](#license) ## Installation @@ -19,80 +19,81 @@ It is a group of input elements with each element only accepting one character. yarn add react-headless-passcode ``` - ## Usage ```tsx import { usePasscode } from "react-headless-passcode"; ``` -With the `usePasscode` hook you just need to pass the `arrayValue` default property and in return you get the `array` in which the actual passcode value is stored, various event hanlders that handles the focus management between multiple inputs and `refs` that references each input element. +With the `usePasscode` hook you just need to pass the `count` property and in return you get the `array` in which the actual passcode value is stored, various event hanlders that handles the focus management logic between multiple inputs and `refs` that references each input element. For example: ```tsx const PasscodeComponent = () => { - const { array, getEventHandlers, refs } = usePasscode({ - arrayValue: [0, 0, 0, 0, 0, 0], - }); - - return ( - <> - {array.map((value, index) => { - const { ...rest } = getEventHandlers(index); - return ( - el && (refs.current[index] = el)} - type="text" - inputMode="numeric" - autoComplete="one-time-code" - maxLength={1} - pattern="\d{1}" - value={String(value)} - key={`index-${index}`} - {...rest} - /> - ); - })} - - ); + const { array, getEventHandlers, refs } = usePasscode({ + count: 4, + }); + + return ( + <> + {array.map((value, index) => { + const { ...rest } = getEventHandlers(index); + return ( + el && (refs.current[index] = el)} + type="text" + inputMode="numeric" + autoComplete="one-time-code" + maxLength={1} + pattern="\d{1}" + value={String(value)} + key={`index-${index}`} + {...rest} + /> + ); + })} + + ); }; - ``` ->**NOTE:** +> **NOTE:** > It is important to initialize the `refs` object with the current input element because this is how the `usepasscode` is able to track the current index and manage the focused state across multiple inputs. Make sure to assign this element to the `refs` or else the focus won't change!! + ```tsx ref={(el) => el && (refs.current[index] = el)} ``` ## Features -- Allow entering alpha numeric characters -- Expose a flag: `isComplete` that tells whether all the input boxes are filled or not -- Expose a state variable: `currentFocusedIndex`. It tells us the currently focused index of the passcode component. -- Exposes event handlers that can be seamlessly used with the input element. -- Passcode value can be pasted partially, fully, from start, or from middle. + +- Allow entering alpha numeric characters +- Expose a flag: `isComplete` that tells whether all the input boxes are filled or not +- Expose a state variable: `currentFocusedIndex`. It tells us the currently focused index of the passcode component. +- Exposes event handlers that can be seamlessly used with the input element. +- Passcode value can be pasted partially, fully, from start, or from middle. ## API The `usePasscode` hook accepts following props -| Prop Name | Type | Description | -|---------------- |------------------------ |----------------------------------------------------------------------- | -| arrayValue | `(number \| string)[]` | Default array value that helps to determine the size of the component | -| isAlphaNumeric | `boolean` | If `true`, allows to enter alpha numeric value in the component | +| Prop Name | Type | Description | +|---------------- |------------------------ |----------------------------------------------------------------------- | +| count | `number` | Number of input boxes to create in the passcode component | +| isAlphaNumeric | `boolean` | If `true`, allows to enter alpha numeric value in the component | The hook returns an object that consists of: -| Property | Type | Description | -|------------------------ |------------------------ |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| array | `(string \| number)[]` | The current array value of the entire component. | -| setArray | `function` | A function that sets the internal state variable:`array`'s value inside the hook. | -| currentFocusedIndex | `number` | Index of the currently focused input element. | -| setCurrentFocusedIndex | `function` | A function that sets the internal state variable: `currentFocusedIndex`'s value inside the hook. | -| getEventHandler | `function` | A function that accepts an index as a parameter. It returns the following event handlers for the input positioned at index `i`: `onChange` `onFocus` `onKeyUp` `onKeyDown` | -| refs | `React.MutableRefObject` | A ref array that contains reference of all the input boxes. | -| isComplete | `boolean` | A boolean flag that tells if all the input boxes are filled or not. | +| Property | Type | Description | +| ---------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| passcode | `(string \| number)[]` | The current array value of the entire component. | +| setPasscode | `function` | A function that sets the internal state variable:`passcode`'s value inside the hook. | +| currentFocusedIndex | `number` | Index of the currently focused input element. | +| setCurrentFocusedIndex | `function` | A function that sets the internal state variable: `currentFocusedIndex`'s value inside the hook. | +| getEventHandler | `function` | A function that accepts an index as a parameter. It returns the following event handlers for the input positioned at index `i`: `onChange` `onFocus` `onKeyUp` `onKeyDown` `onPaste` | +| refs | `React.MutableRefObject` | A ref array that contains reference of all the input boxes. | +| isComplete | `boolean` | A boolean flag that tells if all the input boxes are filled or not. | ## License -React is [MIT licensed](./LICENSE). \ No newline at end of file + +React is [MIT licensed](./LICENSE). diff --git a/package/lib/hook/usePasscode.test.tsx b/package/lib/hook/usePasscode.test.tsx index 868f0bd..7bafe19 100644 --- a/package/lib/hook/usePasscode.test.tsx +++ b/package/lib/hook/usePasscode.test.tsx @@ -4,14 +4,14 @@ import userEvent from "@testing-library/user-event"; import usePasscode from "./usePasscode"; const TestComponent = (props: { isAlphaNumeric: boolean }) => { - const { array, getEventHandlers, refs } = usePasscode({ + const { passcode, getEventHandlers, refs } = usePasscode({ count: 4, isAlphaNumeric: props.isAlphaNumeric, }); return ( <> - {array.map((value: string | number, index: number) => ( + {passcode.map((value: string | number, index: number) => ( el && (refs.current[index] = el)} type="text" @@ -30,9 +30,9 @@ const TestComponent = (props: { isAlphaNumeric: boolean }) => { }; describe("test basic workflow", () => { - it("1. test whether passing no. of inputs creates an array of equal number ", () => { - const { result } = renderHook(() => usePasscode({ count: 4 })); - expect(result.current.array).toHaveLength(4); + it("1. test whether passing count prop creates an array(input elements) with size count", () => { + render(); + expect(screen.getAllByTestId(/index-[0-9]/)).toHaveLength(4); }); it("2. test if the focus changes to next element when typed", async () => { diff --git a/package/lib/hook/usePasscode.ts b/package/lib/hook/usePasscode.ts index ce1f775..869982c 100644 --- a/package/lib/hook/usePasscode.ts +++ b/package/lib/hook/usePasscode.ts @@ -1,7 +1,6 @@ import { BaseSyntheticEvent, KeyboardEvent, - useEffect, useRef, useState, useMemo, @@ -22,11 +21,13 @@ type PasscodeProps = { const usePasscode = (props: PasscodeProps) => { const { count, isAlphaNumeric = false } = props; const filledArray = useMemo(() => Array(count).fill("", 0, count), [count]); - const [array, setArray] = useState(filledArray); + const [passcode, setPasscode] = useState(filledArray); const [currentFocusedIndex, setCurrentFocusedIndex] = useState(0); const inputRefs = useRef | []>([]); - const isComplete = array?.every((value: string | number) => value !== ""); + const isComplete = passcode?.every( + (value: string | number) => value !== "" + ); /** * A function that returns the necessary event handlers based on index. @@ -34,7 +35,7 @@ const usePasscode = (props: PasscodeProps) => { const getEventHandlers = (index: number) => { const onChange = (e: BaseSyntheticEvent) => { // Change the arrayValue and update only when number key is pressed - setArray((preValue: (string | number)[]) => { + setPasscode((preValue: (string | number)[]) => { const newArray = [...preValue]; if (parseInt(e.target.value)) { @@ -49,7 +50,6 @@ const usePasscode = (props: PasscodeProps) => { const onFocus = (e: BaseSyntheticEvent) => { setCurrentFocusedIndex(index); - e.target.focus(); }; const onKeyUp = (e: KeyboardEvent) => { @@ -70,13 +70,13 @@ const usePasscode = (props: PasscodeProps) => { /** * Update focus only when number key is pressed * We do a -2 below because we don't want the last input to update the currentFocusedIndex - * If we allow it then we get array out of bound error. + * If we allow it then we get passcode out of bound error. * */ if ( (isAlphaNumeric ? ALPHANUMERIC_REGEX.test(e.key) : parseInt(e.key)) && - index <= array.length - 2 + index <= passcode.length - 2 ) { setCurrentFocusedIndex(index + 1); if ( @@ -92,21 +92,12 @@ const usePasscode = (props: PasscodeProps) => { // Preventing typing of any other keys except for 1 to 9 And backspace const onKeyDown = (e: KeyboardEvent) => { - if (shouldPreventDefault(e.which, isAlphaNumeric, e.metaKey)) { + if (shouldPreventDefault(e.key, isAlphaNumeric, e.metaKey)) { e.preventDefault(); } }; - return { - onKeyUp, - onKeyDown, - onFocus, - onChange, - }; - }; - - useEffect(() => { - document.addEventListener("paste", async () => { + const onPaste = async (e: BaseSyntheticEvent) => { const copyPermission = await getClipboardReadPermission(); if (copyPermission.state === "denied") { throw new Error("Not allowed to read clipboard."); @@ -114,7 +105,7 @@ const usePasscode = (props: PasscodeProps) => { const clipboardContent = await getClipboardContent(); try { - // We convert the clipboard conent into an array of string or number depending upon isAlphaNumeric; + // We convert the clipboard conent into an passcode of string or number depending upon isAlphaNumeric; let newArray: Array = clipboardContent.split(""); newArray = isAlphaNumeric @@ -125,38 +116,41 @@ const usePasscode = (props: PasscodeProps) => { * Pasting of this content is stopped when the last input is reached. **/ const filledArray = getFilledArray( - array, + passcode, newArray, currentFocusedIndex ); - setArray(filledArray); - // Below we update the current focused index and also focus to the last input + setPasscode(filledArray); + + const newFocusedIndex = currentFocusedIndex + newArray.length; if ( - newArray.length < array.length && - currentFocusedIndex === 0 + newFocusedIndex >= 0 && + newFocusedIndex < passcode.length - 1 ) { - setCurrentFocusedIndex(newArray.length - 1); - inputRefs.current[newArray.length - 1].focus(); + setCurrentFocusedIndex(newFocusedIndex); + inputRefs.current[newFocusedIndex].focus(); } else { - setCurrentFocusedIndex(array.length - 1); - inputRefs.current[array.length - 1].focus(); + setCurrentFocusedIndex(passcode.length - 1); + inputRefs.current[passcode.length - 1].focus(); } } catch (err) { console.error(err); } - }); + }; - return () => { - document.removeEventListener("paste", () => - console.log("Removed paste listner") - ); + return { + onKeyUp, + onKeyDown, + onFocus, + onChange, + onPaste, }; - }, [currentFocusedIndex, array, isAlphaNumeric]); + }; return { - array, - setArray, + passcode, + setPasscode, currentFocusedIndex, setCurrentFocusedIndex, getEventHandlers, diff --git a/package/lib/utils/index.ts b/package/lib/utils/index.ts index cf1cc93..17c5698 100644 --- a/package/lib/utils/index.ts +++ b/package/lib/utils/index.ts @@ -1,43 +1,37 @@ export const ALPHANUMERIC_REGEX = /^[a-z0-9]$/i; export const shouldPreventDefault = ( - keyCode: number, - isAlphaNumeric: boolean = false, - isMeta: boolean = false + key: string, + isAlphaNumeric: boolean = false, + isMeta: boolean = false ) => { - const isAlphabet = keyCode >= 64 && keyCode <= 90; + const parsed = Number(key); - // Below flag also checks if the typeed key is from numpad - const isNumeric = - (keyCode >= 48 && keyCode <= 57) || (keyCode >= 96 && keyCode <= 105); + // By default we only allow numbers to be pressed = DONE + if (parsed) return false; - //Crtl + v: - if (isMeta && keyCode === 86) { - return false; - } + // Crtl + V + if (isMeta && key === "v") return false; - // By default we only allow numbers to be pressed - if (isNumeric) return false; + // Allow Backspace + if (key === "Backspace") return false; - // We only allow alphabets to be pressed when the isAplhaNumeric flag is true - if (isAlphabet && isAlphaNumeric) return false; - - // Backspace - if (keyCode === 8) { - return false; - } + // We only allow alphabets to be pressed when the isAplhaNumeric flag is true = DONE + if (isAlphaNumeric && isNaN(parsed)) { + return false; + } - return true; + return true; }; export const getClipboardReadPermission = () => { - return navigator.permissions.query({ - name: "clipboard-read" as PermissionName, - }); + return navigator.permissions.query({ + name: "clipboard-read" as PermissionName, + }); }; export const getClipboardContent = () => { - return navigator.clipboard.readText(); + return navigator.clipboard.readText(); }; /** @@ -48,19 +42,19 @@ export const getClipboardContent = () => { * The array before the focused index will be filled with existing values. */ export const getFilledArray = ( - arr: (number | string)[], - pastingArr: (number | string)[], - currentFocusedIndex: number + arr: (number | string)[], + pastingArr: (number | string)[], + currentFocusedIndex: number ) => { - const lastIndex = arr.length - 1; - - if (currentFocusedIndex > 0) { - for (let i = currentFocusedIndex; i <= lastIndex; i++) { - arr[i] = pastingArr[i - currentFocusedIndex] ?? ""; + const lastIndex = arr.length - 1; + + if (currentFocusedIndex > 0) { + for (let i = currentFocusedIndex; i <= lastIndex; i++) { + arr[i] = pastingArr[i - currentFocusedIndex] ?? ""; + } + return arr; + } else { + // Starts pasting the values in the array from 0th index + return [...pastingArr, ...arr.slice(pastingArr.length - 1, lastIndex)]; } - return arr; - } else { - // Starts pasting the values in the array from 0th index - return [...pastingArr, ...arr.slice(pastingArr.length - 1, lastIndex)]; - } }; diff --git a/package/lib/utils/utils.test.ts b/package/lib/utils/utils.test.ts new file mode 100644 index 0000000..acc589f --- /dev/null +++ b/package/lib/utils/utils.test.ts @@ -0,0 +1,31 @@ +import { shouldPreventDefault } from "./index"; + +describe("test shouldPreventDefault", () => { + test("1. Should return false if key that is pressed is: Ctrl + V, Backspace, and digits 1-9 when isAlphaNumeric = false", () => { + let key = "1"; + expect(shouldPreventDefault(key)).toBeFalsy(); + + key = "v"; + expect(shouldPreventDefault(key, false, true)).toBeFalsy(); + + key = "Backspace"; + expect(shouldPreventDefault(key)).toBeFalsy(); + + key = "a"; + expect(shouldPreventDefault(key)).toBeTruthy(); + }); + + test("2. Should return false if key that is pressed is: Ctrl + V, Backspace, and digits 1-9 when isAlphaNumeric = true", () => { + let key = "1"; + expect(shouldPreventDefault(key, true)).toBeFalsy(); + + key = "v"; // Ctrl + v + expect(shouldPreventDefault(key, true, true)).toBeFalsy(); + + key = "Backspace"; + expect(shouldPreventDefault(key, true)).toBeFalsy(); + + key = "a"; + expect(shouldPreventDefault(key, true)).toBeFalsy(); + }); +});