Home Reference Source

src/components/controls/InputMask/InputMask.jsx

import React from 'react';
import PropTypes from 'prop-types';
import MaskedInput from 'react-text-mask';
import cn from 'classnames';
import { isEqual } from 'lodash';
import createNumberMask from 'text-mask-addons/dist/createNumberMask';

/**
 * Компонент интерфейса разбивки по страницам
 * @reactProps {string} className - кастомный css-клсасс
 * @reactProps {string} preset - пресет для маски. Варианты: phone(телефон), post-code(почтовый индекс), date(дата), money(деньги), percentage(процент), card (кредитная карта)
 * @reactProps {string|array|function} mask - маска. Стандартная конфигурация: 9 - цифра, S - английская буква, Б - русская буква. Дополнительную конфигурациюю можно осуществить, используя проперти dictionary
 * @reactProps {function} onChange - выполняется при изменении значения поля
 * @reactProps {string} placeholder - плэйсходер для поля
 * @reactProps {string} placeholderChar - символ, который будет на месте незаполненного символа маски
 * @reactProps {string} value - максимальное кол-во кнопок перехода между страницами
 * @reactProps {number} dictionary - дополнительные символы-ключи для маски
 * @reactProps {boolean} guide - @see https://github.com/text-mask/text-mask/blob/master/componentDocumentation.md#guide
 * @reactProps {boolean} keepCharPositions - @see https://github.com/text-mask/text-mask/blob/master/componentDocumentation.md#keepcharpositions
 * @reactProps {boolean} resetOnNotValid - сбрасывать / оставлять невалижное значение при потере фокуса
 * @reactProps {object} presetConfig - настройки пресета для InputMoney
 * @example
 * <InputMask onChange={this.onChange}
 *             mask="99 x 99"
 *             dictionary={{x: \[01]\}}
 *             placeholderChar='?'
 *             />
 */
class InputMask extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: props.value, guide: false };
    this.valid = false;
    this.dict = {
      '9': /\d/,
      S: /[A-Za-z]/,
      Б: /[А-Яа-я]/,
      ...props.dictionary,
    };
    this.mask = this.mask.bind(this);
    this.preset = this.preset.bind(this);
    this._indexOfFirstPlaceHolder = this._indexOfFirstPlaceHolder.bind(this);
    this._indexOfLastPlaceholder = this._indexOfLastPlaceholder.bind(this);
    this._isValid = this._isValid.bind(this);
    this._mapToArray = this._mapToArray.bind(this);
    this._onChange = this._onChange.bind(this);
    this._onBlur = this._onBlur.bind(this);
    this._onFocus = this._onFocus.bind(this);
  }

  /**
   * преобразует маску-функцию, маску-строку в массив-маску (с regexp вместо символов) при помощи _mapToArray
   * @returns (number) возвращает массив-маску
   */
  mask() {
    const { mask } = this.props;
    if (Array.isArray(mask)) {
      return mask;
    } else if (typeof mask === 'function') {
      return mask();
    }
    return this._mapToArray(mask);
  }

  /**
   * возвращает маку для пресета
   * @returns (number) возвращает массив-маску для пресета-аргумента
   */
  preset(preset) {
    const { presetConfig } = this.props;
    switch (preset) {
      case 'phone':
        return this._mapToArray('+9 (999)-999-99-99');
      case 'post-code':
        return this._mapToArray('999999');
      case 'date':
        return this._mapToArray('99.99.9999');
      case 'money':
        return createNumberMask(presetConfig);
      case 'percentage':
        return createNumberMask({ prefix: '', suffix: '%' });
      case 'card':
        return this._mapToArray('9999 9999 9999 9999');
    }
  }
  /**
   * возвращает индекс первого символа маски, который еще не заполнен
   * @returns (number) индекс первого символа маски, который еще не заполнен
   */
  _indexOfFirstPlaceHolder(value = '') {
    return value.toString().indexOf(this.props.placeholderChar);
  }

  /**
   * возвращает индекс последнего символа маски, который еще не заполнен
   * @returns (number) индекс последнего символа маски, который еще не заполнен
   */
  _indexOfLastPlaceholder(mask) {
    if (typeof mask === 'function') {
      return mask()
        .map(item => item instanceof RegExp)
        .lastIndexOf(true);
    } else if (typeof mask === 'string') {
      return Math.max(
        ...Object.keys(this.dict).map(char => mask.lastIndexOf(char))
      );
    } else if (Array.isArray(mask)) {
      return mask.map(item => item instanceof RegExp).lastIndexOf(true);
    }
    return -1;
  }
  /**
   * проверка на валидность (соответсвие маске)
   */
  _isValid(value) {
    const { preset, mask, guide } = this.props;
    if (guide) {
      return value && this._indexOfFirstPlaceHolder(value) === -1;
    }
    return (
      value.length > this._indexOfLastPlaceholder(this.preset(preset) || mask)
    );
  }

  /**
   * преобразование строки маски в массив ( уже  с регулярными выражениями)
   */
  _mapToArray(mask) {
    return mask.split('').map(char => {
      return this.dict[char] ? this.dict[char] : char;
    });
  }

  _onChange(e) {
    const { value } = e.target;
    this.valid = this._isValid(value);
    this.setState({ value, guide: this.props.guide }, () => {
      (this.valid || value === '') && this.props.onChange(value);
    });
  }

  _onBlur(e) {
    const { resetOnNotValid, onBlur } = this.props;
    const { value } = this.state;
    this.valid = this._isValid(value);
    onBlur(value);
    if (!this.valid) {
      const newValue = resetOnNotValid ? '' : value;
      this.setState({ value: newValue, guide: false }, () =>
        this.props.onChange(newValue)
      );
    }
  }

  _onFocus() {
    this.valid = this._isValid(this.state.value);
    if (!this.valid) {
      this.setState({ guide: this.props.guide });
    }
  }

  /**
   * обработка новых пропсов
   */
  componentDidUpdate(prevProps) {
    if (!isEqual(prevProps.value, this.props.value)) {
      this.setState({ value: this.props.value });
    }
    this.dict = { ...this.dict, ...this.props.dictionary };
    this.valid = this._isValid(this.state.value);
  }

  /**
   * базовый рендер компонента
   */
  render() {
    const {
      preset,
      placeholderChar,
      placeholder,
      className,
      autoFocus,
    } = this.props;
    const mask = this.preset(preset);
    return (
      <MaskedInput
        className={cn(['form-control', { [className]: className }])}
        placeholderChar={placeholderChar}
        placeholder={placeholder}
        guide={this.state.guide}
        mask={mask || this.mask.bind(this)}
        value={this.state.value}
        onBlur={this._onBlur.bind(this)}
        onChange={this._onChange.bind(this)}
        onFocus={this._onFocus.bind(this)}
        keepCharPositions={this.props.keepCharPositions}
        render={(ref, props) => {
          delete props.defaultValue;
          return <input ref={ref} {...props} autoFocus={autoFocus} />;
        }}
      />
    );
  }
}

InputMask.defaultProps = {
  onChange: () => {},
  placeholderChar: '_',
  guide: true,
  keepCharPositions: false,
  resetOnNotValid: true,
  value: '',
  dictionary: {},
  mask: '',
  presetConfig: {},
  onBlur: () => {},
};

InputMask.propTypes = {
  className: PropTypes.string,
  preset: PropTypes.string,
  mask: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.array,
    PropTypes.func,
  ]),
  onChange: PropTypes.func,
  placeholder: PropTypes.string,
  placeholderChar: PropTypes.string,
  value: PropTypes.string,
  dictionary: PropTypes.object,
  guide: PropTypes.bool,
  keepCharPositions: PropTypes.bool,
  resetOnNotValid: PropTypes.bool,
  presetConfig: PropTypes.object,
  onBlur: PropTypes.func,
};

export default InputMask;