Skip to content
Open
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
4 changes: 3 additions & 1 deletion src/__tests__/CountDown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {render, act, waitFor} from '@testing-library/react-native'
import dayjs from 'dayjs'
import {CountDown, CountDownRef} from '../components/CountDown'
Expand Down Expand Up @@ -365,7 +366,8 @@ describe('CountDown Component', () => {
const {getByText} = renderWithProvider(<CountDown value={30} fontSize={24} textColor="#FF0000" />)

const countdownText = getByText('30s')
expect(countdownText.props.style).toMatchObject({
const flatStyle = StyleSheet.flatten(countdownText.props.style)
expect(flatStyle).toMatchObject({
fontSize: 24,
color: '#FF0000',
})
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/RadioButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('RadioButton test', () => {
const {getByTestId} = renderWithProvider(<RadioButton initial={true} />)
const circle = getByTestId('circle')

expect(circle.props.style.backgroundColor).toBe('#004282')
expect(StyleSheet.flatten(circle.props.style).backgroundColor).toBe('#004282')
})

it('should be remain state', () => {
Expand All @@ -65,6 +65,6 @@ describe('RadioButton test', () => {
fireEvent.press(radionButton)
expect(onPressMock).not.toHaveBeenCalled()

expect(circle.props.style.backgroundColor).toBe('transparent')
expect(StyleSheet.flatten(circle.props.style).backgroundColor).toBe('transparent')
})
})
172 changes: 154 additions & 18 deletions src/components/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import React, {forwardRef, ForwardRefExoticComponent, useCallback, useImperativeHandle, useRef} from 'react'
import React, {
forwardRef,
ForwardRefExoticComponent,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import type {
StyleProp,
TextInputProps as RNTextInputProperties,
Expand All @@ -8,11 +16,11 @@ import type {
TouchableOpacityProps,
ViewProps,
} from 'react-native'
import {TextInput as RNTextInput, TouchableOpacity, StyleSheet, View} from 'react-native'
import {TextInput as RNTextInput, StyleSheet, TouchableOpacity, View} from 'react-native'
import Animated, {interpolate, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'
import styled from 'styled-components/native'
import TextInputOutlined from './TextInputOutlined'
import {CustomIcon, CustomIconProps, Error} from './components'
import {isIOS} from '../../helpers'
import {useTheme} from '../../hooks'
import TextInputFlat from './TextInputFlat'

Expand Down Expand Up @@ -61,6 +69,15 @@ export interface TextInputProps extends RNTextInputProperties {

/** If true, the text input will be focused when the user touches the input */
focusOnTouch?: boolean

/** If true, the label will animate from placeholder position to top-left when focused or has value */
animatedLabel?: boolean

/** Distance between the label and the input */
animatedLabelDistance?: number

/** Style for the animated label */
animatedLabelStyle?: StyleProp<TextStyle>
}

interface CompoundedComponent
Expand All @@ -77,6 +94,8 @@ export interface InputContainerProps {
isFocused?: boolean
}

const ANIMATION_DURATION = 150

export const TextInput = forwardRef<TextInputRef, TextInputProps>(
(
{
Expand All @@ -99,13 +118,43 @@ export const TextInput = forwardRef<TextInputRef, TextInputProps>(
onSubmitEditing,
onBlur,
focusOnTouch,
animatedLabel,
animatedLabelDistance,
animatedLabelStyle,
value,
defaultValue,
placeholder,
...rest
},
ref,
) => {
const TextInputTheme = useTheme().components.TextInput
const inputRef = useRef<RNTextInput>(null)

// Track focus state and internal value for animated label
const [isFocused, setIsFocused] = useState(false)
const [hasValue, setHasValue] = useState(!!value || !!defaultValue)

// Shared value for label position (0 = placeholder position, 1 = top position)
const labelAnimatedValue = useSharedValue(!!value || !!defaultValue ? 1 : 0)

// Update hasValue when controlled value changes
useEffect(() => {
if (value !== undefined) {
setHasValue(!!value)
}
}, [value])

// Animate label when focus or value changes
useEffect(() => {
if (animatedLabel && label) {
const shouldAnimate = isFocused || hasValue
labelAnimatedValue.value = withTiming(shouldAnimate ? 1 : 0, {
duration: ANIMATION_DURATION,
})
}
}, [isFocused, hasValue, animatedLabel, label, labelAnimatedValue])

useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => inputRef.current?.clear(),
Expand All @@ -116,18 +165,65 @@ export const TextInput = forwardRef<TextInputRef, TextInputProps>(
inputRef.current?.focus()
}, [])

const handleInputFocus = useCallback(() => {
setIsFocused(true)
onFocus?.()
}, [onFocus])

const handleInputBlur = useCallback(() => {
setIsFocused(false)
onBlur?.()
}, [onBlur])

const handleChangeText = useCallback(
(text: string) => {
setHasValue(!!text)
onChangeText?.(text)
},
[onChangeText],
)

const componentFocusOnTouch = focusOnTouch ?? TextInputTheme.focusOnTouch ?? false

const ContainerComponent = componentFocusOnTouch
? (TouchableOpacity as React.JSXElementConstructor<TouchableOpacityProps>)
: (View as React.JSXElementConstructor<ViewProps>)

// Determine if we should show animated label
const showAnimatedLabel = animatedLabel && !!label
const animatedLabelDistanceValue = animatedLabelDistance ?? TextInputTheme.animatedLabelDistance ?? 12

// Animated styles for label using reanimated
const animatedLabelStyleTransform = useAnimatedStyle(() => {
if (!showAnimatedLabel) {
return {}
}
return {
transform: [
{
translateY: interpolate(labelAnimatedValue.value, [0, 1], [0, -animatedLabelDistanceValue]),
},
{
scale: interpolate(labelAnimatedValue.value, [0, 1], [1, 0.85]),
},
],
}
}, [showAnimatedLabel, animatedLabelDistanceValue, labelAnimatedValue])

//
const textInputBasedStyle: StyleProp<ViewStyle> = animatedLabel
? {position: 'relative', top: animatedLabelDistanceValue}
: {}

// When animatedLabel is true, placeholder should be empty
const effectivePlaceholder = showAnimatedLabel ? '' : placeholder

return (
<ContainerComponent
style={[TextInputTheme.containerStyle, StyleSheet.flatten(containerStyle)]}
onPress={componentFocusOnTouch ? handleFocus : undefined}
activeOpacity={1}>
{!!label && (
{!!label && !showAnimatedLabel && (
<Title
testID="test-title"
style={[TextInputTheme.labelStyle, StyleSheet.flatten(labelStyle)]}
Expand All @@ -142,19 +238,42 @@ export const TextInput = forwardRef<TextInputRef, TextInputProps>(
onPress={handleFocus}
disabled={!editable}>
{!!leftComponent && leftComponent}
<TextInputComponent
testID="test-TextInputComponent"
ref={inputRef}
style={[TextInputTheme.inputStyle, StyleSheet.flatten(inputStyle)]}
editable={editable ?? TextInputTheme.editable}
multiline={multiline ?? TextInputTheme.multiline}
numberOfLines={numberOfLines ?? TextInputTheme.numberOfLines}
onChangeText={onChangeText}
onFocus={onFocus}
onSubmitEditing={onSubmitEditing}
onBlur={onBlur}
{...rest}
/>
<InputWrapper>
{showAnimatedLabel && (
<AnimatedLabelContainer
style={animatedLabelStyleTransform}
pointerEvents="none"
testID="test-animated-label">
<AnimatedLabelText
style={[
TextInputTheme.labelStyle,
TextInputTheme.animatedLabelStyle,
StyleSheet.flatten(labelStyle),
StyleSheet.flatten(animatedLabelStyle),
]}
{...labelProps}>
{label}
{!!isRequire && <StarText testID="test-startText"> *</StarText>}
</AnimatedLabelText>
</AnimatedLabelContainer>
)}
<TextInputComponent
testID="test-TextInputComponent"
ref={inputRef}
style={[textInputBasedStyle, TextInputTheme.inputStyle, StyleSheet.flatten(inputStyle)]}
editable={editable}
multiline={multiline ?? TextInputTheme.multiline}
numberOfLines={numberOfLines ?? TextInputTheme.numberOfLines}
onChangeText={handleChangeText}
onFocus={handleInputFocus}
onSubmitEditing={onSubmitEditing}
onBlur={handleInputBlur}
value={value}
defaultValue={defaultValue}
placeholder={effectivePlaceholder}
{...rest}
/>
</InputWrapper>
{!!rightComponent && rightComponent}
</TouchableContainer>
{!!errorText && <Error errorProps={errorProps} errorText={errorText} />}
Expand All @@ -170,10 +289,27 @@ const TouchableContainer = styled.TouchableOpacity(({theme}) => ({
alignItems: 'center',
}))

const InputWrapper = styled.View({
flex: 1,
justifyContent: 'center',
})

const AnimatedLabelContainer = styled(Animated.View)({
position: 'absolute',
left: 0,
right: 0,
transformOrigin: 'left center',
zIndex: 999,
})

const AnimatedLabelText = styled(Animated.Text)(({theme}) => ({
fontSize: theme?.fontSizes?.sm,
color: theme?.colors?.textColor,
}))

const Title = styled.Text(({theme}) => ({
fontSize: theme?.fontSizes?.xs,
color: theme?.colors?.textColor,
paddingLeft: isIOS ? 0 : theme?.spacing?.tiny,
paddingBottom: theme?.spacing?.tiny,
}))

Expand Down
16 changes: 15 additions & 1 deletion src/theme/components/TextInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,16 @@ export type TextInputThemeProps = Pick<TextInputProps, 'editable' | 'numberOfLin
/**
* Auto focus the input by touching it's container
*/
focusOnTouch: boolean
focusOnTouch?: boolean
/**
* Distance between the label and the input
*/
animatedLabelDistance?: number

/**
* Style for the animated label
*/
animatedLabelStyle?: StyleProp<TextStyle>
}

export const TextInputTheme: TextInputThemeProps = {
Expand Down Expand Up @@ -111,4 +120,9 @@ export const TextInputTheme: TextInputThemeProps = {
labelFontSize: 14,
errorFontSize: 12,
focusOnTouch: false,
animatedLabelDistance: 12,
animatedLabelStyle: {
fontSize: 16,
color: base.colors.black,
},
}
Loading