UNPKG

react-dates

Version:

A responsive and accessible date range picker component built with React

574 lines (500 loc) 15.9 kB
import React from 'react'; import shallowCompare from 'react-addons-shallow-compare'; import moment from 'moment'; import { css, withStyles, withStylesPropTypes } from 'react-with-styles'; import Portal from 'react-portal'; import { forbidExtraProps } from 'airbnb-prop-types'; import { addEventListener } from 'consolidated-events'; import isTouchDevice from 'is-touch-device'; import { DateRangePickerPhrases } from '../defaultPhrases'; import OutsideClickHandler from './OutsideClickHandler'; import getResponsiveContainerStyles from '../utils/getResponsiveContainerStyles'; import isInclusivelyAfterDay from '../utils/isInclusivelyAfterDay'; import DateRangePickerInputController from './DateRangePickerInputController'; import DayPickerRangeController from './DayPickerRangeController'; import CloseButton from './CloseButton'; import DateRangePickerShape from '../shapes/DateRangePickerShape'; import { START_DATE, END_DATE, HORIZONTAL_ORIENTATION, VERTICAL_ORIENTATION, ANCHOR_LEFT, ANCHOR_RIGHT, OPEN_DOWN, OPEN_UP, DAY_SIZE, ICON_BEFORE_POSITION, } from '../constants'; const propTypes = forbidExtraProps({ ...withStylesPropTypes, ...DateRangePickerShape, }); const defaultProps = { // required props for a functional interactive DateRangePicker startDate: null, endDate: null, focusedInput: null, // input related props startDateId: START_DATE, startDatePlaceholderText: 'Start Date', endDateId: END_DATE, endDatePlaceholderText: 'End Date', disabled: false, required: false, readOnly: false, screenReaderInputMessage: '', showClearDates: false, showDefaultInputIcon: false, inputIconPosition: ICON_BEFORE_POSITION, customInputIcon: null, customArrowIcon: null, customCloseIcon: null, // calendar presentation and interaction related props renderMonth: null, orientation: HORIZONTAL_ORIENTATION, anchorDirection: ANCHOR_LEFT, openDirection: OPEN_DOWN, horizontalMargin: 0, withPortal: false, withFullScreenPortal: false, initialVisibleMonth: null, numberOfMonths: 2, keepOpenOnDateSelect: false, reopenPickerOnClearDates: false, renderCalendarInfo: null, hideKeyboardShortcutsPanel: false, daySize: DAY_SIZE, isRTL: false, firstDayOfWeek: null, verticalHeight: null, // navigation related props navPrev: null, navNext: null, onPrevMonthClick() {}, onNextMonthClick() {}, onClose() {}, // day presentation and interaction related props renderDay: null, minimumNights: 1, enableOutsideDays: false, isDayBlocked: () => false, isOutsideRange: day => !isInclusivelyAfterDay(day, moment()), isDayHighlighted: () => false, // internationalization displayFormat: () => moment.localeData().longDateFormat('L'), monthFormat: 'MMMM YYYY', weekDayFormat: 'dd', phrases: DateRangePickerPhrases, }; class DateRangePicker extends React.Component { constructor(props) { super(props); this.state = { dayPickerContainerStyles: {}, isDateRangePickerInputFocused: false, isDayPickerFocused: false, showKeyboardShortcuts: false, }; this.isTouchDevice = false; this.onOutsideClick = this.onOutsideClick.bind(this); this.onDateRangePickerInputFocus = this.onDateRangePickerInputFocus.bind(this); this.onDayPickerFocus = this.onDayPickerFocus.bind(this); this.onDayPickerBlur = this.onDayPickerBlur.bind(this); this.showKeyboardShortcutsPanel = this.showKeyboardShortcutsPanel.bind(this); this.responsivizePickerPosition = this.responsivizePickerPosition.bind(this); this.setDayPickerContainerRef = this.setDayPickerContainerRef.bind(this); } componentDidMount() { this.removeEventListener = addEventListener( window, 'resize', this.responsivizePickerPosition, { passive: true }, ); this.responsivizePickerPosition(); if (this.props.focusedInput) { this.setState({ isDateRangePickerInputFocused: true, }); } this.isTouchDevice = isTouchDevice(); } shouldComponentUpdate(nextProps, nextState) { return shallowCompare(this, nextProps, nextState); } componentDidUpdate(prevProps) { if (!prevProps.focusedInput && this.props.focusedInput && this.isOpened()) { // The date picker just changed from being closed to being open. this.responsivizePickerPosition(); } } componentWillUnmount() { if (this.removeEventListener) this.removeEventListener(); } onOutsideClick() { const { onFocusChange, onClose, startDate, endDate, } = this.props; if (!this.isOpened()) return; this.setState({ isDateRangePickerInputFocused: false, isDayPickerFocused: false, showKeyboardShortcuts: false, }); onFocusChange(null); onClose({ startDate, endDate }); } onDateRangePickerInputFocus(focusedInput) { const { onFocusChange, withPortal, withFullScreenPortal } = this.props; if (focusedInput) { const moveFocusToDayPicker = withPortal || withFullScreenPortal || this.isTouchDevice; if (moveFocusToDayPicker) { this.onDayPickerFocus(); } else { this.onDayPickerBlur(); } } onFocusChange(focusedInput); } onDayPickerFocus() { const { focusedInput, onFocusChange } = this.props; if (!focusedInput) onFocusChange(START_DATE); this.setState({ isDateRangePickerInputFocused: false, isDayPickerFocused: true, showKeyboardShortcuts: false, }); } onDayPickerBlur() { this.setState({ isDateRangePickerInputFocused: true, isDayPickerFocused: false, showKeyboardShortcuts: false, }); } setDayPickerContainerRef(ref) { this.dayPickerContainer = ref; } isOpened() { const { focusedInput } = this.props; return focusedInput === START_DATE || focusedInput === END_DATE; } responsivizePickerPosition() { // It's possible the portal props have been changed in response to window resizes // So let's ensure we reset this back to the base state each time this.setState({ dayPickerContainerStyles: {} }); if (!this.isOpened()) { return; } const { anchorDirection, horizontalMargin, withPortal, withFullScreenPortal, } = this.props; const { dayPickerContainerStyles } = this.state; 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, ), }); } } showKeyboardShortcutsPanel() { this.setState({ isDateRangePickerInputFocused: false, isDayPickerFocused: true, showKeyboardShortcuts: true, }); } maybeRenderDayPickerWithPortal() { const { withPortal, withFullScreenPortal } = this.props; if (!this.isOpened()) { return null; } if (withPortal || withFullScreenPortal) { return ( <Portal isOpened> {this.renderDayPicker()} </Portal> ); } return this.renderDayPicker(); } renderDayPicker() { const { anchorDirection, openDirection, isDayBlocked, isDayHighlighted, isOutsideRange, numberOfMonths, orientation, monthFormat, renderMonth, navPrev, navNext, onPrevMonthClick, onNextMonthClick, onDatesChange, onFocusChange, withPortal, withFullScreenPortal, daySize, enableOutsideDays, focusedInput, startDate, endDate, minimumNights, keepOpenOnDateSelect, renderDay, renderCalendarInfo, firstDayOfWeek, initialVisibleMonth, hideKeyboardShortcutsPanel, customCloseIcon, onClose, phrases, isRTL, weekDayFormat, styles, verticalHeight, } = this.props; const { dayPickerContainerStyles, isDayPickerFocused, showKeyboardShortcuts } = this.state; const onOutsideClick = (!withFullScreenPortal && withPortal) ? this.onOutsideClick : undefined; const initialVisibleMonthThunk = initialVisibleMonth || ( () => (startDate || endDate || moment()) ); const closeIcon = customCloseIcon || ( <CloseButton {...css(styles.DateRangePicker_closeButton_svg)} /> ); return ( <div // eslint-disable-line jsx-a11y/no-static-element-interactions ref={this.setDayPickerContainerRef} {...css( styles.DateRangePicker_picker, anchorDirection === ANCHOR_LEFT && styles.DateRangePicker_picker__directionLeft, anchorDirection === ANCHOR_RIGHT && styles.DateRangePicker_picker__directionRight, orientation === HORIZONTAL_ORIENTATION && styles.DateRangePicker_picker__horizontal, openDirection === OPEN_DOWN && styles.DateRangePicker_picker__openDown, openDirection === OPEN_UP && styles.DateRangePicker_picker__openUp, orientation === VERTICAL_ORIENTATION && styles.DateRangePicker_picker__vertical, (withPortal || withFullScreenPortal) && styles.DateRangePicker_picker__portal, withFullScreenPortal && styles.DateRangePicker_picker__fullScreenPortal, isRTL && styles.DateRangePicker_picker__rtl, )} style={dayPickerContainerStyles} onClick={onOutsideClick} > <DayPickerRangeController orientation={orientation} enableOutsideDays={enableOutsideDays} numberOfMonths={numberOfMonths} onPrevMonthClick={onPrevMonthClick} onNextMonthClick={onNextMonthClick} onDatesChange={onDatesChange} onFocusChange={onFocusChange} onClose={onClose} focusedInput={focusedInput} startDate={startDate} endDate={endDate} monthFormat={monthFormat} renderMonth={renderMonth} withPortal={withPortal || withFullScreenPortal} daySize={daySize} initialVisibleMonth={initialVisibleMonthThunk} hideKeyboardShortcutsPanel={hideKeyboardShortcutsPanel} navPrev={navPrev} navNext={navNext} minimumNights={minimumNights} isOutsideRange={isOutsideRange} isDayHighlighted={isDayHighlighted} isDayBlocked={isDayBlocked} keepOpenOnDateSelect={keepOpenOnDateSelect} renderDay={renderDay} renderCalendarInfo={renderCalendarInfo} isFocused={isDayPickerFocused} showKeyboardShortcuts={showKeyboardShortcuts} onBlur={this.onDayPickerBlur} phrases={phrases} isRTL={isRTL} firstDayOfWeek={firstDayOfWeek} weekDayFormat={weekDayFormat} verticalHeight={verticalHeight} /> {withFullScreenPortal && ( <button {...css(styles.DateRangePicker_closeButton)} type="button" onClick={this.onOutsideClick} aria-label={phrases.closeDatePicker} > {closeIcon} </button> )} </div> ); } render() { const { startDate, startDateId, startDatePlaceholderText, endDate, endDateId, endDatePlaceholderText, focusedInput, screenReaderInputMessage, showClearDates, showDefaultInputIcon, inputIconPosition, customInputIcon, customArrowIcon, customCloseIcon, disabled, required, readOnly, openDirection, phrases, isOutsideRange, minimumNights, withPortal, withFullScreenPortal, displayFormat, reopenPickerOnClearDates, keepOpenOnDateSelect, onDatesChange, onClose, isRTL, styles, } = this.props; const { isDateRangePickerInputFocused } = this.state; const onOutsideClick = (!withPortal && !withFullScreenPortal) ? this.onOutsideClick : undefined; return ( <div {...css(styles.DateRangePicker)}> <OutsideClickHandler onOutsideClick={onOutsideClick}> <DateRangePickerInputController startDate={startDate} startDateId={startDateId} startDatePlaceholderText={startDatePlaceholderText} isStartDateFocused={focusedInput === START_DATE} endDate={endDate} endDateId={endDateId} endDatePlaceholderText={endDatePlaceholderText} isEndDateFocused={focusedInput === END_DATE} displayFormat={displayFormat} showClearDates={showClearDates} showCaret={!withPortal && !withFullScreenPortal} showDefaultInputIcon={showDefaultInputIcon} inputIconPosition={inputIconPosition} customInputIcon={customInputIcon} customArrowIcon={customArrowIcon} customCloseIcon={customCloseIcon} disabled={disabled} required={required} readOnly={readOnly} openDirection={openDirection} reopenPickerOnClearDates={reopenPickerOnClearDates} keepOpenOnDateSelect={keepOpenOnDateSelect} isOutsideRange={isOutsideRange} minimumNights={minimumNights} withFullScreenPortal={withFullScreenPortal} onDatesChange={onDatesChange} onFocusChange={this.onDateRangePickerInputFocus} onArrowDown={this.onDayPickerFocus} onQuestionMark={this.showKeyboardShortcutsPanel} onClose={onClose} phrases={phrases} screenReaderMessage={screenReaderInputMessage} isFocused={isDateRangePickerInputFocused} isRTL={isRTL} /> {this.maybeRenderDayPickerWithPortal()} </OutsideClickHandler> </div> ); } } DateRangePicker.propTypes = propTypes; DateRangePicker.defaultProps = defaultProps; export { DateRangePicker as PureDateRangePicker }; export default withStyles(({ reactDates: { color, zIndex, spacing } }) => ({ DateRangePicker: { position: 'relative', display: 'inline-block', }, DateRangePicker_picker: { zIndex: zIndex + 1, backgroundColor: color.background, position: 'absolute', }, DateRangePicker_picker__rtl: { direction: 'rtl', }, DateRangePicker_picker__directionLeft: { left: 0, }, DateRangePicker_picker__directionRight: { right: 0, }, DateRangePicker_picker__openDown: { top: spacing.inputMarginBottom, }, DateRangePicker_picker__openUp: { bottom: spacing.inputMarginBottom, }, DateRangePicker_picker__portal: { backgroundColor: 'rgba(0, 0, 0, 0.3)', position: 'fixed', top: 0, left: 0, height: '100%', width: '100%', }, DateRangePicker_picker__fullScreenPortal: { backgroundColor: color.background, }, DateRangePicker_closeButton: { background: 'none', border: 0, color: 'inherit', font: 'inherit', lineHeight: 'normal', overflow: 'visible', cursor: 'pointer', position: 'absolute', top: 0, right: 0, padding: 15, zIndex: zIndex + 2, ':hover': { color: `darken(${color.core.grayLighter}, 10%)`, textDecoration: 'none', }, ':focus': { color: `darken(${color.core.grayLighter}, 10%)`, textDecoration: 'none', }, }, DateRangePicker_closeButton_svg: { height: 15, width: 15, fill: color.core.grayLighter, }, }))(DateRangePicker);