From 949beef09a6a717c7015faa5c43355fca6623ef7 Mon Sep 17 00:00:00 2001 From: Anday <48630069+anday013@users.noreply.github.com> Date: Sun, 8 Dec 2024 23:29:17 +0100 Subject: [PATCH] feat: implement placeholder (#86) * feat: add basic logic for placeholder * test: cover placeholder * docs: add `placeholder` and `placeholderTextStyle` props * test: add missing suites for placeholder --- README.MD | 24 +++--- package-lock.json | 14 +-- src/OtpInput/OtpInput.tsx | 13 ++- src/OtpInput/OtpInput.types.ts | 2 + src/OtpInput/__tests__/OtpInput.test.tsx | 85 ++++++++++++++++++- .../__snapshots__/OtpInput.test.tsx.snap | 15 ++++ src/OtpInput/__tests__/useOtpInput.test.ts | 79 ++++++++++++++++- src/OtpInput/useOtpInput.ts | 25 ++++-- 8 files changed, 227 insertions(+), 30 deletions(-) diff --git a/README.MD b/README.MD index 66d7192..0b53986 100644 --- a/README.MD +++ b/README.MD @@ -83,14 +83,15 @@ The `react-native-otp-entry` component accepts the following props: | Prop | Type | Description | | ---------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `numberOfDigits` | number | The number of digits to be displayed in the OTP entry. | -| `textInputProps` | TextInputProps | Extra props passed to underlying hidden TextInput (see: https://reactnative.dev/docs/textinput) | +| `theme` | Theme | Custom styles for each element. (See below) | +| `textInputProps` | TextInputProps | Extra props passed to underlying hidden TextInput (see: ) | | `autoFocus` | boolean | _Default: true_. Sets autofocus. | | `focusColor` | ColorValue | The color of the input field border and stick when it is focused. | +| `placeholder` | string | Placeholder value to the input. | | `onTextChange` | (text: string) => void | A callback function is invoked when the OTP text changes. It receives the updated text as an argument. | | `onFilled` | (text: string) => void | A callback function is invoked when the OTP input is fully filled. It receives a full otp code as an argument. | | `blurOnFilled` | boolean | _Default: false_. Blurs (unfocuses) the input when the OTP input is fully filled. | | `hideStick` | boolean | _Default: false_. Hides cursor of the focused input. | -| `theme` | Theme | Custom styles for each element. | | `focusStickBlinkingDuration` | number | The duration (in milliseconds) for the focus stick to blink. | | `disabled` | boolean | _Default: false_. Disables the input | | `type` | 'alpha' \| 'numeric' \| 'alphanumeric' | The type of input. 'alpha': letters only, 'numeric': numbers only, 'alphanumeric': letters or numbers. | @@ -98,15 +99,16 @@ The `react-native-otp-entry` component accepts the following props: | `onFocus` | () => void | A callback function is invoked when the OTP input is focused. | | `onBlur` | () => void | A callback function is invoked when the OTP input is blurred. | -| Theme | Type | Description | -| ------------------------------- | --------- | ---------------------------------------------------------------------------------- | -| `containerStyle` | ViewStyle | Custom styles for the root `View`. | -| `pinCodeContainerStyle` | ViewStyle | Custom styles for the container that wraps each individual digit in the OTP entry. | -| `pinCodeTextStyle` | TextStyle | Custom styles for the text within each individual digit in the OTP entry. | -| `focusStickStyle` | ViewStyle | Custom styles for the focus stick, which indicates the focused input field. | -| `focusedPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is focused. | -| `filledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it has a value. | -| `disabledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is disabled. | +| Theme | Type | Description | +| ------------------------------- | --------- | ------------------------------------------------------------------------------------- | +| `containerStyle` | ViewStyle | Custom styles for the root `View`. | +| `pinCodeContainerStyle` | ViewStyle | Custom styles for the container that wraps each individual digit in the OTP entry. | +| `pinCodeTextStyle` | TextStyle | Custom styles for the text within each individual digit in the OTP entry. | +| `placeholderTextStyle` | TextStyle | Custom styles for the placeholder text within each individual digit in the OTP entry. | +| `focusStickStyle` | ViewStyle | Custom styles for the focus stick, which indicates the focused input field. | +| `focusedPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is focused. | +| `filledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it has a value. | +| `disabledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is disabled. | **Note:** The `ViewStyle` and `TextStyle` types are imported from `react-native` and represent the style objects used in React Native for views and text, respectively. diff --git a/package-lock.json b/package-lock.json index f87c034..2501137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4379,9 +4379,10 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "license": "MIT", "peer": true, "dependencies": { "nice-try": "^1.0.4", @@ -5497,10 +5498,11 @@ } }, "node_modules/jest-changed-files/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", diff --git a/src/OtpInput/OtpInput.tsx b/src/OtpInput/OtpInput.tsx index e17287f..0e7d31e 100644 --- a/src/OtpInput/OtpInput.tsx +++ b/src/OtpInput/OtpInput.tsx @@ -8,7 +8,7 @@ import { useOtpInput } from "./useOtpInput"; export const OtpInput = forwardRef((props, ref) => { const { - models: { text, inputRef, focusedInputIndex, isFocused }, + models: { text, inputRef, focusedInputIndex, isFocused, isPlaceholderActive }, actions: { clear, handlePress, handleTextChange, focus, handleFocus, handleBlur }, forms: { setTextWithRef }, } = useOtpInput(props); @@ -23,6 +23,7 @@ export const OtpInput = forwardRef((props, ref) => { theme = {}, textInputProps, type = "numeric", + placeholder, } = props; const { containerStyle, @@ -33,6 +34,7 @@ export const OtpInput = forwardRef((props, ref) => { focusedPinCodeContainerStyle, filledPinCodeContainerStyle, disabledPinCodeContainerStyle, + placeholderTextStyle, } = theme; useImperativeHandle(ref, () => ({ clear, focus, setValue: setTextWithRef })); @@ -58,12 +60,17 @@ export const OtpInput = forwardRef((props, ref) => { return stylesArray; }; + const placeholderStyle = { + opacity: isPlaceholderActive ? 0.5 : pinCodeTextStyle?.opacity || 1, + ...(isPlaceholderActive ? placeholderTextStyle : []), + }; + return ( {Array(numberOfDigits) .fill(0) .map((_, index) => { - const char = text[index]; + const char = isPlaceholderActive ? placeholder?.[index] || " " : text[index]; const isFocusedInput = index === focusedInputIndex && !disabled && Boolean(isFocused); const isFilledLastInput = text.length === numberOfDigits && index === text.length - 1; const isFocusedContainer = isFocusedInput || (isFilledLastInput && Boolean(isFocused)); @@ -83,7 +90,7 @@ export const OtpInput = forwardRef((props, ref) => { focusStickBlinkingDuration={focusStickBlinkingDuration} /> ) : ( - + {char && secureTextEntry ? "•" : char} )} diff --git a/src/OtpInput/OtpInput.types.ts b/src/OtpInput/OtpInput.types.ts index 2e43bcd..a1fa77f 100644 --- a/src/OtpInput/OtpInput.types.ts +++ b/src/OtpInput/OtpInput.types.ts @@ -16,6 +16,7 @@ export interface OtpInputProps { disabled?: boolean; textInputProps?: TextInputProps; type?: "alpha" | "numeric" | "alphanumeric"; + placeholder?: string; } export interface OtpInputRef { @@ -36,4 +37,5 @@ export interface Theme { focusStickStyle?: ViewStyle; focusedPinCodeContainerStyle?: ViewStyle; disabledPinCodeContainerStyle?: ViewStyle; + placeholderTextStyle?: TextStyle; } diff --git a/src/OtpInput/__tests__/OtpInput.test.tsx b/src/OtpInput/__tests__/OtpInput.test.tsx index 3a6f38b..cb8ab3c 100644 --- a/src/OtpInput/__tests__/OtpInput.test.tsx +++ b/src/OtpInput/__tests__/OtpInput.test.tsx @@ -1,10 +1,17 @@ -import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native"; import * as React from "react"; -import { Platform } from "react-native"; +import { Platform, TextInput } from "react-native"; import { OtpInput } from "../OtpInput"; import { OtpInputProps, OtpInputRef } from "../OtpInput.types"; const renderOtpInput = (props?: Partial) => render(); +const renderOtpInputWithExtraInput = (props?: Partial) => + render( + <> + + + + ); describe("OtpInput", () => { describe("UI", () => { @@ -257,4 +264,78 @@ describe("OtpInput", () => { expect(screen.queryByText("6")).toBeFalsy(); }); }); + describe("Placeholder", () => { + test("should show placeholder if text is empty", () => { + renderOtpInput({ placeholder: "000000" }); + + const inputs = screen.getAllByTestId("otp-input"); + inputs.forEach((input) => { + waitFor(() => expect(input).toHaveTextContent("0")); + }); + }); + + test("should hide placeholder if text is not empty", () => { + renderOtpInput({ placeholder: "000000" }); + + const input = screen.getByTestId("otp-input-hidden"); + fireEvent.changeText(input, "123456"); + + const placeholder = screen.queryByText("000000"); + + expect(placeholder).toBeFalsy(); + }); + + test("should hide placeholder if input is focused", () => { + renderOtpInput({ placeholder: "000000" }); + + const input = screen.getByTestId("otp-input-hidden"); + fireEvent.press(input); + + const placeholder = screen.queryByText("000000"); + + expect(placeholder).toBeFalsy(); + }); + + test("should show placeholder if input is blurred and text is empty", () => { + renderOtpInputWithExtraInput({ placeholder: "000000" }); + + const input = screen.getByTestId("otp-input-hidden"); + const otherInput = screen.getByTestId("other-input"); + fireEvent.press(input); + // Blur the input + fireEvent.press(otherInput); + + const inputs = screen.getAllByTestId("otp-input"); + inputs.forEach((input) => { + waitFor(() => expect(input).toHaveTextContent("0")); + }); + }); + + test("should hide placeholder if input is blurred and text is not empty", () => { + renderOtpInputWithExtraInput({ placeholder: "000000" }); + + const input = screen.getByTestId("otp-input-hidden"); + const otherInput = screen.getByTestId("other-input"); + fireEvent.press(input); + fireEvent.changeText(input, "123456"); + // Blur the input + fireEvent.press(otherInput); + + const placeholder = screen.queryByText("000000"); + + expect(placeholder).toBeFalsy(); + }); + + test('should leave empty spaces if "placeholder" is shorter than "numberOfDigits"', () => { + renderOtpInput({ placeholder: "123" }); + + const inputs = screen.getAllByTestId("otp-input"); + waitFor(() => inputs[0].toHaveTextContent("1")); + waitFor(() => expect(inputs[1]).toHaveTextContent("2")); + waitFor(() => expect(inputs[2]).toHaveTextContent("3")); + waitFor(() => expect(inputs[3]).toHaveTextContent(" ")); + waitFor(() => expect(inputs[4]).toHaveTextContent(" ")); + waitFor(() => expect(inputs[5]).toHaveTextContent(" ")); + }); + }); }); diff --git a/src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap b/src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap index 4c9815e..2e90307 100644 --- a/src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap +++ b/src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap @@ -142,6 +142,9 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, + { + "opacity": 1, + }, ] } /> @@ -199,6 +202,9 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, + { + "opacity": 1, + }, ] } /> @@ -256,6 +262,9 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, + { + "opacity": 1, + }, ] } /> @@ -313,6 +322,9 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, + { + "opacity": 1, + }, ] } /> @@ -370,6 +382,9 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, + { + "opacity": 1, + }, ] } /> diff --git a/src/OtpInput/__tests__/useOtpInput.test.ts b/src/OtpInput/__tests__/useOtpInput.test.ts index 6669699..b482fdd 100644 --- a/src/OtpInput/__tests__/useOtpInput.test.ts +++ b/src/OtpInput/__tests__/useOtpInput.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook } from "@testing-library/react-native"; +import { act, renderHook, waitFor } from "@testing-library/react-native"; import * as React from "react"; import { Keyboard } from "react-native"; import { OtpInputProps } from "../OtpInput.types"; @@ -10,6 +10,7 @@ const renderUseOtInput = (props?: Partial) => describe("useOtpInput", () => { afterEach(() => { jest.clearAllMocks(); + jest.clearAllTimers(); }); test("should return models as defined", () => { @@ -296,4 +297,80 @@ describe("useOtpInput", () => { expect(result.current.models.inputRef.current?.blur).not.toHaveBeenCalled(); }); }); + + describe("Placeholder", () => { + test("should call setIsPlaceholderActive with `true`", () => { + const mockSetState = jest.fn(); + jest.spyOn(React, "useState").mockImplementation(() => [false, mockSetState]); + + renderUseOtInput({ placeholder: "00000000" }); + + waitFor(() => { + expect(mockSetState).toBeCalledWith(true); + }); + }); + + test("should set isPlaceholderActive to 'true' when placeholder is provided and input is not focused and text is empty", () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + + waitFor(() => { + expect(result.current.models.isPlaceholderActive).toBe(true); + }); + }); + + test("should set isPlaceholderActive to 'true' when placeholder is provided and text is empty", () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + result.current.actions.handleFocus(); + result.current.actions.handleBlur(); + + waitFor(() => { + expect(result.current.models.isPlaceholderActive).toBe(true); + }); + }); + + test("should set isPlaceholderActive to 'false' when placeholder is provided and input is focused", () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + result.current.actions.handleFocus(); + waitFor(() => { + expect(result.current.models.isPlaceholderActive).toBe(false); + }); + }); + + test("should set isPlaceholderActive to 'false' when placeholder is provided and text is not empty", () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + result.current.actions.handleTextChange("123456"); + waitFor(() => { + expect(result.current.models.isPlaceholderActive).toBe(false); + }); + }); + + test("should set isPlaceholderActive to 'false' when placeholder is provided and input is focused and text is not empty", async () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + result.current.actions.handleTextChange("123456"); + result.current.actions.handleFocus(); + waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(false)); + }); + + test("should set isPlaceholderActive to 'false' when placeholder is provided and input is not focused and text is not empty", async () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + result.current.actions.handleTextChange("123456"); + result.current.actions.handleBlur(); + waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(false)); + }); + + test("should set isPlaceholderActive to 'true' when placeholder is provided and input is focused and text is empty", async () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + result.current.actions.handleFocus(); + result.current.actions.handleBlur(); + waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(true)); + }); + + test("should set isPlaceholderActive to 'true' when placeholder is provided and input is not focused and text is empty", async () => { + const { result } = renderUseOtInput({ placeholder: "00000000" }); + result.current.actions.handleTextChange("123456"); + result.current.actions.handleTextChange(""); + result.current.actions.handleBlur(); + waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(true)); + }); + }); }); diff --git a/src/OtpInput/useOtpInput.ts b/src/OtpInput/useOtpInput.ts index eed86a8..12de719 100644 --- a/src/OtpInput/useOtpInput.ts +++ b/src/OtpInput/useOtpInput.ts @@ -1,7 +1,13 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Keyboard, TextInput } from "react-native"; import { OtpInputProps } from "./OtpInput.types"; +const regexMap = { + alpha: /[^a-zA-Z]/, + numeric: /[^\d]/, + alphanumeric: /[^a-zA-Z\d]/, +}; + export const useOtpInput = ({ onTextChange, onFilled, @@ -12,16 +18,21 @@ export const useOtpInput = ({ type, onFocus, onBlur, + placeholder, }: OtpInputProps) => { const [text, setText] = useState(""); + const [isPlaceholderActive, setIsPlaceholderActive] = useState(!!placeholder && !text); const [isFocused, setIsFocused] = useState(autoFocus); const inputRef = useRef(null); const focusedInputIndex = text.length; - const regexMap = { - alpha: /[^a-zA-Z]/, - numeric: /[^\d]/, - alphanumeric: /[^a-zA-Z\d]/, - }; + + useEffect(() => { + if (placeholder && !isFocused && !text) { + setIsPlaceholderActive(true); + } else { + setIsPlaceholderActive(false); + } + }, [placeholder, isFocused, text]); const handlePress = () => { // To fix bug when keyboard is not popping up after being dismissed @@ -66,7 +77,7 @@ export const useOtpInput = ({ }; return { - models: { text, inputRef, focusedInputIndex, isFocused }, + models: { text, inputRef, focusedInputIndex, isFocused, isPlaceholderActive }, actions: { handlePress, handleTextChange, clear, focus, handleFocus, handleBlur }, forms: { setText, setTextWithRef }, };