swushi / react-native-input-outline

MIT License
123 stars 15 forks source link

Allow selectionColor changing #19

Open SrBrahma opened 3 years ago

SrBrahma commented 3 years ago

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

image

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.

swushi commented 2 years ago

If you wish, I can implement it.

@SrBrahma PR's are always welcome!

SrBrahma commented 2 years ago

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)

```tsx 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((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(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 ( {!!leftText && {'R$'}} {trailingIcon && ( {renderTrailingIcon()} )} {placeholder} {characterCount && ({`${value.length} / ${characterCount}`})} {errorState() ? ({error}) : (assistiveText && ({assistiveText})) } ); }); ```

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:

```tsx /* 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> = 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>({ // 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): 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(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 { 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.