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}
-
+
+ {showAnimatedLabel && (
+
+
+ {label}
+ {!!isRequire && *}
+
+
+ )}
+
+
{!!rightComponent && rightComponent}
{!!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
}
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,
+ },
}