diff --git a/src/components/Input/InputOtp/InputOtp.stories.ts b/src/components/Input/InputOtp/InputOtp.stories.ts new file mode 100644 index 0000000..94fd7cf --- /dev/null +++ b/src/components/Input/InputOtp/InputOtp.stories.ts @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { InputOtp } from './InputOtp' + +const meta: Meta = { + title: 'Form/InputOtp', + component: InputOtp, + args: { disabled: false, error: false, length: 4 }, + argTypes: { onComplete: { action: 'onComplete' } }, +} + +export default meta + +type Story = StoryObj + +const InputOtpStory: Story = {} + +export { InputOtpStory as InputOtp } diff --git a/src/components/Input/InputOtp/InputOtp.tsx b/src/components/Input/InputOtp/InputOtp.tsx new file mode 100644 index 0000000..f62dd15 --- /dev/null +++ b/src/components/Input/InputOtp/InputOtp.tsx @@ -0,0 +1,133 @@ +import { memo, useCallback, useMemo, useRef, useState } from 'react' + +import { + Pressable, + View, + TextInput, + type TextInputProps, + type PressableProps, +} from 'react-native' + +import { makeStyles } from '../../../utils/makeStyles' + +import { InputOtpItem } from './InputOtpItem' + +export interface InputOtpProps + extends Omit< + TextInputProps, + | 'value' + | 'onChangeText' + | 'onFocus' + | 'onBlur' + | 'ref' + | 'keyboardType' + | 'style' + >, + Pick { + length: number + onComplete?: (value: string) => void + disabled?: boolean + error?: boolean +} + +export const InputOtp = memo( + ({ + length, + onComplete, + disabled = false, + error = false, + testOnly_pressed, + testID, + ...rest + }) => { + const styles = useStyles() + const [value, setValue] = useState('') + const [isFocused, setIsFocused] = useState(false) + + const inputRef = useRef(null) + + const handlePress = useCallback(() => { + inputRef.current?.focus() + }, []) + + const handleChange = useCallback( + (text: string) => { + const sanitizedText = text.replace(/[^0-9]/g, '') + setValue(sanitizedText) + + if (sanitizedText.length === length) { + onComplete?.(sanitizedText) + inputRef.current?.blur() + } + }, + [length, onComplete] + ) + + const handleFocus = useCallback(() => { + setIsFocused(true) + }, []) + + const handleBlur = useCallback(() => { + setIsFocused(false) + }, []) + + const activeIndex = useMemo( + () => Math.min(value.length, length - 1), + [value.length, length] + ) + + const renderArray = useMemo( + () => Array.from({ length }).fill(null), + [length] + ) + + return ( + + {({ pressed }) => ( + <> + + {renderArray.map((_, index) => ( + + ))} + + + + )} + + ) + } +) + +const useStyles = makeStyles(({ spacing }) => ({ + container: {}, + + content: { flexDirection: 'row', gap: spacing.Gap['gap-2'] }, + + input: { position: 'absolute', width: 1, height: 1, opacity: 0 }, +})) diff --git a/src/components/Input/InputOtp/InputOtpItem.tsx b/src/components/Input/InputOtp/InputOtpItem.tsx new file mode 100644 index 0000000..5789568 --- /dev/null +++ b/src/components/Input/InputOtp/InputOtpItem.tsx @@ -0,0 +1,95 @@ +import { memo, useEffect } from 'react' +import { View, type ViewProps, Text } from 'react-native' + +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated' + +import { makeStyles } from '../../../utils/makeStyles' + +export interface InputOtpItemProps extends Pick { + value?: string + error: boolean + pressed: boolean + disabled: boolean + focused: boolean +} + +const CURSOR_ANIMATION_DURATION = 500 + +export const InputOtpItem = memo( + ({ value, error, pressed, disabled, focused, testID }) => { + const styles = useStyles() + + const opacity = useSharedValue(1) + + useEffect(() => { + if (focused) { + opacity.value = withRepeat( + withTiming(0.2, { + duration: CURSOR_ANIMATION_DURATION, + easing: Easing.ease, + }), + -1, + true + ) + } else { + opacity.value = 1 + } + }, [focused, opacity]) + + const cursorBlinking = useAnimatedStyle(() => ({ opacity: opacity.value })) + + return ( + + + {value} + {focused ? ( + + | + + ) : null} + + + ) + } +) + +const useStyles = makeStyles(({ theme, border, fonts, typography }) => ({ + container: { + minHeight: theme.Button.Common.buttonHeight, + minWidth: theme.Button.Common.buttonHeight, + paddingHorizontal: theme.Form.InputText.inputPaddingLeftRight, + paddingVertical: theme.Form.InputText.inputPaddingTopBottom, + borderBottomWidth: border.Width.border, + borderColor: theme.Form.InputText.inputBorderColor, + alignItems: 'center', + justifyContent: 'center', + }, + + text: { + fontSize: typography.Size['text-2xl'], + fontFamily: fonts.secondary, + fontWeight: '400', + color: theme.Form.InputText.inputTextColor, + }, + + pressed: { borderColor: theme.Form.InputText.inputHoverBorderColor }, + + error: { borderColor: theme.Form.InputText.inputErrorBorderColor }, + + disabled: { mixBlendMode: 'luminosity', opacity: 0.6 }, + + cursor: { color: theme.Form.InputText.inputFocusBorderColor }, +})) diff --git a/src/components/Input/InputOtp/__tests__/InputOtp.test.tsx b/src/components/Input/InputOtp/__tests__/InputOtp.test.tsx new file mode 100644 index 0000000..3b023b5 --- /dev/null +++ b/src/components/Input/InputOtp/__tests__/InputOtp.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render } from '@testing-library/react-native' + +import { InputOtp, type InputOtpProps } from '../InputOtp' + +describe('InputOtp component tests', () => { + const inputSnapshotCases = generatePropsCombinations({ + disabled: [true, false], + error: [true, false], + testOnly_pressed: [true, false], + length: [2, 4, 8], + }) + + test.each(inputSnapshotCases)( + 'length - $length, error - $error, disabled - $disabled, pressed - $testOnly_pressed', + (props) => { + const renderInput = render() + + expect(renderInput.toJSON()).toMatchSnapshot() + } + ) + + test('Handle input', async () => { + const mockedOnComplete = jest.fn() + + const { getByTestId } = render( + + ) + const hiddenInput = getByTestId('InputOtpHiddenInput') + + fireEvent.changeText(hiddenInput, '55') + + expect(mockedOnComplete).not.toHaveBeenCalled() + + fireEvent.changeText(hiddenInput, '5543') + + expect(mockedOnComplete).toHaveBeenCalledWith('5543') + }) +}) diff --git a/src/components/Input/InputOtp/__tests__/__snapshots__/InputOtp.test.tsx.snap b/src/components/Input/InputOtp/__tests__/__snapshots__/InputOtp.test.tsx.snap new file mode 100644 index 0000000..7a1c09e --- /dev/null +++ b/src/components/Input/InputOtp/__tests__/__snapshots__/InputOtp.test.tsx.snap @@ -0,0 +1,5353 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InputOtp component tests length - 2, error - false, disabled - false, pressed - false 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 2, error - false, disabled - false, pressed - true 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 2, error - false, disabled - true, pressed - false 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 2, error - false, disabled - true, pressed - true 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 2, error - true, disabled - false, pressed - false 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 2, error - true, disabled - false, pressed - true 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 2, error - true, disabled - true, pressed - false 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 2, error - true, disabled - true, pressed - true 1`] = ` + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - false, disabled - false, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - false, disabled - false, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - false, disabled - true, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - false, disabled - true, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - true, disabled - false, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - true, disabled - false, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - true, disabled - true, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 4, error - true, disabled - true, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - false, disabled - false, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - false, disabled - false, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - false, disabled - true, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - false, disabled - true, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - true, disabled - false, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - true, disabled - false, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - true, disabled - true, pressed - false 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`InputOtp component tests length - 8, error - true, disabled - true, pressed - true 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`;