react-date-range-headless
Version:
A React component for choosing dates and date ranges. A fork of hypeserver/react-date-range in which the Headless UI Listbox (Select) uses instead html select and options
644 lines (622 loc) • 23.2 kB
JavaScript
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { rangeShape } from '../DayCell';
import Month from '../Month';
import DateInput from '../DateInput';
import { calcFocusDate, generateStyles, getMonthDisplayRange } from '../../utils';
import classnames from 'classnames';
import ReactList from 'react-list';
import { shallowEqualObjects } from 'shallow-equal';
import {
addMonths,
subMonths,
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';
import { ariaLabelsShape } from '../../accessibility';
import { Listbox } from '@headlessui/react';
import { ReactComponent as ArrowIcon } from './arrow.svg';
class Calendar extends PureComponent {
constructor(props, context) {
super(props, context);
this.dateOptions = { locale: props.locale };
if (props.weekStartsOn !== undefined) this.dateOptions.weekStartsOn = props.weekStartsOn;
this.styles = generateStyles([coreStyles, props.classNames]);
this.listSizeCache = {};
this.isFirstRender = true;
this.state = {
monthNames: this.getMonthNames(),
focusedDate: calcFocusDate(null, props),
drag: {
status: false,
range: { startDate: null, endDate: null },
disablePreview: false,
},
scrollArea: this.calcScrollArea(props),
};
}
getMonthNames() {
return [...Array(12).keys()].map(i => this.props.locale.localize.month(i));
}
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) {
if (preventUnnecessary && props.preventSnapRefocus) {
const focusedDateDiff = differenceInCalendarMonths(date, this.state.focusedDate);
const isAllowedForward = props.calendarFocus === 'forwards' && focusedDateDiff >= 0;
const isAllowedBackward = props.calendarFocus === 'backwards' && focusedDateDiff <= 0;
if ((isAllowedForward || isAllowedBackward) && Math.abs(focusedDateDiff) < props.months) {
return;
}
}
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.isFirstRender = true;
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));
}
}
componentDidUpdate(prevProps) {
const propMapper = {
dateRange: 'ranges',
date: 'date',
};
const targetProp = propMapper[this.props.displayMode];
if (this.props[targetProp] !== prevProps[targetProp]) {
this.updateShownDate(this.props);
}
if (
prevProps.locale !== this.props.locale ||
prevProps.weekStartsOn !== this.props.weekStartsOn
) {
this.dateOptions = { locale: this.props.locale };
if (this.props.weekStartsOn !== undefined)
this.dateOptions.weekStartsOn = this.props.weekStartsOn;
this.setState({
monthNames: this.getMonthNames(),
});
}
if (!shallowEqualObjects(prevProps.scroll, this.props.scroll)) {
this.setState({ scrollArea: this.calcScrollArea(this.props) });
}
}
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 { focusedDate } = this.state;
const { isFirstRender } = this;
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, focusedDate);
if (isFocusedToDifferent && !isFirstRender) {
this.setState({ focusedDate: visibleMonth });
onShownDateChange && onShownDateChange(visibleMonth);
}
this.isFirstRender = false;
};
renderMonthAndYear = (focusedDate, changeShownDate, props) => {
const { showMonthArrow, minDate, maxDate, showMonthAndYearPickers, ariaLabels } = props;
const upperYearLimit = (maxDate || Calendar.defaultProps.maxDate).getFullYear();
const lowerYearLimit = (minDate || Calendar.defaultProps.minDate).getFullYear();
const styles = this.styles;
const currentMonthName = this.state.monthNames.find((month, i) => i === focusedDate.getMonth());
return (
<div onMouseUp={e => e.stopPropagation()} className={styles.monthAndYearWrapper}>
{showMonthArrow ? (
<button
type="button"
className={classnames(styles.nextPrevButton, styles.prevButton)}
onClick={() => changeShownDate(-1, 'monthOffset')}
aria-label={ariaLabels.prevButton}>
<i />
</button>
) : null}
{showMonthAndYearPickers ? (
<span className={styles.monthAndYearPickers}>
<span className={styles.monthPicker}>
<Listbox
value={focusedDate.getMonth()}
onChange={e => changeShownDate(e, 'setMonth')}>
<Listbox.Button>
<span>{currentMonthName}</span>
{/*<img src={arrow} alt="expandArrow" />*/}
<ArrowIcon className="arrow" />
</Listbox.Button>
<Listbox.Options>
{this.state.monthNames.map((monthName, i) => {
return (
<Listbox.Option key={i} value={i}>
<span>{monthName}</span>
</Listbox.Option>
);
})}
</Listbox.Options>
</Listbox>
</span>
<span className={styles.monthAndYearDivider} />
<span className={styles.yearPicker}>
<Listbox
value={focusedDate.getFullYear()}
onChange={e => changeShownDate(e, 'setYear')}>
<Listbox.Button>
<span>{focusedDate.getFullYear()}</span>
{/*<img className="arrow" src={arrow} alt="expandArrow" />*/}
<ArrowIcon className="arrow" />
</Listbox.Button>
<Listbox.Options>
{new Array(upperYearLimit - lowerYearLimit + 1)
.fill(upperYearLimit)
.map((val, i) => {
const year = val - i;
return (
<Listbox.Option key={year} value={year}>
<span>{year}</span>
</Listbox.Option>
);
})
.reverse()}
</Listbox.Options>
</Listbox>
</span>
</span>
) : (
<span className={styles.monthAndYearPickers}>
{this.state.monthNames[focusedDate.getMonth()]} {focusedDate.getFullYear()}
</span>
)}
{showMonthArrow ? (
<button
type="button"
className={classnames(styles.nextPrevButton, styles.nextButton)}
onClick={() => changeShownDate(+1, 'monthOffset')}
aria-label={ariaLabels.nextButton}>
<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, this.props.weekdayDisplayFormat, this.dateOptions)}
</span>
))}
</div>
);
}
renderDateDisplay = () => {
const {
focusedRange,
color,
ranges,
rangeColors,
dateDisplayFormat,
editableDateInputs,
startDatePlaceholder,
endDatePlaceholder,
ariaLabels,
} = 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 }}>
<DateInput
className={classnames(styles.dateDisplayItem, {
[styles.dateDisplayItemActive]: focusedRange[0] === i && focusedRange[1] === 0,
})}
readOnly={!editableDateInputs}
disabled={range.disabled}
value={range.startDate}
placeholder={startDatePlaceholder}
dateOptions={this.dateOptions}
dateDisplayFormat={dateDisplayFormat}
ariaLabel={
ariaLabels.dateInput &&
ariaLabels.dateInput[range.key] &&
ariaLabels.dateInput[range.key].startDate
}
onChange={this.onDragSelectionEnd}
onFocus={() => this.handleRangeFocusChange(i, 0)}
/>
<DateInput
className={classnames(styles.dateDisplayItem, {
[styles.dateDisplayItemActive]: focusedRange[0] === i && focusedRange[1] === 1,
})}
readOnly={!editableDateInputs}
disabled={range.disabled}
value={range.endDate}
placeholder={endDatePlaceholder}
dateOptions={this.dateOptions}
dateDisplayFormat={dateDisplayFormat}
ariaLabel={
ariaLabels.dateInput &&
ariaLabels.dateInput[range.key] &&
ariaLabels.dateInput[range.key].endDate
}
onChange={this.onDragSelectionEnd}
onFocus={() => this.handleRangeFocusChange(i, 1)}
/>
</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;
};
render() {
const {
showDateDisplay,
onPreviewChange,
scroll,
direction,
disabledDates,
disabledDay,
maxDate,
minDate,
rangeColors,
color,
navigatorRenderer,
className,
preview,
} = this.props;
const { scrollArea, focusedDate } = this.state;
const isVertical = direction === 'vertical';
const monthAndYearRenderer = 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, className)}
onMouseUp={() => this.setState({ drag: { status: false, range: {} } })}
onMouseLeave={() => {
this.setState({ drag: { status: false, range: {} } });
}}>
{showDateDisplay && this.renderDateDisplay()}
{monthAndYearRenderer(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={onPreviewChange || this.updatePreview}
preview={preview || this.state.preview}
ranges={ranges}
key={key}
drag={this.state.drag}
dateOptions={this.dateOptions}
disabledDates={disabledDates}
disabledDay={disabledDay}
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}
onHoverDate={this.props.onHoverDate}
/>
);
}}
/>
</div>
</div>
) : (
<div
className={classnames(
this.styles.months,
isVertical ? this.styles.monthsVertical : this.styles.monthsHorizontal
)}>
{new Array(this.props.months).fill(null).map((_, i) => {
let monthStep = addMonths(this.state.focusedDate, i);
if (this.props.calendarFocus === 'backwards') {
monthStep = subMonths(this.state.focusedDate, this.props.months - 1 - i);
}
return (
<Month
{...this.props}
onPreviewChange={onPreviewChange || this.updatePreview}
preview={preview || this.state.preview}
ranges={ranges}
key={i}
drag={this.state.drag}
dateOptions={this.dateOptions}
disabledDates={disabledDates}
disabledDay={disabledDay}
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}
onHoverDate={this.props.onHoverDate}
/>
);
})}
</div>
)}
</div>
);
}
}
Calendar.defaultProps = {
showMonthArrow: true,
showMonthAndYearPickers: true,
disabledDates: [],
disabledDay: () => {},
classNames: {},
locale: defaultLocale,
ranges: [],
focusedRange: [0, 0],
dateDisplayFormat: 'MMM d, yyyy',
monthDisplayFormat: 'MMM yyyy',
weekdayDisplayFormat: 'E',
dayDisplayFormat: 'd',
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'],
startDatePlaceholder: 'Early',
endDatePlaceholder: 'Continuous',
editableDateInputs: false,
dragSelectionEnabled: true,
fixedHeight: false,
calendarFocus: 'forwards',
preventSnapRefocus: false,
ariaLabels: {},
onHoverDate: () => {},
};
Calendar.propTypes = {
showMonthArrow: PropTypes.bool,
showMonthAndYearPickers: PropTypes.bool,
disabledDates: PropTypes.array,
disabledDay: PropTypes.func,
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,
weekdayDisplayFormat: PropTypes.string,
weekStartsOn: PropTypes.number,
dayDisplayFormat: 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']),
startDatePlaceholder: PropTypes.string,
endDatePlaceholder: PropTypes.string,
navigatorRenderer: PropTypes.func,
rangeColors: PropTypes.arrayOf(PropTypes.string),
editableDateInputs: PropTypes.bool,
dragSelectionEnabled: PropTypes.bool,
fixedHeight: PropTypes.bool,
calendarFocus: PropTypes.string,
preventSnapRefocus: PropTypes.bool,
ariaLabels: ariaLabelsShape,
onHoverDate: PropTypes.func,
};
export default Calendar;