Skip to content

Commit 3488a48

Browse files
feat(text fields): implemented outlined text input
1 parent e0563c6 commit 3488a48

File tree

5 files changed

+261
-3
lines changed

5 files changed

+261
-3
lines changed

src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {PrimaryTabs, type PrimaryTabsProps} from './navigation/tabs/primary-tabs
1717
export {SecondaryTabs, type SecondaryTabsProps} from './navigation/tabs/secondary-tabs/SecondaryTabs.component';
1818

1919
export {FilledTextInput, type FilledTextInputProps} from './text-inputs/filled-text-input/FilledTextInput.component';
20+
export {OutlinedTextInput, type OutlinedTextInputProps} from './text-inputs/outlined-text-input/OutlinedTextInput.component';
2021

2122
export {
2223
CenterAlignedTopAppBar,

src/text-inputs/filled-text-input/FilledTextInput.component.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface FilledTextInputProps<T> extends TextInputProps {
4343
onOuterContainerLayout?: (e: LayoutChangeEvent) => void;
4444
}
4545

46-
const FOCUSED_LABEL_TOP_PLACEMENT = 8;
46+
const UNFOCUSED_LABEL_TOP_PLACEMENT = 8;
4747
const DEFAULT_LABEL_SMALL_FONT_SIZE = 12;
4848
const DEFAULT_LABEL_LARGE_FONT_SIZE = 16;
4949
const ACTIVE_INDICATOR_FOCUSED_HEIGHT = 3;
@@ -111,7 +111,7 @@ export const FilledTextInput = forwardRef(
111111
];
112112
const [unfocusedLabelFontSize, unfocusedLabelTopPlacement] = placeholder
113113
? [smallLabelFontSize, 0]
114-
: [largeLabelFontSize, FOCUSED_LABEL_TOP_PLACEMENT];
114+
: [largeLabelFontSize, UNFOCUSED_LABEL_TOP_PLACEMENT];
115115

116116
const labelAnimatedStyles = useAnimatedStyle(
117117
() => ({
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import React, {forwardRef, useCallback, useRef, useState, 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 {useTheme} from '../../theme/useTheme.hook';
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+
import {OUTLINED_TEXT_INPUT_CONTAINER_PADDING_VERTICAL, OUTLINED_TEXT_INPUT_CONTAINER_PADDING_HORIZONTAL, styles} from './outlined-text-input.styles';
22+
23+
export interface OutlinedTextInputProps<T> extends TextInputProps {
24+
label: string;
25+
26+
disabled?: boolean;
27+
errorText?: string;
28+
suportingText?: string;
29+
30+
leadingIconProps?: T;
31+
trailingIconProps?: T;
32+
trailingIcon?: React.FC<T>;
33+
leadingIcon?: React.FC<T>;
34+
35+
leadingComponent?: ReactNode;
36+
trailingComponent?: ReactNode;
37+
38+
labelStyle?: StyleProp<ViewStyle>;
39+
supportingTextStyle?: StyleProp<TextStyle>;
40+
innerContainerStyle?: StyleProp<ViewStyle>;
41+
outerContainerStyle?: StyleProp<ViewStyle>;
42+
43+
onOuterContainerLayout?: (e: LayoutChangeEvent) => void;
44+
}
45+
46+
const BORDER_FOCUSED_WIDTH = 3;
47+
const BORDER_UNFOCUSED_WIDTH = 1;
48+
const UNFOCUSED_LABEL_TOP_PLACEMENT = 8;
49+
const DEFAULT_LABEL_SMALL_FONT_SIZE = 12;
50+
const DEFAULT_LABEL_LARGE_FONT_SIZE = 16;
51+
const DEFAULT_LABEL_SMALL_LINE_HEIGHT = 15;
52+
const FOCUSED_LABEL_SLOT_PADDING_HORIZONTAL = 4;
53+
54+
export const OutlinedTextInput = forwardRef(
55+
<T extends IconProps>(
56+
{
57+
label,
58+
placeholder,
59+
60+
errorText,
61+
suportingText,
62+
disabled = false,
63+
64+
leadingIcon,
65+
trailingIcon,
66+
leadingIconProps = {} as T,
67+
trailingIconProps = {} as T,
68+
69+
leadingComponent,
70+
trailingComponent,
71+
72+
style,
73+
labelStyle,
74+
outerContainerStyle,
75+
innerContainerStyle,
76+
supportingTextStyle,
77+
78+
onFocus,
79+
onBlur,
80+
onOuterContainerLayout,
81+
...props
82+
}: OutlinedTextInputProps<T>,
83+
ref: React.Ref<TextInput>
84+
) => {
85+
const [labelWidth, setLabeWidth] = useState(0);
86+
const [labelDistanceToContainerStart, setLabelDistanceToContainerStart] = useState(0);
87+
88+
const {surfaceContainer} = useTheme();
89+
const {bodyLarge, bodySmall} = useTypography();
90+
91+
// eslint-disable-next-line react-hooks/rules-of-hooks
92+
const inputRef = (ref as React.MutableRefObject<TextInput>) || useRef<TextInput>(null);
93+
const {
94+
valueColor,
95+
selectionColor,
96+
placeholderColor,
97+
labelFocusedColor,
98+
trailingIconColor,
99+
leadingIconColor,
100+
labelUnfocusedColor,
101+
supportingTextColor,
102+
activeIndicatorFocusedColor,
103+
activeIndicatorUnfocusedColor,
104+
} = useTextInputColors({
105+
disabled,
106+
isError: (errorText?.length ?? 0) > 0,
107+
});
108+
const {focusAnim, focusAnimRange, focusInput, onInputBlur, onInputFocus} = useTextInputFocus({inputRef, onBlur, onFocus});
109+
110+
const LeadingIcon = leadingIcon;
111+
const TrailingIcon = errorText ? ErrorIcon : trailingIcon;
112+
113+
const [smallLabelFontSize, largeLabelFontSize, smallLabelHeight] = [
114+
bodySmall.fontSize ?? DEFAULT_LABEL_SMALL_FONT_SIZE,
115+
bodyLarge.fontSize ?? DEFAULT_LABEL_LARGE_FONT_SIZE,
116+
bodySmall.lineHeight ?? DEFAULT_LABEL_SMALL_LINE_HEIGHT,
117+
];
118+
119+
const labelFontSizeCoeff = placeholder ? 1 : smallLabelFontSize / largeLabelFontSize;
120+
const focusedLabelSlotWidth = labelWidth * labelFontSizeCoeff + FOCUSED_LABEL_SLOT_PADDING_HORIZONTAL * 2;
121+
const focusedLabelTop = -(OUTLINED_TEXT_INPUT_CONTAINER_PADDING_VERTICAL + smallLabelHeight / 2 + BORDER_FOCUSED_WIDTH / 2);
122+
123+
const focusedLabelStart = -(labelDistanceToContainerStart - OUTLINED_TEXT_INPUT_CONTAINER_PADDING_HORIZONTAL - BORDER_UNFOCUSED_WIDTH);
124+
const focusedLabelSlotStart = OUTLINED_TEXT_INPUT_CONTAINER_PADDING_HORIZONTAL - FOCUSED_LABEL_SLOT_PADDING_HORIZONTAL;
125+
const unfocusedLabelSlotStartWithoutPlaceholder = OUTLINED_TEXT_INPUT_CONTAINER_PADDING_HORIZONTAL + focusedLabelSlotWidth / 2;
126+
127+
const [unfocusedLabelFontSize, unfocusedLabelTop, unfocusedLabelStart, unfocusedLabelSlotWidth, unfocusedLabelSlotStart] = placeholder
128+
? [smallLabelFontSize, focusedLabelTop, focusedLabelStart, focusedLabelSlotWidth, focusedLabelSlotStart]
129+
: [largeLabelFontSize, UNFOCUSED_LABEL_TOP_PLACEMENT, 0, 0, unfocusedLabelSlotStartWithoutPlaceholder];
130+
131+
const labelSlotHeight = BORDER_FOCUSED_WIDTH * 4;
132+
const labelSlotTop = -(BORDER_FOCUSED_WIDTH + labelSlotHeight / 2);
133+
134+
const labelAnimatedStyles = useAnimatedStyle(() => ({
135+
top: interpolate(focusAnim.value, focusAnimRange, [unfocusedLabelTop, focusedLabelTop]),
136+
start: interpolate(focusAnim.value, focusAnimRange, [unfocusedLabelStart, focusedLabelStart]),
137+
color: interpolateColor(focusAnim.value, focusAnimRange, [labelUnfocusedColor, labelFocusedColor] as string[]),
138+
fontSize: interpolate(focusAnim.value, focusAnimRange, [unfocusedLabelFontSize, smallLabelFontSize]),
139+
}));
140+
141+
const focusedLabelSlot = useAnimatedStyle(() => ({
142+
top: labelSlotTop,
143+
start: interpolate(focusAnim.value, focusAnimRange, [unfocusedLabelSlotStart, focusedLabelSlotStart]),
144+
width: interpolate(focusAnim.value, focusAnimRange, [unfocusedLabelSlotWidth, focusedLabelSlotWidth]),
145+
height: labelSlotHeight,
146+
}));
147+
148+
const containerAnimatedStyles = useAnimatedStyle(() => ({
149+
borderColor: interpolateColor(focusAnim.value, focusAnimRange, [activeIndicatorUnfocusedColor, activeIndicatorFocusedColor] as string[]),
150+
borderWidth: interpolate(focusAnim.value, focusAnimRange, [BORDER_UNFOCUSED_WIDTH, BORDER_FOCUSED_WIDTH]),
151+
}));
152+
153+
const getLabelWidth = useCallback(
154+
(e: LayoutChangeEvent) => {
155+
if (!labelWidth) {
156+
setLabeWidth(e.nativeEvent.layout.width);
157+
}
158+
},
159+
[labelWidth]
160+
);
161+
162+
const getLabelDistanceToContainerStart = useCallback(
163+
(e: LayoutChangeEvent) => {
164+
if (!labelDistanceToContainerStart) {
165+
setLabelDistanceToContainerStart(e.nativeEvent.layout.x);
166+
}
167+
},
168+
[labelDistanceToContainerStart]
169+
);
170+
171+
return (
172+
<View style={outerContainerStyle} onLayout={onOuterContainerLayout}>
173+
<TouchableWithoutFeedback onPress={focusInput}>
174+
<Animated.View style={[styles.container, containerAnimatedStyles, innerContainerStyle]}>
175+
{leadingComponent}
176+
{LeadingIcon ? <LeadingIcon color={leadingIconColor} style={styles.leadingIcon} {...leadingIconProps} /> : null}
177+
<Animated.View style={[styles.labelSlot, {backgroundColor: surfaceContainer.backgroundLow}, focusedLabelSlot]} />
178+
<View onLayout={getLabelDistanceToContainerStart} style={[styles.inputWithLabelContainer]}>
179+
<Animated.Text onLayout={getLabelWidth} style={[styles.label, bodyLarge, labelAnimatedStyles, labelStyle]}>
180+
{label}
181+
</Animated.Text>
182+
<TextInput
183+
ref={inputRef}
184+
onBlur={onInputBlur}
185+
onFocus={onInputFocus}
186+
editable={!disabled}
187+
placeholder={placeholder}
188+
selectionColor={selectionColor}
189+
placeholderTextColor={placeholderColor}
190+
style={[styles.input, bodyLarge, {color: valueColor}, style]}
191+
{...props}
192+
/>
193+
</View>
194+
{trailingComponent}
195+
{TrailingIcon ? <TrailingIcon color={trailingIconColor} style={styles.trailingIcon} {...trailingIconProps} /> : null}
196+
</Animated.View>
197+
</TouchableWithoutFeedback>
198+
{suportingText || errorText ? (
199+
<Text style={[bodySmall, styles.supportingText, {color: supportingTextColor}, supportingTextStyle]}>{errorText ?? suportingText}</Text>
200+
) : null}
201+
</View>
202+
);
203+
}
204+
);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {StyleSheet} from 'react-native';
2+
3+
export const OUTLINED_TEXT_INPUT_CONTAINER_PADDING_VERTICAL = 8;
4+
export const OUTLINED_TEXT_INPUT_CONTAINER_PADDING_HORIZONTAL = 16;
5+
6+
export const styles = StyleSheet.create({
7+
container: {
8+
flexDirection: 'row',
9+
alignItems: 'center',
10+
11+
height: 56,
12+
paddingHorizontal: OUTLINED_TEXT_INPUT_CONTAINER_PADDING_HORIZONTAL,
13+
paddingVertical: OUTLINED_TEXT_INPUT_CONTAINER_PADDING_VERTICAL,
14+
borderRadius: 4,
15+
width: '100%',
16+
},
17+
inputWithLabelContainer: {
18+
flex: 1,
19+
20+
height: '100%',
21+
},
22+
input: {
23+
marginTop: 'auto',
24+
marginBottom: 'auto',
25+
paddingBottom: 0,
26+
paddingStart: 0,
27+
},
28+
label: {
29+
position: 'absolute',
30+
},
31+
activeIndicator: {
32+
position: 'absolute',
33+
zIndex: 1,
34+
bottom: 0,
35+
left: 0,
36+
right: 0,
37+
},
38+
supportingText: {
39+
alignSelf: 'flex-start',
40+
41+
marginHorizontal: 16,
42+
marginTop: 4,
43+
},
44+
leadingIcon: {
45+
marginEnd: 16,
46+
},
47+
trailingIcon: {
48+
marginStart: 16,
49+
},
50+
labelSlot: {
51+
position: 'absolute',
52+
},
53+
});

src/text-inputs/use-text-input-colors.hook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const useTextInputColors = ({disabled, isError}: UseTextInputColorsParams
5959
activeIndicatorFocusedColor: primaryColorBasedOnError,
6060
activeIndicatorUnfocusedColor: surfaceVariantColorBasedOnError,
6161
},
62-
[disabled]
62+
[disabled, disabledOnSurfaceColor, disabledSurfaceContaienerHighestColor, primaryColorBasedOnError, surfaceVariantColorBasedOnError]
6363
);
6464

6565
return {

0 commit comments

Comments
 (0)