UNPKG

21.3 kBJSXView Raw
1import React from 'react';
2import moment from 'moment';
3import { withStyles, withStylesPropTypes } from 'react-with-styles';
4import { Portal } from 'react-portal';
5import { forbidExtraProps } from 'airbnb-prop-types';
6import { addEventListener } from 'consolidated-events';
7import isTouchDevice from 'is-touch-device';
8import OutsideClickHandler from 'react-outside-click-handler';
9
10import DateRangePickerShape from '../shapes/DateRangePickerShape';
11import { DateRangePickerPhrases } from '../defaultPhrases';
12
13import getResponsiveContainerStyles from '../utils/getResponsiveContainerStyles';
14import getDetachedContainerStyles from '../utils/getDetachedContainerStyles';
15import getInputHeight from '../utils/getInputHeight';
16import isInclusivelyAfterDay from '../utils/isInclusivelyAfterDay';
17import disableScroll from '../utils/disableScroll';
18import noflip from '../utils/noflip';
19
20import DateRangePickerInputController from './DateRangePickerInputController';
21import DayPickerRangeController from './DayPickerRangeController';
22import CloseButton from './CloseButton';
23
24import {
25 START_DATE,
26 END_DATE,
27 HORIZONTAL_ORIENTATION,
28 VERTICAL_ORIENTATION,
29 ANCHOR_LEFT,
30 ANCHOR_RIGHT,
31 OPEN_DOWN,
32 OPEN_UP,
33 DAY_SIZE,
34 ICON_BEFORE_POSITION,
35 INFO_POSITION_BOTTOM,
36 FANG_HEIGHT_PX,
37 DEFAULT_VERTICAL_SPACING,
38} from '../constants';
39
40const propTypes = forbidExtraProps({
41 ...withStylesPropTypes,
42 ...DateRangePickerShape,
43});
44
45const defaultProps = {
46 // required props for a functional interactive DateRangePicker
47 startDate: null,
48 endDate: null,
49 focusedInput: null,
50
51 // input related props
52 startDatePlaceholderText: 'Start Date',
53 endDatePlaceholderText: 'End Date',
54 startDateAriaLabel: undefined,
55 endDateAriaLabel: undefined,
56 startDateOffset: undefined,
57 endDateOffset: undefined,
58 disabled: false,
59 required: false,
60 readOnly: false,
61 screenReaderInputMessage: '',
62 showClearDates: false,
63 showDefaultInputIcon: false,
64 inputIconPosition: ICON_BEFORE_POSITION,
65 customInputIcon: null,
66 customArrowIcon: null,
67 customCloseIcon: null,
68 noBorder: false,
69 block: false,
70 small: false,
71 regular: false,
72 keepFocusOnInput: false,
73
74 // calendar presentation and interaction related props
75 renderMonthText: null,
76 renderWeekHeaderElement: null,
77 orientation: HORIZONTAL_ORIENTATION,
78 anchorDirection: ANCHOR_LEFT,
79 openDirection: OPEN_DOWN,
80 horizontalMargin: 0,
81 withPortal: false,
82 withFullScreenPortal: false,
83 appendToBody: false,
84 disableScroll: false,
85 initialVisibleMonth: null,
86 numberOfMonths: 2,
87 keepOpenOnDateSelect: false,
88 reopenPickerOnClearDates: false,
89 renderCalendarInfo: null,
90 calendarInfoPosition: INFO_POSITION_BOTTOM,
91 hideKeyboardShortcutsPanel: false,
92 daySize: DAY_SIZE,
93 isRTL: false,
94 firstDayOfWeek: null,
95 verticalHeight: null,
96 transitionDuration: undefined,
97 verticalSpacing: DEFAULT_VERTICAL_SPACING,
98 horizontalMonthPadding: undefined,
99
100 // navigation related props
101 navPrev: null,
102 navNext: null,
103
104 onPrevMonthClick() {},
105 onNextMonthClick() {},
106
107 onClose() {},
108
109 // day presentation and interaction related props
110 renderCalendarDay: undefined,
111 renderDayContents: null,
112 renderMonthElement: null,
113 minimumNights: 1,
114 enableOutsideDays: false,
115 isDayBlocked: () => false,
116 isOutsideRange: (day) => !isInclusivelyAfterDay(day, moment()),
117 isDayHighlighted: () => false,
118
119 // internationalization
120 displayFormat: () => moment.localeData().longDateFormat('L'),
121 monthFormat: 'MMMM YYYY',
122 weekDayFormat: 'dd',
123 phrases: DateRangePickerPhrases,
124 dayAriaLabelFormat: undefined,
125};
126
127class DateRangePicker extends React.PureComponent {
128 constructor(props) {
129 super(props);
130 this.state = {
131 dayPickerContainerStyles: {},
132 isDateRangePickerInputFocused: false,
133 isDayPickerFocused: false,
134 showKeyboardShortcuts: false,
135 };
136
137 this.isTouchDevice = false;
138
139 this.onOutsideClick = this.onOutsideClick.bind(this);
140 this.onDateRangePickerInputFocus = this.onDateRangePickerInputFocus.bind(this);
141 this.onDayPickerFocus = this.onDayPickerFocus.bind(this);
142 this.onDayPickerFocusOut = this.onDayPickerFocusOut.bind(this);
143 this.onDayPickerBlur = this.onDayPickerBlur.bind(this);
144 this.showKeyboardShortcutsPanel = this.showKeyboardShortcutsPanel.bind(this);
145
146 this.responsivizePickerPosition = this.responsivizePickerPosition.bind(this);
147 this.disableScroll = this.disableScroll.bind(this);
148
149 this.setDayPickerContainerRef = this.setDayPickerContainerRef.bind(this);
150 this.setContainerRef = this.setContainerRef.bind(this);
151 }
152
153 componentDidMount() {
154 this.removeEventListener = addEventListener(
155 window,
156 'resize',
157 this.responsivizePickerPosition,
158 { passive: true },
159 );
160 this.responsivizePickerPosition();
161 this.disableScroll();
162
163 const { focusedInput } = this.props;
164 if (focusedInput) {
165 this.setState({
166 isDateRangePickerInputFocused: true,
167 });
168 }
169
170 this.isTouchDevice = isTouchDevice();
171 }
172
173 componentDidUpdate(prevProps) {
174 const { focusedInput } = this.props;
175 if (!prevProps.focusedInput && focusedInput && this.isOpened()) {
176 // The date picker just changed from being closed to being open.
177 this.responsivizePickerPosition();
178 this.disableScroll();
179 } else if (prevProps.focusedInput && !focusedInput && !this.isOpened()) {
180 // The date picker just changed from being open to being closed.
181 if (this.enableScroll) this.enableScroll();
182 }
183 }
184
185 componentWillUnmount() {
186 this.removeDayPickerEventListeners();
187 if (this.removeEventListener) this.removeEventListener();
188 if (this.enableScroll) this.enableScroll();
189 }
190
191 onOutsideClick(event) {
192 const {
193 onFocusChange,
194 onClose,
195 startDate,
196 endDate,
197 appendToBody,
198 } = this.props;
199
200 if (!this.isOpened()) return;
201 if (appendToBody && this.dayPickerContainer.contains(event.target)) return;
202
203 this.setState({
204 isDateRangePickerInputFocused: false,
205 isDayPickerFocused: false,
206 showKeyboardShortcuts: false,
207 });
208
209 onFocusChange(null);
210 onClose({ startDate, endDate });
211 }
212
213 onDateRangePickerInputFocus(focusedInput) {
214 const {
215 onFocusChange,
216 readOnly,
217 withPortal,
218 withFullScreenPortal,
219 keepFocusOnInput,
220 } = this.props;
221
222 if (focusedInput) {
223 const withAnyPortal = withPortal || withFullScreenPortal;
224 const moveFocusToDayPicker = withAnyPortal
225 || (readOnly && !keepFocusOnInput)
226 || (this.isTouchDevice && !keepFocusOnInput);
227
228 if (moveFocusToDayPicker) {
229 this.onDayPickerFocus();
230 } else {
231 this.onDayPickerBlur();
232 }
233 }
234
235 onFocusChange(focusedInput);
236 }
237
238 onDayPickerFocus() {
239 const { focusedInput, onFocusChange } = this.props;
240 if (!focusedInput) onFocusChange(START_DATE);
241
242 this.setState({
243 isDateRangePickerInputFocused: false,
244 isDayPickerFocused: true,
245 showKeyboardShortcuts: false,
246 });
247 }
248
249 onDayPickerFocusOut(event) {
250 // In cases where **relatedTarget** is not null, it points to the right
251 // element here. However, in cases where it is null (such as clicking on a
252 // specific day) or it is **document.body** (IE11), the appropriate value is **event.target**.
253 //
254 // We handle both situations here by using the ` || ` operator to fallback
255 // to *event.target** when **relatedTarget** is not provided.
256 const relatedTarget = event.relatedTarget === document.body
257 ? event.target
258 : (event.relatedTarget || event.target);
259 if (this.dayPickerContainer.contains(relatedTarget)) return;
260 this.onOutsideClick(event);
261 }
262
263 onDayPickerBlur() {
264 this.setState({
265 isDateRangePickerInputFocused: true,
266 isDayPickerFocused: false,
267 showKeyboardShortcuts: false,
268 });
269 }
270
271 setDayPickerContainerRef(ref) {
272 if (ref === this.dayPickerContainer) return;
273 if (this.dayPickerContainer) this.removeDayPickerEventListeners();
274
275 this.dayPickerContainer = ref;
276 if (!ref) return;
277
278 this.addDayPickerEventListeners();
279 }
280
281 setContainerRef(ref) {
282 this.container = ref;
283 }
284
285 addDayPickerEventListeners() {
286 // NOTE: We are using a manual event listener here, because React doesn't
287 // provide FocusOut, while blur and keydown don't provide the information
288 // needed in order to know whether we have left focus or not.
289 //
290 // For reference, this issue is further described here:
291 // - https://github.com/facebook/react/issues/6410
292 this.removeDayPickerFocusOut = addEventListener(
293 this.dayPickerContainer,
294 'focusout',
295 this.onDayPickerFocusOut,
296 );
297 }
298
299 removeDayPickerEventListeners() {
300 if (this.removeDayPickerFocusOut) this.removeDayPickerFocusOut();
301 }
302
303 isOpened() {
304 const { focusedInput } = this.props;
305 return focusedInput === START_DATE || focusedInput === END_DATE;
306 }
307
308 disableScroll() {
309 const { appendToBody, disableScroll: propDisableScroll } = this.props;
310 if (!appendToBody && !propDisableScroll) return;
311 if (!this.isOpened()) return;
312
313 // Disable scroll for every ancestor of this DateRangePicker up to the
314 // document level. This ensures the input and the picker never move. Other
315 // sibling elements or the picker itself can scroll.
316 this.enableScroll = disableScroll(this.container);
317 }
318
319 responsivizePickerPosition() {
320 // It's possible the portal props have been changed in response to window resizes
321 // So let's ensure we reset this back to the base state each time
322 const { dayPickerContainerStyles } = this.state;
323
324 if (Object.keys(dayPickerContainerStyles).length > 0) {
325 this.setState({ dayPickerContainerStyles: {} });
326 }
327
328 if (!this.isOpened()) {
329 return;
330 }
331
332 const {
333 openDirection,
334 anchorDirection,
335 horizontalMargin,
336 withPortal,
337 withFullScreenPortal,
338 appendToBody,
339 } = this.props;
340
341 const isAnchoredLeft = anchorDirection === ANCHOR_LEFT;
342 if (!withPortal && !withFullScreenPortal) {
343 const containerRect = this.dayPickerContainer.getBoundingClientRect();
344 const currentOffset = dayPickerContainerStyles[anchorDirection] || 0;
345 const containerEdge = isAnchoredLeft
346 ? containerRect[ANCHOR_RIGHT]
347 : containerRect[ANCHOR_LEFT];
348
349 this.setState({
350 dayPickerContainerStyles: {
351 ...getResponsiveContainerStyles(
352 anchorDirection,
353 currentOffset,
354 containerEdge,
355 horizontalMargin,
356 ),
357 ...(appendToBody && getDetachedContainerStyles(
358 openDirection,
359 anchorDirection,
360 this.container,
361 )),
362 },
363 });
364 }
365 }
366
367 showKeyboardShortcutsPanel() {
368 this.setState({
369 isDateRangePickerInputFocused: false,
370 isDayPickerFocused: true,
371 showKeyboardShortcuts: true,
372 });
373 }
374
375 maybeRenderDayPickerWithPortal() {
376 const { withPortal, withFullScreenPortal, appendToBody } = this.props;
377
378 if (!this.isOpened()) {
379 return null;
380 }
381
382 if (withPortal || withFullScreenPortal || appendToBody) {
383 return (
384 <Portal>
385 {this.renderDayPicker()}
386 </Portal>
387 );
388 }
389
390 return this.renderDayPicker();
391 }
392
393 renderDayPicker() {
394 const {
395 anchorDirection,
396 css,
397 openDirection,
398 isDayBlocked,
399 isDayHighlighted,
400 isOutsideRange,
401 numberOfMonths,
402 orientation,
403 monthFormat,
404 renderMonthText,
405 renderWeekHeaderElement,
406 navPrev,
407 navNext,
408 onPrevMonthClick,
409 onNextMonthClick,
410 onDatesChange,
411 onFocusChange,
412 withPortal,
413 withFullScreenPortal,
414 daySize,
415 enableOutsideDays,
416 focusedInput,
417 startDate,
418 startDateOffset,
419 endDate,
420 endDateOffset,
421 minimumNights,
422 keepOpenOnDateSelect,
423 renderCalendarDay,
424 renderDayContents,
425 renderCalendarInfo,
426 renderMonthElement,
427 calendarInfoPosition,
428 firstDayOfWeek,
429 initialVisibleMonth,
430 hideKeyboardShortcutsPanel,
431 customCloseIcon,
432 onClose,
433 phrases,
434 dayAriaLabelFormat,
435 isRTL,
436 weekDayFormat,
437 styles,
438 verticalHeight,
439 transitionDuration,
440 verticalSpacing,
441 horizontalMonthPadding,
442 small,
443 disabled,
444 theme: { reactDates },
445 } = this.props;
446
447 const { dayPickerContainerStyles, isDayPickerFocused, showKeyboardShortcuts } = this.state;
448
449 const onOutsideClick = (!withFullScreenPortal && withPortal)
450 ? this.onOutsideClick
451 : undefined;
452 const initialVisibleMonthThunk = initialVisibleMonth || (
453 () => (startDate || endDate || moment())
454 );
455
456 const closeIcon = customCloseIcon || (
457 <CloseButton {...css(styles.DateRangePicker_closeButton_svg)} />
458 );
459
460 const inputHeight = getInputHeight(reactDates, small);
461
462 const withAnyPortal = withPortal || withFullScreenPortal;
463
464 return (
465 <div // eslint-disable-line jsx-a11y/no-static-element-interactions
466 ref={this.setDayPickerContainerRef}
467 {...css(
468 styles.DateRangePicker_picker,
469 anchorDirection === ANCHOR_LEFT && styles.DateRangePicker_picker__directionLeft,
470 anchorDirection === ANCHOR_RIGHT && styles.DateRangePicker_picker__directionRight,
471 orientation === HORIZONTAL_ORIENTATION && styles.DateRangePicker_picker__horizontal,
472 orientation === VERTICAL_ORIENTATION && styles.DateRangePicker_picker__vertical,
473 !withAnyPortal && openDirection === OPEN_DOWN && {
474 top: inputHeight + verticalSpacing,
475 },
476 !withAnyPortal && openDirection === OPEN_UP && {
477 bottom: inputHeight + verticalSpacing,
478 },
479 withAnyPortal && styles.DateRangePicker_picker__portal,
480 withFullScreenPortal && styles.DateRangePicker_picker__fullScreenPortal,
481 isRTL && styles.DateRangePicker_picker__rtl,
482 dayPickerContainerStyles,
483 )}
484 onClick={onOutsideClick}
485 >
486 <DayPickerRangeController
487 orientation={orientation}
488 enableOutsideDays={enableOutsideDays}
489 numberOfMonths={numberOfMonths}
490 onPrevMonthClick={onPrevMonthClick}
491 onNextMonthClick={onNextMonthClick}
492 onDatesChange={onDatesChange}
493 onFocusChange={onFocusChange}
494 onClose={onClose}
495 focusedInput={focusedInput}
496 startDate={startDate}
497 startDateOffset={startDateOffset}
498 endDate={endDate}
499 endDateOffset={endDateOffset}
500 monthFormat={monthFormat}
501 renderMonthText={renderMonthText}
502 renderWeekHeaderElement={renderWeekHeaderElement}
503 withPortal={withAnyPortal}
504 daySize={daySize}
505 initialVisibleMonth={initialVisibleMonthThunk}
506 hideKeyboardShortcutsPanel={hideKeyboardShortcutsPanel}
507 navPrev={navPrev}
508 navNext={navNext}
509 minimumNights={minimumNights}
510 isOutsideRange={isOutsideRange}
511 isDayHighlighted={isDayHighlighted}
512 isDayBlocked={isDayBlocked}
513 keepOpenOnDateSelect={keepOpenOnDateSelect}
514 renderCalendarDay={renderCalendarDay}
515 renderDayContents={renderDayContents}
516 renderCalendarInfo={renderCalendarInfo}
517 renderMonthElement={renderMonthElement}
518 calendarInfoPosition={calendarInfoPosition}
519 isFocused={isDayPickerFocused}
520 showKeyboardShortcuts={showKeyboardShortcuts}
521 onBlur={this.onDayPickerBlur}
522 phrases={phrases}
523 dayAriaLabelFormat={dayAriaLabelFormat}
524 isRTL={isRTL}
525 firstDayOfWeek={firstDayOfWeek}
526 weekDayFormat={weekDayFormat}
527 verticalHeight={verticalHeight}
528 transitionDuration={transitionDuration}
529 disabled={disabled}
530 horizontalMonthPadding={horizontalMonthPadding}
531 />
532
533 {withFullScreenPortal && (
534 <button
535 {...css(styles.DateRangePicker_closeButton)}
536 type="button"
537 onClick={this.onOutsideClick}
538 aria-label={phrases.closeDatePicker}
539 >
540 {closeIcon}
541 </button>
542 )}
543 </div>
544 );
545 }
546
547 render() {
548 const {
549 css,
550 startDate,
551 startDateId,
552 startDatePlaceholderText,
553 startDateAriaLabel,
554 endDate,
555 endDateId,
556 endDatePlaceholderText,
557 endDateAriaLabel,
558 focusedInput,
559 screenReaderInputMessage,
560 showClearDates,
561 showDefaultInputIcon,
562 inputIconPosition,
563 customInputIcon,
564 customArrowIcon,
565 customCloseIcon,
566 disabled,
567 required,
568 readOnly,
569 openDirection,
570 phrases,
571 isOutsideRange,
572 minimumNights,
573 withPortal,
574 withFullScreenPortal,
575 displayFormat,
576 reopenPickerOnClearDates,
577 keepOpenOnDateSelect,
578 onDatesChange,
579 onClose,
580 isRTL,
581 noBorder,
582 block,
583 verticalSpacing,
584 small,
585 regular,
586 styles,
587 } = this.props;
588
589 const { isDateRangePickerInputFocused } = this.state;
590
591 const enableOutsideClick = (!withPortal && !withFullScreenPortal);
592
593 const hideFang = verticalSpacing < FANG_HEIGHT_PX;
594
595 const input = (
596 <DateRangePickerInputController
597 startDate={startDate}
598 startDateId={startDateId}
599 startDatePlaceholderText={startDatePlaceholderText}
600 isStartDateFocused={focusedInput === START_DATE}
601 startDateAriaLabel={startDateAriaLabel}
602 endDate={endDate}
603 endDateId={endDateId}
604 endDatePlaceholderText={endDatePlaceholderText}
605 isEndDateFocused={focusedInput === END_DATE}
606 endDateAriaLabel={endDateAriaLabel}
607 displayFormat={displayFormat}
608 showClearDates={showClearDates}
609 showCaret={!withPortal && !withFullScreenPortal && !hideFang}
610 showDefaultInputIcon={showDefaultInputIcon}
611 inputIconPosition={inputIconPosition}
612 customInputIcon={customInputIcon}
613 customArrowIcon={customArrowIcon}
614 customCloseIcon={customCloseIcon}
615 disabled={disabled}
616 required={required}
617 readOnly={readOnly}
618 openDirection={openDirection}
619 reopenPickerOnClearDates={reopenPickerOnClearDates}
620 keepOpenOnDateSelect={keepOpenOnDateSelect}
621 isOutsideRange={isOutsideRange}
622 minimumNights={minimumNights}
623 withFullScreenPortal={withFullScreenPortal}
624 onDatesChange={onDatesChange}
625 onFocusChange={this.onDateRangePickerInputFocus}
626 onKeyDownArrowDown={this.onDayPickerFocus}
627 onKeyDownQuestionMark={this.showKeyboardShortcutsPanel}
628 onClose={onClose}
629 phrases={phrases}
630 screenReaderMessage={screenReaderInputMessage}
631 isFocused={isDateRangePickerInputFocused}
632 isRTL={isRTL}
633 noBorder={noBorder}
634 block={block}
635 small={small}
636 regular={regular}
637 verticalSpacing={verticalSpacing}
638 >
639 {this.maybeRenderDayPickerWithPortal()}
640 </DateRangePickerInputController>
641 );
642
643 return (
644 <div
645 ref={this.setContainerRef}
646 {...css(
647 styles.DateRangePicker,
648 block && styles.DateRangePicker__block,
649 )}
650 >
651 {enableOutsideClick && (
652 <OutsideClickHandler onOutsideClick={this.onOutsideClick}>
653 {input}
654 </OutsideClickHandler>
655 )}
656 {enableOutsideClick || input}
657 </div>
658 );
659 }
660}
661
662DateRangePicker.propTypes = propTypes;
663DateRangePicker.defaultProps = defaultProps;
664
665export { DateRangePicker as PureDateRangePicker };
666export default withStyles(({ reactDates: { color, zIndex } }) => ({
667 DateRangePicker: {
668 position: 'relative',
669 display: 'inline-block',
670 },
671
672 DateRangePicker__block: {
673 display: 'block',
674 },
675
676 DateRangePicker_picker: {
677 zIndex: zIndex + 1,
678 backgroundColor: color.background,
679 position: 'absolute',
680 },
681
682 DateRangePicker_picker__rtl: {
683 direction: noflip('rtl'),
684 },
685
686 DateRangePicker_picker__directionLeft: {
687 left: noflip(0),
688 },
689
690 DateRangePicker_picker__directionRight: {
691 right: noflip(0),
692 },
693
694 DateRangePicker_picker__portal: {
695 backgroundColor: 'rgba(0, 0, 0, 0.3)',
696 position: 'fixed',
697 top: 0,
698 left: noflip(0),
699 height: '100%',
700 width: '100%',
701 },
702
703 DateRangePicker_picker__fullScreenPortal: {
704 backgroundColor: color.background,
705 },
706
707 DateRangePicker_closeButton: {
708 background: 'none',
709 border: 0,
710 color: 'inherit',
711 font: 'inherit',
712 lineHeight: 'normal',
713 overflow: 'visible',
714 cursor: 'pointer',
715
716 position: 'absolute',
717 top: 0,
718 right: noflip(0),
719 padding: 15,
720 zIndex: zIndex + 2,
721
722 ':hover': {
723 color: `darken(${color.core.grayLighter}, 10%)`,
724 textDecoration: 'none',
725 },
726
727 ':focus': {
728 color: `darken(${color.core.grayLighter}, 10%)`,
729 textDecoration: 'none',
730 },
731 },
732
733 DateRangePicker_closeButton_svg: {
734 height: 15,
735 width: 15,
736 fill: color.core.grayLighter,
737 },
738}), { pureComponent: typeof React.PureComponent !== 'undefined' })(DateRangePicker);