Skip to content

Commit

Permalink
feat: implement placeholder (#86)
Browse files Browse the repository at this point in the history
* feat: add basic logic for placeholder

* test: cover placeholder

* docs: add `placeholder` and `placeholderTextStyle` props

* test: add missing suites for placeholder
  • Loading branch information
anday013 authored Dec 8, 2024
1 parent 98a38b2 commit 949beef
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 30 deletions.
24 changes: 13 additions & 11 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -83,30 +83,32 @@ 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: <https://reactnative.dev/docs/textinput>) |
| `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. |
| `secureTextEntry` | boolean | _Default: false_. Obscures the text entered so that sensitive text like PIN stay secure. |
| `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.

Expand Down
14 changes: 8 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 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 },
models: { text, inputRef, focusedInputIndex, isFocused, isPlaceholderActive },
actions: { clear, handlePress, handleTextChange, focus, handleFocus, handleBlur },
forms: { setTextWithRef },
} = useOtpInput(props);
Expand All @@ -23,6 +23,7 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
theme = {},
textInputProps,
type = "numeric",
placeholder,
} = props;
const {
containerStyle,
Expand All @@ -33,6 +34,7 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
focusedPinCodeContainerStyle,
filledPinCodeContainerStyle,
disabledPinCodeContainerStyle,
placeholderTextStyle,
} = theme;

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

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

return (
<View style={[styles.container, containerStyle, inputsContainerStyle]}>
{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));
Expand All @@ -83,7 +90,7 @@ export const OtpInput = forwardRef<OtpInputRef, OtpInputProps>((props, ref) => {
focusStickBlinkingDuration={focusStickBlinkingDuration}
/>
) : (
<Text style={[styles.codeText, pinCodeTextStyle]}>
<Text style={[styles.codeText, pinCodeTextStyle, placeholderStyle]}>
{char && secureTextEntry ? "•" : char}
</Text>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/OtpInput/OtpInput.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface OtpInputProps {
disabled?: boolean;
textInputProps?: TextInputProps;
type?: "alpha" | "numeric" | "alphanumeric";
placeholder?: string;
}

export interface OtpInputRef {
Expand All @@ -36,4 +37,5 @@ export interface Theme {
focusStickStyle?: ViewStyle;
focusedPinCodeContainerStyle?: ViewStyle;
disabledPinCodeContainerStyle?: ViewStyle;
placeholderTextStyle?: TextStyle;
}
85 changes: 83 additions & 2 deletions src/OtpInput/__tests__/OtpInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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<OtpInputProps>) => render(<OtpInput {...props} />);
const renderOtpInputWithExtraInput = (props?: Partial<OtpInputProps>) =>
render(
<>
<OtpInput {...props} />
<TextInput testID="other-input" />
</>
);

describe("OtpInput", () => {
describe("UI", () => {
Expand Down Expand Up @@ -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(" "));
});
});
});
15 changes: 15 additions & 0 deletions src/OtpInput/__tests__/__snapshots__/OtpInput.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
]
}
/>
Expand Down Expand Up @@ -199,6 +202,9 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
]
}
/>
Expand Down Expand Up @@ -256,6 +262,9 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
]
}
/>
Expand Down Expand Up @@ -313,6 +322,9 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
]
}
/>
Expand Down Expand Up @@ -370,6 +382,9 @@ exports[`OtpInput UI should render correctly 1`] = `
"fontSize": 28,
},
undefined,
{
"opacity": 1,
},
]
}
/>
Expand Down
79 changes: 78 additions & 1 deletion src/OtpInput/__tests__/useOtpInput.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,6 +10,7 @@ const renderUseOtInput = (props?: Partial<OtpInputProps>) =>
describe("useOtpInput", () => {
afterEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});

test("should return models as defined", () => {
Expand Down Expand Up @@ -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));
});
});
});
Loading

0 comments on commit 949beef

Please sign in to comment.