Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/components/Input/InputOtp/InputOtp.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react'

import { InputOtp } from './InputOtp'

const meta: Meta<typeof InputOtp> = {
title: 'Form/InputOtp',
component: InputOtp,
args: { disabled: false, error: false, length: 4 },
argTypes: { onComplete: { action: 'onComplete' } },
}

export default meta

type Story = StoryObj<typeof InputOtp>

const InputOtpStory: Story = {}

export { InputOtpStory as InputOtp }
133 changes: 133 additions & 0 deletions src/components/Input/InputOtp/InputOtp.tsx
Original file line number Diff line number Diff line change
@@ -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<PressableProps, 'testOnly_pressed'> {
length: number
onComplete?: (value: string) => void
disabled?: boolean
error?: boolean
}

export const InputOtp = memo<InputOtpProps>(
({
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<TextInput>(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 (
<Pressable
disabled={disabled}
style={styles.container}
testID={testID}
testOnly_pressed={testOnly_pressed}
onPress={handlePress}
>
{({ pressed }) => (
<>
<View style={styles.content}>
{renderArray.map((_, index) => (
<InputOtpItem
disabled={disabled}
error={error}
focused={isFocused ? index === activeIndex : false}
// eslint-disable-next-line react/no-array-index-key
key={index}
pressed={pressed}
testID={`${testID}Item`}
value={value[index]}
/>
))}
</View>
<TextInput
keyboardType='number-pad'
maxLength={length}
ref={inputRef}
style={styles.input}
testID={`${testID}HiddenInput`}
value={value}
onBlur={handleBlur}
onChangeText={handleChange}
onFocus={handleFocus}
{...rest}
/>
</>
)}
</Pressable>
)
}
)

const useStyles = makeStyles(({ spacing }) => ({
container: {},

content: { flexDirection: 'row', gap: spacing.Gap['gap-2'] },

input: { position: 'absolute', width: 1, height: 1, opacity: 0 },
}))
95 changes: 95 additions & 0 deletions src/components/Input/InputOtp/InputOtpItem.tsx
Original file line number Diff line number Diff line change
@@ -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<ViewProps, 'testID'> {
value?: string
error: boolean
pressed: boolean
disabled: boolean
focused: boolean
}

const CURSOR_ANIMATION_DURATION = 500

export const InputOtpItem = memo<InputOtpItemProps>(
({ 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 (
<View
style={[
styles.container,
error && styles.error,
pressed && styles.pressed,
disabled && styles.disabled,
]}
>
<Text style={styles.text} testID={testID}>
{value}
{focused ? (
<Animated.Text style={[styles.cursor, cursorBlinking]}>
|
</Animated.Text>
) : null}
</Text>
</View>
)
}
)

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 },
}))
38 changes: 38 additions & 0 deletions src/components/Input/InputOtp/__tests__/InputOtp.test.tsx
Original file line number Diff line number Diff line change
@@ -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<InputOtpProps>({
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(<InputOtp {...props} />)

expect(renderInput.toJSON()).toMatchSnapshot()
}
)

test('Handle input', async () => {
const mockedOnComplete = jest.fn()

const { getByTestId } = render(
<InputOtp length={4} testID='InputOtp' onComplete={mockedOnComplete} />
)
const hiddenInput = getByTestId('InputOtpHiddenInput')

fireEvent.changeText(hiddenInput, '55')

expect(mockedOnComplete).not.toHaveBeenCalled()

fireEvent.changeText(hiddenInput, '5543')

expect(mockedOnComplete).toHaveBeenCalledWith('5543')
})
})
Loading
Loading