Skip to content

Commit e0563c6

Browse files
feat(text fields): implemented filled text input
1 parent 056a566 commit e0563c6

File tree

8 files changed

+357
-0
lines changed

8 files changed

+357
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from 'react';
2+
import Svg, {Path} from 'react-native-svg';
3+
4+
import {path} from './path.json';
5+
import type {IconProps} from '../icon-props';
6+
7+
const DEFAULT_SIZE = 24;
8+
const DEFAULT_COLOR = '#000';
9+
10+
export const ErrorIcon: React.FC<IconProps> = ({color = DEFAULT_COLOR, size = DEFAULT_SIZE, ...props}) => (
11+
<Svg viewBox="0 0 24 24" width={size} height={size} fill={'none'} {...props}>
12+
<Path fill={color} d={path} />
13+
</Svg>
14+
);

src/icons/error-icon/path.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"path": "M12 17c0.283 0 0.521-0.096 0.712-0.288s0.288-0.429 0.288-0.712c0-0.283-0.096-0.521-0.288-0.713s-0.429-0.288-0.712-0.288-0.521 0.096-0.712 0.288c-0.192 0.192-0.288 0.429-0.288 0.713s0.096 0.521 0.288 0.712c0.192 0.192 0.429 0.288 0.712 0.288zM11 13h2v-6h-2v6zM12 22c-1.383 0-2.683-0.262-3.9-0.787s-2.275-1.238-3.175-2.138c-0.9-0.9-1.612-1.958-2.137-3.175s-0.788-2.517-0.788-3.9c0-1.383 0.262-2.683 0.787-3.9s1.238-2.275 2.137-3.175c0.9-0.9 1.958-1.612 3.175-2.137s2.517-0.788 3.9-0.788c1.383 0 2.683 0.262 3.9 0.787s2.275 1.237 3.175 2.138c0.9 0.9 1.613 1.958 2.138 3.175s0.788 2.517 0.788 3.9c0 1.383-0.263 2.683-0.788 3.9s-1.237 2.275-2.138 3.175-1.958 1.613-3.175 2.138c-1.217 0.525-2.517 0.787-3.9 0.787z"
3+
}

src/icons/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {PlusIcon} from './plus-icon/PlusIcon.component';
44
export {PhotoIcon} from './photo-icon/PhotoIcon.component';
55
export {TodayIcon} from './today-icon/TodayIcon.component';
66
export {CloseIcon} from './close-icon/CloseIcon.component';
7+
export {ErrorIcon} from './error-icon/ErrorIcon.component';
78
export {DeleteIcon} from './delete-icon/DeleteIcon.component';
89
export {SearchIcon} from './search-icon/SearchIcon.component';
910
export {CommuteIcon} from './commute-icon/CommuteIcon.component';

src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export {SuggestionChip, type SuggestionChipProps} from './chips/suggestion-chip/
1616
export {PrimaryTabs, type PrimaryTabsProps} from './navigation/tabs/primary-tabs/PrimaryTabs.component';
1717
export {SecondaryTabs, type SecondaryTabsProps} from './navigation/tabs/secondary-tabs/SecondaryTabs.component';
1818

19+
export {FilledTextInput, type FilledTextInputProps} from './text-inputs/filled-text-input/FilledTextInput.component';
20+
1921
export {
2022
CenterAlignedTopAppBar,
2123
type CenterAlignedTopAppBarProps,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import React, {forwardRef, useRef, type ReactNode} from 'react';
2+
import Animated, {useAnimatedStyle, interpolateColor, interpolate} from 'react-native-reanimated';
3+
import {
4+
Text,
5+
View,
6+
TextInput,
7+
TouchableWithoutFeedback,
8+
type StyleProp,
9+
type ViewStyle,
10+
type TextStyle,
11+
type TextInputProps,
12+
type LayoutChangeEvent,
13+
} from 'react-native';
14+
15+
import {ErrorIcon} from '../../icons';
16+
import {styles} from './filled-text-input.styles';
17+
import {type IconProps} from '../../icons/icon-props';
18+
import {useTextInputColors} from '../use-text-input-colors.hook';
19+
import {useTextInputFocus} from '../use-text-input-focus-anim.hook';
20+
import {useTypography} from '../../typography/useTypography.component';
21+
22+
export interface FilledTextInputProps<T> extends TextInputProps {
23+
label: string;
24+
25+
disabled?: boolean;
26+
errorText?: string;
27+
suportingText?: string;
28+
29+
leadingIconProps?: T;
30+
trailingIconProps?: T;
31+
trailingIcon?: React.FC<T>;
32+
leadingIcon?: React.FC<T>;
33+
34+
leadingComponent?: ReactNode;
35+
trailingComponent?: ReactNode;
36+
37+
labelStyle?: StyleProp<ViewStyle>;
38+
supportingTextStyle?: StyleProp<TextStyle>;
39+
innerContainerStyle?: StyleProp<ViewStyle>;
40+
outerContainerStyle?: StyleProp<ViewStyle>;
41+
activeIndicatorStyle?: StyleProp<ViewStyle>;
42+
43+
onOuterContainerLayout?: (e: LayoutChangeEvent) => void;
44+
}
45+
46+
const FOCUSED_LABEL_TOP_PLACEMENT = 8;
47+
const DEFAULT_LABEL_SMALL_FONT_SIZE = 12;
48+
const DEFAULT_LABEL_LARGE_FONT_SIZE = 16;
49+
const ACTIVE_INDICATOR_FOCUSED_HEIGHT = 3;
50+
const ACTIVE_INDICATOR_UNFOCUSED_HEIGHT = 1;
51+
52+
export const FilledTextInput = forwardRef(
53+
<T extends IconProps>(
54+
{
55+
label,
56+
placeholder,
57+
58+
errorText,
59+
suportingText,
60+
disabled = false,
61+
62+
leadingIcon,
63+
trailingIcon,
64+
leadingIconProps = {} as T,
65+
trailingIconProps = {} as T,
66+
67+
leadingComponent,
68+
trailingComponent,
69+
70+
style,
71+
labelStyle,
72+
outerContainerStyle,
73+
innerContainerStyle,
74+
supportingTextStyle,
75+
activeIndicatorStyle,
76+
77+
onFocus,
78+
onBlur,
79+
onOuterContainerLayout,
80+
...props
81+
}: FilledTextInputProps<T>,
82+
ref: React.Ref<TextInput>
83+
) => {
84+
const {bodyLarge, bodySmall} = useTypography();
85+
// eslint-disable-next-line react-hooks/rules-of-hooks
86+
const inputRef = (ref as React.MutableRefObject<TextInput>) || useRef<TextInput>(null);
87+
const {
88+
valueColor,
89+
containerColor,
90+
selectionColor,
91+
placeholderColor,
92+
labelFocusedColor,
93+
trailingIconColor,
94+
leadingIconColor,
95+
labelUnfocusedColor,
96+
supportingTextColor,
97+
activeIndicatorFocusedColor,
98+
activeIndicatorUnfocusedColor,
99+
} = useTextInputColors({
100+
disabled,
101+
isError: (errorText?.length ?? 0) > 0,
102+
});
103+
const {focusAnim, focusAnimRange, focusInput, onInputBlur, onInputFocus} = useTextInputFocus({inputRef, onBlur, onFocus});
104+
105+
const LeadingIcon = leadingIcon;
106+
const TrailingIcon = errorText ? ErrorIcon : trailingIcon;
107+
108+
const [smallLabelFontSize, largeLabelFontSize] = [
109+
bodySmall.fontSize ?? DEFAULT_LABEL_SMALL_FONT_SIZE,
110+
bodyLarge.fontSize ?? DEFAULT_LABEL_LARGE_FONT_SIZE,
111+
];
112+
const [unfocusedLabelFontSize, unfocusedLabelTopPlacement] = placeholder
113+
? [smallLabelFontSize, 0]
114+
: [largeLabelFontSize, FOCUSED_LABEL_TOP_PLACEMENT];
115+
116+
const labelAnimatedStyles = useAnimatedStyle(
117+
() => ({
118+
top: interpolate(focusAnim.value, focusAnimRange, [unfocusedLabelTopPlacement, 0]),
119+
color: interpolateColor(focusAnim.value, focusAnimRange, [labelUnfocusedColor, labelFocusedColor] as string[]),
120+
fontSize: interpolate(focusAnim.value, focusAnimRange, [unfocusedLabelFontSize, smallLabelFontSize]),
121+
}),
122+
[labelUnfocusedColor, labelFocusedColor, unfocusedLabelTopPlacement, unfocusedLabelFontSize, smallLabelFontSize]
123+
);
124+
125+
const activeIndicatorAnimatedStyles = useAnimatedStyle(
126+
() => ({
127+
backgroundColor: interpolateColor(focusAnim.value, focusAnimRange, [activeIndicatorUnfocusedColor, activeIndicatorFocusedColor] as string[]),
128+
height: interpolate(focusAnim.value, focusAnimRange, [ACTIVE_INDICATOR_UNFOCUSED_HEIGHT, ACTIVE_INDICATOR_FOCUSED_HEIGHT]),
129+
}),
130+
[activeIndicatorUnfocusedColor, activeIndicatorFocusedColor]
131+
);
132+
133+
return (
134+
<View style={outerContainerStyle} onLayout={onOuterContainerLayout}>
135+
<TouchableWithoutFeedback onPress={focusInput}>
136+
<Animated.View style={[styles.container, {backgroundColor: containerColor}, innerContainerStyle]}>
137+
{leadingComponent}
138+
{LeadingIcon ? <LeadingIcon color={leadingIconColor} style={styles.leadingIcon} {...leadingIconProps} /> : null}
139+
<View style={[styles.inputWithLabelContainer]}>
140+
<Animated.Text style={[styles.label, bodyLarge, labelAnimatedStyles, labelStyle]}>{label}</Animated.Text>
141+
<TextInput
142+
ref={inputRef}
143+
onBlur={onInputBlur}
144+
onFocus={onInputFocus}
145+
editable={!disabled}
146+
placeholder={placeholder}
147+
selectionColor={selectionColor}
148+
placeholderTextColor={placeholderColor}
149+
style={[styles.input, bodyLarge, {color: valueColor}, style]}
150+
{...props}
151+
/>
152+
</View>
153+
{TrailingIcon ? <TrailingIcon color={trailingIconColor} style={styles.trailingIcon} {...trailingIconProps} /> : null}
154+
{trailingComponent}
155+
<Animated.View style={[styles.activeIndicator, activeIndicatorAnimatedStyles, activeIndicatorStyle]} />
156+
</Animated.View>
157+
</TouchableWithoutFeedback>
158+
{suportingText || errorText ? (
159+
<Text style={[bodySmall, styles.supportingText, {color: supportingTextColor}, supportingTextStyle]}>{errorText ?? suportingText}</Text>
160+
) : null}
161+
</View>
162+
);
163+
}
164+
);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {StyleSheet} from 'react-native';
2+
3+
export const styles = StyleSheet.create({
4+
container: {
5+
flexDirection: 'row',
6+
alignItems: 'center',
7+
8+
height: 56,
9+
paddingHorizontal: 16,
10+
paddingVertical: 8,
11+
borderTopStartRadius: 4,
12+
borderTopEndRadius: 4,
13+
width: '100%',
14+
},
15+
inputWithLabelContainer: {
16+
flex: 1,
17+
18+
height: '100%',
19+
},
20+
input: {
21+
marginTop: 'auto',
22+
paddingBottom: 0,
23+
paddingStart: 0,
24+
},
25+
label: {
26+
position: 'absolute',
27+
},
28+
activeIndicator: {
29+
position: 'absolute',
30+
zIndex: 1,
31+
bottom: 0,
32+
left: 0,
33+
right: 0,
34+
},
35+
supportingText: {
36+
alignSelf: 'flex-start',
37+
38+
marginHorizontal: 16,
39+
marginTop: 4,
40+
},
41+
leadingIcon: {
42+
marginEnd: 16,
43+
},
44+
trailingIcon: {
45+
marginStart: 16,
46+
},
47+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {useMemo} from 'react';
2+
import {useTheme} from '../theme/useTheme.hook';
3+
import {convertToRGBA} from '../utils/convert-to-rgba';
4+
5+
interface UseTextInputColorsParams {
6+
isError: boolean;
7+
disabled: boolean;
8+
}
9+
10+
export const useTextInputColors = ({disabled, isError}: UseTextInputColorsParams) => {
11+
const {surface, surfaceContainer, error, primary} = useTheme();
12+
13+
const primaryColorBasedOnError = isError ? error.background : primary.background;
14+
const surfaceVariantColorBasedOnError = isError ? error.background : surface.textVariant;
15+
16+
const [disabledOnSurfaceColor, disabledSurfaceContaienerHighestColor] = useMemo(
17+
() => [convertToRGBA(surface.text as string, 0.38), convertToRGBA(surfaceContainer.backgroundHighest as string, 0.38)],
18+
[surface, surfaceContainer]
19+
);
20+
21+
const {
22+
valueColor,
23+
containerColor,
24+
selectionColor,
25+
placeholderColor,
26+
labelFocusedColor,
27+
trailingIconColor,
28+
leadingIconColor,
29+
labelUnfocusedColor,
30+
supportingTextColor,
31+
activeIndicatorFocusedColor,
32+
activeIndicatorUnfocusedColor,
33+
} = useMemo(
34+
() =>
35+
disabled
36+
? {
37+
valueColor: disabledOnSurfaceColor,
38+
containerColor: disabledSurfaceContaienerHighestColor,
39+
selectionColor: disabledOnSurfaceColor,
40+
placeholderColor: disabledOnSurfaceColor,
41+
labelFocusedColor: disabledOnSurfaceColor,
42+
trailingIconColor: disabledOnSurfaceColor,
43+
leadingIconColor: disabledOnSurfaceColor,
44+
labelUnfocusedColor: disabledOnSurfaceColor,
45+
supportingTextColor: disabledOnSurfaceColor,
46+
activeIndicatorFocusedColor: disabledOnSurfaceColor,
47+
activeIndicatorUnfocusedColor: disabledOnSurfaceColor,
48+
}
49+
: {
50+
valueColor: surface.text,
51+
containerColor: surfaceContainer.backgroundHighest,
52+
selectionColor: primaryColorBasedOnError,
53+
placeholderColor: surface.textVariant,
54+
labelFocusedColor: primaryColorBasedOnError,
55+
trailingIconColor: surfaceVariantColorBasedOnError,
56+
leadingIconColor: surface.textVariant,
57+
labelUnfocusedColor: surfaceVariantColorBasedOnError,
58+
supportingTextColor: surfaceVariantColorBasedOnError,
59+
activeIndicatorFocusedColor: primaryColorBasedOnError,
60+
activeIndicatorUnfocusedColor: surfaceVariantColorBasedOnError,
61+
},
62+
[disabled]
63+
);
64+
65+
return {
66+
valueColor,
67+
containerColor,
68+
selectionColor,
69+
placeholderColor,
70+
labelFocusedColor,
71+
trailingIconColor,
72+
leadingIconColor,
73+
labelUnfocusedColor,
74+
supportingTextColor,
75+
activeIndicatorFocusedColor,
76+
activeIndicatorUnfocusedColor,
77+
};
78+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {useCallback} from 'react';
2+
import {useSharedValue, withTiming} from 'react-native-reanimated';
3+
import {TextInput, type TextInputProps, type NativeSyntheticEvent, type TextInputFocusEventData} from 'react-native';
4+
5+
interface useTextInputFocusParams extends Pick<TextInputProps, 'onFocus' | 'onBlur'> {
6+
inputRef: React.MutableRefObject<TextInput>;
7+
}
8+
9+
export const useTextInputFocus = ({inputRef, onFocus, onBlur}: useTextInputFocusParams) => {
10+
const focusAnim = useSharedValue(0);
11+
12+
const onInputFocus = useCallback(
13+
(e: NativeSyntheticEvent<TextInputFocusEventData>) => {
14+
focusAnim.value = withTiming(1);
15+
16+
if (onFocus) {
17+
onFocus(e);
18+
}
19+
},
20+
[onFocus]
21+
);
22+
23+
const onInputBlur = useCallback(
24+
(e: NativeSyntheticEvent<TextInputFocusEventData>) => {
25+
focusAnim.value = withTiming(0);
26+
27+
if (onBlur) {
28+
onBlur(e);
29+
}
30+
},
31+
[onBlur]
32+
);
33+
34+
const focusInput = useCallback(() => {
35+
if (!inputRef.current?.isFocused()) {
36+
inputRef.current?.focus();
37+
}
38+
}, []);
39+
40+
return {
41+
focusAnim,
42+
focusAnimRange: [0, 1],
43+
44+
focusInput,
45+
onInputBlur,
46+
onInputFocus,
47+
};
48+
};

0 commit comments

Comments
 (0)