/** * Date range picker component * * @author Fedorov Platon * @date 2021-06-25 */ import * as React from 'react'; import {Input} from '../input/Input'; import {POPOVER_BOUNDARIES, TRIGGER} from '../../constants'; import {Calendar} from '../calendar/Calendar'; import {CalendarProps} from '../calendar/Calendar.types'; import {DropDown} from '../dropdown/DropDown'; import {safeInvoke} from '../../utils/safeInvoke'; import moment from 'moment'; import * as styles from './dateRangePicker.m.scss'; import {AdditionalTemplates, TemplateDate} from '../date_picker/AdditionalTemplates'; import {Wrapper} from '../wrapper/Wrapper'; import {joinClassNames} from '../../utils/joinClassNames'; import {parseNoSeparatedDate} from '../../utils/parseNoSeparatedDate'; import ReactInputMask from 'react-input-mask'; import {formatChars} from '../input/InputMask'; import {Icon} from '../icon/Icon'; const defaultProps = { mode: 'month', pickerType: 'dateRange', isInline: false }; export type DateRangePickerProps = Omit & { autoFocus?: boolean; allowClear?: boolean; errorMessage?: React.ReactNode; viewFormat: string; valueFormat: string; isReadOnly?: boolean; isDisabled?: boolean; placeholder?: [string, string]; mode?: 'month' | 'year'; isDefaultOpen?: boolean; isOpen?: boolean; isUtc?: boolean; onVisibleChange?: (isOpen: boolean) => void; onClose?: () => void; onBlur?: () => void; value?: [string, string]; defaultValue?: [string, string]; onChange?: (dateArray: [string, string]) => void; pickerType?: 'dateRange' | 'dateTimeRange'; isInline?: boolean; additionalDateTemplates?: DateRangeTemplate[]; errorTimeMessage: string; } export type DateRangeTemplate = { date: [string, string] | [moment.Moment, moment.Moment]; name: string; }; interface IState { isOpen: boolean; textFrom: string; textTo: string; dateFrom: moment.Moment | undefined; dateTo: moment.Moment | undefined; timeFrom: string; timeTo: string; isFocused: boolean; isSetFrom: boolean; isTargetHovered: boolean; textFromPossible: string | undefined; textToPossible: string | undefined; } export class DateRangePicker extends React.Component { static defaultProps = defaultProps; constructor (props: DateRangePickerProps) { super(props); let textFrom = ''; let textTo = ''; let dateFrom: moment.Moment | undefined; let dateTo: moment.Moment | undefined; let parsedDateFrom, parsedDateTo; if (props.value && !props.defaultValue) { parsedDateFrom = this.getMoment(props.value[0], props.valueFormat); parsedDateTo = this.getMoment(props.value[1], props.valueFormat); } if (props.defaultValue) { parsedDateFrom = this.getMoment(props.defaultValue[0], props.valueFormat); parsedDateTo = this.getMoment(props.defaultValue[1], props.valueFormat); } if (parsedDateFrom && parsedDateFrom.isValid()) { textFrom = parsedDateFrom.format(this.viewFormat); dateFrom = parsedDateFrom; } if (parsedDateTo && parsedDateTo.isValid()) { textTo = parsedDateTo.format(this.viewFormat); dateTo = parsedDateTo; } this.state = { isOpen: props.isOpen || props.isDefaultOpen || false, textFrom: textFrom, textTo: textTo, dateFrom: dateFrom, timeFrom: dateFrom ? dateFrom.format(this.timeFormat) : '', dateTo: dateTo, timeTo: dateFrom ? dateFrom.format(this.timeFormat) : '', isSetFrom: true, isFocused: Boolean(props.isInline), isTargetHovered: false, textFromPossible: undefined, textToPossible: undefined }; } wrapperContainer = React.createRef(); inputFrom = React.createRef(); inputTo = React.createRef(); calendar = React.createRef(); isFromFilled = false; isToFilled = false; isPanelChanged = false; isMount = false; override componentDidMount () { this.isMount = true; } get viewFormat (): string { return this.props.viewFormat; } get valueFormat (): string { return this.props.valueFormat; } get timeFormat (): string { return 'HH:mm:ss'; } get viewMask (): string { return this.viewFormat.replace(/\w/g, '9'); } get viewEmptyMask (): string { return this.viewFormat.replace(/\w/g, '_'); } private getMoment (value?: string, valueFormat?: string): moment.Moment { return this.isUTC ? moment.utc(value, valueFormat) : moment(value, valueFormat); } private get isUTC () { return this.props.isUtc || false; } isDateDisabled = (isFrom: boolean) => (date: moment.Moment) => { if (this.props.disabledDate && this.props.disabledDate(date)) { return true; } if (isFrom && this.state.dateTo) { return date.diff(this.state.dateTo) > 0; } else if (!isFrom && this.state.dateFrom) { return date.diff(this.state.dateFrom) < 0; } return false; } isValidRange = (date: moment.Moment) => { if (!this.props.validRange) { return true; } const validRange = this.props.validRange; return date.diff(validRange[0]) >= 0 && date.diff(validRange[1]) <= 0; } onInputKeyDown = (e: React.KeyboardEvent) => { if (this.props.isInline) { if (e.key === 'Enter') { if (e.currentTarget === this.inputFrom.current) { this.inputTo.current?.focus(); } else { this.inputFrom.current?.focus(); } } } else { if (e.key === 'Enter') { if (e.currentTarget === this.inputFrom.current) { if (this.state.isOpen) { this.inputFrom.current?.blur(); this.switchInput(true)(); } else { this.onVisibleChange(true); } } else { this.inputTo.current?.blur(); this.switchInput(false)(); } } if (e.key === 'Tab' && e.currentTarget === this.inputTo.current) { this.inputTo.current?.blur(); this.onVisibleChange(false); this.onDatePickerBlur(); } } } onInputFocus = () => { const isSetFrom = document.activeElement === this.inputFrom.current; if (this.props.isInline) { if (this.isMount) { this.setState({ isSetFrom: isSetFrom }); } return; } if (!this.state.isOpen) { this.setState({ isOpen: true, isFocused: true, isSetFrom: isSetFrom }); safeInvoke(this.props.onVisibleChange, true); } else { this.setState({ isSetFrom: isSetFrom }); } } onInputBlur = (isFrom: boolean) => () => { const {dateFrom, dateTo} = this.state; const date = isFrom ? dateFrom : dateTo; const textDate = date ? date.format(this.viewFormat) : ''; const timeDate = date ? date.format(this.timeFormat) : ''; const clearAnother = date ? this.isDateDisabled(isFrom)(date) : false; if (isFrom) { this.isFromFilled = true; this.setState((prevState) => { return { textFrom: textDate, timeFrom: timeDate, dateTo: clearAnother ? undefined : prevState.dateTo, textTo: clearAnother ? '' : prevState.textTo, timeTo: clearAnother ? '' : prevState.timeTo }; }); this.onChange(dateFrom, clearAnother ? undefined : dateTo); } else { this.isToFilled = true; this.setState((prevState) => { return { dateFrom: clearAnother ? undefined : prevState.dateFrom, textFrom: clearAnother ? '' : prevState.textFrom, timeFrom: clearAnother ? '' : prevState.timeFrom, textTo: textDate, timeTo: timeDate }; }); this.onChange(clearAnother ? undefined : dateFrom, dateTo); } } onInputChange = (isFrom: boolean) => (e: React.ChangeEvent) => { const text = e.currentTarget.value; const isTextEmpty = text === this.viewEmptyMask; if (isTextEmpty) { if (isFrom) { this.setState({textFrom: text, dateFrom: undefined}); } else { this.setState({textTo: text, dateTo: undefined}); } } else { const date = parseNoSeparatedDate(text, this.viewFormat, this.isUTC); if (date && date.isValid() && this.isValidRange(date)) { if (isFrom) { this.setState({ textFrom: text, dateFrom: date, timeFrom: date.format(this.timeFormat), isOpen: true }); } else { this.setState({ textTo: text, timeTo: date.format(this.timeFormat), dateTo: date }); } } else { if (isFrom) { this.setState({textFrom: text, isOpen: true}); } else { this.setState({textTo: text}); } } } } onInputFromClick = () => { if (!this.props.isInline && this.state.isSetFrom && this.state.isOpen === false) { this.onVisibleChange(true); } } onWrapperMouseEnter = () => { this.setState({isTargetHovered: true}); } onWrapperMouseLeave = () => { this.setState({isTargetHovered: false}); } onWrapperClickOutside = () => { if (this.props.isInline === false && this.state.isOpen === false) { this.onDatePickerBlur(); } } onDatePickerBlur = () => { this.isFromFilled = false; this.isToFilled = false; const dateFrom = this.state.dateFrom; const dateTo = this.state.dateTo; this.setState({ isOpen: false, isSetFrom: true, isFocused: Boolean(this.props.isInline), dateFrom: dateFrom, dateTo: dateTo, textFrom: dateFrom ? dateFrom.format(this.viewFormat) : '', textTo: dateTo ? dateTo.format(this.viewFormat) : '', timeFrom: dateFrom ? dateFrom.format(this.timeFormat) : '', timeTo: dateTo ? dateTo.format(this.timeFormat) : '' }); this.onChange(dateFrom, dateTo); if (!this.props.isInline) { safeInvoke(this.props.onBlur); } } onClear = () => { this.isFromFilled = false; this.isToFilled = false; this.setState({ dateFrom: undefined, dateTo: undefined, textFrom: '', textTo: '', timeFrom: '', timeTo: '' }); this.onChange(undefined, undefined); this.inputFrom.current?.focus(); } onVisibleChange = (visible: boolean) => { const isOpen = this.state.isOpen; if (visible && !isOpen) { this.setState({isOpen: visible}); safeInvoke(this.props.onVisibleChange, visible); } else if (!visible && isOpen) { this.setState({isOpen: visible}); safeInvoke(this.props.onClose); safeInvoke(this.props.onVisibleChange, false); } } onDropDownClose = (e: React.SyntheticEvent | Event) => { const isEsc = e instanceof KeyboardEvent; if (isEsc || (this.wrapperContainer.current && e.target instanceof Node && !this.wrapperContainer.current.contains(e.target)) ) { if (!this.state.isSetFrom) { this.onVisibleChange(false); } if (!isEsc) { this.onDatePickerBlur(); } else { this.inputTo.current?.focus(); } } } onChange = (dateFrom?: moment.Moment, dateTo?: moment.Moment) => { const dateFromValueFormatted = dateFrom ? dateFrom.format(this.valueFormat) : ''; const dateToValueFormatted = dateTo ? dateTo.format(this.valueFormat) : ''; const value = this.props.value; if (value === undefined || value[0] !== dateFromValueFormatted || value[1] !== dateToValueFormatted) { safeInvoke(this.props.onChange, [dateFromValueFormatted, dateToValueFormatted]); } } switchInput = (isFrom: boolean) => () => { if (this.isPanelChanged) { this.isPanelChanged = false; return; } if (isFrom) { this.isFromFilled = true; } else { this.isToFilled = true; } if (!this.props.isInline && this.isFromFilled && this.isToFilled) { this.inputFrom.current?.focus(); this.isFromFilled = false; this.isToFilled = false; this.onVisibleChange(false); return; } if (isFrom) { this.inputTo.current?.focus(); } else { this.inputFrom.current?.focus(); } } onCalendarPanelChange = (date: moment.Moment, mode: 'year' | 'month') => { this.isPanelChanged = true; safeInvoke(this.props.onPanelChange, date, mode); } onCalendarSelect = (isFrom: boolean) => (date: moment.Moment) => { const timeInState = isFrom ? this.state.timeFrom : this.state.timeTo; const dateFrom = isFrom ? date : this.state.dateFrom; const dateTo = isFrom ? this.state.dateTo : date; if (timeInState === '') { date.hours(0); date.minutes(0); date.seconds(0); } if (!this.isDateDisabled(isFrom)(date)) { if (isFrom) { this.setState({ dateFrom: date, textFrom: date.format(this.viewFormat), timeFrom: date.format(this.timeFormat), textFromPossible: undefined }, this.switchInput(isFrom)); } else { this.setState({ dateTo: date, textTo: date.format(this.viewFormat), timeTo: date.format(this.timeFormat), textToPossible: undefined }, this.switchInput(isFrom)); } } this.onChange(dateFrom, dateTo); } onAdditionalTemplateClickCallback = () => { if (!this.props.isInline) { this.inputTo.current?.focus(); this.onVisibleChange(false); } } onAdditionalTemplateClick = (date: TemplateDate) => { let templateFrom: string | moment.Moment | undefined; let templateTo: string | moment.Moment | undefined; if (!Array.isArray(date)) { templateFrom = date; templateTo = date; } else { templateFrom = date[0]; templateTo = date[1]; } const [dateFrom, dateTo] = typeof templateFrom === 'string' && typeof templateTo === 'string' ? [this.getMoment(templateFrom, this.valueFormat), this.getMoment(templateTo, this.valueFormat)] : [templateFrom, templateTo]; if (typeof dateFrom === 'string' || typeof dateTo === 'string') { return; } this.setState({ dateFrom: dateFrom, textFrom: dateFrom.format(this.viewFormat), timeFrom: dateFrom.format(this.timeFormat), dateTo: dateTo, textTo: dateTo.format(this.viewFormat), timeTo: dateTo.format(this.timeFormat) }, this.onAdditionalTemplateClickCallback); this.onChange(dateFrom, dateTo); } onTimeInputBlur = (isFrom: boolean) => () => { const time = isFrom ? this.state.timeFrom : this.state.timeTo; const isTimeValid = /^(([01][0-9])|(2[0-3])):[0-5][0-9]:[0-5][0-9]$/.test(time); const {dateFrom, dateTo} = this.state; const isClearTime = time === '__:__:__' && ((isFrom && dateFrom) || (!isFrom && dateTo)); let dateToSet = isFrom ? dateFrom?.clone() : dateTo?.clone(); if (isClearTime && dateToSet) { dateToSet.hours(0); dateToSet.minutes(0); dateToSet.seconds(0); } if (isTimeValid) { const [h, m, s] = time.split(':').map((item) => parseInt(item)); if (dateToSet === undefined) { dateToSet = this.getMoment(time, this.timeFormat); } else { if (dateToSet.hours() !== h || dateToSet.minutes() !== m || dateToSet.seconds() !== s) { dateToSet.hours(h); dateToSet.minutes(m); dateToSet.seconds(s); } } } if (!isTimeValid && !isClearTime) { if (isFrom) { this.setState({ timeFrom: dateToSet ? dateToSet.format(this.timeFormat) : '' }); } else { this.setState({ timeTo: dateToSet ? dateToSet.format(this.timeFormat) : '' }); } } else if (dateToSet) { if (isFrom) { this.setState({ dateFrom: dateToSet, textFrom: dateToSet.format(this.viewFormat) }); this.onChange(dateToSet, dateTo); } else { this.setState({ dateTo: dateToSet, textTo: dateToSet.format(this.viewFormat) }); this.onChange(dateToSet, dateTo); } } } onTimeInputChange = (isFrom: boolean) => (e: React.ChangeEvent) => { if (isFrom) { this.setState({timeFrom: e.target.value}); } else { this.setState({timeTo: e.target.value}); } } onCellMouseEnter = (date: moment.Moment) => () => { const isFrom = this.state.isSetFrom; const currentDate = isFrom ? this.state.dateFrom : this.state.dateTo; date.hours(currentDate ? currentDate.hours() : 0); date.minutes(currentDate ? currentDate.minutes() : 0); date.seconds(currentDate ? currentDate.seconds() : 0); if (!this.isDateDisabled(isFrom)(date) && this.isValidRange(date)) { if (isFrom) { this.setState({textFromPossible: date.format(this.viewFormat)}); } else { this.setState({textToPossible: date.format(this.viewFormat)}); } } } onCellMouseLeave = () => { this.setState({textFromPossible: undefined, textToPossible: undefined}); } renderDateFullCell = (date: moment.Moment) => { if (this.props.dateFullCellRender) { return this.props.dateFullCellRender(date); } const dateFrom = this.state.dateFrom?.clone().startOf('date'); const dateTo = this.state.dateTo?.clone().startOf('date'); const dateRender = date.clone().startOf('date'); const isEdgeDate = (dateFrom && dateFrom.diff(dateRender) === 0) || (dateTo && dateTo.diff(dateRender) === 0); const isDateBetween = dateFrom && dateTo && (dateRender.diff(dateFrom) >= 0 && dateRender.diff(dateTo) <= 0); const isPossibleDate = this.state.textFromPossible && this.state.dateTo || this.state.dateFrom && this.state.textToPossible; let containerClassName = styles.dateFullCellContainer; let valueClassName; if (isEdgeDate) { valueClassName = joinClassNames(styles.dateFullCellValue, styles.edgeDate); } else if (isDateBetween) { valueClassName = joinClassNames(styles.dateFullCellValue, styles.betweenDate); } else { valueClassName = styles.dateFullCellValue; } if (isPossibleDate) { if (this.state.textFromPossible && this.state.dateTo) { const possibleDateFrom = this.getMoment(this.state.textFromPossible, this.viewFormat).startOf('date'); if (dateRender.diff(possibleDateFrom) >= 0 && dateRender.diff(dateTo) <= 0) { valueClassName = joinClassNames(valueClassName, styles.possibleDate); } } else { const possibleDateTo = this.getMoment(this.state.textToPossible, this.viewFormat).startOf('date'); if (dateRender.diff(dateFrom) >= 0 && dateRender.diff(possibleDateTo) <= 0) { valueClassName = joinClassNames(valueClassName, styles.possibleDate); } } } if (this.props.mode === 'month') { return (
{date.date()}
); } return null; } renderWrapper = (textFrom: string, textTo: string) => { const isInputReadonly = this.props.isDisabled || this.props.isReadOnly; const valueFrom = this.state.textFromPossible ? this.state.textFromPossible : textFrom; const valueTo = this.state.textToPossible ? this.state.textToPossible : textTo; const placeholder = this.props.placeholder; const allowClear = this.props.allowClear && this.state.isTargetHovered && !isInputReadonly; const onMouseEnter = this.props.allowClear ? this.onWrapperMouseEnter : undefined; const onMouseLeave = this.props.allowClear ? this.onWrapperMouseLeave : undefined; const isFromFocused = document.activeElement === this.inputFrom.current; const isToFocused = document.activeElement === this.inputTo.current; return (
{(inputProps: React.HTMLAttributes) => { return ( ); }} {(inputProps: React.HTMLAttributes) => { return ( ); }} { allowClear ? ( ) : ( ) }
); } renderSecondCalendarHeader = () => { return null; } renderCalendar = (dateFrom: moment.Moment, dateTo: moment.Moment, isFrom: boolean, props: any) => { const date = isFrom ? dateFrom : dateTo; const renderDateFullCell = this.props.mode !== 'year' ? this.renderDateFullCell : undefined; const isDisabled = this.props.isDisabled || this.props.isReadOnly; let dateSecond; if (this.props.mode !== 'month') { dateSecond = date.clone().startOf('year').add(1, 'year'); } else { dateSecond = date.clone().startOf('month').add(1, 'month'); } return ( <>
{this.props.pickerType === 'dateTimeRange' && (
{ isFrom ? ( ) : ( ) }
)} { this.props.additionalDateTemplates && ( ) } ); } override render () { const { value, autoFocus, allowClear, errorMessage, isReadOnly, isDisabled, placeholder, viewFormat, valueFormat, onChange, disabledDate, defaultValue, onClose, ...props } = this.props; const isOpen = this.props.isOpen === undefined ? this.state.isOpen : this.props.isOpen; const isFrom = this.state.isSetFrom; const isFocused = this.state.isFocused; let dateFrom: moment.Moment | undefined; let dateTo: moment.Moment | undefined; let textFrom: string; let textTo: string; if (value === undefined || isFocused) { if (value && this.props.isInline) { if (document.activeElement === this.inputFrom.current) { dateFrom = this.state.dateFrom; textFrom = this.state.textFrom; } else { dateFrom = value[0] === '' ? undefined : this.getMoment(value[0], this.valueFormat); textFrom = dateFrom ? dateFrom.format(this.viewFormat) : ''; } if (document.activeElement === this.inputTo.current) { dateTo = this.state.dateTo; textTo = this.state.textTo; } else { dateTo = value[1] === '' ? undefined : this.getMoment(value[1], this.valueFormat); textTo = dateTo ? dateTo.format(this.viewFormat) : ''; } } else { dateFrom = this.state.dateFrom; dateTo = this.state.dateTo; textFrom = this.state.textFrom; textTo = this.state.textTo; } } else { dateFrom = value[0] === '' ? undefined : this.getMoment(value[0], this.valueFormat); dateTo = value[1] === '' ? undefined : this.getMoment(value[1], this.valueFormat); textFrom = dateFrom ? dateFrom.format(this.viewFormat) : ''; textTo = dateTo ? dateTo.format(this.viewFormat) : ''; } if (!dateFrom && dateTo) { dateFrom = dateTo; } else if (!dateTo && dateFrom) { dateTo = dateFrom; } if (!dateFrom) { dateFrom = this.getMoment(); } if (!dateTo) { dateTo = this.getMoment(); } if (this.props.isInline) { return (
{this.renderWrapper(textFrom, textTo)} {this.renderCalendar(dateFrom, dateTo, isFrom, props)}
); } return ( {this.renderWrapper(textFrom, textTo)} )} isOpen={isOpen} boundaries={POPOVER_BOUNDARIES.DOCUMENT} onClose={this.onDropDownClose} isDisabled={isDisabled || isReadOnly} > {this.renderCalendar(dateFrom, dateTo, isFrom, props)} ); } }