diff --git a/example/src/pages/NumberPad.tsx b/example/src/pages/NumberPad.tsx
index 6999f6b8..ef0c7572 100644
--- a/example/src/pages/NumberPad.tsx
+++ b/example/src/pages/NumberPad.tsx
@@ -13,7 +13,7 @@ import {
useIOTheme
} from "@pagopa/io-app-design-system";
import { useNavigation } from "@react-navigation/native";
-import * as React from "react";
+import React, { useCallback, useEffect, useState } from "react";
import { Alert, View } from "react-native";
const PIN_LENGTH = 6;
@@ -25,16 +25,20 @@ export const NumberPadScreen = () => {
const theme = useIOTheme();
const navigation = useNavigation();
- const [value, setValue] = React.useState("");
- const [blueBackground, setBlueBackground] = React.useState(false);
+ const [value, setValue] = useState("");
+ const [blueBackground, setBlueBackground] = useState(false);
- const onValueChange = (v: string) => {
- if (v.length <= PIN_LENGTH) {
- setValue(v);
- }
- };
+ const onNumberPress = useCallback((v: number) => {
+ setValue((prev) => prev.length < PIN_LENGTH ? `${prev}${v}` : prev);
+ }, []);
- React.useEffect(() => {
+ const onDeletePress = useCallback(() => {
+ setValue((prev) => prev.slice(0, -1));
+ }, []);
+
+ const onBiometricPress = useCallback(() => Alert.alert("biometric"),[]);
+
+ useEffect(() => {
navigation.setOptions({
headerStyle: {
backgroundColor: blueBackground
@@ -74,18 +78,18 @@ export const NumberPadScreen = () => {
value={value}
length={PIN_LENGTH}
variant={blueBackground ? "light" : "dark"}
- onValueChange={onValueChange}
+ onValueChange={setValue}
onValidate={v => v === "123456"}
/>
Alert.alert("biometric")}
+ onBiometricPress={onBiometricPress}
/>
diff --git a/package.json b/package.json
index 41c73d21..c3bde65e 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"prettify": "prettier --write \"src/**/*.(ts|tsx)\"",
"prepack": "bob build",
+ "prepare": "bob build",
"release": "release-it",
"example": "yarn --cwd example",
"bootstrap": "yarn example && yarn install",
diff --git a/src/components/numberpad/NumberButton.tsx b/src/components/numberpad/NumberButton.tsx
index 8e396d26..81a30922 100644
--- a/src/components/numberpad/NumberButton.tsx
+++ b/src/components/numberpad/NumberButton.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo } from "react";
+import React, { memo, useCallback, useMemo } from "react";
import { Pressable } from "react-native";
import Animated, {
Extrapolate,
@@ -22,8 +22,19 @@ import { H3 } from "../typography";
type NumberButtonVariantType = "light" | "dark";
type NumberButtonProps = {
+ /**
+ * Used to choose the component color variant between `dark` and `light`.
+ */
variant: NumberButtonVariantType;
+ /**
+ * The button value.
+ */
number: number;
+ /**
+ * The action to be executed when the button is pressed.
+ * @param number
+ * @returns void
+ */
onPress: (number: number) => void;
};
@@ -58,8 +69,12 @@ const legacyColorMap: Record = {
foreground: "white"
}
};
-
-export const NumberButton = ({
+/**
+ * Based on a `Pressable` element, it displays a number button with animations on press In and Out.
+ *
+ * @returns {JSX.Element} The rendered `NumberButton`
+ */
+export const NumberButton = memo(({
number,
variant,
onPress
@@ -110,13 +125,17 @@ export const NumberButton = ({
isPressed.value = 0;
}, [isPressed]);
+ const handleOnPress = useCallback(() => {
+ onPress(number);
+ }, [number, onPress]);
+
return (
onPress(number)}
+ onPress={handleOnPress}
>
);
-};
+});
diff --git a/src/components/numberpad/NumberPad.tsx b/src/components/numberpad/NumberPad.tsx
index 9b68b381..e1561d32 100644
--- a/src/components/numberpad/NumberPad.tsx
+++ b/src/components/numberpad/NumberPad.tsx
@@ -1,6 +1,6 @@
-import React, { ComponentProps } from "react";
-import { View } from "react-native";
-import { BiometricsValidType } from "../../utils/types";
+import React, { ComponentProps, Fragment, useCallback, useMemo } from "react";
+import { StyleSheet, View } from "react-native";
+import { BiometricsValidType, Optional } from "../../utils/types";
import { IONumberPadButtonStyles, IOStyles } from "../../core";
import { VSpacer } from "../spacer";
import { IconButton } from "../buttons";
@@ -9,21 +9,49 @@ import { NumberButton } from "./NumberButton";
type BiometricAuthProps =
| {
- biometricType: BiometricsValidType;
- onBiometricPress: () => void;
- biometricAccessibilityLabel: string;
- }
+ /**
+ * Type of device biometric.
+ */
+ biometricType: BiometricsValidType;
+ /**
+ * Function to be executed when the biometric button is pressed.
+ * @returns void
+ */
+ onBiometricPress: () => void;
+ /**
+ * This label will be read from ScreenReaders and give informations about biometric button.
+ */
+ biometricAccessibilityLabel: string;
+ }
| {
- biometricType?: never;
- onBiometricPress?: never;
- biometricAccessibilityLabel?: never;
- };
+ biometricType?: never;
+ onBiometricPress?: never;
+ biometricAccessibilityLabel?: never;
+ };
type NumberPadProps = {
- value: string;
- onValueChange: (value: string) => void;
- variant: ComponentProps["variant"];
+ /**
+ * Used to choose the component color variant between `dark` and `light`.
+ *
+ * The default value is `dark`.
+ */
+ variant?: ComponentProps["variant"];
+ /**
+ * This label will be read from ScreenReaders and give informations about delete button.
+ */
deleteAccessibilityLabel: string;
+ /**
+ * This function is passed to all numeric buttons to handle their press action.
+ * @param value
+ * @returns void
+ */
+ onNumberPress: (value: number) => void;
+ /**
+ * This function is passed to the delete button to handle the action to trigger when it's pressed.
+ *
+ * @returns void
+ */
+ onDeletePress: () => void;
} & BiometricAuthProps;
const mapIconSpecByBiometric: Record<
@@ -35,79 +63,53 @@ const mapIconSpecByBiometric: Record<
BIOMETRICS: { icon: "fingerprint", size: 24 }
};
-const ButtonWrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
-);
+/**
+ * This component displays a custom numeric keyboard.
+ *
+ * It accepts an optional `biometricType` prop which enables an extra keyboard button that accepts a `onBiometricPress` prop used to handle the action to be executed when it's pressed.
+ * @returns {JSX.Element} The rendered numeric keyboard component.
+ */
export const NumberPad = ({
- value,
variant = "dark",
- onValueChange,
biometricType,
- onBiometricPress,
biometricAccessibilityLabel,
- deleteAccessibilityLabel
-}: NumberPadProps) => {
- const numberPadPress = (number: number) => {
- const newValue = `${value}${number}`;
- onValueChange(newValue);
- };
-
- const onDeletePress = () => {
- const newValue = value.slice(0, -1);
- onValueChange(newValue);
- };
-
- type ButtonType = "biometric" | "delete";
+ deleteAccessibilityLabel,
+ onNumberPress,
+ onBiometricPress,
+ onDeletePress
+}: NumberPadProps): JSX.Element => {
+ /**
+ * Renders the buttons row from a given array.
+ */
+ // eslint-disable-next-line arrow-body-style
+ const renderButtonsRow = useCallback((row: Array>) => {
+ return row.map((item) => {
+ if (typeof item === "number") {
+ return (
+
+ );
+ }
- const RowButtons = ({
- buttons
- }: {
- buttons: ReadonlyArray;
- }) => (
-
- {buttons.map(elem => {
- if (typeof elem === "number") {
- return (
-
+
- );
- }
-
- if (elem === "delete") {
- return (
-
-
-
- );
- }
- return biometricType ? (
-
+
+ );
+ }
+ if (biometricType && mapIconSpecByBiometric[biometricType]) {
+ return (
+
- ) : (
-
);
- })}
-
- );
+ }
+
+ return ;
+ });
+ }, [biometricAccessibilityLabel, biometricType, deleteAccessibilityLabel, onBiometricPress, onDeletePress, onNumberPress, variant]);
+
+ // eslint-disable-next-line arrow-body-style
+ const numberPad = useMemo(() => {
+ return [[1, 2, 3], [4, 5, 6], [7, 8, 9], [biometricType, 0, 'delete']].map((row, i, self) =>
+
+
+ {renderButtonsRow(row)}
+
+ {i < self.length - 1 && }
+
+ );
+ }, [biometricType, renderButtonsRow]);
return (
-
-
-
-
-
-
-
+ {numberPad}
);
};
+
+const ButtonWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+const styles = StyleSheet.create({
+ numberPad: {
+ justifyContent: "space-between",
+ alignItems: "center",
+ flexGrow: 1
+ }
+});
\ No newline at end of file
diff --git a/src/components/numberpad/__test__/NumberPad.test.tsx b/src/components/numberpad/__test__/NumberPad.test.tsx
new file mode 100644
index 00000000..3d3adc02
--- /dev/null
+++ b/src/components/numberpad/__test__/NumberPad.test.tsx
@@ -0,0 +1,77 @@
+import { fireEvent, render } from '@testing-library/react-native';
+import React from 'react';
+import { NumberPad } from '../NumberPad';
+
+const mockOnDelete = jest.fn();
+const mockOnNumberPress = jest.fn();
+const mockOnBiometricPress = jest.fn();
+
+describe(NumberPad, () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it('Should match the snapshot', () => {
+ const component = renderNumberPad();
+ expect(component).toMatchSnapshot();
+ });
+ it('Should properly call onNumberPress without side effects', () => {
+ const { getByText } = renderNumberPad();
+ const one = getByText('1');
+ const nine = getByText('9');
+ const reactCreatElement = jest.spyOn(React, 'createElement');
+
+ // Press one
+ fireEvent.press(one);
+ expect(mockOnNumberPress).toHaveBeenCalledTimes(1);
+ expect(mockOnNumberPress).toHaveBeenCalledWith(1);
+ mockOnNumberPress.mockClear();
+
+ // Press nine
+ fireEvent.press(nine);
+ expect(mockOnNumberPress).toHaveBeenCalledTimes(1);
+ expect(mockOnNumberPress).toHaveBeenCalledWith(9);
+
+ expect(reactCreatElement).not.toHaveBeenCalled();
+ expect(mockOnDelete).not.toHaveBeenCalled();
+ expect(mockOnBiometricPress).not.toHaveBeenCalled();
+ });
+ it('Should properly call onDeletePress without side effects', () => {
+ const { getByLabelText } = renderNumberPad();
+ const deleteButton = getByLabelText('delete');
+ const reactCreatElement = jest.spyOn(React, 'createElement');
+
+ fireEvent.press(deleteButton);
+
+ expect(mockOnDelete).toHaveBeenCalledTimes(1);
+ expect(reactCreatElement).not.toHaveBeenCalled();
+ expect(mockOnNumberPress).not.toHaveBeenCalled();
+ expect(mockOnBiometricPress).not.toHaveBeenCalled();
+ });
+ it('Should properly call onBiometricPress without side effects', () => {
+ const { getByLabelText } = renderNumberPad();
+ const biometricButton = getByLabelText('touch id trigger');
+ const reactCreatElement = jest.spyOn(React, 'createElement');
+
+ fireEvent.press(biometricButton);
+
+ expect(mockOnBiometricPress).toHaveBeenCalledTimes(1);
+ expect(reactCreatElement).not.toHaveBeenCalled();
+ expect(mockOnNumberPress).not.toHaveBeenCalled();
+ expect(mockOnDelete).not.toHaveBeenCalled();
+ });
+});
+
+function renderNumberPad() {
+ return render(
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/numberpad/__test__/__snapshots__/NumberPad.test.tsx.snap b/src/components/numberpad/__test__/__snapshots__/NumberPad.test.tsx.snap
new file mode 100644
index 00000000..07e5af85
--- /dev/null
+++ b/src/components/numberpad/__test__/__snapshots__/NumberPad.test.tsx.snap
@@ -0,0 +1,1266 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NumberPad Should match the snapshot 1`] = `
+
+
+
+
+
+ 1
+
+
+
+
+
+
+ 2
+
+
+
+
+
+
+ 3
+
+
+
+
+
+
+
+
+
+ 4
+
+
+
+
+
+
+ 5
+
+
+
+
+
+
+ 6
+
+
+
+
+
+
+
+
+
+ 7
+
+
+
+
+
+
+ 8
+
+
+
+
+
+
+ 9
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 56785fa2..b4599e14 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -34,3 +34,13 @@ export type InputType = "credit-card" | "default";
// Biometrics type used in io-app code base
// https://github.com/pagopa/io-app/blob/master/ts/utils/biometrics.ts#L31
export type BiometricsValidType = "BIOMETRICS" | "FACE_ID" | "TOUCH_ID";
+
+/**
+ * Returns a type with the desired type or null
+ */
+export type Nullable = T | null;
+
+/**
+ * Returns a type with the desired type or undefined
+ */
+export type Optional = T | undefined;
\ No newline at end of file
diff --git a/stories/components/numberpad/NumberPad.stories.tsx b/stories/components/numberpad/NumberPad.stories.tsx
index d8978c14..eb413edd 100644
--- a/stories/components/numberpad/NumberPad.stories.tsx
+++ b/stories/components/numberpad/NumberPad.stories.tsx
@@ -1,6 +1,4 @@
-import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
-import { useArgs } from "@storybook/preview-api";
import { withMaxWitdth } from "../../utils";
import { NumberPad } from "../../../src/components";
@@ -14,14 +12,7 @@ const meta = {
layout: "padded"
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs
- tags: ["autodocs"],
- render: function Render(args) {
- const [{ value }, updateargs] = useArgs();
- const onChange = (value: string) => {
- updateargs({ value });
- };
- return ;
- }
+ tags: ["autodocs"]
} satisfies Meta;
export default meta;
@@ -30,7 +21,6 @@ type Story = StoryObj;
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Light: Story = {
args: {
- value: "",
deleteAccessibilityLabel: "Delete",
variant: "light"
}