From 6d549b5860d50ea704393ba184e01726fb21826f Mon Sep 17 00:00:00 2001 From: Anday <48630069+anday013@users.noreply.github.com> Date: Sat, 21 Dec 2024 01:29:14 +0100 Subject: [PATCH] feat: placeholder disappears as you type (#89) --- src/OtpInput/OtpInput.tsx | 18 +++-- src/OtpInput/__tests__/OtpInput.test.tsx | 72 ++++++++++++----- .../__snapshots__/OtpInput.test.tsx.snap | 20 ++--- src/OtpInput/__tests__/useOtpInput.test.ts | 78 ++----------------- src/OtpInput/useOtpInput.ts | 19 ++--- 5 files changed, 83 insertions(+), 124 deletions(-) diff --git a/src/OtpInput/OtpInput.tsx b/src/OtpInput/OtpInput.tsx index 0e7d31e..66415ad 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, isPlaceholderActive }, + models: { text, inputRef, focusedInputIndex, isFocused, placeholder }, actions: { clear, handlePress, handleTextChange, focus, handleFocus, handleBlur }, forms: { setTextWithRef }, } = useOtpInput(props); @@ -23,7 +23,6 @@ export const OtpInput = forwardRef((props, ref) => { theme = {}, textInputProps, type = "numeric", - placeholder, } = props; const { containerStyle, @@ -61,8 +60,8 @@ export const OtpInput = forwardRef((props, ref) => { }; const placeholderStyle = { - opacity: isPlaceholderActive ? 0.5 : pinCodeTextStyle?.opacity || 1, - ...(isPlaceholderActive ? placeholderTextStyle : []), + opacity: !!placeholder ? 0.5 : pinCodeTextStyle?.opacity || 1, + ...(!!placeholder ? placeholderTextStyle : []), }; return ( @@ -70,7 +69,8 @@ export const OtpInput = forwardRef((props, ref) => { {Array(numberOfDigits) .fill(0) .map((_, index) => { - const char = isPlaceholderActive ? placeholder?.[index] || " " : text[index]; + const isPlaceholderCell = !!placeholder && !text?.[index]; + const char = isPlaceholderCell ? 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)); @@ -90,7 +90,13 @@ export const OtpInput = forwardRef((props, ref) => { focusStickBlinkingDuration={focusStickBlinkingDuration} /> ) : ( - + {char && secureTextEntry ? "•" : char} )} diff --git a/src/OtpInput/__tests__/OtpInput.test.tsx b/src/OtpInput/__tests__/OtpInput.test.tsx index cb8ab3c..be85e72 100644 --- a/src/OtpInput/__tests__/OtpInput.test.tsx +++ b/src/OtpInput/__tests__/OtpInput.test.tsx @@ -265,13 +265,43 @@ describe("OtpInput", () => { }); }); describe("Placeholder", () => { - test("should show placeholder if text is empty", () => { - renderOtpInput({ placeholder: "000000" }); + test("should cover the whole input if placeholder is set with one char", async () => { + renderOtpInput({ placeholder: "0", hideStick: true }); const inputs = screen.getAllByTestId("otp-input"); - inputs.forEach((input) => { - waitFor(() => expect(input).toHaveTextContent("0")); - }); + await Promise.all( + inputs.map(async (input) => { + await waitFor(() => expect(input).toHaveTextContent("0")); + }) + ); + }); + + test("should show placeholder if text is empty", async () => { + renderOtpInput({ placeholder: "000000", hideStick: true }); + + const inputs = screen.getAllByTestId("otp-input"); + await Promise.all( + inputs.map(async (input) => { + await waitFor(() => expect(input).toHaveTextContent("0")); + }) + ); + }); + + test("should show values for filled part", async () => { + renderOtpInput({ placeholder: "000000", hideStick: true }); + const otp = "0124"; + + const hiddenInput = screen.getByTestId("otp-input-hidden"); + fireEvent.changeText(hiddenInput, otp); + + const inputs = screen.getAllByTestId("otp-input"); + await Promise.all( + inputs.map(async (input, index) => { + await waitFor(() => + expect(input).toHaveTextContent(index < otp.length ? otp[index].toString() : "0") + ); + }) + ); }); test("should hide placeholder if text is not empty", () => { @@ -286,7 +316,7 @@ describe("OtpInput", () => { }); test("should hide placeholder if input is focused", () => { - renderOtpInput({ placeholder: "000000" }); + renderOtpInput({ placeholder: "000000", hideStick: true }); const input = screen.getByTestId("otp-input-hidden"); fireEvent.press(input); @@ -296,8 +326,8 @@ describe("OtpInput", () => { expect(placeholder).toBeFalsy(); }); - test("should show placeholder if input is blurred and text is empty", () => { - renderOtpInputWithExtraInput({ placeholder: "000000" }); + test("should show placeholder if input is blurred and text is empty", async () => { + renderOtpInputWithExtraInput({ placeholder: "000000", hideStick: true }); const input = screen.getByTestId("otp-input-hidden"); const otherInput = screen.getByTestId("other-input"); @@ -306,13 +336,15 @@ describe("OtpInput", () => { fireEvent.press(otherInput); const inputs = screen.getAllByTestId("otp-input"); - inputs.forEach((input) => { - waitFor(() => expect(input).toHaveTextContent("0")); - }); + await Promise.all( + inputs.map(async (input) => { + await waitFor(() => expect(input).toHaveTextContent("0")); + }) + ); }); test("should hide placeholder if input is blurred and text is not empty", () => { - renderOtpInputWithExtraInput({ placeholder: "000000" }); + renderOtpInputWithExtraInput({ placeholder: "000000", hideStick: true }); const input = screen.getByTestId("otp-input-hidden"); const otherInput = screen.getByTestId("other-input"); @@ -326,16 +358,16 @@ describe("OtpInput", () => { expect(placeholder).toBeFalsy(); }); - test('should leave empty spaces if "placeholder" is shorter than "numberOfDigits"', () => { - renderOtpInput({ placeholder: "123" }); + test('should leave empty spaces if "placeholder" is shorter than "numberOfDigits"', async () => { + renderOtpInput({ placeholder: "123", hideStick: true }); 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(" ")); + expect(inputs[0]).toHaveTextContent("1"); + expect(inputs[1]).toHaveTextContent("2"); + expect(inputs[2]).toHaveTextContent("3"); + expect(inputs[3]).toHaveTextContent(""); + expect(inputs[4]).toHaveTextContent(""); + 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 2e90307..690f4b9 100644 --- a/src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap +++ b/src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap @@ -142,9 +142,7 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, - { - "opacity": 1, - }, + {}, ] } /> @@ -202,9 +200,7 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, - { - "opacity": 1, - }, + {}, ] } /> @@ -262,9 +258,7 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, - { - "opacity": 1, - }, + {}, ] } /> @@ -322,9 +316,7 @@ exports[`OtpInput UI should render correctly 1`] = ` "fontSize": 28, }, undefined, - { - "opacity": 1, - }, + {}, ] } /> @@ -382,9 +374,7 @@ 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 b482fdd..20b23bb 100644 --- a/src/OtpInput/__tests__/useOtpInput.test.ts +++ b/src/OtpInput/__tests__/useOtpInput.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook, waitFor } from "@testing-library/react-native"; +import { act, renderHook } from "@testing-library/react-native"; import * as React from "react"; import { Keyboard } from "react-native"; import { OtpInputProps } from "../OtpInput.types"; @@ -299,78 +299,14 @@ describe("useOtpInput", () => { }); 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 be populated to numberOfDigits if has only single char", () => { + const { result } = renderUseOtInput({ placeholder: "2", numberOfDigits: 5 }); + expect(result.current.models.placeholder).toBe("22222"); }); - 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)); + test("should not be populated if more than one", () => { + const { result } = renderUseOtInput({ placeholder: "22", numberOfDigits: 3 }); + expect(result.current.models.placeholder).toBe("22"); }); }); }); diff --git a/src/OtpInput/useOtpInput.ts b/src/OtpInput/useOtpInput.ts index 12de719..3d6456f 100644 --- a/src/OtpInput/useOtpInput.ts +++ b/src/OtpInput/useOtpInput.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Keyboard, TextInput } from "react-native"; import { OtpInputProps } from "./OtpInput.types"; @@ -18,21 +18,16 @@ export const useOtpInput = ({ type, onFocus, onBlur, - placeholder, + placeholder: _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; - - useEffect(() => { - if (placeholder && !isFocused && !text) { - setIsPlaceholderActive(true); - } else { - setIsPlaceholderActive(false); - } - }, [placeholder, isFocused, text]); + const placeholder = useMemo( + () => (_placeholder?.length === 1 ? _placeholder.repeat(numberOfDigits) : _placeholder), + [_placeholder, numberOfDigits] + ); const handlePress = () => { // To fix bug when keyboard is not popping up after being dismissed @@ -77,7 +72,7 @@ export const useOtpInput = ({ }; return { - models: { text, inputRef, focusedInputIndex, isFocused, isPlaceholderActive }, + models: { text, inputRef, focusedInputIndex, isFocused, placeholder }, actions: { handlePress, handleTextChange, clear, focus, handleFocus, handleBlur }, forms: { setText, setTextWithRef }, };