diff --git a/src/.stories/index.js b/src/.stories/index.js index 6e407269..a38906cd 100644 --- a/src/.stories/index.js +++ b/src/.stories/index.js @@ -8,6 +8,7 @@ import InfiniteCalendar, { withKeyboardSupport, withMultipleDates, withRange, + withMonthRange, } from '../'; import styles from './stories.scss'; @@ -65,6 +66,22 @@ storiesOf('Higher Order Components', module) Component={withRange(withKeyboardSupport(Calendar))} /> )) + .add('Month Range selection', () => ( + + )) .add('Multiple date selection', () => { return ( ({ + YearsComponent: YearsComponent, + })), + withProps(({passThrough, selected, ...props}) => ({ + /* eslint-disable sort-keys */ + passThrough: { + ...passThrough, + Years: { + onSelect: (date) => handleSelect(date, {selected, ...props}), + handlers: { + onMouseOver: !isTouchDevice && props.selectionStart + ? (e) => handleMouseOver(e, {selected, ...props}) + : null, + }, + }, + }, + selected: { + start: selected && selected.start, + end: selected && selected.end, + }, + })), +); + +function handleSelect(date, {onSelect, selected, selectionStart, setSelectionStart, min, max, minDate, maxDate}) { + if (selectionStart) { + onSelect({ + eventType: EVENT_TYPE.END, + ...getMonthRangeDate({ + start: selectionStart, + end: date, + minSelected: minDate, + maxSelected: maxDate, + minScrolled: min, + maxScrolled: max, + }), + }); + setSelectionStart(null); + } else { + onSelect({ + eventType: EVENT_TYPE.START, + ...getMonthRangeDate({ + start: date, + end: date, + minSelected: minDate, + maxSelected: maxDate, + minScrolled: min, + maxScrolled: max, + }), + }); + setSelectionStart(date); + } +} + +function handleMouseOver(e, {onSelect, selectionStart}) { + e.stopPropagation(); + const month = e.target.getAttribute('data-month'); + if (!month) { return; } + onSelect({ + eventType: EVENT_TYPE.HOVER, + ...getMonthRangeDate({ + start: selectionStart, + end: month, + }), + }); +} + +function getMonthRangeDate({start, end, minSelected, maxSelected, minScrolled, maxScrolled}) { + const sortedDate = getSortedSelection({start, end}); + const compareStartDate = []; + const compareEndDate = []; + if (sortedDate.start) { + compareStartDate.push(sortedDate.start, startOfMonth(sortedDate.start)); + minScrolled && compareStartDate.push(minScrolled); + minSelected && compareStartDate.push(minSelected); + } + if (sortedDate.end) { + compareEndDate.push(endOfMonth(sortedDate.end)); + maxScrolled && compareEndDate.push(maxScrolled); + maxSelected && compareEndDate.push(maxSelected); + } + return { + start: compareStartDate.length > 0 ? max(...compareStartDate) : sortedDate.start, + end: compareEndDate.length > 0 ? min(...compareEndDate) : sortedDate.end, + }; +} + +if (typeof window !== 'undefined') { + window.addEventListener('touchstart', function onTouch() { + isTouchDevice = true; + + window.removeEventListener('touchstart', onTouch, false); + }); +} diff --git a/src/Day/Day.scss b/src/Day/Day.scss index 4118178a..abedb247 100644 --- a/src/Day/Day.scss +++ b/src/Day/Day.scss @@ -1,16 +1,5 @@ @import "../variables"; - -@mixin circle() { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: $rowHeight - 4px; - height: $rowHeight - 4px; - margin-top: -0.5 * ($rowHeight - 4px); - margin-left: -0.5 * ($rowHeight - 4px); - border-radius: 50%; -} +@import "../Calendar/Range/Range"; .root { display: inline-block; @@ -31,7 +20,7 @@ z-index: 1; &:before { - @include circle(); + @include circle($rowHeight); background-color: $cellHoverBg; z-index: -1; @@ -63,7 +52,7 @@ } &:before { - @include circle(); + @include circle($rowHeight); box-shadow: inset 0 0 0 1px; z-index: -1; } @@ -84,7 +73,7 @@ } .selection { - @include circle(); + @include circle($rowHeight); line-height: $rowHeight; z-index: 2; diff --git a/src/Years/Years.scss b/src/Years/Years.scss index a986b739..94b37673 100644 --- a/src/Years/Years.scss +++ b/src/Years/Years.scss @@ -1,4 +1,7 @@ @import "../variables"; +@import "../Calendar/Range/Range"; + +$cellSize: 44px; .root { position: absolute; @@ -41,7 +44,6 @@ } .year { - $cellSize: 44px; display: flex; padding: 0 20px; @@ -91,6 +93,7 @@ justify-content: center; list-style: none; border-radius: 50%; + margin-bottom: 2px; box-sizing: border-box; color: #444; @@ -106,6 +109,12 @@ background-color: blue; color: #FFF !important; border: 0; + + .selection { + @include circle($cellSize); + line-height: $cellSize; + z-index: 2; + } } &.disabled { cursor: not-allowed; @@ -159,3 +168,74 @@ padding-bottom: $spacing; } } + +/* + * Range selection styles + */ +.range.selected { + &.start, &.end { + &:after { + content: ''; + position: absolute; + top: 50%; + width: 50%; + height: $cellSize; + margin-top: -0.5 * $cellSize; + background-color: #559fff; + } + } + + &.disabled { + background-color: #EEE !important; + .selection.selection { + background-color: #EEE !important; + color: #FFF; + } + &:after { + background-color: #EEE !important; + } + } + + &.start { + .selection { + background-color: #448aff; + border-top-left-radius: 50%; + border-bottom-left-radius: 50%; + } + + &:after { + right: 0; + } + + &.end:after { + display: none; + } + } + &.betweenRange { + border-radius: 0; + .selection { + left: 0; + right: 0; + width: 100%; + margin-left: 0; + display: flex; + justify-content: center; + align-items: center; + border-radius: 0; + } + } + &.end { + &:after { + left: 0; + } + + .selection { + border-top-right-radius: 50%; + border-bottom-right-radius: 50%; + + color: #559fff; + background-color: #FFF; + box-sizing: border-box; + } + } +} diff --git a/src/Years/index.js b/src/Years/index.js index 6136286b..d1923cac 100644 --- a/src/Years/index.js +++ b/src/Years/index.js @@ -2,11 +2,15 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import VirtualList from 'react-tiny-virtual-list'; import classNames from 'classnames'; -import {emptyFn, getMonthsForYear} from '../utils'; +import {emptyFn, getMonthsForYear, isRange} from '../utils'; import format from 'date-fns/format'; import isAfter from 'date-fns/is_after'; import isBefore from 'date-fns/is_before'; import isSameMonth from 'date-fns/is_same_month'; +import startOfMonth from 'date-fns/start_of_month'; +import endOfMonth from 'date-fns/end_of_month'; +import parse from 'date-fns/parse'; +import isWithinRange from 'date-fns/is_within_range'; import styles from './Years.scss'; const SPACING = 40; @@ -33,6 +37,12 @@ export default class Years extends Component { showMonths: true, }; + constructor(props) { + super(props); + const years = this.props.years.slice(0, this.props.years.length); + this.selectedYearIndex = years.indexOf(this.getSelected(this.props.selected).start.getFullYear()); + } + handleClick(date, e) { let { hideOnSelect, @@ -48,18 +58,31 @@ export default class Years extends Component { } } - renderMonths(year) { - const {locale: {locale}, selected, theme, today, min, max, minDate, maxDate} = this.props; - const months = getMonthsForYear(year, selected.getDate()); + getSelected(selected) { + if (isRange(selected)) { + return { + start: startOfMonth(selected.start), + end: endOfMonth(selected.end), + }; + } + // remove time + return { + start: parse(format(selected, 'YYYY-MM-DD')), + end: parse(format(selected, 'YYYY-MM-DD')), + } + } + renderMonths(year) { + const {locale: {locale}, selected, theme, today, min, max, minDate, maxDate, handlers} = this.props; + const months = getMonthsForYear(year, this.getSelected(selected).start.getDate()); return (
    {months.map((date, index) => { - const isSelected = isSameMonth(date, selected); + const isSelected = isWithinRange(date, this.getSelected(selected).start, this.getSelected(selected).end); const isCurrentMonth = isSameMonth(date, today); const isDisabled = ( - isBefore(date, min) || - isBefore(date, minDate) || + isBefore(date, startOfMonth(min)) || + isBefore(date, startOfMonth(minDate)) || isAfter(date, max) || isAfter(date, maxDate) ); @@ -72,7 +95,8 @@ export default class Years extends Component { }, isCurrentMonth && { borderColor: theme.todayColor, }); - + const isStart = isSameMonth(date, selected.start); + const isEnd = isSameMonth(date, selected.end); return (
  1. - {format(date, 'MMM', {locale})} +
    {format(date, 'MMM', {locale})}
  2. ); })} @@ -103,7 +136,7 @@ export default class Years extends Component { const {height, selected, showMonths, theme, today, width} = this.props; const currentYear = today.getFullYear(); const years = this.props.years.slice(0, this.props.years.length); - const selectedYearIndex = years.indexOf(selected.getFullYear()); + const selectedYearIndex = this.selectedYearIndex; const rowHeight = showMonths ? 110 : 50; const heights = years.map((val, index) => index === 0 || index === years.length - 1 ? rowHeight + SPACING @@ -142,8 +175,8 @@ export default class Years extends Component { [styles.first]: index === 0, [styles.last]: index === years.length - 1, })} - onClick={() => this.handleClick(new Date(selected).setYear(year))} - title={`Set year to ${year}`} + onClick={() => !isRange(selected) && this.handleClick(new Date(selected).setYear(year))} + title={isRange(selected) ? '' : `Set year to ${year}`} data-year={year} style={Object.assign({}, style, { color: ( diff --git a/src/index.js b/src/index.js index b8fac0de..31ae48d4 100644 --- a/src/index.js +++ b/src/index.js @@ -6,8 +6,9 @@ export {default as Calendar} from './Calendar'; export {withDateSelection} from './Calendar/withDateSelection'; export {withKeyboardSupport} from './Calendar/withKeyboardSupport'; export {withMultipleDates, defaultMultipleDateInterpolation} from './Calendar/withMultipleDates'; -export {withRange, EVENT_TYPE} from './Calendar/withRange'; - +export {withRange} from './Calendar/withRange'; +export {EVENT_TYPE} from './Calendar/Range'; +export {withMonthRange} from './Calendar/withMonthRange'; /* * By default, Calendar is a controlled component. * Export a sensible default for minimal setup diff --git a/src/utils/index.js b/src/utils/index.js index f7c3211f..80c88c08 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -6,6 +6,7 @@ import isBefore from 'date-fns/is_before'; import isSameDay from 'date-fns/is_same_day'; import endOfDay from 'date-fns/end_of_day'; import startOfDay from 'date-fns/start_of_day'; +import parse from 'date-fns/parse'; import {withPropsOnChange} from 'recompose'; export const keyCodes = { @@ -186,4 +187,18 @@ export function range(start, stop, step = 1) { return range; }; +export function isRange(date) { + if (!date) { + return false; + } + const {start, end} = date; + return start !== undefined && end !== undefined; +} + +export function getSortedDate(start, end) { + return isBefore(start, end) + ? {start, end} + : {start: end, end: start}; +} + export {default as animate} from './animate';