Skip to content

Commit 949beef

Browse files
authored
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
1 parent 98a38b2 commit 949beef

File tree

8 files changed

+227
-30
lines changed

8 files changed

+227
-30
lines changed

README.MD

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,30 +83,32 @@ The `react-native-otp-entry` component accepts the following props:
8383
| Prop | Type | Description |
8484
| ---------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
8585
| `numberOfDigits` | number | The number of digits to be displayed in the OTP entry. |
86-
| `textInputProps` | TextInputProps | Extra props passed to underlying hidden TextInput (see: https://reactnative.dev/docs/textinput) |
86+
| `theme` | Theme | Custom styles for each element. (See below) |
87+
| `textInputProps` | TextInputProps | Extra props passed to underlying hidden TextInput (see: <https://reactnative.dev/docs/textinput>) |
8788
| `autoFocus` | boolean | _Default: true_. Sets autofocus. |
8889
| `focusColor` | ColorValue | The color of the input field border and stick when it is focused. |
90+
| `placeholder` | string | Placeholder value to the input. |
8991
| `onTextChange` | (text: string) => void | A callback function is invoked when the OTP text changes. It receives the updated text as an argument. |
9092
| `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. |
9193
| `blurOnFilled` | boolean | _Default: false_. Blurs (unfocuses) the input when the OTP input is fully filled. |
9294
| `hideStick` | boolean | _Default: false_. Hides cursor of the focused input. |
93-
| `theme` | Theme | Custom styles for each element. |
9495
| `focusStickBlinkingDuration` | number | The duration (in milliseconds) for the focus stick to blink. |
9596
| `disabled` | boolean | _Default: false_. Disables the input |
9697
| `type` | 'alpha' \| 'numeric' \| 'alphanumeric' | The type of input. 'alpha': letters only, 'numeric': numbers only, 'alphanumeric': letters or numbers. |
9798
| `secureTextEntry` | boolean | _Default: false_. Obscures the text entered so that sensitive text like PIN stay secure. |
9899
| `onFocus` | () => void | A callback function is invoked when the OTP input is focused. |
99100
| `onBlur` | () => void | A callback function is invoked when the OTP input is blurred. |
100101

101-
| Theme | Type | Description |
102-
| ------------------------------- | --------- | ---------------------------------------------------------------------------------- |
103-
| `containerStyle` | ViewStyle | Custom styles for the root `View`. |
104-
| `pinCodeContainerStyle` | ViewStyle | Custom styles for the container that wraps each individual digit in the OTP entry. |
105-
| `pinCodeTextStyle` | TextStyle | Custom styles for the text within each individual digit in the OTP entry. |
106-
| `focusStickStyle` | ViewStyle | Custom styles for the focus stick, which indicates the focused input field. |
107-
| `focusedPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is focused. |
108-
| `filledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it has a value. |
109-
| `disabledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is disabled. |
102+
| Theme | Type | Description |
103+
| ------------------------------- | --------- | ------------------------------------------------------------------------------------- |
104+
| `containerStyle` | ViewStyle | Custom styles for the root `View`. |
105+
| `pinCodeContainerStyle` | ViewStyle | Custom styles for the container that wraps each individual digit in the OTP entry. |
106+
| `pinCodeTextStyle` | TextStyle | Custom styles for the text within each individual digit in the OTP entry. |
107+
| `placeholderTextStyle` | TextStyle | Custom styles for the placeholder text within each individual digit in the OTP entry. |
108+
| `focusStickStyle` | ViewStyle | Custom styles for the focus stick, which indicates the focused input field. |
109+
| `focusedPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is focused. |
110+
| `filledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it has a value. |
111+
| `disabledPinCodeContainerStyle` | ViewStyle | Custom styles for the input field when it is disabled. |
110112

111113
**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.
112114

package-lock.json

Lines changed: 8 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/OtpInput/OtpInput.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useOtpInput } from "./useOtpInput";
88

99
export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
1010
const {
11-
models: { text, inputRef, focusedInputIndex, isFocused },
11+
models: { text, inputRef, focusedInputIndex, isFocused, isPlaceholderActive },
1212
actions: { clear, handlePress, handleTextChange, focus, handleFocus, handleBlur },
1313
forms: { setTextWithRef },
1414
} = useOtpInput(props);
@@ -23,6 +23,7 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
2323
theme = {},
2424
textInputProps,
2525
type = "numeric",
26+
placeholder,
2627
} = props;
2728
const {
2829
containerStyle,
@@ -33,6 +34,7 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
3334
focusedPinCodeContainerStyle,
3435
filledPinCodeContainerStyle,
3536
disabledPinCodeContainerStyle,
37+
placeholderTextStyle,
3638
} = theme;
3739

3840
useImperativeHandle(ref, () => ({ clear, focus, setValue: setTextWithRef }));
@@ -58,12 +60,17 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
5860
return stylesArray;
5961
};
6062

63+
const placeholderStyle = {
64+
opacity: isPlaceholderActive ? 0.5 : pinCodeTextStyle?.opacity || 1,
65+
...(isPlaceholderActive ? placeholderTextStyle : []),
66+
};
67+
6168
return (
6269
<View style={[styles.container, containerStyle, inputsContainerStyle]}>
6370
{Array(numberOfDigits)
6471
.fill(0)
6572
.map((_, index) => {
66-
const char = text[index];
73+
const char = isPlaceholderActive ? placeholder?.[index] || " " : text[index];
6774
const isFocusedInput = index === focusedInputIndex && !disabled && Boolean(isFocused);
6875
const isFilledLastInput = text.length === numberOfDigits && index === text.length - 1;
6976
const isFocusedContainer = isFocusedInput || (isFilledLastInput && Boolean(isFocused));
@@ -83,7 +90,7 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
8390
focusStickBlinkingDuration={focusStickBlinkingDuration}
8491
/>
8592
) : (
86-
<Text style={[styles.codeText, pinCodeTextStyle]}>
93+
<Text style={[styles.codeText, pinCodeTextStyle, placeholderStyle]}>
8794
{char && secureTextEntry ? "•" : char}
8895
</Text>
8996
)}

src/OtpInput/OtpInput.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface OtpInputProps {
1616
disabled?: boolean;
1717
textInputProps?: TextInputProps;
1818
type?: "alpha" | "numeric" | "alphanumeric";
19+
placeholder?: string;
1920
}
2021

2122
export interface OtpInputRef {
@@ -36,4 +37,5 @@ export interface Theme {
3637
focusStickStyle?: ViewStyle;
3738
focusedPinCodeContainerStyle?: ViewStyle;
3839
disabledPinCodeContainerStyle?: ViewStyle;
40+
placeholderTextStyle?: TextStyle;
3941
}

src/OtpInput/__tests__/OtpInput.test.tsx

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import { act, fireEvent, render, screen } from "@testing-library/react-native";
1+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native";
22
import * as React from "react";
3-
import { Platform } from "react-native";
3+
import { Platform, TextInput } from "react-native";
44
import { OtpInput } from "../OtpInput";
55
import { OtpInputProps, OtpInputRef } from "../OtpInput.types";
66

77
const renderOtpInput = (props?: Partial<OtpInputProps>) => render(<OtpInput {...props} />);
8+
const renderOtpInputWithExtraInput = (props?: Partial<OtpInputProps>) =>
9+
render(
10+
<>
11+
<OtpInput {...props} />
12+
<TextInput testID="other-input" />
13+
</>
14+
);
815

916
describe("OtpInput", () => {
1017
describe("UI", () => {
@@ -257,4 +264,78 @@ describe("OtpInput", () => {
257264
expect(screen.queryByText("6")).toBeFalsy();
258265
});
259266
});
267+
describe("Placeholder", () => {
268+
test("should show placeholder if text is empty", () => {
269+
renderOtpInput({ placeholder: "000000" });
270+
271+
const inputs = screen.getAllByTestId("otp-input");
272+
inputs.forEach((input) => {
273+
waitFor(() => expect(input).toHaveTextContent("0"));
274+
});
275+
});
276+
277+
test("should hide placeholder if text is not empty", () => {
278+
renderOtpInput({ placeholder: "000000" });
279+
280+
const input = screen.getByTestId("otp-input-hidden");
281+
fireEvent.changeText(input, "123456");
282+
283+
const placeholder = screen.queryByText("000000");
284+
285+
expect(placeholder).toBeFalsy();
286+
});
287+
288+
test("should hide placeholder if input is focused", () => {
289+
renderOtpInput({ placeholder: "000000" });
290+
291+
const input = screen.getByTestId("otp-input-hidden");
292+
fireEvent.press(input);
293+
294+
const placeholder = screen.queryByText("000000");
295+
296+
expect(placeholder).toBeFalsy();
297+
});
298+
299+
test("should show placeholder if input is blurred and text is empty", () => {
300+
renderOtpInputWithExtraInput({ placeholder: "000000" });
301+
302+
const input = screen.getByTestId("otp-input-hidden");
303+
const otherInput = screen.getByTestId("other-input");
304+
fireEvent.press(input);
305+
// Blur the input
306+
fireEvent.press(otherInput);
307+
308+
const inputs = screen.getAllByTestId("otp-input");
309+
inputs.forEach((input) => {
310+
waitFor(() => expect(input).toHaveTextContent("0"));
311+
});
312+
});
313+
314+
test("should hide placeholder if input is blurred and text is not empty", () => {
315+
renderOtpInputWithExtraInput({ placeholder: "000000" });
316+
317+
const input = screen.getByTestId("otp-input-hidden");
318+
const otherInput = screen.getByTestId("other-input");
319+
fireEvent.press(input);
320+
fireEvent.changeText(input, "123456");
321+
// Blur the input
322+
fireEvent.press(otherInput);
323+
324+
const placeholder = screen.queryByText("000000");
325+
326+
expect(placeholder).toBeFalsy();
327+
});
328+
329+
test('should leave empty spaces if "placeholder" is shorter than "numberOfDigits"', () => {
330+
renderOtpInput({ placeholder: "123" });
331+
332+
const inputs = screen.getAllByTestId("otp-input");
333+
waitFor(() => inputs[0].toHaveTextContent("1"));
334+
waitFor(() => expect(inputs[1]).toHaveTextContent("2"));
335+
waitFor(() => expect(inputs[2]).toHaveTextContent("3"));
336+
waitFor(() => expect(inputs[3]).toHaveTextContent(" "));
337+
waitFor(() => expect(inputs[4]).toHaveTextContent(" "));
338+
waitFor(() => expect(inputs[5]).toHaveTextContent(" "));
339+
});
340+
});
260341
});

src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ exports[`OtpInput UI should render correctly 1`] = `
142142
"fontSize": 28,
143143
},
144144
undefined,
145+
{
146+
"opacity": 1,
147+
},
145148
]
146149
}
147150
/>
@@ -199,6 +202,9 @@ exports[`OtpInput UI should render correctly 1`] = `
199202
"fontSize": 28,
200203
},
201204
undefined,
205+
{
206+
"opacity": 1,
207+
},
202208
]
203209
}
204210
/>
@@ -256,6 +262,9 @@ exports[`OtpInput UI should render correctly 1`] = `
256262
"fontSize": 28,
257263
},
258264
undefined,
265+
{
266+
"opacity": 1,
267+
},
259268
]
260269
}
261270
/>
@@ -313,6 +322,9 @@ exports[`OtpInput UI should render correctly 1`] = `
313322
"fontSize": 28,
314323
},
315324
undefined,
325+
{
326+
"opacity": 1,
327+
},
316328
]
317329
}
318330
/>
@@ -370,6 +382,9 @@ exports[`OtpInput UI should render correctly 1`] = `
370382
"fontSize": 28,
371383
},
372384
undefined,
385+
{
386+
"opacity": 1,
387+
},
373388
]
374389
}
375390
/>

src/OtpInput/__tests__/useOtpInput.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, renderHook } from "@testing-library/react-native";
1+
import { act, renderHook, waitFor } from "@testing-library/react-native";
22
import * as React from "react";
33
import { Keyboard } from "react-native";
44
import { OtpInputProps } from "../OtpInput.types";
@@ -10,6 +10,7 @@ const renderUseOtInput = (props?: Partial<OtpInputProps>) =>
1010
describe("useOtpInput", () => {
1111
afterEach(() => {
1212
jest.clearAllMocks();
13+
jest.clearAllTimers();
1314
});
1415

1516
test("should return models as defined", () => {
@@ -296,4 +297,80 @@ describe("useOtpInput", () => {
296297
expect(result.current.models.inputRef.current?.blur).not.toHaveBeenCalled();
297298
});
298299
});
300+
301+
describe("Placeholder", () => {
302+
test("should call setIsPlaceholderActive with `true`", () => {
303+
const mockSetState = jest.fn();
304+
jest.spyOn(React, "useState").mockImplementation(() => [false, mockSetState]);
305+
306+
renderUseOtInput({ placeholder: "00000000" });
307+
308+
waitFor(() => {
309+
expect(mockSetState).toBeCalledWith(true);
310+
});
311+
});
312+
313+
test("should set isPlaceholderActive to 'true' when placeholder is provided and input is not focused and text is empty", () => {
314+
const { result } = renderUseOtInput({ placeholder: "00000000" });
315+
316+
waitFor(() => {
317+
expect(result.current.models.isPlaceholderActive).toBe(true);
318+
});
319+
});
320+
321+
test("should set isPlaceholderActive to 'true' when placeholder is provided and text is empty", () => {
322+
const { result } = renderUseOtInput({ placeholder: "00000000" });
323+
result.current.actions.handleFocus();
324+
result.current.actions.handleBlur();
325+
326+
waitFor(() => {
327+
expect(result.current.models.isPlaceholderActive).toBe(true);
328+
});
329+
});
330+
331+
test("should set isPlaceholderActive to 'false' when placeholder is provided and input is focused", () => {
332+
const { result } = renderUseOtInput({ placeholder: "00000000" });
333+
result.current.actions.handleFocus();
334+
waitFor(() => {
335+
expect(result.current.models.isPlaceholderActive).toBe(false);
336+
});
337+
});
338+
339+
test("should set isPlaceholderActive to 'false' when placeholder is provided and text is not empty", () => {
340+
const { result } = renderUseOtInput({ placeholder: "00000000" });
341+
result.current.actions.handleTextChange("123456");
342+
waitFor(() => {
343+
expect(result.current.models.isPlaceholderActive).toBe(false);
344+
});
345+
});
346+
347+
test("should set isPlaceholderActive to 'false' when placeholder is provided and input is focused and text is not empty", async () => {
348+
const { result } = renderUseOtInput({ placeholder: "00000000" });
349+
result.current.actions.handleTextChange("123456");
350+
result.current.actions.handleFocus();
351+
waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(false));
352+
});
353+
354+
test("should set isPlaceholderActive to 'false' when placeholder is provided and input is not focused and text is not empty", async () => {
355+
const { result } = renderUseOtInput({ placeholder: "00000000" });
356+
result.current.actions.handleTextChange("123456");
357+
result.current.actions.handleBlur();
358+
waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(false));
359+
});
360+
361+
test("should set isPlaceholderActive to 'true' when placeholder is provided and input is focused and text is empty", async () => {
362+
const { result } = renderUseOtInput({ placeholder: "00000000" });
363+
result.current.actions.handleFocus();
364+
result.current.actions.handleBlur();
365+
waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(true));
366+
});
367+
368+
test("should set isPlaceholderActive to 'true' when placeholder is provided and input is not focused and text is empty", async () => {
369+
const { result } = renderUseOtInput({ placeholder: "00000000" });
370+
result.current.actions.handleTextChange("123456");
371+
result.current.actions.handleTextChange("");
372+
result.current.actions.handleBlur();
373+
waitFor(() => expect(result.current.models.isPlaceholderActive).toBe(true));
374+
});
375+
});
299376
});

0 commit comments

Comments
 (0)