react-native-input-outline
react-native-input-outline copied to clipboard
Allow selectionColor changing
The TextInput is defined this way:
https://github.com/swushi/react-native-input-outline/blob/8bd774d8bb1bfc11d15b17fbf96135da91019bf4/src/components/InputOutline.tsx#L424-L436
The selectionColor from the props are replaced by the errorColor : activeColor.
In my case, where the app color is yellow/orange, the selectionColor is too strong, where the border color is ok
I have commented for now the lib selectionColor, so I can change it by myself. My ideia is to have
selectionColor={selectionColor ? (typeof selectionColor === string ? selectionColor : selectionColor(errorState()) : (errorState() ? errorColor : activeColor)}
and the selectionColor would be just a string or (errorState: boolean) => string, a function where the dev can set the color of the selection if there is an error or not.
If you wish, I can implement it.
If you wish, I can implement it.
@SrBrahma PR's are always welcome!
Hi, @swushi!
Now my todo-queue is quite long, so I can't make a proper PR for it now. I don't remember exactly all the changes I made in your lib for my personal uses and tastes back then, but here is my changed code, if you or someone else has interest:
(Note that as I was adapting it for my future personal components lib, there were some personal code styles changes)
import React, {
forwardRef, useCallback, useEffect, useImperativeHandle,
useMemo, useRef, useState,
} from 'react';
import {
LogBox, StyleSheet, Text, TextInput, TextInputProps,
TextStyle, TouchableWithoutFeedback, View,
} from 'react-native';
import Animated, {
Extrapolate, interpolate, interpolateColor,
useAnimatedStyle, useSharedValue, withTiming,
} from 'react-native-reanimated';
// color issue
LogBox.ignoreLogs(['You are setting the style `{ color: ... }` as a prop.']);
export interface InputOutlineMethods {
/** Requests focus for the given input or view. The exact behavior triggered will depend on the platform and type of view. */
focus: () => void;
/** Removes focus from an input or view. This is the opposite of focus() */
blur: () => void;
/** Returns current focus of input. */
isFocused: boolean;
/** Removes all text from the TextInput. */
clear: () => void;
}
export interface InputOutlineProps extends TextInputProps {
inputStyle?: TextStyle;
leftText?: string;
/** Placeholder for the textinput.
* @default Placeholder */
placeholder?: string;
/** Font size for TextInput.
* @default 14 */
fontSize?: number;
/** Color of TextInput font.
* @default 'black' */
fontColor?: string;
/** Font family for all fonts.
* @default undefined */
fontFamily?: string;
/** Vertical padding for TextInput Container. Used to calculate animations.
* @default 12 */
paddingVertical?: number;
/** Vertical padding for TextInput Container.
* @default 16 */
paddingHorizontal?: number;
/** Color when focused.
* @default 'blue' */
activeColor?: string;
/** Color when blurred (not focused).
* @default 'grey' */
inactiveColor?: string;
/** Background color of the InputOutline.
* @default 'white' */
backgroundColor?: string;
/** Error message is displayed. If anything is provided to error besides null or undefined, then the component is
* within an error state, thus displaying the error message provided here and errorColor.
* @default undefined */
error?: string;
/** Color that is displayed when in error state. Error state is anything that is not null or undefined.
* @default 'red' */
errorColor?: string;
/** Trailing Icon for the TextInput.
* @default undefined */
trailingIcon?: React.FC;
/** Border radius applied to container.
* @default 5 */
roundness?: number;
/** Will show a character count helper text and limit the characters being entered.
* @default undefined */
characterCount?: number;
characterCountFontSize?: number;
characterCountFontFamily?: string;
characterCountColor?: string;
/** Helper text that can be displayed to assist users with Inputs. `error` prop will override this.
* @default undefined */
assistiveText?: string;
/** Font size of assistive text.
* @default 10 */
assistiveTextFontSize?: number;
/** Color of assistive text.
* @default inactiveColor */
assistiveTextColor?: string;
/** Font family of assistive text.
* @default undefined */
assistiveFontFamily?: string;
/** Font size of error text.
* @default 10 */
errorFontSize?: number;
/** Font family of error text.
* @default undefined */
errorFontFamily?: string;
}
type InputOutline = InputOutlineMethods;
export const InputOutline = forwardRef<InputOutline, InputOutlineProps>((props, ref) => {
// establish provided props
const {
onBlur,
leftText,
inputStyle,
// theme colors
inactiveColor = 'grey',
activeColor = 'blue',
errorColor = 'red',
backgroundColor = 'white',
// fonts
fontSize = 14,
fontColor = 'black',
fontFamily,
error,
errorFontSize = 10,
errorFontFamily,
assistiveText,
assistiveTextFontSize = 10,
assistiveTextColor = inactiveColor,
assistiveFontFamily,
characterCount,
characterCountFontFamily,
characterCountColor = inactiveColor,
characterCountFontSize = 10,
// styling
paddingHorizontal = 16,
paddingVertical = 12,
roundness = 5,
style,
// features
placeholder = 'Placeholder',
trailingIcon,
// others
value: _providedValue = '',
onChangeText,
...inputProps
} = props;
// value of input
const [value, setValue] = useState(_providedValue);
// animation vars
const inputRef = useRef<TextInput>(null);
const placeholderMap = useSharedValue(_providedValue ? 1 : 0);
const placeholderSize = useSharedValue(0);
const colorMap = useSharedValue(0);
// helper functinos
const focus = () => inputRef.current?.focus();
const blur = () => inputRef.current?.blur();
const isFocused = () => Boolean(inputRef.current?.isFocused());
const clear = () => {
Boolean(inputRef.current?.clear());
setValue('');
};
const errorState = useCallback(
() => error !== null && error !== undefined,
[error],
);
const handleFocus = () => {
placeholderMap.value = withTiming(1); // focused
if (!errorState()) colorMap.value = withTiming(1); // active
focus();
};
const handleBlur = () => {
onBlur?.(null as any);
if (!value) placeholderMap.value = withTiming(0); // blur
if (!errorState()) colorMap.value = withTiming(0); // inactive
blur();
};
const handleChangeText = (text: string) => {
onChangeText && onChangeText(text);
setValue(text);
};
const handlePlaceholderLayout = useCallback(
({ nativeEvent }) => {
const { width } = nativeEvent.layout;
placeholderSize.value = width;
},
[placeholderSize],
);
const renderTrailingIcon = useCallback(() => {
if (trailingIcon) return trailingIcon({});
return null;
}, [trailingIcon]);
// handle value update
useEffect(() => {
if (_providedValue.length) placeholderMap.value = withTiming(1); // focused;
setValue(_providedValue);
}, [_providedValue, placeholderMap]);
// error handling
useEffect(() => {
if (errorState()) {
colorMap.value = 2; // error -- no animation here, snap to color immediately
} else {
colorMap.value = isFocused() ? 1 : 0; // to active or inactive color if focused
}
}, [error, colorMap, errorState]);
const animatedPlaceholderStyles = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(placeholderMap.value,
[0, 1],
[0, -(paddingVertical + fontSize * 0.7)],
),
},
{ scale: interpolate(placeholderMap.value, [0, 1], [1, 0.7]) },
{
translateX: interpolate(
placeholderMap.value,
[0, 1],
[0, -placeholderSize.value * 0.2],
),
},
],
}));
const animatedPlaceholderTextStyles = useAnimatedStyle(() => ({
color: interpolateColor(
colorMap.value,
[0, 1, 2],
[inactiveColor, activeColor, errorColor],
),
}));
const animatedPlaceholderSpacerStyles = useAnimatedStyle(() => ({
width: interpolate(
placeholderMap.value,
[0, 1],
[0, placeholderSize.value * 0.7 + 7],
Extrapolate.CLAMP,
),
}));
const animatedContainerStyle = useAnimatedStyle(() => ({
borderColor: placeholderSize.value > 0
? interpolateColor(
colorMap.value,
[0, 1, 2],
[inactiveColor, activeColor, errorColor],
)
: inactiveColor,
}));
useImperativeHandle(ref, () => ({
focus: handleFocus,
blur: handleBlur,
isFocused: isFocused(),
clear: clear,
}));
const styles = StyleSheet.create({
container: {
borderWidth: 1,
borderRadius: roundness,
alignSelf: 'stretch',
flexDirection: 'row',
backgroundColor,
},
inputContainer: {
flex: 1,
paddingVertical,
paddingHorizontal,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
input: {
textAlignVertical: ((inputProps?.numberOfLines ?? 1) > 1) ? 'top' : 'center',
textAlign: leftText ? 'right' : 'left',
flex: 1,
fontSize,
fontFamily,
color: fontColor,
},
placeholder: {
position: 'absolute',
top: paddingVertical,
left: paddingHorizontal,
},
placeholderText: {
fontSize,
fontFamily,
},
placeholderSpacer: {
position: 'absolute',
top: -1,
left: paddingHorizontal - 3,
backgroundColor,
height: 2,
},
errorText: {
position: 'absolute',
color: errorColor,
fontSize: errorFontSize,
fontFamily: errorFontFamily,
bottom: -errorFontSize - 7,
left: paddingHorizontal,
},
trailingIcon: {
position: 'absolute',
right: paddingHorizontal,
alignSelf: 'center',
},
counterText: {
position: 'absolute',
color: errorState() ? errorColor : characterCountColor,
fontSize: characterCountFontSize,
bottom: -characterCountFontSize - 7,
right: paddingHorizontal,
fontFamily: characterCountFontFamily,
},
assistiveText: {
position: 'absolute',
color: assistiveTextColor,
fontSize: assistiveTextFontSize,
bottom: -assistiveTextFontSize - 7,
left: paddingHorizontal,
fontFamily: assistiveFontFamily,
},
leftText: {
textAlignVertical: 'center',
fontSize: 14,
marginLeft: -4,
paddingRight: 4,
height: '100%',
color: '#555',
fontWeight: 'bold',
},
});
const placeholderStyle = useMemo(() => {
return [styles.placeholder, animatedPlaceholderStyles];
}, [styles.placeholder, animatedPlaceholderStyles]);
return (
<Animated.View style={[styles.container, animatedContainerStyle, style]}>
<TouchableWithoutFeedback onPress={handleFocus}>
<View style={styles.inputContainer}>
{!!leftText && <Text style={styles.leftText}>{'R$'}</Text>}
<TextInput
{...inputProps}
ref={inputRef}
style={[styles.input, inputStyle]}
pointerEvents={isFocused() ? 'auto' : 'none'}
onFocus={handleFocus}
onBlur={handleBlur}
onChangeText={handleChangeText}
maxLength={characterCount ? characterCount : undefined}
// selectionColor={errorState() ? errorColor : activeColor}
placeholder=""
value={value}
/>
</View>
</TouchableWithoutFeedback>
{trailingIcon && (
<View style={styles.trailingIcon}>{renderTrailingIcon()}</View>
)}
<Animated.View
style={[styles.placeholderSpacer, animatedPlaceholderSpacerStyles]}
/>
<Animated.View
style={placeholderStyle}
onLayout={handlePlaceholderLayout}
pointerEvents="none"
>
<Animated.Text style={[styles.placeholderText, animatedPlaceholderTextStyles]}>
{placeholder}
</Animated.Text>
</Animated.View>
{characterCount && (<Text style={styles.counterText}>{`${value.length} / ${characterCount}`}</Text>)}
{errorState()
? (<Text style={[styles.errorText]}>{error}</Text>)
: (assistiveText && (<Text style={[styles.assistiveText]}>{assistiveText}</Text>))
}
</Animated.View>
);
});
It still has a bug on the first render with the placeholder animation, if I am not mistaken. I don't have experience with RN animation so I couldn't figure out on how to fix it.
And I have a wrapper component for it using react-hook-form:
/* eslint-disable @typescript-eslint/ban-types */
import React, { useState } from 'react';
import { Control, useController } from 'react-hook-form';
import { TextInputProps as RNTextInputProps, ViewStyle } from 'react-native';
import { C } from '../../main/constsUi';
import { stringToNumber, valueToPriceString } from '../../utils/utils';
import { InputOutline, InputOutlineProps } from './InputOutline';
export type TextInputCoreProps = RNTextInputProps & {
containerStyle?: ViewStyle;
/** If should add marginTop.
*
* `true` will use a default value, but you may provide a number.
*
* @default false */
marginTop?: number | boolean;
icon?: JSX.Element;
// name: Names;
};
export type TextInputProps<T extends Control<any, object>> = InputOutlineProps & Omit<{
/** If will add a basic margin bottom.
* @default true */
marginBottom?: boolean;
control: T;
/** How you will get it with react-hook-form */
id: keyof T['_defaultValues'];
/** User-readable name of this input. */
title: string;
optional?: boolean;
required?: boolean;
preset?: 'email' | 'price';
maxLength?: number;
pretitle?: string;
min?: number;
/** This won't overwrite useForm defaultValues. */
}, 'defaultValue'>;
const validNumeric = (v: string | number) => {
if (typeof v === 'string')
v = Number(v.replace(',', '.'));
return !isNaN(v);
};
export function TextInput<T extends Control<any, object>>({
// marginTop: marginTopArg = true,
// icon,
id,
control,
defaultValue = '',
/** @default true */
optional: optionalProp = true,
/** @default false */
required: requiredProp = false,
title,
preset,
style,
min,
maxLength,
marginBottom = true,
leftText,
...props
}: TextInputProps<T>): JSX.Element {
const required = requiredProp || !optionalProp;
const isPrice = preset === 'price';
const isNumeric = !!(() => {
if (isPrice) return true;
})();
const mustBeNotNegative = !!(() => {
if (isPrice) return true;
})();
const maxDecimalPlaces: number | undefined = (() => {
if (isPrice) return 2;
})();
const { field, fieldState } = useController({
name: (id ?? 'notDefined') as any,
control: control as any,
defaultValue,
rules: {
required: { value: required, message: 'Campo requerido' },
maxLength,
validate: {
...isNumeric && { isNumeric: (v: number) => !isNaN(v) || 'Número inválido' },
...mustBeNotNegative && { mustBeNotNegative: (v: number) => v >= 0 || 'Deve ser positivo' },
...maxDecimalPlaces !== undefined && {
maxDecimalPlaces: (v: number) =>
((v.toString().split('.')[1] ?? []).length <= 2) || `>= ${maxDecimalPlaces} casas decimais`,
},
...min !== undefined && { minValue: (v: number) => v >= min || `Mínimo é ${min}` },
},
},
});
const getPrettyValue = (v: string | number): string => {
if (isPrice && validNumeric(v))
v = valueToPriceString(v, { preset: 'BRL', includeSymbol: false });
return String(v);
};
const [displayValue, setDisplayValue] = useState<string>(getPrettyValue(field.value));
const onBlur = (): void => {
if (isNumeric && displayValue === '') {
return setDisplayValue(getPrettyValue(0));
}
if (!fieldState.error)
return setDisplayValue(getPrettyValue(field.value));
};
const onChange = (v: string) => {
let logicalValue: string | number = v;
if (isNumeric) {
logicalValue = stringToNumber(v);
}
field.onChange(logicalValue as number);
setDisplayValue(v);
};
return <InputOutline
error={fieldState.error ? String(fieldState.error.message) : undefined}
activeColor={C.textInputSelected}
value={displayValue} // ensure string to avoid errors.
onChangeText={onChange}
style={[{ textAlignVertical: 'top' }, marginBottom && { marginBottom: 32 }, style]}
placeholder={title}
numberOfLines={1}
fontSize={18}
characterCountFontSize={12}
selectionColor={C.textInputSelection}
characterCount={maxLength}
onBlur={onBlur}
{...((): InputOutlineProps | undefined => {
switch (preset) {
case 'email': return {
textContentType: 'emailAddress',
autoCompleteType: 'email',
keyboardType: 'email-address',
autoCapitalize: 'none',
};
case 'price': return {
keyboardType: 'numeric',
leftText: leftText ?? 'R$', // TODO add i18n support
};
}
})()}
{...props}
/>;
}
The latter is quite messy and specific for portuguese language, but maybe you know how it's when developing an entire app alone hehe
My intention with both is to make a all-around text input component for not only this app but also my next ones. react-hook-form fits very nicely with your lib.