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" }