UNPKG

react-dates

Version:

A responsive and accessible date range picker component built with React

425 lines (356 loc) 12.5 kB
import React from 'react'; import ReactDOM from 'react-dom'; import moment from 'moment'; import cx from 'classnames'; import Portal from 'react-portal'; import includes from 'array-includes'; import isTouchDevice from '../utils/isTouchDevice'; import toMomentObject from '../utils/toMomentObject'; import toLocalizedDateString from '../utils/toLocalizedDateString'; import isInclusivelyAfterDay from '../utils/isInclusivelyAfterDay'; import isInclusivelyBeforeDay from '../utils/isInclusivelyBeforeDay'; import isNextDay from '../utils/isNextDay'; import isSameDay from '../utils/isSameDay'; import OutsideClickHandler from './OutsideClickHandler'; import DateRangePickerInput from './DateRangePickerInput'; import DayPicker from './DayPicker'; import CloseButton from '../svg/close.svg'; import DateRangePickerShape from '../shapes/DateRangePickerShape'; import { START_DATE, END_DATE, HORIZONTAL_ORIENTATION, VERTICAL_ORIENTATION, } from '../constants'; const propTypes = DateRangePickerShape; const defaultProps = { startDateId: START_DATE, endDateId: END_DATE, focusedInput: null, minimumNights: 1, isDayBlocked: () => false, disabledDays: [], isOutsideRange: day => !isInclusivelyAfterDay(day, moment()), enableOutsideDays: false, numberOfMonths: 2, showClearDates: false, disabled: false, orientation: HORIZONTAL_ORIENTATION, withPortal: false, withFullScreenPortal: false, onDatesChange() {}, onFocusChange() {}, onPrevMonthClick() {}, onNextMonthClick() {}, // i18n monthFormat: 'MMMM YYYY', phrases: { closeDatePicker: 'Close', clearDates: 'Clear Dates', }, }; export default class DateRangePicker extends React.Component { constructor(props) { super(props); this.state = { hoverDate: null, }; this.isTouchDevice = isTouchDevice(); this.onOutsideClick = this.onOutsideClick.bind(this); this.onDayMouseEnter = this.onDayMouseEnter.bind(this); this.onDayMouseLeave = this.onDayMouseLeave.bind(this); this.onDayClick = this.onDayClick.bind(this); this.onClearFocus = this.onClearFocus.bind(this); this.onStartDateChange = this.onStartDateChange.bind(this); this.onStartDateFocus = this.onStartDateFocus.bind(this); this.onEndDateChange = this.onEndDateChange.bind(this); this.onEndDateFocus = this.onEndDateFocus.bind(this); this.clearDates = this.clearDates.bind(this); } onClearFocus() { this.props.onFocusChange(null); } onDayClick(day, modifiers, e) { if (e) e.preventDefault(); if (includes(modifiers, 'blocked')) return; const { focusedInput } = this.props; let { startDate, endDate } = this.props; if (focusedInput === START_DATE) { this.props.onFocusChange(END_DATE); startDate = day; if (isInclusivelyAfterDay(day, endDate)) { endDate = null; } } else if (focusedInput === END_DATE) { if (isInclusivelyBeforeDay(day, startDate)) { startDate = day; endDate = null; } else { endDate = day; if (!startDate) { this.props.onFocusChange(START_DATE); } else { this.props.onFocusChange(null); } } } this.props.onDatesChange({ startDate, endDate }); } onDayMouseEnter(day) { if (this.isTouchDevice) return; this.setState({ hoverDate: day, }); } onDayMouseLeave() { if (this.isTouchDevice) return; this.setState({ hoverDate: null, }); } onEndDateChange(endDateString) { const endDate = toMomentObject(endDateString); const { startDate, isOutsideRange, onDatesChange, onFocusChange } = this.props; const isEndDateValid = endDate && !isOutsideRange(endDate) && !isInclusivelyBeforeDay(endDate, startDate); if (isEndDateValid) { onDatesChange({ startDate, endDate }); onFocusChange(null); } else { onDatesChange({ startDate, endDate: null, }); } } onEndDateFocus() { const { startDate, onFocusChange, orientation, disabled } = this.props; if (!startDate && orientation === VERTICAL_ORIENTATION && !disabled) { // Since the vertical datepicker is full screen, we never want to focus the end date first // because there's no indication that that is the case once the datepicker is open and it // might confuse the user onFocusChange(START_DATE); } else if (!disabled) { onFocusChange(END_DATE); } } onOutsideClick() { const { focusedInput, onFocusChange } = this.props; if (!focusedInput) return; onFocusChange(null); } onStartDateChange(startDateString) { const startDate = toMomentObject(startDateString); let { endDate } = this.props; const { isOutsideRange, onDatesChange, onFocusChange } = this.props; const isStartDateValid = startDate && !isOutsideRange(startDate); if (isStartDateValid) { if (isInclusivelyBeforeDay(endDate, startDate)) { endDate = null; } onDatesChange({ startDate, endDate }); onFocusChange(END_DATE); } else { onDatesChange({ startDate: null, endDate, }); } } onStartDateFocus() { if (!this.props.disabled) { this.props.onFocusChange(START_DATE); } } getDayPickerContainerClasses() { const { focusedInput, orientation, withPortal, withFullScreenPortal } = this.props; const { hoverDate } = this.state; const showDatepicker = focusedInput === START_DATE || focusedInput === END_DATE; const dayPickerClassName = cx('DateRangePicker__picker', { 'DateRangePicker__picker--show': showDatepicker, 'DateRangePicker__picker--invisible': !showDatepicker, 'DateRangePicker__picker--start': focusedInput === START_DATE, 'DateRangePicker__picker--end': focusedInput === END_DATE, 'DateRangePicker__picker--horizontal': orientation === HORIZONTAL_ORIENTATION, 'DateRangePicker__picker--vertical': orientation === VERTICAL_ORIENTATION, 'DateRangePicker__picker--portal': withPortal || withFullScreenPortal, 'DateRangePicker__picker--full-screen-portal': withFullScreenPortal, 'DateRangePicker__picker--valid-date-hovered': hoverDate && !this.isBlocked(hoverDate), }); return dayPickerClassName; } getDayPickerDOMNode() { return ReactDOM.findDOMNode(this.dayPicker); } clearDates() { this.props.onDatesChange({ startDate: null, endDate: null }); this.props.onFocusChange(START_DATE); } doesNotMeetMinimumNights(day) { const { startDate, isOutsideRange, focusedInput, minimumNights } = this.props; if (focusedInput !== END_DATE) return false; if (startDate) { const dayDiff = day.diff(startDate, 'days'); return dayDiff < minimumNights && dayDiff >= 0; } return isOutsideRange(moment(day).subtract(minimumNights, 'days')); } isDayAfterHoveredStartDate(day) { const { startDate, endDate } = this.props; const { hoverDate } = this.state; return !!startDate && !endDate && isNextDay(hoverDate, day) && isSameDay(hoverDate, startDate); } isEndDate(day) { return isSameDay(day, this.props.endDate); } isHovered(day) { return isSameDay(day, this.state.hoverDate); } isInHoveredSpan(day) { const { startDate, endDate } = this.props; const { hoverDate } = this.state; const isForwardRange = !!startDate && !endDate && (day.isBetween(startDate, hoverDate) || isSameDay(hoverDate, day)); const isBackwardRange = !!endDate && !startDate && (day.isBetween(hoverDate, endDate) || isSameDay(hoverDate, day)); return isForwardRange || isBackwardRange; } isInSelectedSpan(day) { const { startDate, endDate } = this.props; return day.isBetween(startDate, endDate); } isLastInRange(day) { return this.isInSelectedSpan(day) && isNextDay(day, this.props.endDate); } isStartDate(day) { return isSameDay(day, this.props.startDate); } isBlocked(day) { const { isDayBlocked, isOutsideRange } = this.props; return isDayBlocked(day) || isOutsideRange(day) || this.doesNotMeetMinimumNights(day); } maybeRenderDayPickerWithPortal() { const { focusedInput, withPortal, withFullScreenPortal } = this.props; if (withPortal || withFullScreenPortal) { return ( <Portal isOpened={focusedInput !== null}> {this.renderDayPicker()} </Portal> ); } return this.renderDayPicker(); } renderDayPicker() { const { isDayBlocked, isOutsideRange, numberOfMonths, orientation, monthFormat, onPrevMonthClick, onNextMonthClick, withPortal, withFullScreenPortal, enableOutsideDays, } = this.props; const modifiers = { blocked: day => this.isBlocked(day), 'blocked-calendar': day => isDayBlocked(day), 'blocked-out-of-range': day => isOutsideRange(day), 'blocked-minimum-nights': day => this.doesNotMeetMinimumNights(day), valid: day => !this.isBlocked(day), // before anything has been set or after both are set hovered: day => this.isHovered(day), // while start date has been set, but end date has not been 'hovered-span': day => this.isInHoveredSpan(day), 'after-hovered-start': day => this.isDayAfterHoveredStartDate(day), 'last-in-range': day => this.isLastInRange(day), // once a start date and end date have been set 'selected-start': day => this.isStartDate(day), 'selected-end': day => this.isEndDate(day), 'selected-span': day => this.isInSelectedSpan(day), }; const onOutsideClick = withPortal ? this.onOutsideClick : () => {}; return ( <div className={this.getDayPickerContainerClasses()}> <DayPicker ref={ref => { this.dayPicker = ref; }} orientation={orientation} enableOutsideDays={enableOutsideDays} modifiers={modifiers} numberOfMonths={numberOfMonths} onDayMouseEnter={this.onDayMouseEnter} onDayMouseLeave={this.onDayMouseLeave} onDayMouseDown={this.onDayClick} onDayTouchTap={this.onDayClick} onPrevMonthClick={onPrevMonthClick} onNextMonthClick={onNextMonthClick} monthFormat={monthFormat} withPortal={withPortal || withFullScreenPortal} onOutsideClick={onOutsideClick} /> {withFullScreenPortal && <button className="DateRangePicker__close" type="button" onClick={this.onOutsideClick} > <span className="screen-reader-only"> {this.props.phrases.closeDatePicker} </span> <CloseButton /> </button> } </div> ); } render() { const { startDate, endDate, focusedInput, showClearDates, disabled, startDateId, endDateId, phrases, withPortal, withFullScreenPortal, } = this.props; const startDateString = toLocalizedDateString(startDate); const endDateString = toLocalizedDateString(endDate); const onOutsideClick = !withPortal && !withFullScreenPortal ? this.onOutsideClick : () => {}; return ( <div className="DateRangePicker"> <OutsideClickHandler onOutsideClick={onOutsideClick}> <DateRangePickerInput startDateId={startDateId} startDatePlaceholderText={this.props.startDatePlaceholderText} isStartDateFocused={focusedInput === START_DATE} endDateId={endDateId} endDatePlaceholderText={this.props.endDatePlaceholderText} isEndDateFocused={focusedInput === END_DATE} onStartDateChange={this.onStartDateChange} onStartDateFocus={this.onStartDateFocus} onStartDateShiftTab={this.onClearFocus} onEndDateChange={this.onEndDateChange} onEndDateFocus={this.onEndDateFocus} onEndDateTab={this.onClearFocus} startDate={startDateString} endDate={endDateString} showClearDates={showClearDates} onClearDates={this.clearDates} disabled={disabled} phrases={phrases} /> {this.maybeRenderDayPickerWithPortal()} </OutsideClickHandler> </div> ); } } DateRangePicker.propTypes = propTypes; DateRangePicker.defaultProps = defaultProps;