react-dates
Version:
A responsive and accessible date range picker component built with React
791 lines (670 loc) • 22.7 kB
JSX
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;