diff --git a/src/__tests__/CountDown.test.tsx b/src/__tests__/CountDown.test.tsx index 70aaa43..12bcf0e 100644 --- a/src/__tests__/CountDown.test.tsx +++ b/src/__tests__/CountDown.test.tsx @@ -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' @@ -365,7 +366,8 @@ describe('CountDown Component', () => { const {getByText} = renderWithProvider() const countdownText = getByText('30s') - expect(countdownText.props.style).toMatchObject({ + const flatStyle = StyleSheet.flatten(countdownText.props.style) + expect(flatStyle).toMatchObject({ fontSize: 24, color: '#FF0000', }) diff --git a/src/__tests__/RadioButton.test.tsx b/src/__tests__/RadioButton.test.tsx index 111f9a0..e81f022 100644 --- a/src/__tests__/RadioButton.test.tsx +++ b/src/__tests__/RadioButton.test.tsx @@ -54,7 +54,7 @@ describe('RadioButton test', () => { const {getByTestId} = renderWithProvider() 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', () => { @@ -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') }) }) diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index bf126cc..0816c83 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -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, @@ -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' @@ -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 } interface CompoundedComponent @@ -77,6 +94,8 @@ export interface InputContainerProps { isFocused?: boolean } +const ANIMATION_DURATION = 150 + export const TextInput = forwardRef( ( { @@ -99,6 +118,12 @@ export const TextInput = forwardRef( onSubmitEditing, onBlur, focusOnTouch, + animatedLabel, + animatedLabelDistance, + animatedLabelStyle, + value, + defaultValue, + placeholder, ...rest }, ref, @@ -106,6 +131,30 @@ export const TextInput = forwardRef( const TextInputTheme = useTheme().components.TextInput const inputRef = useRef(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(), @@ -116,18 +165,65 @@ export const TextInput = forwardRef( 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) : (View as React.JSXElementConstructor) + // 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 = animatedLabel + ? {position: 'relative', top: animatedLabelDistanceValue} + : {} + + // When animatedLabel is true, placeholder should be empty + const effectivePlaceholder = showAnimatedLabel ? '' : placeholder + return ( - {!!label && ( + {!!label && !showAnimatedLabel && ( ( 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} />} @@ -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, })) diff --git a/src/theme/components/TextInput.ts b/src/theme/components/TextInput.ts index 3b2e50c..5269c8c 100644 --- a/src/theme/components/TextInput.ts +++ b/src/theme/components/TextInput.ts @@ -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 = { @@ -111,4 +120,9 @@ export const TextInputTheme: TextInputThemeProps = { labelFontSize: 14, errorFontSize: 12, focusOnTouch: false, + animatedLabelDistance: 12, + animatedLabelStyle: { + fontSize: 16, + color: base.colors.black, + }, }