UNPKG

react-dates

Version:

A responsive and accessible date range picker component built with React

791 lines (670 loc) 22.7 kB
import React from 'react'; import moment from 'moment'; import cx from 'classnames'; import Portal from 'react-portal'; import { forbidExtraProps } from 'airbnb-prop-types'; import { addEventListener, removeEventListener } from 'consolidated-events'; import values from 'object.values'; import SingleDatePickerShape from '../shapes/SingleDatePickerShape'; import { SingleDatePickerPhrases } from '../defaultPhrases'; import OutsideClickHandler from './OutsideClickHandler'; import toMomentObject from '../utils/toMomentObject'; import toLocalizedDateString from '../utils/toLocalizedDateString'; import getResponsiveContainerStyles from '../utils/getResponsiveContainerStyles'; import isTouchDevice from '../utils/isTouchDevice'; import getVisibleDays from '../utils/getVisibleDays'; import isDayVisible from '../utils/isDayVisible'; import toISODateString from '../utils/toISODateString'; import toISOMonthString from '../utils/toISOMonthString'; import SingleDatePickerInput from './SingleDatePickerInput'; import DayPicker from './DayPicker'; import CloseButton from '../svg/close.svg'; import isInclusivelyAfterDay from '../utils/isInclusivelyAfterDay'; import isSameDay from '../utils/isSameDay'; import isAfterDay from '../utils/isAfterDay'; import isBeforeDay from '../utils/isBeforeDay'; import { HORIZONTAL_ORIENTATION, VERTICAL_ORIENTATION, ANCHOR_LEFT, ANCHOR_RIGHT, DAY_SIZE, } from '../../constants'; const propTypes = forbidExtraProps(SingleDatePickerShape); const defaultProps = { // required props for a functional interactive SingleDatePicker date: null, focused: false, // input related props id: 'date', placeholder: 'Date', disabled: false, required: false, readOnly: false, screenReaderInputMessage: '', showClearDate: false, customCloseIcon: null, // calendar presentation and interaction related props orientation: HORIZONTAL_ORIENTATION, anchorDirection: ANCHOR_LEFT, horizontalMargin: 0, withPortal: false, withFullScreenPortal: false, initialVisibleMonth: null, numberOfMonths: 2, keepOpenOnDateSelect: false, reopenPickerOnClearDate: false, renderCalendarInfo: null, hideKeyboardShortcutsPanel: false, daySize: DAY_SIZE, isRTL: false, // navigation related props navPrev: null, navNext: null, onPrevMonthClick() {}, onNextMonthClick() {}, onClose() {}, // month presentation and interaction related props renderMonth: null, // day presentation and interaction related props renderDay: null, enableOutsideDays: false, isDayBlocked: () => false, isOutsideRange: day => !isInclusivelyAfterDay(day, moment()), isDayHighlighted: () => {}, // internationalization props displayFormat: () => moment.localeData().longDateFormat('L'), monthFormat: 'MMMM YYYY', phrases: SingleDatePickerPhrases, }; export default class SingleDatePicker extends React.Component { constructor(props) { super(props); this.isTouchDevice = false; this.today = moment(); this.modifiers = { today: day => this.isToday(day), blocked: day => this.isBlocked(day), 'blocked-calendar': day => props.isDayBlocked(day), 'blocked-out-of-range': day => props.isOutsideRange(day), 'highlighted-calendar': day => props.isDayHighlighted(day), valid: day => !this.isBlocked(day), hovered: day => this.isHovered(day), selected: day => this.isSelected(day), }; const { currentMonth, visibleDays } = this.getStateForNewMonth(props); this.state = { dayPickerContainerStyles: {}, hoverDate: null, isDayPickerFocused: false, isInputFocused: false, currentMonth, visibleDays, }; this.onDayMouseEnter = this.onDayMouseEnter.bind(this); this.onDayMouseLeave = this.onDayMouseLeave.bind(this); this.onDayClick = this.onDayClick.bind(this); this.onDayPickerFocus = this.onDayPickerFocus.bind(this); this.onDayPickerBlur = this.onDayPickerBlur.bind(this); this.onPrevMonthClick = this.onPrevMonthClick.bind(this); this.onNextMonthClick = this.onNextMonthClick.bind(this); this.onChange = this.onChange.bind(this); this.onFocus = this.onFocus.bind(this); this.onClearFocus = this.onClearFocus.bind(this); this.clearDate = this.clearDate.bind(this); this.getFirstFocusableDay = this.getFirstFocusableDay.bind(this); this.responsivizePickerPosition = this.responsivizePickerPosition.bind(this); } /* istanbul ignore next */ componentDidMount() { this.resizeHandle = addEventListener( window, 'resize', this.responsivizePickerPosition, { passive: true }, ); this.responsivizePickerPosition(); if (this.props.focused) { this.setState({ isInputFocused: true, }); } this.isTouchDevice = isTouchDevice(); } componentWillReceiveProps(nextProps) { const { date, focused, isOutsideRange, isDayBlocked, isDayHighlighted, initialVisibleMonth, numberOfMonths, enableOutsideDays, } = nextProps; let { visibleDays } = this.state; if (isOutsideRange !== this.props.isOutsideRange) { this.modifiers['blocked-out-of-range'] = day => isOutsideRange(day); } if (isDayBlocked !== this.props.isDayBlocked) { this.modifiers['blocked-calendar'] = day => isDayBlocked(day); } if (isDayHighlighted !== this.props.isDayHighlighted) { this.modifiers['highlighted-calendar'] = day => isDayHighlighted(day); } if ( initialVisibleMonth !== this.props.initialVisibleMonth || numberOfMonths !== this.props.numberOfMonths || enableOutsideDays !== this.props.enableOutsideDays ) { const newMonthState = this.getStateForNewMonth(nextProps); const currentMonth = newMonthState.currentMonth; visibleDays = newMonthState.visibleDays; this.setState({ currentMonth, visibleDays, }); } const didDateChange = date !== this.props.date; const didFocusChange = focused !== this.props.focused; let modifiers = {}; if (didDateChange) { modifiers = this.deleteModifier(modifiers, this.props.date, 'selected'); modifiers = this.addModifier(modifiers, date, 'selected'); } if (didFocusChange) { values(visibleDays).forEach((days) => { Object.keys(days).forEach((day) => { const momentObj = moment(day); if (isDayBlocked(momentObj)) { modifiers = this.addModifier(modifiers, momentObj, 'blocked-calendar'); } else { modifiers = this.deleteModifier(modifiers, momentObj, 'blocked-calendar'); } if (isDayHighlighted(momentObj)) { modifiers = this.addModifier(modifiers, momentObj, 'highlighted-calendar'); } else { modifiers = this.deleteModifier(modifiers, momentObj, 'highlighted-calendar'); } }); }); } const today = moment(); if (!isSameDay(this.today, today)) { modifiers = this.deleteModifier(modifiers, this.today, 'today'); modifiers = this.addModifier(modifiers, today, 'today'); this.today = today; } if (Object.keys(modifiers).length > 0) { this.setState({ visibleDays: { ...visibleDays, ...modifiers, }, }); } } componentWillUpdate() { this.today = moment(); } componentDidUpdate(prevProps) { if (!prevProps.focused && this.props.focused) { this.responsivizePickerPosition(); } } /* istanbul ignore next */ componentWillUnmount() { removeEventListener(this.resizeHandle); } onChange(dateString) { const { startDate, isOutsideRange, keepOpenOnDateSelect, onDateChange, onFocusChange, onClose, } = this.props; const endDate = toMomentObject(dateString, this.getDisplayFormat()); const isValid = endDate && !isOutsideRange(endDate); if (isValid) { onDateChange(endDate); if (!keepOpenOnDateSelect) { onFocusChange({ focused: false }); onClose({ startDate, endDate }); } } else { onDateChange(null); } } onDayClick(day, e) { if (e) e.preventDefault(); if (this.isBlocked(day)) return; const { onDateChange, keepOpenOnDateSelect, onFocusChange, onClose, startDate, endDate, } = this.props; onDateChange(day); if (!keepOpenOnDateSelect) { onFocusChange({ focused: null }); onClose({ startDate, endDate }); } } onDayMouseEnter(day) { if (this.isTouchDevice) return; const { hoverDate, visibleDays } = this.state; let modifiers = this.deleteModifier({}, hoverDate, 'hovered'); modifiers = this.addModifier(modifiers, day, 'hovered'); this.setState({ hoverDate: day, visibleDays: { ...visibleDays, ...modifiers, }, }); } onDayMouseLeave() { const { hoverDate, visibleDays } = this.state; if (this.isTouchDevice || !hoverDate) return; const modifiers = this.deleteModifier({}, hoverDate, 'hovered'); this.setState({ hoverDate: null, visibleDays: { ...visibleDays, ...modifiers, }, }); } onFocus() { const { disabled, onFocusChange, withPortal, withFullScreenPortal } = this.props; const moveFocusToDayPicker = withPortal || withFullScreenPortal || this.isTouchDevice; if (moveFocusToDayPicker) { this.onDayPickerFocus(); } else { this.onDayPickerBlur(); } if (!disabled) { onFocusChange({ focused: true }); } } onClearFocus() { const { startDate, endDate, focused, onFocusChange, onClose } = this.props; if (!focused) return; this.setState({ isInputFocused: false, isDayPickerFocused: false, }); onFocusChange({ focused: false }); onClose({ startDate, endDate }); } onDayPickerFocus() { this.setState({ isInputFocused: false, isDayPickerFocused: true, }); } onDayPickerBlur() { this.setState({ isInputFocused: true, isDayPickerFocused: false, }); } onPrevMonthClick() { const { onPrevMonthClick, numberOfMonths, enableOutsideDays } = this.props; const { currentMonth, visibleDays } = this.state; const newVisibleDays = {}; Object.keys(visibleDays).sort().slice(0, numberOfMonths + 1).forEach((month) => { newVisibleDays[month] = visibleDays[month]; }); const prevMonth = currentMonth.clone().subtract(1, 'month'); const prevMonthVisibleDays = getVisibleDays(prevMonth, 1, enableOutsideDays); this.setState({ currentMonth: prevMonth, visibleDays: { ...newVisibleDays, ...this.getModifiers(prevMonthVisibleDays), }, }); onPrevMonthClick(); } onNextMonthClick() { const { onNextMonthClick, numberOfMonths, enableOutsideDays } = this.props; const { currentMonth, visibleDays } = this.state; const newVisibleDays = {}; Object.keys(visibleDays).sort().slice(1).forEach((month) => { newVisibleDays[month] = visibleDays[month]; }); const nextMonth = currentMonth.clone().add(numberOfMonths, 'month'); const nextMonthVisibleDays = getVisibleDays(nextMonth, 1, enableOutsideDays); this.setState({ currentMonth: currentMonth.clone().add(1, 'month'), visibleDays: { ...newVisibleDays, ...this.getModifiers(nextMonthVisibleDays), }, }); onNextMonthClick(); } getDateString(date) { const displayFormat = this.getDisplayFormat(); if (date && displayFormat) { return date && date.format(displayFormat); } return toLocalizedDateString(date); } getDayPickerContainerClasses() { const { orientation, withPortal, withFullScreenPortal, anchorDirection, isRTL } = this.props; const { hoverDate } = this.state; const dayPickerClassName = cx('SingleDatePicker__picker', { 'SingleDatePicker__picker--direction-left': anchorDirection === ANCHOR_LEFT, 'SingleDatePicker__picker--direction-right': anchorDirection === ANCHOR_RIGHT, 'SingleDatePicker__picker--horizontal': orientation === HORIZONTAL_ORIENTATION, 'SingleDatePicker__picker--vertical': orientation === VERTICAL_ORIENTATION, 'SingleDatePicker__picker--portal': withPortal || withFullScreenPortal, 'SingleDatePicker__picker--full-screen-portal': withFullScreenPortal, 'SingleDatePicker__picker--valid-date-hovered': hoverDate && !this.isBlocked(hoverDate), 'SingleDatePicker__picker--rtl': isRTL, }); return dayPickerClassName; } getDisplayFormat() { const { displayFormat } = this.props; return typeof displayFormat === 'string' ? displayFormat : displayFormat(); } getFirstFocusableDay(newMonth) { const { date, numberOfMonths } = this.props; let focusedDate = newMonth.clone().startOf('month'); if (date) { focusedDate = date.clone(); } if (this.isBlocked(focusedDate)) { const days = []; const lastVisibleDay = newMonth.clone().add(numberOfMonths - 1, 'months').endOf('month'); let currentDay = focusedDate.clone(); while (!isAfterDay(currentDay, lastVisibleDay)) { currentDay = currentDay.clone().add(1, 'day'); days.push(currentDay); } const viableDays = days.filter(day => !this.isBlocked(day) && isAfterDay(day, focusedDate)); if (viableDays.length > 0) focusedDate = viableDays[0]; } return focusedDate; } getModifiers(visibleDays) { const modifiers = {}; Object.keys(visibleDays).forEach((month) => { modifiers[month] = {}; visibleDays[month].forEach((day) => { modifiers[month][toISODateString(day)] = this.getModifiersForDay(day); }); }); return modifiers; } getModifiersForDay(day) { return new Set(Object.keys(this.modifiers).filter(modifier => this.modifiers[modifier](day))); } getStateForNewMonth(nextProps) { const { initialVisibleMonth, date, numberOfMonths, enableOutsideDays } = nextProps; const initialVisibleMonthThunk = initialVisibleMonth || (date ? () => date : () => this.today); const currentMonth = initialVisibleMonthThunk(); const visibleDays = this.getModifiers(getVisibleDays(currentMonth, numberOfMonths, enableOutsideDays)); return { currentMonth, visibleDays }; } addModifier(updatedDays, day, modifier) { const { numberOfMonths, enableOutsideDays } = this.props; const { currentMonth, visibleDays } = this.state; if (!day || !isDayVisible(day, currentMonth, numberOfMonths, enableOutsideDays)) { return updatedDays; } let monthIso = toISOMonthString(day); let month = updatedDays[monthIso] || visibleDays[monthIso]; const iso = toISODateString(day); if (enableOutsideDays) { const startOfMonth = day.clone().startOf('month'); const endOfMonth = day.clone().endOf('month'); if ( isBeforeDay(startOfMonth, currentMonth.clone().startOf('month')) || isAfterDay(endOfMonth, currentMonth.clone().endOf('month')) ) { monthIso = Object.keys(visibleDays).filter(monthKey => ( monthKey !== monthIso && Object.keys(visibleDays[monthKey]).indexOf(iso) > -1 ))[0]; month = updatedDays[monthIso] || visibleDays[monthIso]; } } const modifiers = new Set(month[iso]); modifiers.add(modifier); return { ...updatedDays, [monthIso]: { ...month, [iso]: modifiers, }, }; } deleteModifier(updatedDays, day, modifier) { const { numberOfMonths, enableOutsideDays } = this.props; const { currentMonth, visibleDays } = this.state; if (!day || !isDayVisible(day, currentMonth, numberOfMonths, enableOutsideDays)) { return updatedDays; } let monthIso = toISOMonthString(day); let month = updatedDays[monthIso] || visibleDays[monthIso]; const iso = toISODateString(day); if (enableOutsideDays) { const startOfMonth = day.clone().startOf('month'); const endOfMonth = day.clone().endOf('month'); if ( isBeforeDay(startOfMonth, currentMonth.clone().startOf('month')) || isAfterDay(endOfMonth, currentMonth.clone().endOf('month')) ) { monthIso = Object.keys(visibleDays).filter(monthKey => ( monthKey !== monthIso && Object.keys(visibleDays[monthKey]).indexOf(iso) > -1 ))[0]; month = updatedDays[monthIso] || visibleDays[monthIso]; } } const modifiers = new Set(month[iso]); modifiers.delete(modifier); return { ...updatedDays, [monthIso]: { ...month, [iso]: modifiers, }, }; } clearDate() { const { onDateChange, reopenPickerOnClearDate, onFocusChange } = this.props; onDateChange(null); if (reopenPickerOnClearDate) { onFocusChange({ focused: true }); } } /* istanbul ignore next */ responsivizePickerPosition() { const { anchorDirection, horizontalMargin, withPortal, withFullScreenPortal, focused, } = this.props; const { dayPickerContainerStyles } = this.state; if (!focused) { return; } const isAnchoredLeft = anchorDirection === ANCHOR_LEFT; if (!withPortal && !withFullScreenPortal) { const containerRect = this.dayPickerContainer.getBoundingClientRect(); const currentOffset = dayPickerContainerStyles[anchorDirection] || 0; const containerEdge = isAnchoredLeft ? containerRect[ANCHOR_RIGHT] : containerRect[ANCHOR_LEFT]; this.setState({ dayPickerContainerStyles: getResponsiveContainerStyles( anchorDirection, currentOffset, containerEdge, horizontalMargin, ), }); } } isBlocked(day) { const { isDayBlocked, isOutsideRange } = this.props; return isDayBlocked(day) || isOutsideRange(day); } isHovered(day) { const { hoverDate } = this.state || {}; return isSameDay(day, hoverDate); } isSelected(day) { return isSameDay(day, this.props.date); } isToday(day) { return isSameDay(day, this.today); } maybeRenderDayPickerWithPortal() { const { focused, withPortal, withFullScreenPortal } = this.props; if (!focused) { return null; } if (withPortal || withFullScreenPortal) { return ( <Portal isOpened> {this.renderDayPicker()} </Portal> ); } return this.renderDayPicker(); } renderDayPicker() { const { enableOutsideDays, numberOfMonths, orientation, monthFormat, navPrev, navNext, withPortal, withFullScreenPortal, focused, renderMonth, renderDay, renderCalendarInfo, hideKeyboardShortcutsPanel, customCloseIcon, phrases, daySize, isRTL, } = this.props; const { dayPickerContainerStyles, isDayPickerFocused, currentMonth, visibleDays } = this.state; const onOutsideClick = (!withFullScreenPortal && withPortal) ? this.onClearFocus : undefined; const closeIcon = customCloseIcon || (<CloseButton />); return ( <div // eslint-disable-line jsx-a11y/no-static-element-interactions ref={(ref) => { this.dayPickerContainer = ref; }} className={this.getDayPickerContainerClasses()} style={dayPickerContainerStyles} onClick={onOutsideClick} > <DayPicker orientation={orientation} enableOutsideDays={enableOutsideDays} modifiers={visibleDays} numberOfMonths={numberOfMonths} onDayClick={this.onDayClick} onDayMouseEnter={this.onDayMouseEnter} onDayMouseLeave={this.onDayMouseLeave} onPrevMonthClick={this.onPrevMonthClick} onNextMonthClick={this.onNextMonthClick} monthFormat={monthFormat} withPortal={withPortal || withFullScreenPortal} hidden={!focused} hideKeyboardShortcutsPanel={hideKeyboardShortcutsPanel} initialVisibleMonth={() => currentMonth} navPrev={navPrev} navNext={navNext} renderMonth={renderMonth} renderDay={renderDay} renderCalendarInfo={renderCalendarInfo} isFocused={isDayPickerFocused} getFirstFocusableDay={this.getFirstFocusableDay} onBlur={this.onDayPickerBlur} phrases={phrases} daySize={daySize} isRTL={isRTL} /> {withFullScreenPortal && ( <button aria-label={phrases.closeDatePicker} className="SingleDatePicker__close" type="button" onClick={this.onClearFocus} > <div className="SingleDatePicker__close-icon"> {closeIcon} </div> </button> )} </div> ); } render() { const { id, placeholder, disabled, focused, required, readOnly, showClearDate, date, phrases, withPortal, withFullScreenPortal, screenReaderInputMessage, isRTL, } = this.props; const { isInputFocused } = this.state; const displayValue = this.getDateString(date); const inputValue = toISODateString(date); const onOutsideClick = (!withPortal && !withFullScreenPortal) ? this.onClearFocus : undefined; return ( <div className="SingleDatePicker"> <OutsideClickHandler onOutsideClick={onOutsideClick}> <SingleDatePickerInput id={id} placeholder={placeholder} focused={focused} isFocused={isInputFocused} disabled={disabled} required={required} readOnly={readOnly} showCaret={!withPortal && !withFullScreenPortal} onClearDate={this.clearDate} showClearDate={showClearDate} displayValue={displayValue} inputValue={inputValue} onChange={this.onChange} onFocus={this.onFocus} onKeyDownShiftTab={this.onClearFocus} onKeyDownTab={this.onClearFocus} onKeyDownArrowDown={this.onDayPickerFocus} screenReaderMessage={screenReaderInputMessage} phrases={phrases} isRTL={isRTL} /> {this.maybeRenderDayPickerWithPortal()} </OutsideClickHandler> </div> ); } } SingleDatePicker.propTypes = propTypes; SingleDatePicker.defaultProps = defaultProps;