Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IOPLT-236] Adds the new OTPInput component #161

Merged
merged 9 commits into from
Dec 11, 2023
9 changes: 9 additions & 0 deletions example/src/navigation/navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { NumberPadScreen } from "../pages/NumberPad";
import { StepperPage } from "../pages/Stepper";
import { HeaderSecondLevelWithStepper } from "../pages/HeaderSecondLevelWithStepper";
import { ForceScrollDownViewPage } from "../pages/ForceScrollDownViewPage";
import { OTPInputScreen } from "../pages/OTPInput";
import { AppParamsList } from "./params";
import APP_ROUTES from "./routes";

Expand Down Expand Up @@ -109,6 +110,14 @@ const AppNavigator = () => (
headerBackTitleVisible: false
}}
/>
<Stack.Screen
name={APP_ROUTES.COMPONENTS.OTP_INPUT.route}
component={OTPInputScreen}
options={{
headerTitle: APP_ROUTES.COMPONENTS.OTP_INPUT.title,
headerBackTitleVisible: false
}}
/>
<Stack.Screen
name={APP_ROUTES.COMPONENTS.BUTTONS.route}
component={Buttons}
Expand Down
1 change: 1 addition & 0 deletions example/src/navigation/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type AppParamsList = {
[DESIGN_SYSTEM_ROUTES.FOUNDATION.PICTOGRAMS.route]: undefined;
[DESIGN_SYSTEM_ROUTES.FOUNDATION.LOGOS.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.NUMBER_PAD.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.OTP_INPUT.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.BUTTONS.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.BADGE.route]: undefined;
[DESIGN_SYSTEM_ROUTES.COMPONENTS.SELECTION.route]: undefined;
Expand Down
1 change: 1 addition & 0 deletions example/src/navigation/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const APP_ROUTES = {
},
COMPONENTS: {
NUMBER_PAD: { route: "DESIGN_SYSTEM_NUMBER_PAD", title: "Number Pad" },
OTP_INPUT: { route: "DESIGN_SYSTEM_OTP_INPUT", title: "OTP Input" },
BUTTONS: { route: "DESIGN_SYSTEM_BUTTONS", title: "Buttons" },
LIST_ITEMS: { route: "DESIGN_SYSTEM_LIST_ITEMS", title: "List Items" },
MODULES: { route: "DESIGN_SYSTEM_MODULES", title: "Modules" },
Expand Down
72 changes: 72 additions & 0 deletions example/src/pages/OTPInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as React from "react";
import { View } from "react-native";
import {
H1,
H5,
IOStyles,
VSpacer,
OTPInput,
LabelSmall
} from "@pagopa/io-app-design-system";
import { useState } from "react";
import { Screen } from "../components/Screen";

const OTP_LENGTH = 8;
const OTP_COMPARE = "12345678";

type WrapperProps = {
secret?: boolean;
validation?: boolean;
};

const OTPWrapper = ({ secret = false, validation = false }: WrapperProps) => {
const [value, setValue] = useState("");
const onValueChange = (v: string) => {
if (v.length <= OTP_LENGTH) {
setValue(v);
}
};

const onValidate = (v: string) => !validation || v === OTP_COMPARE;

return (
<OTPInput
value={value}
onValueChange={onValueChange}
length={OTP_LENGTH}
secret={secret}
onValidate={onValidate}
errorMessage={"Wrong OTP"}
/>
);
};
/**
* This Screen is used to test components in isolation while developing.
* @returns a screen with a flexed view where you can test components
*/
export const OTPInputScreen = () => (
<View
style={{
flexGrow: 1
}}
>
<Screen>
<View style={IOStyles.alignCenter}>
<H1>OTP Input</H1>
</View>
<VSpacer />
<H5>Default</H5>
<VSpacer />
<OTPWrapper />
<VSpacer />
<H5>Secret</H5>
<VSpacer />
<OTPWrapper secret />
<VSpacer />
<H5>Validation+Secret</H5>
<LabelSmall>Correct OTP {`${OTP_COMPARE}`}</LabelSmall>
<VSpacer />
<OTPWrapper secret validation />
</Screen>
</View>
);
38 changes: 5 additions & 33 deletions src/components/codeInput/CodeInput.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import React, { useEffect, useMemo } from "react";
import { StyleSheet, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming
} from "react-native-reanimated";
import Animated from "react-native-reanimated";
import { IOColors, IOStyles } from "../../core";
import { triggerHaptic } from "../../functions";
import { HSpacer } from "../spacer";
import { useErrorShakeAnimation } from "../../utils/hooks/useErrorShakeAnimation";

type CodeInputProps = {
value: string;
Expand Down Expand Up @@ -56,12 +51,7 @@ export const CodeInput = ({
}: CodeInputProps) => {
const [status, setStatus] = React.useState<"default" | "error">("default");

const translate = useSharedValue(0);
const shakeOffset: number = 8;

const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translate.value }]
}));
const { translate, animatedStyle, shakeAnimation } = useErrorShakeAnimation();

const fillColor = useMemo(
() =>
Expand All @@ -82,25 +72,7 @@ export const CodeInput = ({
triggerHaptic("notificationError");

// eslint-disable-next-line functional/immutable-data
translate.value = withSequence(
withTiming(shakeOffset, {
duration: 75,
easing: Easing.inOut(Easing.cubic)
}),
withTiming(-shakeOffset, {
duration: 75,
easing: Easing.inOut(Easing.cubic)
}),
withTiming(shakeOffset / 2, {
duration: 75,
easing: Easing.inOut(Easing.cubic)
}),
withTiming(-shakeOffset / 2, {
duration: 75,
easing: Easing.inOut(Easing.cubic)
}),
withTiming(0, { duration: 75, easing: Easing.inOut(Easing.cubic) })
);
translate.value = shakeAnimation();

const timer = setTimeout(() => {
setStatus("default");
Expand All @@ -110,7 +82,7 @@ export const CodeInput = ({
}
}
return;
}, [value, onValidate, length, onValueChange, translate]);
}, [value, onValidate, length, onValueChange, translate, shakeAnimation]);

return (
<Animated.View style={[IOStyles.row, styles.wrapper, animatedStyle]}>
Expand Down
1 change: 1 addition & 0 deletions src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from "./logos";
export * from "./loadingSpinner";
export * from "./modules";
export * from "./numberpad";
export * from "./otpInput";
export * from "./pictograms";
export * from "./radio";
export * from "./spacer";
Expand Down
67 changes: 67 additions & 0 deletions src/components/otpInput/BoxedInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as React from "react";
import { useMemo } from "react";
import { View, StyleSheet } from "react-native";
import { IOColors } from "../../core";
import { BaseTypography, H6 } from "../typography";

type Props = {
status: "default" | "focus" | "error";
secret?: boolean;
value?: string;
};

const styles = StyleSheet.create({
baseBox: {
alignItems: "center",
justifyContent: "center",
width: 35,
height: 60,
borderRadius: 8
},
defaultBox: {
borderWidth: 1,
borderColor: IOColors["grey-200"]
},
focusedBox: {
borderWidth: 2,
borderColor: IOColors["blueIO-500"]
},
errorBox: {
borderWidth: 1,
borderColor: IOColors["error-850"],
backgroundColor: IOColors["error-100"]
}
});

// FIXME Replace this component with H3 once the legacy look is deprecated https://pagopa.atlassian.net/browse/IOPLT-153
const SecretValue = () => (
<BaseTypography
font="DMMono"
weight="SemiBold"
color="bluegreyDark"
fontStyle={{ fontSize: 22, lineHeight: 33 }}
accessible={false}
>
{"•"}
</BaseTypography>
);

export const BoxedInput = ({ status, value, secret }: Props) => {
const derivedStyle = useMemo(() => {
switch (status) {
case "error":
return styles.errorBox;
case "focus":
return styles.focusedBox;
case "default":
default:
return styles.defaultBox;
}
}, [status]);
return (
<View style={[styles.baseBox, derivedStyle]} accessible={false}>
{value &&
(secret ? <SecretValue /> : <H6 accessible={false}>{value}</H6>)}
</View>
);
};
130 changes: 130 additions & 0 deletions src/components/otpInput/OTPInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as React from "react";
import { useEffect, useState } from "react";
CrisTofani marked this conversation as resolved.
Show resolved Hide resolved
import { Pressable, TextInput } from "react-native";
import Animated from "react-native-reanimated";
import { IOStyles } from "../../core/IOStyles";
import { LabelSmall } from "../typography";
import { triggerHaptic } from "../../functions";
import { VSpacer } from "../spacer";
import { useErrorShakeAnimation } from "../../utils/hooks/useErrorShakeAnimation";
import { BoxedInput } from "./BoxedInput";

type Props = {
value: string;
onValueChange: (value: string) => void;
length: number;
secret?: boolean;
autocomplete?: boolean;
onValidate?: (value: string) => boolean;
errorMessage?: string;
};

/**
* `OTPInput` is a component that allows the user to enter a one-time password.
* It has an hidden `TextInput` that is used to handle the keyboard and the focus.
* The input handles the autocompletion of the OTP code.
* @param value - The value of the OTP code
* @param onValueChange - The function to call when the value changes
* @param length - The length of the OTP code
* @param secret - If the OTP code should be hidden
* @param autocomplete - If the OTP code should be autocompleted
* @param onValidate - The function to call when the OTP code is validated
* @param errorMessage - The error message to display
* @returns
*/
export const OTPInput = ({
value,
onValueChange,
length,
onValidate,
errorMessage = "",
secret = false,
autocomplete = false
}: Props) => {
const [hasFocus, setHasFocus] = useState(false);
const [hasError, setHasError] = useState(false);
const [inputValue, setInputValue] = useState(value);

const { translate, animatedStyle, shakeAnimation } = useErrorShakeAnimation();

const inputRef = React.createRef<TextInput>();
const handleChange = (value: string) => {
if (value.length > length) {
return;
}
setInputValue(value);
onValueChange(value);
};

useEffect(() => {
if (onValidate && value.length === length) {
const isValid = onValidate(value);

if (!isValid) {
setHasError(true);
triggerHaptic("notificationError");

// eslint-disable-next-line functional/immutable-data
translate.value = shakeAnimation();

const timer = setTimeout(() => {
setHasError(false);
setInputValue("");
onValueChange("");
}, 500);
return () => clearTimeout(timer);
}
}
return;
}, [value, onValidate, length, onValueChange, translate, shakeAnimation]);
CrisTofani marked this conversation as resolved.
Show resolved Hide resolved

return (
<Animated.View style={[{ flexGrow: 1 }, animatedStyle]}>
<Pressable
onPress={() => {
inputRef.current?.focus();
setHasFocus(true);
}}
style={[IOStyles.row, { justifyContent: "space-around" }]}
accessible={true}
accessibilityLabel="OTP Input"
accessibilityValue={{ text: inputValue }}
>
<TextInput
value={inputValue}
onChangeText={handleChange}
style={{ position: "absolute", opacity: 0 }}
maxLength={length}
ref={inputRef}
onBlur={() => setHasFocus(false)}
keyboardType="numeric"
inputMode="numeric"
returnKeyType="done"
textContentType="oneTimeCode"
autoComplete={autocomplete ? "sms-otp" : undefined}
accessible={true}
/>
{[...Array(length)].map((_, i) => (
<BoxedInput
key={i}
status={
hasError
? "error"
: hasFocus && inputValue.length === i
? "focus"
: "default"
}
secret={secret}
value={inputValue[i]}
/>
))}
</Pressable>
<VSpacer size={4} />
{hasError && errorMessage && (
<LabelSmall color="error-850" style={{ textAlign: "center" }}>
{errorMessage}
</LabelSmall>
)}
</Animated.View>
);
};
1 change: 1 addition & 0 deletions src/components/otpInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./OTPInput";
Loading