react-calendar-custom-date
Version:
A React component for choosing dates and date ranges.
540 lines (528 loc) • 19 kB
JavaScript
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { rangeShape } from './DayCell.js';
import Month from './Month.js';
import { calcFocusDate, generateStyles, getMonthDisplayRange } from '../utils';
import classnames from 'classnames';
import ReactList from 'react-list';
import {
addMonths,
format,
eachDayOfInterval,
startOfWeek,
endOfWeek,
isSameDay,
addYears,
setYear,
setMonth,
differenceInCalendarMonths,
startOfMonth,
endOfMonth,
addDays,
isSameMonth,
differenceInDays,
min,
max,
} from 'date-fns';
import defaultLocale from 'date-fns/locale/en-US';
import coreStyles from '../styles';
class Calendar extends PureComponent {
constructor(props, context) {
super(props, context);
this.changeShownDate = this.changeShownDate.bind(this);
this.focusToDate = this.focusToDate.bind(this);
this.updateShownDate = this.updateShownDate.bind(this);
this.handleRangeFocusChange = this.handleRangeFocusChange.bind(this);
this.renderDateDisplay = this.renderDateDisplay.bind(this);
this.onDragSelectionStart = this.onDragSelectionStart.bind(this);
this.onDragSelectionEnd = this.onDragSelectionEnd.bind(this);
this.onDragSelectionMove = this.onDragSelectionMove.bind(this);
this.renderMonthAndYear = this.renderMonthAndYear.bind(this);
this.updatePreview = this.updatePreview.bind(this);
this.estimateMonthSize = this.estimateMonthSize.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.dateOptions = { locale: props.locale };
this.styles = generateStyles([coreStyles, props.classNames]);
this.listSizeCache = {};
this.state = {
focusedDate: calcFocusDate(null, props),
drag: {
status: false,
range: { startDate: null, endDate: null },
disablePreview: false,
},
scrollArea: this.calcScrollArea(props),
};
}
calcScrollArea(props) {
const { direction, months, scroll } = props;
if (!scroll.enabled) return { enabled: false };
const longMonthHeight = scroll.longMonthHeight || scroll.monthHeight;
if (direction === 'vertical') {
return {
enabled: true,
monthHeight: scroll.monthHeight || 220,
longMonthHeight: longMonthHeight || 260,
calendarWidth: 'auto',
calendarHeight: (scroll.calendarHeight || longMonthHeight || 240) * months,
};
}
return {
enabled: true,
monthWidth: scroll.monthWidth || 332,
calendarWidth: (scroll.calendarWidth || scroll.monthWidth || 332) * months,
monthHeight: longMonthHeight || 300,
calendarHeight: longMonthHeight || 300,
};
}
focusToDate(date, props = this.props, preventUnnecessary = true) {
if (!props.scroll.enabled) {
this.setState({ focusedDate: date });
return;
}
const targetMonthIndex = differenceInCalendarMonths(date, props.minDate, this.dateOptions);
const visibleMonths = this.list.getVisibleRange();
if (preventUnnecessary && visibleMonths.includes(targetMonthIndex)) return;
this.list.scrollTo(targetMonthIndex);
this.setState({ focusedDate: date });
}
updateShownDate(props = this.props) {
const newProps = props.scroll.enabled
? {
...props,
months: this.list.getVisibleRange().length,
}
: props;
const newFocus = calcFocusDate(this.state.focusedDate, newProps);
this.focusToDate(newFocus, newProps);
}
updatePreview(val) {
if (!val) {
this.setState({ preview: null });
return;
}
const preview = {
startDate: val,
endDate: val,
color: this.props.color,
};
this.setState({ preview });
}
componentDidMount() {
if (this.props.scroll.enabled) {
// prevent react-list's initial render focus problem
setTimeout(() => this.focusToDate(this.state.focusedDate), 1);
}
}
componentWillReceiveProps(nextProps) {
const propMapper = {
dateRange: 'ranges',
date: 'date',
};
const targetProp = propMapper[nextProps.displayMode];
if (this.props.locale !== nextProps.locale) {
this.dateOptions = { locale: nextProps.locale };
}
if (JSON.stringify(this.props.scroll) !== JSON.stringify(nextProps.scroll)) {
this.setState({ scrollArea: this.calcScrollArea(nextProps) });
}
if (nextProps[targetProp] !== this.props[targetProp]) {
this.updateShownDate(nextProps);
}
}
changeShownDate(value, mode = 'set') {
const { focusedDate } = this.state;
const { onShownDateChange, minDate, maxDate } = this.props;
const modeMapper = {
monthOffset: () => addMonths(focusedDate, value),
setMonth: () => setMonth(focusedDate, value),
setYear: () => setYear(focusedDate, value),
set: () => value,
};
const newDate = min([max([modeMapper[mode](), minDate]), maxDate]);
this.focusToDate(newDate, this.props, false);
onShownDateChange && onShownDateChange(newDate);
}
handleRangeFocusChange(rangesIndex, rangeItemIndex) {
this.props.onRangeFocusChange && this.props.onRangeFocusChange([rangesIndex, rangeItemIndex]);
}
handleScroll() {
const { onShownDateChange, minDate } = this.props;
const visibleMonths = this.list.getVisibleRange();
// prevent scroll jump with wrong visible value
if (visibleMonths[0] === undefined) return;
const visibleMonth = addMonths(minDate, visibleMonths[0] || 0);
const isFocusedToDifferent = !isSameMonth(visibleMonth, this.state.focusedDate);
if (isFocusedToDifferent) {
this.setState({ focusedDate: visibleMonth });
onShownDateChange && onShownDateChange(visibleMonth);
}
}
renderMonthAndYear(focusedDate, changeShownDate, props) {
const { showMonthArrow, locale, minDate, maxDate, showMonthAndYearPickers } = props;
const upperYearLimit = (maxDate || Calendar.defaultProps.maxDate).getFullYear();
const lowerYearLimit = (minDate || Calendar.defaultProps.minDate).getFullYear();
const styles = this.styles;
return (
<div onMouseUp={e => e.stopPropagation()} className={styles.monthAndYearWrapper}>
{showMonthArrow ? (
<button
type="button"
className={classnames(styles.nextPrevButton, styles.prevButton)}
onClick={() => changeShownDate(-1, 'monthOffset')}>
<i />
</button>
) : null}
{showMonthAndYearPickers ? (
<span className={styles.monthAndYearPickers}>
<span className={styles.monthPicker}>
<select
value={focusedDate.getMonth()}
onChange={e => changeShownDate(e.target.value, 'setMonth')}>
{locale.localize.months().map((month, i) => (
<option key={i} value={i}>
{month}
</option>
))}
</select>
</span>
<span className={styles.monthAndYearDivider} />
<span className={styles.yearPicker}>
<select
value={focusedDate.getFullYear()}
onChange={e => changeShownDate(e.target.value, 'setYear')}>
{new Array(upperYearLimit - lowerYearLimit + 1)
.fill(upperYearLimit)
.map((val, i) => {
const year = val - i;
return (
<option key={year} value={year}>
{year}
</option>
);
})}
</select>
</span>
</span>
) : (
<span className={styles.monthAndYearPickers}>
{locale.localize.months()[focusedDate.getMonth()]} {focusedDate.getFullYear()}
</span>
)}
{showMonthArrow ? (
<button
type="button"
className={classnames(styles.nextPrevButton, styles.nextButton)}
onClick={() => changeShownDate(+1, 'monthOffset')}>
<i />
</button>
) : null}
</div>
);
}
renderWeekdays() {
const now = new Date();
return (
<div className={this.styles.weekDays}>
{eachDayOfInterval({
start: startOfWeek(now, this.dateOptions),
end: endOfWeek(now, this.dateOptions),
}).map((day, i) => (
<span className={this.styles.weekDay} key={i}>
{format(day, 'ddd', this.dateOptions)}
</span>
))}
</div>
);
}
renderDateDisplay() {
const { focusedRange, color, ranges, rangeColors } = this.props;
const defaultColor = rangeColors[focusedRange[0]] || color;
const styles = this.styles;
return (
<div className={styles.dateDisplayWrapper}>
{ranges.map((range, i) => {
if (range.showDateDisplay === false || (range.disabled && !range.showDateDisplay))
return null;
return (
<div
className={styles.dateDisplay}
key={i}
style={{ color: range.color || defaultColor }}>
<span
className={classnames(styles.dateDisplayItem, {
[styles.dateDisplayItemActive]: focusedRange[0] === i && focusedRange[1] === 0,
})}
onFocus={() => this.handleRangeFocusChange(i, 0)}>
<input
disabled={range.disabled}
readOnly
value={this.formatDateDisplay(range.startDate, 'Early')}
/>
</span>
<span
className={classnames(styles.dateDisplayItem, {
[styles.dateDisplayItemActive]: focusedRange[0] === i && focusedRange[1] === 1,
})}
onFocus={() => this.handleRangeFocusChange(i, 1)}>
<input
disabled={range.disabled}
readOnly
value={this.formatDateDisplay(range.endDate, 'Continuous')}
/>
</span>
</div>
);
})}
</div>
);
}
onDragSelectionStart(date) {
const { onChange, dragSelectionEnabled } = this.props;
if (dragSelectionEnabled) {
this.setState({
drag: {
status: true,
range: { startDate: date, endDate: date },
disablePreview: true,
},
});
} else {
onChange && onChange(date);
}
}
onDragSelectionEnd(date) {
const { updateRange, displayMode, onChange, dragSelectionEnabled } = this.props;
if (!dragSelectionEnabled) return;
if (displayMode === 'date' || !this.state.drag.status) {
onChange && onChange(date);
return;
}
const newRange = {
startDate: this.state.drag.range.startDate,
endDate: date,
};
if (displayMode !== 'dateRange' || isSameDay(newRange.startDate, date)) {
this.setState({ drag: { status: false, range: {} } }, () => onChange && onChange(date));
} else {
this.setState({ drag: { status: false, range: {} } }, () => {
updateRange && updateRange(newRange);
});
}
}
onDragSelectionMove(date) {
const { drag } = this.state;
if (!drag.status || !this.props.dragSelectionEnabled) return;
this.setState({
drag: {
status: drag.status,
range: { startDate: drag.range.startDate, endDate: date },
disablePreview: true,
},
});
}
estimateMonthSize(index, cache) {
const { direction, minDate } = this.props;
const { scrollArea } = this.state;
if (cache) {
this.listSizeCache = cache;
if (cache[index]) return cache[index];
}
if (direction === 'horizontal') return scrollArea.monthWidth;
const monthStep = addMonths(minDate, index);
const { start, end } = getMonthDisplayRange(monthStep, this.dateOptions);
const isLongMonth = differenceInDays(end, start, this.dateOptions) + 1 > 7 * 5;
return isLongMonth ? scrollArea.longMonthHeight : scrollArea.monthHeight;
}
formatDateDisplay(date, defaultText) {
if (!date) return defaultText;
return format(date, this.props.dateDisplayFormat, this.dateOptions);
}
render() {
const {
showDateDisplay,
onPreviewChange,
scroll,
direction,
disabledDates,
maxDate,
minDate,
rangeColors,
color,
} = this.props;
const { scrollArea, focusedDate } = this.state;
const isVertical = direction === 'vertical';
const navigatorRenderer = this.props.navigatorRenderer || this.renderMonthAndYear;
const ranges = this.props.ranges.map((range, i) => ({
...range,
color: range.color || rangeColors[i] || color,
}));
return (
<div
className={classnames(this.styles.calendarWrapper, this.props.className)}
onMouseUp={() => this.setState({ drag: { status: false, range: {} } })}
onMouseLeave={() => {
this.setState({ drag: { status: false, range: {} } });
}}>
{showDateDisplay && this.renderDateDisplay()}
{navigatorRenderer(focusedDate, this.changeShownDate, this.props)}
{scroll.enabled ? (
<div>
{isVertical && this.renderWeekdays(this.dateOptions)}
<div
className={classnames(
this.styles.infiniteMonths,
isVertical ? this.styles.monthsVertical : this.styles.monthsHorizontal
)}
onMouseLeave={() => onPreviewChange && onPreviewChange()}
style={{
width: scrollArea.calendarWidth + 11,
height: scrollArea.calendarHeight + 11,
}}
onScroll={this.handleScroll}>
<ReactList
length={differenceInCalendarMonths(
endOfMonth(maxDate),
addDays(startOfMonth(minDate), -1),
this.dateOptions
)}
treshold={500}
type="variable"
ref={target => (this.list = target)}
itemSizeEstimator={this.estimateMonthSize}
axis={isVertical ? 'y' : 'x'}
itemRenderer={(index, key) => {
const monthStep = addMonths(minDate, index);
return (
<Month
{...this.props}
onPreviewChange={this.props.onPreviewChange || this.updatePreview}
preview={this.props.preview || this.state.preview}
ranges={ranges}
key={key}
drag={this.state.drag}
dateOptions={this.dateOptions}
disabledDates={disabledDates}
month={monthStep}
onDragSelectionStart={this.onDragSelectionStart}
onDragSelectionEnd={this.onDragSelectionEnd}
onDragSelectionMove={this.onDragSelectionMove}
onMouseLeave={() => onPreviewChange && onPreviewChange()}
styles={this.styles}
style={
isVertical
? { height: this.estimateMonthSize(index) }
: { height: scrollArea.monthHeight, width: this.estimateMonthSize(index) }
}
showMonthName
showWeekDays={!isVertical}
/>
);
}}
/>
</div>
</div>
) : (
<div
className={classnames(
this.styles.months,
isVertical ? this.styles.monthsVertical : this.styles.monthsHorizontal
)}>
{new Array(this.props.months).fill(null).map((_, i) => {
const monthStep = addMonths(this.state.focusedDate, i);
return (
<Month
{...this.props}
onPreviewChange={this.props.onPreviewChange || this.updatePreview}
preview={this.props.preview || this.state.preview}
ranges={ranges}
key={i}
drag={this.state.drag}
dateOptions={this.dateOptions}
disabledDates={disabledDates}
month={monthStep}
onDragSelectionStart={this.onDragSelectionStart}
onDragSelectionEnd={this.onDragSelectionEnd}
onDragSelectionMove={this.onDragSelectionMove}
onMouseLeave={() => onPreviewChange && onPreviewChange()}
styles={this.styles}
showWeekDays={!isVertical || i === 0}
showMonthName={!isVertical || i > 0}
/>
);
})}
</div>
)}
</div>
);
}
}
Calendar.defaultProps = {
showMonthArrow: true,
showMonthAndYearPickers: true,
disabledDates: [],
classNames: {},
locale: defaultLocale,
ranges: [],
focusedRange: [0, 0],
dateDisplayFormat: 'MMM D, YYYY',
monthDisplayFormat: 'MMM YYYY',
showDateDisplay: true,
showPreview: true,
displayMode: 'date',
months: 1,
color: '#3d91ff',
scroll: {
enabled: false,
},
direction: 'vertical',
maxDate: addYears(new Date(), 20),
minDate: addYears(new Date(), -100),
rangeColors: ['#3d91ff', '#3ecf8e', '#fed14c'],
dragSelectionEnabled: true,
};
Calendar.propTypes = {
showMonthArrow: PropTypes.bool,
showMonthAndYearPickers: PropTypes.bool,
disabledDates: PropTypes.array,
minDate: PropTypes.object,
maxDate: PropTypes.object,
date: PropTypes.object,
onChange: PropTypes.func,
onPreviewChange: PropTypes.func,
onRangeFocusChange: PropTypes.func,
classNames: PropTypes.object,
locale: PropTypes.object,
shownDate: PropTypes.object,
onShownDateChange: PropTypes.func,
ranges: PropTypes.arrayOf(rangeShape),
preview: PropTypes.shape({
startDate: PropTypes.object,
endDate: PropTypes.object,
color: PropTypes.string,
}),
dateDisplayFormat: PropTypes.string,
monthDisplayFormat: PropTypes.string,
focusedRange: PropTypes.arrayOf(PropTypes.number),
initialFocusedRange: PropTypes.arrayOf(PropTypes.number),
months: PropTypes.number,
className: PropTypes.string,
showDateDisplay: PropTypes.bool,
showPreview: PropTypes.bool,
displayMode: PropTypes.oneOf(['dateRange', 'date']),
color: PropTypes.string,
updateRange: PropTypes.func,
scroll: PropTypes.shape({
enabled: PropTypes.bool,
monthHeight: PropTypes.number,
longMonthHeight: PropTypes.number,
monthWidth: PropTypes.number,
calendarWidth: PropTypes.number,
calendarHeight: PropTypes.number,
}),
direction: PropTypes.oneOf(['vertical', 'horizontal']),
navigatorRenderer: PropTypes.func,
rangeColors: PropTypes.arrayOf(PropTypes.string),
dragSelectionEnabled: PropTypes.bool,
};
export default Calendar;