react-native-input-outline icon indicating copy to clipboard operation
react-native-input-outline copied to clipboard

Allow selectionColor changing

Open ftzi opened this issue 3 years ago • 2 comments

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.

ftzi avatar Oct 09 '21 05:10 ftzi

If you wish, I can implement it.

@SrBrahma PR's are always welcome!

swushi avatar Dec 06 '21 18:12 swushi

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.

ftzi avatar Dec 06 '21 18:12 ftzi