Home Reference Source

src/components/controls/DatePicker/Calendar.jsx

import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import cx from 'classnames';
import { FormattedMessage } from 'react-intl';

import Day from './Day';
import CalendarHeader from './CalendarHeader';
import Clock from './Clock';
import {
  weeks,
  isDateFromPrevMonth,
  isDateFromNextMonth,
  addTime,
} from './utils';

/**
 * @reactProps {date} value
 * @reactProps {boolean} auto
 * @reactProps {function} select
 * @reactProps {boolean} hasDefaultTime
 * @reactProps {function} setPlacement
 * @reactProps {function} setVisibility
 * @reactProps {string} placement
 * @reactProps {moment} max
 * @reactProps {moment} min
 * @reactProps {string} locale
 * @reactProps {object} time
 * @reactProps {number} time.mins
 * @reactProps {number} time.hours
 */
class Calendar extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      displayesMonth: props.value
        ? props.value.clone().startOf('month')
        : moment().startOf('month'),
      calendarType: Calendar.BY_DAYS,
      tempTimeObj: props.value ? this.objFromTime(props.value) : props.time,
    };
    this.nextMonth = this.nextMonth.bind(this);
    this.prevMonth = this.prevMonth.bind(this);
    this.nextYear = this.nextYear.bind(this);
    this.prevYear = this.prevYear.bind(this);
    this.prevDecade = this.prevDecade.bind(this);
    this.nextDecade = this.nextDecade.bind(this);
    this.onKeyDown = this.onKeyDown.bind(this);
    this.setValue = this.setValue.bind(this);
    this.onItemClick = this.onItemClick.bind(this);
    this.setTimeUnit = this.setTimeUnit.bind(this);
    this.setTime = this.setTime.bind(this);
    this.changeCalendarType = this.changeCalendarType.bind(this);
    this.setHourRef = this.setHourRef.bind(this);
    this.setMinuteRef = this.setMinuteRef.bind(this);
    this.setSecondRef = this.setSecondRef.bind(this);
  }

  changeCalendarType(type) {
    this.setState({ calendarType: type });
  }

  componentWillReceiveProps(props) {
    if (props.value) {
      this.setState({
        displayesMonth: props.value
          ? props.value.clone().startOf('month')
          : moment().startOf('month'),
        tempTimeObj: props.value ? this.objFromTime(props.value) : props.time,
      });
    }
  }

  setHourRef(el) {
    this.hourRef = el;
  }

  setMinuteRef(el) {
    this.minuteRef = el;
  }

  setSecondRef(el) {
    this.secondRef = el;
  }

  /**
   * Рендер хэдера календаря
   */
  renderHeader() {
    const { displayesMonth, calendarType } = this.state;
    const { locale } = this.props;
    const {
      nextMonth,
      nextYear,
      prevMonth,
      prevYear,
      setValue,
      nextDecade,
      prevDecade,
      changeCalendarType,
    } = this;
    return (
      <CalendarHeader
        {...{
          nextMonth,
          nextYear,
          prevMonth,
          prevYear,
          nextDecade,
          prevDecade,
          displayesMonth,
          setValue,
          locale,
          calendarType,
          changeCalendarType,
        }}
      />
    );
  }

  /**
   *  установка значения года или месяца (при выборе из списка)
   * @param val
   * @param attr
   */
  setValue(val, attr) {
    if (attr === 'months') {
      this.setState({
        displayesMonth: this.state.displayesMonth
          .clone()
          .add(-this.state.displayesMonth.month() + val, attr),
      });
    } else {
      this.setState({
        displayesMonth: this.state.displayesMonth
          .clone()
          .add(moment().year() - this.state.displayesMonth.year() + val, attr),
      });
    }
  }

  /**
   * Сдвиг даты на месяц назад
   */
  prevMonth() {
    const displayesMonth = this.state.displayesMonth.subtract(1, 'months');
    this.setState({ displayesMonth });
  }

  /**
   * Сдвиг даты на месяц вперед
   */
  nextMonth() {
    const displayesMonth = this.state.displayesMonth.add(1, 'months');
    this.setState({ displayesMonth });
  }

  /**
   * Сдвиг даты на месяц вперед
   */
  prevYear() {
    const displayesMonth = this.state.displayesMonth.subtract(1, 'years');
    this.setState({ displayesMonth });
  }

  /**
   * Сдвиг даты на год вперед
   */
  nextYear() {
    const displayesMonth = this.state.displayesMonth.add(1, 'years');
    this.setState({ displayesMonth });
  }

  nextDecade() {
    const displayesMonth = this.state.displayesMonth.add(10, 'years');
    this.setState({ displayesMonth });
  }

  prevDecade() {
    const displayesMonth = this.state.displayesMonth.subtract(10, 'years');
    this.setState({ displayesMonth });
  }

  setDate(...args) {
    const displayesMonth = this.state.displayesMonth.set(...args);
    this.setState({ displayesMonth });
  }

  /**
   * Рендер назавний дней недели
   */
  renderNameOfDays() {
    const nameOfDays =
      this.props.locale === 'ru'
        ? ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
        : ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
    return (
      <tr>
        {nameOfDays.map((day, i) => (
          <td key={i}>{day}</td>
        ))}
      </tr>
    );
  }

  /**
   * Рендер данного месяца по неделям
   */
  renderWeeks() {
    const displayesMonth = this.state.displayesMonth;
    const { hours, mins, seconds } = this.state.tempTimeObj;
    const firstDay = addTime(
      displayesMonth.clone().startOf('isoWeek'),
      hours,
      mins,
      seconds
    );
    return weeks(firstDay).map((week, i) => this.renderWeek(week, i));
  }

  /**
   * Рендер недели
   */
  renderWeek(week, i) {
    return <tr key={i}>{week.map((day, i) => this.renderDay(day, i))}</tr>;
  }

  /**
   * Рендер дня
   */
  renderDay(day, i) {
    const { min, max, value, select, inputName } = this.props;
    let disabled = false;
    if (min && max) {
      disabled = day.isBefore(min, 'day') || day.isAfter(max, 'day');
    } else if (min) {
      disabled = day.isBefore(min, 'day');
    } else if (max) {
      disabled = day.isAfter(max, 'day');
    }
    const displayesMonth = this.state.displayesMonth.clone();
    const otherMonth =
      isDateFromNextMonth(day, displayesMonth) ||
      isDateFromPrevMonth(day, displayesMonth);
    const selected = day.isSame(value, 'day');
    const current = !value && day.isSame(moment(), 'day');
    const props = {
      day,
      otherMonth,
      selected,
      disabled,
      select,
      inputName,
      current,
    };

    return <Day key={i} {...props} />;
  }

  /**
   * Навигация по кнопкам, вверх/вниз - смена года, вправо/влево - смена месяца
   */
  onKeyDown(e) {
    const { calendarType } = this.state;
    const evtobj = window.event ? window.event : e;
    const leftKeyCode = 37;
    const rightKeyCode = 39;
    if (evtobj.ctrlKey) {
      switch (evtobj.keyCode) {
        case leftKeyCode:
          if (calendarType === Calendar.BY_DAYS) {
            this.prevMonth();
          } else if (calendarType === Calendar.BY_MONTHS) {
            this.prevYear();
          } else if (calendarType === Calendar.BY_YEARS) {
            this.prevDecade();
          }
          break;
        case rightKeyCode:
          if (calendarType === Calendar.BY_DAYS) {
            this.nextMonth();
          } else if (calendarType === Calendar.BY_MONTHS) {
            this.nextYear();
          } else if (calendarType === Calendar.BY_YEARS) {
            this.nextDecade();
          }
          break;
      }
    }
  }

  /**
   * Навешивание листенера на нажатие кнопок
   */
  componentWillMount() {
    document.addEventListener('keydown', this.onKeyDown);
  }

  /**
   * Удаление листенера при анмаунте компонента
   */
  componentWillUnmount() {
    document.removeEventListener('keydown', this.onKeyDown);
  }

  renderTime() {
    const { value, hasDefaultTime, timeFormat } = this.props;
    return hasDefaultTime ? (
      this.props.value && this.props.value.format(timeFormat)
    ) : (
      <FormattedMessage
        id="Datepicker.time-choose"
        defaultMessage="Выберите время"
      />
    );
  }

  objFromTime(date) {
    return {
      mins: date.minutes(),
      seconds: date.seconds(),
      hours: date.hours(),
    };
  }

  timeFromObj(timeObj) {
    const { hours, mins, seconds } = timeObj;
    return moment(`${hours}:${mins}:${seconds}`, 'H:m:s').format(
      this.props.timeFormat || 'H:mm:ss'
    );
  }

  renderByDays() {
    return (
      <React.Fragment>
        <table className="n2o-calendar-table">
          <thead>{this.renderNameOfDays()}</thead>
          <tbody>{this.renderWeeks()}</tbody>
        </table>
        {this.props.timeFormat && (
          <a
            className="n2o-calendar-time-container"
            href="/test"
            onClick={e => {
              e.preventDefault();
              this.changeCalendarType(Calendar.TIME_PICKER);
            }}
          >
            {this.renderTime()}
          </a>
        )}
      </React.Fragment>
    );
  }

  renderByMonths() {
    const { displayesMonth } = this.state;
    const { locale } = this.props;
    const year = displayesMonth.format('YYYY');
    return (
      <div className="n2o-calendar-body">
        {this.renderList(moment.localeData(locale).months(), 'month-item')}
      </div>
    );
  }

  onItemClick(itemType, item, i) {
    if (itemType === 'month-item') {
      this.setDate('month', i);
    } else {
      this.setDate('year', item);
    }
    this.changeCalendarType(Calendar.BY_DAYS);
  }

  renderList(list, className) {
    const { value } = this.props;

    const isActive = (className, item, i) => {
      if (!value) return false;
      if (className !== 'month-item') {
        return item === value.year();
      }
      return i === value.month();
    };
    const isOtherDecade = i =>
      className === 'year-item' && (i === 0 || i === 11);
    return list.map((item, i) => {
      return (
        <div
          className={cx('n2o-calendar-body-item', className, {
            active: isActive(className, item, i),
            'other-decade': isOtherDecade(i),
          })}
          onClick={() => this.onItemClick(className, item, i)}
        >
          {item}
        </div>
      );
    });
  }

  renderByYears() {
    const { displayesMonth } = this.state;
    const { locale } = this.props;
    const decadeStart = parseInt(+displayesMonth.format('YYYY') / 10) * 10;
    const years = Array.from(
      new Array(12),
      (val, index) => decadeStart + index - 1
    );
    return (
      <div className="n2o-calendar-body">
        {this.renderList(years, 'year-item')}
      </div>
    );
  }

  componentDidUpdate() {
    if (this.state.calendarType === Calendar.TIME_PICKER) {
      this.minuteRef && this.minuteRef.scrollIntoView();
      this.secondRef && this.secondRef.scrollIntoView();
      this.hourRef && this.hourRef.scrollIntoView();
    }
  }

  setTimeUnit(value, unit) {
    this.setState({
      tempTimeObj: { ...this.state.tempTimeObj, [unit]: value },
    });
  }

  setTime() {
    const { value, inputName, markTimeAsSet, select } = this.props;
    const { hours, mins, seconds } = this.state.tempTimeObj;
    this.changeCalendarType(Calendar.BY_DAYS);
    markTimeAsSet(inputName);
    select(
      addTime(value.clone().startOf('day'), hours, mins, seconds),
      inputName,
      false
    );
  }

  renderTimePicker() {
    const { mins, seconds, hours } = this.state.tempTimeObj;
    return (
      <div>
        <div className="n2o-calendar-timepicker">
          <div className="n2o-calendar-picker hour-picker">
            {Array.from(new Array(24), (val, index) => (
              <div
                className={cx('n2o-calendar-time-unit', {
                  active: index === hours,
                })}
                onClick={() => this.setTimeUnit(index, 'hours')}
                ref={index === hours ? this.setHourRef : null}
              >
                {index}
              </div>
            ))}
          </div>
          <div className="n2o-calendar-picker minute-picker">
            {Array.from(new Array(60), (val, index) => (
              <div
                className={cx('n2o-calendar-time-unit', {
                  active: index === mins,
                })}
                ref={index === mins ? this.setMinuteRef : null}
                onClick={() => this.setTimeUnit(index, 'mins')}
              >
                {index}
              </div>
            ))}
          </div>
          <div className="n2o-calendar-picker second-picker">
            {Array.from(new Array(60), (val, index) => (
              <div
                className={cx('n2o-calendar-time-unit', {
                  active: index === seconds,
                })}
                ref={index === seconds ? this.setSecondRef : null}
                onClick={() => this.setTimeUnit(index, 'seconds')}
              >
                {index}
              </div>
            ))}
          </div>
        </div>
        <div className="n2o-calendar-time-buttons">
          <button
            className="btn btn-secondary btn-sm"
            onClick={() => this.changeCalendarType(Calendar.BY_DAYS)}
          >
            {' '}
            Назад
          </button>
          <button className="btn btn-primary btn-sm" onClick={this.setTime}>
            {' '}
            Выбрать
          </button>
        </div>
      </div>
    );
  }

  renderBody(type) {
    let body = null;
    switch (type) {
      case Calendar.BY_MONTHS:
        body = this.renderByMonths();
        break;
      case Calendar.BY_YEARS:
        body = this.renderByYears();
        break;
      case Calendar.TIME_PICKER:
        body = this.renderTimePicker();
        break;
      case Calendar.BY_DAYS:
      default:
        body = this.renderByDays();
    }
    return body;
  }

  render() {
    const { calendarType } = this.state;
    const {
      inputValue,
      inputOnClick,
      inputClassName,
      format,
      calRef,
    } = this.props;
    return (
      <div
        className={cx('n2o-calendar', 'calenadar', {
          time: this.props.timeFormat,
        })}
        tabIndex="0"
      >
        {this.renderHeader(calendarType)}
        {this.renderBody(calendarType)}
      </div>
    );
  }
}

Calendar.BY_DAYS = 'by_days';
Calendar.BY_MONTHS = 'by_months';
Calendar.BY_YEARS = 'by_years';
Calendar.TIME_PICKER = 'time_picker';

Calendar.defaultProps = {
  time: {
    mins: 0,
    hours: 0,
  },
  placement: 'bottom',
  locale: 'ru',
  clock: true,
};

Calendar.propTypes = {
  value: PropTypes.instanceOf(moment).isRequired,
  auto: PropTypes.bool,
  select: PropTypes.func,
  hasDefaultTime: PropTypes.bool,
  setPlacement: PropTypes.func,
  setVisibility: PropTypes.func,
  placement: PropTypes.string,
  max: PropTypes.instanceOf(moment),
  min: PropTypes.instanceOf(moment),
  locale: PropTypes.oneOf(['en', 'ru']),
  time: PropTypes.shape({
    mins: PropTypes.number,
    hours: PropTypes.number,
  }),
};

export default Calendar;