import React, { Component } from 'react'; import PropTypes from 'prop-types'; import mergeClassNames from 'merge-class-names'; import Navigation from './Calendar/Navigation'; import CenturyView from './CenturyView'; import DecadeView from './DecadeView'; import YearView from './YearView'; import MonthView from './MonthView'; import { getBegin, getBeginNext, getEnd, getValueRange, } from './shared/dates'; import { isCalendarType, isClassName, isMaxDate, isMinDate, isRef, isValue, isView, } from './shared/propTypes'; import { between } from './shared/utils'; const defaultMinDate = new Date(); defaultMinDate.setFullYear(1, 0, 1); defaultMinDate.setHours(0, 0, 0, 0); const defaultMaxDate = new Date(8.64e15); const baseClassName = 'react-calendar'; const allViews = ['century', 'decade', 'year', 'month']; const allValueTypes = [...allViews.slice(1), 'day']; function toDate(value) { if (value instanceof Date) { return value; } return new Date(value); } /** * Returns views array with disallowed values cut off. */ function getLimitedViews(minDetail, maxDetail) { return allViews.slice(allViews.indexOf(minDetail), allViews.indexOf(maxDetail) + 1); } /** * Determines whether a given view is allowed with currently applied settings. */ function isViewAllowed(view, minDetail, maxDetail) { const views = getLimitedViews(minDetail, maxDetail); return views.indexOf(view) !== -1; } /** * Gets either provided view if allowed by minDetail and maxDetail, or gets * the default view if not allowed. */ function getView(view, minDetail, maxDetail) { if (isViewAllowed(view, minDetail, maxDetail)) { return view; } return maxDetail; } /** * Returns value type that can be returned with currently applied settings. */ function getValueType(maxDetail) { return allValueTypes[allViews.indexOf(maxDetail)]; } function getValue(value, index) { if (!value) { return null; } const rawValue = Array.isArray(value) && value.length === 2 ? value[index] : value; if (!rawValue) { return null; } const valueDate = toDate(rawValue); if (isNaN(valueDate.getTime())) { throw new Error(`Invalid date: ${value}`); } return valueDate; } function getDetailValue({ value, minDate, maxDate, maxDetail, }, index) { const valuePiece = getValue(value, index); if (!valuePiece) { return null; } const valueType = getValueType(maxDetail); const detailValueFrom = [getBegin, getEnd][index](valueType, valuePiece); return between(detailValueFrom, minDate, maxDate); } const getDetailValueFrom = (args) => getDetailValue(args, 0); const getDetailValueTo = (args) => getDetailValue(args, 1); const getDetailValueArray = (args) => { const { value } = args; if (Array.isArray(value)) { return value; } return [getDetailValueFrom, getDetailValueTo].map((fn) => fn(args)); }; function getActiveStartDate(props) { const { maxDate, maxDetail, minDate, minDetail, value, view, } = props; const rangeType = getView(view, minDetail, maxDetail); const valueFrom = ( getDetailValueFrom({ value, minDate, maxDate, maxDetail, }) || new Date() ); return getBegin(rangeType, valueFrom); } function getInitialActiveStartDate(props) { const { activeStartDate, defaultActiveStartDate, defaultValue, defaultView, maxDetail, minDetail, value, view, ...otherProps } = props; const rangeType = getView(view, minDetail, maxDetail); const valueFrom = activeStartDate || defaultActiveStartDate; if (valueFrom) { return getBegin(rangeType, valueFrom); } return getActiveStartDate({ maxDetail, minDetail, value: value || defaultValue, view: view || defaultView, ...otherProps, }); } const getIsSingleValue = (value) => value && [].concat(value).length === 1; export default class Calendar extends Component { state = { /* eslint-disable react/destructuring-assignment */ activeStartDate: this.props.defaultActiveStartDate, value: this.props.defaultValue, view: this.props.defaultView, /* eslint-enable react/destructuring-assignment */ }; get activeStartDate() { const { activeStartDate: activeStartDateProps } = this.props; const { activeStartDate: activeStartDateState } = this.state; return activeStartDateProps || activeStartDateState || getInitialActiveStartDate(this.props); } get value() { const { selectRange, value: valueProps } = this.props; const { value: valueState } = this.state; // In the middle of range selection, use value from state if (selectRange && getIsSingleValue(valueState)) { return valueState; } return valueProps !== undefined ? valueProps : valueState; } get valueType() { const { maxDetail } = this.props; return getValueType(maxDetail); } get view() { const { minDetail, maxDetail, view: viewProps } = this.props; const { view: viewState } = this.state; return getView(viewProps || viewState, minDetail, maxDetail); } get views() { const { minDetail, maxDetail } = this.props; return getLimitedViews(minDetail, maxDetail); } get hover() { const { selectRange } = this.props; const { hover } = this.state; return selectRange ? hover : null; } get drillDownAvailable() { const { view, views } = this; return views.indexOf(view) < views.length - 1; } get drillUpAvailable() { const { view, views } = this; return views.indexOf(view) > 0; } /** * Gets current value in a desired format. */ getProcessedValue(value) { const { minDate, maxDate, maxDetail, returnValue, } = this.props; const processFunction = (() => { switch (returnValue) { case 'start': return getDetailValueFrom; case 'end': return getDetailValueTo; case 'range': return getDetailValueArray; default: throw new Error('Invalid returnValue.'); } })(); return processFunction({ value, minDate, maxDate, maxDetail, }); } setStateAndCallCallbacks = (nextState, event, callback) => { const { activeStartDate: previousActiveStartDate, view: previousView, } = this; const { allowPartialRange, onActiveStartDateChange, onChange, onViewChange, selectRange, } = this.props; const prevArgs = { activeStartDate: previousActiveStartDate, view: previousView, }; this.setState(nextState, () => { const args = { activeStartDate: nextState.activeStartDate || this.activeStartDate, value: nextState.value || this.value, view: nextState.view || this.view, }; function shouldUpdate(key) { return ( // Key must exist, and… key in nextState && ( // …key changed from undefined to defined or the other way around, or… typeof nextState[key] !== typeof prevArgs[key] // …value changed. || ( nextState[key] instanceof Date ? nextState[key].getTime() !== prevArgs[key].getTime() : nextState[key] !== prevArgs[key] ) ) ); } if (shouldUpdate('activeStartDate')) { if (onActiveStartDateChange) onActiveStartDateChange(args); } if (shouldUpdate('view')) { if (onViewChange) onViewChange(args); } if (shouldUpdate('value')) { if (onChange) { if (selectRange) { const isSingleValue = getIsSingleValue(nextState.value); if (!isSingleValue) { onChange(nextState.value, event); } else if (allowPartialRange) { onChange([nextState.value], event); } } else { onChange(nextState.value, event); } } } if (callback) callback(args); }); } /** * Called when the user uses navigation buttons. */ setActiveStartDate = (activeStartDate) => { this.setStateAndCallCallbacks({ activeStartDate }); } drillDown = (nextActiveStartDate, event) => { if (!this.drillDownAvailable) { return; } this.onClickTile(nextActiveStartDate, event); const { view, views } = this; const { onDrillDown } = this.props; const nextView = views[views.indexOf(view) + 1]; this.setStateAndCallCallbacks({ activeStartDate: nextActiveStartDate, view: nextView, }, undefined, onDrillDown); } drillUp = () => { if (!this.drillUpAvailable) { return; } const { activeStartDate, view, views } = this; const { onDrillUp } = this.props; const nextView = views[views.indexOf(view) - 1]; const nextActiveStartDate = getBegin(nextView, activeStartDate); this.setStateAndCallCallbacks({ activeStartDate: nextActiveStartDate, view: nextView, }, undefined, onDrillUp); } onChange = (value, event) => { const { selectRange } = this.props; this.onClickTile(value, event); let nextValue; if (selectRange) { // Range selection turned on const { value: previousValue, valueType } = this; if (!getIsSingleValue(previousValue)) { // Value has 0 or 2 elements - either way we're starting a new array // First value nextValue = getBegin(valueType, value); } else { // Second value nextValue = getValueRange(valueType, previousValue, value); } } else { // Range selection turned off nextValue = this.getProcessedValue(value); } const nextActiveStartDate = getActiveStartDate({ ...this.props, value: nextValue, }); event.persist(); this.setStateAndCallCallbacks({ activeStartDate: nextActiveStartDate, value: nextValue, }, event); } onClickTile = (value, event) => { const { view } = this; const { onClickDay, onClickDecade, onClickMonth, onClickYear, } = this.props; const callback = (() => { switch (view) { case 'century': return onClickDecade; case 'decade': return onClickYear; case 'year': return onClickMonth; case 'month': return onClickDay; default: throw new Error(`Invalid view: ${view}.`); } })(); if (callback) callback(value, event); } onMouseOver = (value) => { this.setState((prevState) => { if (prevState.hover && (prevState.hover.getTime() === value.getTime())) { return null; } return { hover: value }; }); } onMouseLeave = () => { this.setState({ hover: null }); } renderContent(next) { const { activeStartDate: currentActiveStartDate, onMouseOver, valueType, value, view, } = this; const { calendarType, locale, maxDate, minDate, selectRange, tileClassName, tileContent, tileDisabled, } = this.props; const { hover } = this; const activeStartDate = ( next ? getBeginNext(view, currentActiveStartDate) : getBegin(view, currentActiveStartDate) ); const onClick = this.drillDownAvailable ? this.drillDown : this.onChange; const commonProps = { activeStartDate, hover, locale, maxDate, minDate, onClick, onMouseOver: selectRange ? onMouseOver : null, tileClassName, tileContent, tileDisabled, value, valueType, }; switch (view) { case 'century': { const { formatYear } = this.props; return ( ); } case 'decade': { const { formatYear } = this.props; return ( ); } case 'year': { const { formatMonth, formatMonthYear } = this.props; return ( ); } case 'month': { const { formatLongDate, formatShortWeekday, onClickWeekNumber, showDoubleView, showFixedNumberOfWeeks, showNeighboringMonth, showWeekNumbers, } = this.props; const { onMouseLeave } = this; return ( ); } default: throw new Error(`Invalid view: ${view}.`); } } renderNavigation() { const { showNavigation } = this.props; if (!showNavigation) { return null; } const { activeStartDate, view, views } = this; const { formatMonthYear, formatYear, locale, maxDate, minDate, navigationAriaLabel, navigationLabel, next2AriaLabel, next2Label, nextAriaLabel, nextLabel, prev2AriaLabel, prev2Label, prevAriaLabel, prevLabel, showDoubleView, } = this.props; return ( ); } render() { const { className, inputRef, selectRange, showDoubleView, } = this.props; const { onMouseLeave, value } = this; const valueArray = [].concat(value); return (
{this.renderNavigation()}
{this.renderContent()} {showDoubleView && this.renderContent(true)}
); } } Calendar.defaultProps = { maxDate: defaultMaxDate, maxDetail: 'month', minDate: defaultMinDate, minDetail: 'century', returnValue: 'start', showNavigation: true, showNeighboringMonth: true, }; const isActiveStartDate = PropTypes.instanceOf(Date); const isLooseValue = PropTypes.oneOfType([ PropTypes.string, isValue, ]); Calendar.propTypes = { activeStartDate: isActiveStartDate, allowPartialRange: PropTypes.bool, calendarType: isCalendarType, className: isClassName, defaultActiveStartDate: isActiveStartDate, defaultValue: isLooseValue, defaultView: isView, formatLongDate: PropTypes.func, formatMonth: PropTypes.func, formatMonthYear: PropTypes.func, formatShortWeekday: PropTypes.func, formatYear: PropTypes.func, inputRef: isRef, locale: PropTypes.string, maxDate: isMaxDate, maxDetail: PropTypes.oneOf(allViews), minDate: isMinDate, minDetail: PropTypes.oneOf(allViews), navigationAriaLabel: PropTypes.string, navigationLabel: PropTypes.func, next2AriaLabel: PropTypes.string, next2Label: PropTypes.node, nextAriaLabel: PropTypes.string, nextLabel: PropTypes.node, onActiveStartDateChange: PropTypes.func, onChange: PropTypes.func, onClickDay: PropTypes.func, onClickDecade: PropTypes.func, onClickMonth: PropTypes.func, onClickWeekNumber: PropTypes.func, onClickYear: PropTypes.func, onDrillDown: PropTypes.func, onDrillUp: PropTypes.func, onViewChange: PropTypes.func, prev2AriaLabel: PropTypes.string, prev2Label: PropTypes.node, prevAriaLabel: PropTypes.string, prevLabel: PropTypes.node, returnValue: PropTypes.oneOf(['start', 'end', 'range']), selectRange: PropTypes.bool, showDoubleView: PropTypes.bool, showFixedNumberOfWeeks: PropTypes.bool, showNavigation: PropTypes.bool, showNeighboringMonth: PropTypes.bool, showWeekNumbers: PropTypes.bool, tileClassName: PropTypes.oneOfType([ PropTypes.func, isClassName, ]), tileContent: PropTypes.oneOfType([ PropTypes.func, PropTypes.node, ]), tileDisabled: PropTypes.func, value: isLooseValue, view: isView, };