UNPKG

react-calendar-custom-date

Version:

A React component for choosing dates and date ranges.

540 lines (528 loc) 19 kB
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;