Skip to content

Commit

Permalink
feat: placeholder disappears as you type (#89)
Browse files Browse the repository at this point in the history
  • Loading branch information
anday013 authored Dec 21, 2024
1 parent 0da2158 commit 6d549b5
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 124 deletions.
18 changes: 12 additions & 6 deletions src/OtpInput/OtpInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useOtpInput } from "./useOtpInput";

export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((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);
Expand All @@ -23,7 +23,6 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
theme = {},
textInputProps,
type = "numeric",
placeholder,
} = props;
const {
containerStyle,
Expand Down Expand Up @@ -61,16 +60,17 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
};

const placeholderStyle = {
opacity: isPlaceholderActive ? 0.5 : pinCodeTextStyle?.opacity || 1,
...(isPlaceholderActive ? placeholderTextStyle : []),
opacity: !!placeholder ? 0.5 : pinCodeTextStyle?.opacity || 1,
...(!!placeholder ? placeholderTextStyle : []),
};

return (
<View style={[styles.container, containerStyle, inputsContainerStyle]}>
{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));
Expand All @@ -90,7 +90,13 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
focusStickBlinkingDuration={focusStickBlinkingDuration}
/>
) : (
<Text style={[styles.codeText, pinCodeTextStyle, placeholderStyle]}>
<Text
style={[
styles.codeText,
pinCodeTextStyle,
isPlaceholderCell ? placeholderStyle : {},
]}
>
{char && secureTextEntry ? "•" : char}
</Text>
)}
Expand Down
72 changes: 52 additions & 20 deletions src/OtpInput/__tests__/OtpInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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("");
});
});
});
20 changes: 5 additions & 15 deletions src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,7 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
{},
]
}
/>
Expand Down Expand Up @@ -202,9 +200,7 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
{},
]
}
/>
Expand Down Expand Up @@ -262,9 +258,7 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
{},
]
}
/>
Expand Down Expand Up @@ -322,9 +316,7 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
{},
]
}
/>
Expand Down Expand Up @@ -382,9 +374,7 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
{},
]
}
/>
Expand Down
78 changes: 7 additions & 71 deletions src/OtpInput/__tests__/useOtpInput.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
});
});
});
19 changes: 7 additions & 12 deletions src/OtpInput/useOtpInput.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<TextInput>(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
Expand Down Expand Up @@ -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 },
};
Expand Down

0 comments on commit 6d549b5

Please sign in to comment.