UNPKG

39.8 kBTypeScriptView Raw
1/*
2 * Copyright 2017 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import classNames from "classnames";
18import * as React from "react";
19import DayPicker from "react-day-picker";
20
21import {
22 AbstractPureComponent2,
23 Boundary,
24 Classes,
25 DISPLAYNAME_PREFIX,
26 InputGroup,
27 InputGroupProps2,
28 Intent,
29 IPopoverProps,
30 Keys,
31 Popover,
32 Position,
33 Props,
34 refHandler,
35 setRef,
36} from "@blueprintjs/core";
37
38import { DateRange } from "./common/dateRange";
39import { areSameTime, isDateValid, isDayInRange } from "./common/dateUtils";
40import * as Errors from "./common/errors";
41import { DateFormatProps, getFormattedDateString } from "./dateFormat";
42import { getDefaultMaxDate, getDefaultMinDate, IDatePickerBaseProps } from "./datePickerCore";
43import { DateRangePicker } from "./dateRangePicker";
44import { DateRangeShortcut } from "./shortcuts";
45
46// we handle events in a kind of generic way in this component, so here we enumerate all the different kinds of events which we have handlers for
47type InputEvent =
48 | React.MouseEvent<HTMLInputElement>
49 | React.KeyboardEvent<HTMLInputElement>
50 | React.FocusEvent<HTMLInputElement>
51 | React.ChangeEvent<HTMLInputElement>;
52
53// eslint-disable-next-line deprecation/deprecation
54export type DateRangeInputProps = IDateRangeInputProps;
55/** @deprecated use DateRangeInputProps */
56export interface IDateRangeInputProps extends IDatePickerBaseProps, DateFormatProps, Props {
57 /**
58 * Whether the start and end dates of the range can be the same day.
59 * If `true`, clicking a selected date will create a one-day range.
60 * If `false`, clicking a selected date will clear the selection.
61 *
62 * @default false
63 */
64 allowSingleDayRange?: boolean;
65
66 /**
67 * Whether the calendar popover should close when a date range is fully selected.
68 *
69 * @default true
70 */
71 closeOnSelection?: boolean;
72
73 /**
74 * Whether displayed months in the calendar are contiguous.
75 * If false, each side of the calendar can move independently to non-contiguous months.
76 *
77 * @default true
78 */
79 contiguousCalendarMonths?: boolean;
80
81 /**
82 * The default date range to be used in the component when uncontrolled.
83 * This will be ignored if `value` is set.
84 */
85 defaultValue?: DateRange;
86
87 /**
88 * Whether the text inputs are non-interactive.
89 *
90 * @default false
91 */
92 disabled?: boolean;
93
94 /**
95 * Props to pass to the end-date [input group](#core/components/text-inputs.input-group).
96 * `disabled` and `value` will be ignored in favor of the top-level props on this component.
97 * `ref` is not supported; use `inputRef` instead.
98 */
99 endInputProps?: InputGroupProps2;
100
101 /**
102 * Called when the user selects a day.
103 * If no days are selected, it will pass `[null, null]`.
104 * If a start date is selected but not an end date, it will pass `[selectedDate, null]`.
105 * If both a start and end date are selected, it will pass `[startDate, endDate]`.
106 */
107 onChange?: (selectedRange: DateRange) => void;
108
109 /**
110 * Called when the user finishes typing in a new date and the date causes an error state.
111 * If the date is invalid, `new Date(undefined)` will be returned for the corresponding
112 * boundary of the date range.
113 * If the date is out of range, the out-of-range date will be returned for the corresponding
114 * boundary of the date range (`onChange` is not called in this case).
115 */
116 onError?: (errorRange: DateRange) => void;
117
118 /**
119 * The error message to display when the selected dates overlap.
120 * This can only happen when typing dates in the input field.
121 *
122 * @default "Overlapping dates"
123 */
124 overlappingDatesMessage?: string;
125
126 /**
127 * The props to pass to the popover.
128 * `autoFocus`, `content`, and `enforceFocus` will be ignored to avoid compromising usability.
129 */
130 popoverProps?: Partial<IPopoverProps>;
131
132 /**
133 * Whether the entire text field should be selected on focus.
134 *
135 * @default false
136 */
137 selectAllOnFocus?: boolean;
138
139 /**
140 * Whether shortcuts to quickly select a range of dates are displayed or not.
141 * If `true`, preset shortcuts will be displayed.
142 * If `false`, no shortcuts will be displayed.
143 * If an array is provided, the custom shortcuts will be displayed.
144 *
145 * @default true
146 */
147 shortcuts?: boolean | DateRangeShortcut[];
148
149 /**
150 * Whether to show only a single month calendar.
151 *
152 * @default false
153 */
154 singleMonthOnly?: boolean;
155
156 /**
157 * Props to pass to the start-date [input group](#core/components/text-inputs.input-group).
158 * `disabled` and `value` will be ignored in favor of the top-level props on this component.
159 * `ref` is not supported; use `inputRef` instead.
160 */
161 startInputProps?: InputGroupProps2;
162
163 /**
164 * The currently selected date range.
165 * If the prop is strictly `undefined`, the component acts in an uncontrolled manner.
166 * If this prop is anything else, the component acts in a controlled manner.
167 * To display an empty value in the input fields in a controlled manner, pass `[null, null]`.
168 * To display an invalid date error in either input field, pass `new Date(undefined)`
169 * for the appropriate date in the value prop.
170 */
171 value?: DateRange;
172}
173
174export interface IDateRangeInputState {
175 isOpen?: boolean;
176 boundaryToModify?: Boundary;
177 lastFocusedField?: Boundary;
178
179 formattedMinDateString?: string;
180 formattedMaxDateString?: string;
181
182 isStartInputFocused?: boolean;
183 isEndInputFocused?: boolean;
184
185 startInputString?: string;
186 endInputString?: string;
187
188 startHoverString?: string;
189 endHoverString?: string;
190
191 selectedEnd?: Date;
192 selectedStart?: Date;
193
194 shouldSelectAfterUpdate?: boolean;
195 wasLastFocusChangeDueToHover?: boolean;
196
197 selectedShortcutIndex?: number;
198}
199
200interface IStateKeysAndValuesObject {
201 keys: {
202 hoverString: "startHoverString" | "endHoverString";
203 inputString: "startInputString" | "endInputString";
204 isInputFocused: "isStartInputFocused" | "isEndInputFocused";
205 selectedValue: "selectedStart" | "selectedEnd";
206 };
207 values: {
208 controlledValue?: Date;
209 hoverString?: string;
210 inputString?: string;
211 isInputFocused?: boolean;
212 selectedValue?: Date;
213 };
214}
215
216export class DateRangeInput extends AbstractPureComponent2<DateRangeInputProps, IDateRangeInputState> {
217 public static defaultProps: Partial<DateRangeInputProps> = {
218 allowSingleDayRange: false,
219 closeOnSelection: true,
220 contiguousCalendarMonths: true,
221 dayPickerProps: {},
222 disabled: false,
223 endInputProps: {},
224 invalidDateMessage: "Invalid date",
225 maxDate: getDefaultMaxDate(),
226 minDate: getDefaultMinDate(),
227 outOfRangeMessage: "Out of range",
228 overlappingDatesMessage: "Overlapping dates",
229 popoverProps: {},
230 selectAllOnFocus: false,
231 shortcuts: true,
232 singleMonthOnly: false,
233 startInputProps: {},
234 };
235
236 public static displayName = `${DISPLAYNAME_PREFIX}.DateRangeInput`;
237
238 public startInputElement: HTMLInputElement | null = null;
239
240 public endInputElement: HTMLInputElement | null = null;
241
242 private handleStartInputRef = refHandler<HTMLInputElement, "startInputElement">(
243 this,
244 "startInputElement",
245 this.props.startInputProps?.inputRef,
246 );
247
248 private handleEndInputRef = refHandler<HTMLInputElement, "endInputElement">(
249 this,
250 "endInputElement",
251 this.props.endInputProps?.inputRef,
252 );
253
254 public constructor(props: DateRangeInputProps, context?: any) {
255 super(props, context);
256 this.reset(props);
257 }
258
259 /**
260 * Public method intended for unit testing only. Do not use in feature work!
261 */
262 public reset(props: DateRangeInputProps = this.props) {
263 const [selectedStart, selectedEnd] = this.getInitialRange();
264 this.state = {
265 formattedMaxDateString: this.getFormattedMinMaxDateString(props, "maxDate"),
266 formattedMinDateString: this.getFormattedMinMaxDateString(props, "minDate"),
267 isOpen: false,
268 selectedEnd,
269 selectedShortcutIndex: -1,
270 selectedStart,
271 };
272 }
273
274 public componentDidUpdate(prevProps: DateRangeInputProps, prevState: IDateRangeInputState) {
275 super.componentDidUpdate(prevProps, prevState);
276 const { isStartInputFocused, isEndInputFocused, shouldSelectAfterUpdate } = this.state;
277
278 if (prevProps.startInputProps?.inputRef !== this.props.startInputProps?.inputRef) {
279 setRef(prevProps.startInputProps?.inputRef, null);
280 this.handleStartInputRef = refHandler(this, "startInputElement", this.props.startInputProps?.inputRef);
281 setRef(this.props.startInputProps?.inputRef, this.startInputElement);
282 }
283 if (prevProps.endInputProps?.inputRef !== this.props.endInputProps?.inputRef) {
284 setRef(prevProps.endInputProps?.inputRef, null);
285 this.handleEndInputRef = refHandler(this, "endInputElement", this.props.endInputProps?.inputRef);
286 setRef(this.props.endInputProps?.inputRef, this.endInputElement);
287 }
288
289 const shouldFocusStartInput = this.shouldFocusInputRef(isStartInputFocused, this.startInputElement);
290 const shouldFocusEndInput = this.shouldFocusInputRef(isEndInputFocused, this.endInputElement);
291
292 if (shouldFocusStartInput) {
293 this.startInputElement?.focus();
294 } else if (shouldFocusEndInput) {
295 this.endInputElement?.focus();
296 }
297
298 if (isStartInputFocused && shouldSelectAfterUpdate) {
299 this.startInputElement?.select();
300 } else if (isEndInputFocused && shouldSelectAfterUpdate) {
301 this.endInputElement?.select();
302 }
303
304 let nextState: IDateRangeInputState = {};
305
306 if (this.props.value !== prevProps.value) {
307 const [selectedStart, selectedEnd] = this.getInitialRange(this.props);
308 nextState = { ...nextState, selectedStart, selectedEnd };
309 }
310
311 // cache the formatted date strings to avoid computing on each render.
312 if (this.props.minDate !== prevProps.minDate) {
313 const formattedMinDateString = this.getFormattedMinMaxDateString(this.props, "minDate");
314 nextState = { ...nextState, formattedMinDateString };
315 }
316 if (this.props.maxDate !== prevProps.maxDate) {
317 const formattedMaxDateString = this.getFormattedMinMaxDateString(this.props, "maxDate");
318 nextState = { ...nextState, formattedMaxDateString };
319 }
320
321 this.setState(nextState);
322 }
323
324 public render() {
325 const { selectedShortcutIndex } = this.state;
326 const { popoverProps = {} } = this.props;
327
328 const popoverContent = (
329 <DateRangePicker
330 {...this.props}
331 selectedShortcutIndex={selectedShortcutIndex}
332 boundaryToModify={this.state.boundaryToModify}
333 onChange={this.handleDateRangePickerChange}
334 onShortcutChange={this.handleShortcutChange}
335 onHoverChange={this.handleDateRangePickerHoverChange}
336 value={this.getSelectedRange()}
337 />
338 );
339
340 const popoverClassName = classNames(popoverProps.className, this.props.className);
341
342 // allow custom props for the popover and each input group, but pass them in an order that
343 // guarantees only some props are overridable.
344 return (
345 /* eslint-disable-next-line deprecation/deprecation */
346 <Popover
347 isOpen={this.state.isOpen}
348 position={Position.BOTTOM_LEFT}
349 {...this.props.popoverProps}
350 autoFocus={false}
351 className={popoverClassName}
352 content={popoverContent}
353 enforceFocus={false}
354 onClose={this.handlePopoverClose}
355 >
356 <div className={Classes.CONTROL_GROUP}>
357 {this.renderInputGroup(Boundary.START)}
358 {this.renderInputGroup(Boundary.END)}
359 </div>
360 {/* eslint-disable-next-line deprecation/deprecation */}
361 </Popover>
362 );
363 }
364
365 protected validateProps(props: DateRangeInputProps) {
366 if (props.value === null) {
367 throw new Error(Errors.DATERANGEINPUT_NULL_VALUE);
368 }
369 }
370
371 private renderInputGroup = (boundary: Boundary) => {
372 const inputProps = this.getInputProps(boundary);
373 const handleInputEvent = boundary === Boundary.START ? this.handleStartInputEvent : this.handleEndInputEvent;
374
375 return (
376 <InputGroup
377 autoComplete="off"
378 disabled={inputProps.disabled || this.props.disabled}
379 {...inputProps}
380 intent={this.isInputInErrorState(boundary) ? Intent.DANGER : inputProps.intent}
381 inputRef={this.getInputRef(boundary)}
382 onBlur={handleInputEvent}
383 onChange={handleInputEvent}
384 onClick={handleInputEvent}
385 onFocus={handleInputEvent}
386 onKeyDown={handleInputEvent}
387 onMouseDown={handleInputEvent}
388 placeholder={this.getInputPlaceholderString(boundary)}
389 value={this.getInputDisplayString(boundary)}
390 />
391 );
392 };
393
394 // Callbacks - DateRangePicker
395 // ===========================
396
397 private handleDateRangePickerChange = (selectedRange: DateRange, didSubmitWithEnter = false) => {
398 // ignore mouse events in the date-range picker if the popover is animating closed.
399 if (!this.state.isOpen) {
400 return;
401 }
402
403 const [selectedStart, selectedEnd] = selectedRange;
404
405 let isOpen = true;
406
407 let isStartInputFocused: boolean;
408 let isEndInputFocused: boolean;
409
410 let startHoverString: string;
411 let endHoverString: string;
412
413 let boundaryToModify: Boundary;
414
415 if (selectedStart == null) {
416 // focus the start field by default or if only an end date is specified
417 if (this.props.timePrecision == null) {
418 isStartInputFocused = true;
419 isEndInputFocused = false;
420 } else {
421 isStartInputFocused = false;
422 isEndInputFocused = false;
423 boundaryToModify = Boundary.START;
424 }
425
426 // for clarity, hide the hover string until the mouse moves over a different date
427 startHoverString = null;
428 } else if (selectedEnd == null) {
429 // focus the end field if a start date is specified
430 if (this.props.timePrecision == null) {
431 isStartInputFocused = false;
432 isEndInputFocused = true;
433 } else {
434 isStartInputFocused = false;
435 isEndInputFocused = false;
436 boundaryToModify = Boundary.END;
437 }
438
439 endHoverString = null;
440 } else if (this.props.closeOnSelection) {
441 isOpen = this.getIsOpenValueWhenDateChanges(selectedStart, selectedEnd);
442 isStartInputFocused = false;
443
444 if (this.props.timePrecision == null && didSubmitWithEnter) {
445 // if we submit via click or Tab, the focus will have moved already.
446 // it we submit with Enter, the focus won't have moved, and setting
447 // the flag to false won't have an effect anyway, so leave it true.
448 isEndInputFocused = true;
449 } else {
450 isEndInputFocused = false;
451 boundaryToModify = Boundary.END;
452 }
453 } else if (this.state.lastFocusedField === Boundary.START) {
454 // keep the start field focused
455 if (this.props.timePrecision == null) {
456 isStartInputFocused = true;
457 isEndInputFocused = false;
458 } else {
459 isStartInputFocused = false;
460 isEndInputFocused = false;
461 boundaryToModify = Boundary.START;
462 }
463 } else if (this.props.timePrecision == null) {
464 // keep the end field focused
465 isStartInputFocused = false;
466 isEndInputFocused = true;
467 } else {
468 isStartInputFocused = false;
469 isEndInputFocused = false;
470 boundaryToModify = Boundary.END;
471 }
472
473 const baseStateChange = {
474 boundaryToModify,
475 endHoverString,
476 endInputString: this.formatDate(selectedEnd),
477 isEndInputFocused,
478 isOpen,
479 isStartInputFocused,
480 startHoverString,
481 startInputString: this.formatDate(selectedStart),
482 wasLastFocusChangeDueToHover: false,
483 };
484
485 if (this.isControlled()) {
486 this.setState(baseStateChange);
487 } else {
488 this.setState({ ...baseStateChange, selectedEnd, selectedStart });
489 }
490
491 this.props.onChange?.(selectedRange);
492 };
493
494 private handleShortcutChange = (_: DateRangeShortcut, selectedShortcutIndex: number) => {
495 this.setState({ selectedShortcutIndex });
496 };
497
498 private handleDateRangePickerHoverChange = (
499 hoveredRange: DateRange,
500 _hoveredDay: Date,
501 hoveredBoundary: Boundary,
502 ) => {
503 // ignore mouse events in the date-range picker if the popover is animating closed.
504 if (!this.state.isOpen) {
505 return;
506 }
507
508 if (hoveredRange == null) {
509 // undo whatever focus changes we made while hovering over various calendar dates
510 const isEndInputFocused = this.state.boundaryToModify === Boundary.END;
511
512 this.setState({
513 endHoverString: null,
514 isEndInputFocused,
515 isStartInputFocused: !isEndInputFocused,
516 lastFocusedField: this.state.boundaryToModify,
517 startHoverString: null,
518 });
519 } else {
520 const [hoveredStart, hoveredEnd] = hoveredRange;
521 const isStartInputFocused =
522 hoveredBoundary != null ? hoveredBoundary === Boundary.START : this.state.isStartInputFocused;
523 const isEndInputFocused =
524 hoveredBoundary != null ? hoveredBoundary === Boundary.END : this.state.isEndInputFocused;
525
526 this.setState({
527 endHoverString: this.formatDate(hoveredEnd),
528 isEndInputFocused,
529 isStartInputFocused,
530 lastFocusedField: isStartInputFocused ? Boundary.START : Boundary.END,
531 shouldSelectAfterUpdate: this.props.selectAllOnFocus,
532 startHoverString: this.formatDate(hoveredStart),
533 wasLastFocusChangeDueToHover: true,
534 });
535 }
536 };
537
538 // Callbacks - Input
539 // =================
540
541 // instantiate these two functions once so we don't have to for each callback on each render.
542
543 private handleStartInputEvent = (e: InputEvent) => {
544 this.handleInputEvent(e, Boundary.START);
545 };
546
547 private handleEndInputEvent = (e: InputEvent) => {
548 this.handleInputEvent(e, Boundary.END);
549 };
550
551 private handleInputEvent = (e: InputEvent, boundary: Boundary) => {
552 const inputProps = this.getInputProps(boundary);
553
554 switch (e.type) {
555 case "blur":
556 this.handleInputBlur(e, boundary);
557 inputProps.onBlur?.(e as React.FocusEvent<HTMLInputElement>);
558 break;
559 case "change":
560 this.handleInputChange(e, boundary);
561 inputProps.onChange?.(e as React.ChangeEvent<HTMLInputElement>);
562 break;
563 case "click":
564 e = e as React.MouseEvent<HTMLInputElement>;
565 this.handleInputClick(e);
566 inputProps.onClick?.(e);
567 break;
568 case "focus":
569 this.handleInputFocus(e, boundary);
570 inputProps.onFocus?.(e as React.FocusEvent<HTMLInputElement>);
571 break;
572 case "keydown":
573 e = e as React.KeyboardEvent<HTMLInputElement>;
574 this.handleInputKeyDown(e);
575 inputProps.onKeyDown?.(e);
576 break;
577 case "mousedown":
578 e = e as React.MouseEvent<HTMLInputElement>;
579 this.handleInputMouseDown();
580 inputProps.onMouseDown?.(e);
581 break;
582 default:
583 break;
584 }
585 };
586
587 // add a keydown listener to persistently change focus when tabbing:
588 // - if focused in start field, Tab moves focus to end field
589 // - if focused in end field, Shift+Tab moves focus to start field
590 private handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
591 // HACKHACK: https://github.com/palantir/blueprint/issues/4165
592 /* eslint-disable deprecation/deprecation */
593 const isTabPressed = e.which === Keys.TAB;
594 const isEnterPressed = e.which === Keys.ENTER;
595 const isShiftPressed = e.shiftKey;
596
597 const { selectedStart, selectedEnd } = this.state;
598
599 // order of JS events is our enemy here. when tabbing between fields,
600 // this handler will fire in the middle of a focus exchange when no
601 // field is currently focused. we work around this by referring to the
602 // most recently focused field, rather than the currently focused field.
603 const wasStartFieldFocused = this.state.lastFocusedField === Boundary.START;
604 const wasEndFieldFocused = this.state.lastFocusedField === Boundary.END;
605
606 // move focus to the other field
607 if (isTabPressed) {
608 let isEndInputFocused: boolean;
609 let isStartInputFocused: boolean;
610 let isOpen = true;
611
612 if (wasStartFieldFocused && !isShiftPressed) {
613 isStartInputFocused = false;
614 isEndInputFocused = true;
615
616 // prevent the default focus-change behavior to avoid race conditions;
617 // we'll handle the focus change ourselves in componentDidUpdate.
618 e.preventDefault();
619 } else if (wasEndFieldFocused && isShiftPressed) {
620 isStartInputFocused = true;
621 isEndInputFocused = false;
622 e.preventDefault();
623 } else {
624 // don't prevent default here, otherwise Tab won't do anything.
625 isStartInputFocused = false;
626 isEndInputFocused = false;
627 isOpen = false;
628 }
629
630 this.setState({
631 isEndInputFocused,
632 isOpen,
633 isStartInputFocused,
634 wasLastFocusChangeDueToHover: false,
635 });
636 } else if (wasStartFieldFocused && isEnterPressed) {
637 const nextStartDate = this.parseDate(this.state.startInputString);
638 this.handleDateRangePickerChange([nextStartDate, selectedEnd], true);
639 } else if (wasEndFieldFocused && isEnterPressed) {
640 const nextEndDate = this.parseDate(this.state.endInputString);
641 this.handleDateRangePickerChange([selectedStart, nextEndDate] as DateRange, true);
642 } else {
643 // let the default keystroke happen without side effects
644 return;
645 }
646 };
647
648 private handleInputMouseDown = () => {
649 // clicking in the field constitutes an explicit focus change. we update
650 // the flag on "mousedown" instead of on "click", because it needs to be
651 // set before onFocus is called ("click" triggers after "focus").
652 this.setState({ wasLastFocusChangeDueToHover: false });
653 };
654
655 private handleInputClick = (e: React.MouseEvent<HTMLInputElement>) => {
656 // unless we stop propagation on this event, a click within an input
657 // will close the popover almost as soon as it opens.
658 e.stopPropagation();
659 };
660
661 private handleInputFocus = (_e: React.FormEvent<HTMLInputElement>, boundary: Boundary) => {
662 const { keys, values } = this.getStateKeysAndValuesForBoundary(boundary);
663 const inputString = getFormattedDateString(values.selectedValue, this.props, true);
664
665 // change the boundary only if the user explicitly focused in the field.
666 // focus changes from hovering don't count; they're just temporary.
667 const boundaryToModify = this.state.wasLastFocusChangeDueToHover ? this.state.boundaryToModify : boundary;
668
669 this.setState({
670 [keys.inputString]: inputString,
671 [keys.isInputFocused]: true,
672 boundaryToModify,
673 isOpen: true,
674 lastFocusedField: boundary,
675 shouldSelectAfterUpdate: this.props.selectAllOnFocus,
676 wasLastFocusChangeDueToHover: false,
677 });
678 };
679
680 private handleInputBlur = (_e: React.FormEvent<HTMLInputElement>, boundary: Boundary) => {
681 const { keys, values } = this.getStateKeysAndValuesForBoundary(boundary);
682
683 const maybeNextDate = this.parseDate(values.inputString);
684 const isValueControlled = this.isControlled();
685
686 let nextState: IDateRangeInputState = {
687 [keys.isInputFocused]: false,
688 shouldSelectAfterUpdate: false,
689 };
690
691 if (this.isInputEmpty(values.inputString)) {
692 if (isValueControlled) {
693 nextState = {
694 ...nextState,
695 [keys.inputString]: getFormattedDateString(values.controlledValue, this.props),
696 };
697 } else {
698 nextState = {
699 ...nextState,
700 [keys.inputString]: null,
701 [keys.selectedValue]: null,
702 };
703 }
704 } else if (!this.isNextDateRangeValid(maybeNextDate, boundary)) {
705 if (!isValueControlled) {
706 nextState = {
707 ...nextState,
708 [keys.inputString]: null,
709 [keys.selectedValue]: maybeNextDate,
710 };
711 }
712 this.props.onError?.(this.getDateRangeForCallback(maybeNextDate, boundary));
713 }
714
715 this.setState(nextState);
716 };
717
718 private handleInputChange = (e: React.FormEvent<HTMLInputElement>, boundary: Boundary) => {
719 const inputString = (e.target as HTMLInputElement).value;
720
721 const { keys } = this.getStateKeysAndValuesForBoundary(boundary);
722 const maybeNextDate = this.parseDate(inputString);
723 const isValueControlled = this.isControlled();
724
725 let nextState: IDateRangeInputState = { shouldSelectAfterUpdate: false };
726
727 if (inputString.length === 0) {
728 // this case will be relevant when we start showing the hovered range in the input
729 // fields. goal is to show an empty field for clarity until the mouse moves over a
730 // different date.
731 const baseState = { ...nextState, [keys.inputString]: "" };
732 if (isValueControlled) {
733 nextState = baseState;
734 } else {
735 nextState = { ...baseState, [keys.selectedValue]: null };
736 }
737 this.props.onChange?.(this.getDateRangeForCallback(null, boundary));
738 } else if (this.isDateValidAndInRange(maybeNextDate)) {
739 // note that error cases that depend on both fields (e.g. overlapping dates) should fall
740 // through into this block so that the UI can update immediately, possibly with an error
741 // message on the other field.
742 // also, clear the hover string to ensure the most recent keystroke appears.
743 const baseState: IDateRangeInputState = {
744 ...nextState,
745 [keys.hoverString]: null,
746 [keys.inputString]: inputString,
747 };
748 if (isValueControlled) {
749 nextState = baseState;
750 } else {
751 nextState = { ...baseState, [keys.selectedValue]: maybeNextDate };
752 }
753 if (this.isNextDateRangeValid(maybeNextDate, boundary)) {
754 this.props.onChange?.(this.getDateRangeForCallback(maybeNextDate, boundary));
755 }
756 } else {
757 // again, clear the hover string to ensure the most recent keystroke appears
758 nextState = { ...nextState, [keys.inputString]: inputString, [keys.hoverString]: null };
759 }
760
761 this.setState(nextState);
762 };
763
764 // Callbacks - Popover
765 // ===================
766
767 private handlePopoverClose = (event: React.SyntheticEvent<HTMLElement>) => {
768 this.setState({ isOpen: false });
769 this.props.popoverProps.onClose?.(event);
770 };
771
772 // Helpers
773 // =======
774
775 private shouldFocusInputRef(isFocused: boolean, inputRef: HTMLInputElement) {
776 return isFocused && inputRef !== undefined && document.activeElement !== inputRef;
777 }
778
779 private getIsOpenValueWhenDateChanges = (nextSelectedStart: Date, nextSelectedEnd: Date): boolean => {
780 if (this.props.closeOnSelection) {
781 // trivial case when TimePicker is not shown
782 if (this.props.timePrecision == null) {
783 return false;
784 }
785
786 const fallbackDate = new Date(new Date().setHours(0, 0, 0, 0));
787 const [selectedStart, selectedEnd] = this.getSelectedRange([fallbackDate, fallbackDate]);
788
789 // case to check if the user has changed TimePicker values
790 if (
791 areSameTime(selectedStart, nextSelectedStart) === true &&
792 areSameTime(selectedEnd, nextSelectedEnd) === true
793 ) {
794 return false;
795 }
796 return true;
797 }
798
799 return true;
800 };
801
802 private getInitialRange = (props = this.props): DateRange => {
803 const { defaultValue, value } = props;
804 if (value != null) {
805 return value;
806 } else if (defaultValue != null) {
807 return defaultValue;
808 } else {
809 return [null, null];
810 }
811 };
812
813 private getSelectedRange = (fallbackRange?: [Date, Date]) => {
814 let selectedStart: Date;
815 let selectedEnd: Date;
816
817 if (this.isControlled()) {
818 [selectedStart, selectedEnd] = this.props.value;
819 } else {
820 selectedStart = this.state.selectedStart;
821 selectedEnd = this.state.selectedEnd;
822 }
823
824 // this helper function checks if the provided boundary date *would* overlap the selected
825 // other boundary date. providing the already-selected start date simply tells us if we're
826 // currently in an overlapping state.
827 const doBoundaryDatesOverlap = this.doBoundaryDatesOverlap(selectedStart, Boundary.START);
828 const dateRange = [selectedStart, doBoundaryDatesOverlap ? undefined : selectedEnd];
829
830 return dateRange.map((selectedBound: Date | undefined, index: number) => {
831 const fallbackDate = fallbackRange != null ? fallbackRange[index] : undefined;
832 return this.isDateValidAndInRange(selectedBound) ? selectedBound : fallbackDate;
833 }) as DateRange;
834 };
835
836 private getInputDisplayString = (boundary: Boundary) => {
837 const { values } = this.getStateKeysAndValuesForBoundary(boundary);
838 const { isInputFocused, inputString, selectedValue, hoverString } = values;
839
840 if (hoverString != null) {
841 return hoverString;
842 } else if (isInputFocused) {
843 return inputString == null ? "" : inputString;
844 } else if (selectedValue == null) {
845 return "";
846 } else if (this.doesEndBoundaryOverlapStartBoundary(selectedValue, boundary)) {
847 return this.props.overlappingDatesMessage;
848 } else {
849 return getFormattedDateString(selectedValue, this.props);
850 }
851 };
852
853 private getInputPlaceholderString = (boundary: Boundary) => {
854 const isStartBoundary = boundary === Boundary.START;
855 const isEndBoundary = boundary === Boundary.END;
856
857 const inputProps = this.getInputProps(boundary);
858 const { isInputFocused } = this.getStateKeysAndValuesForBoundary(boundary).values;
859
860 // use the custom placeholder text for the input, if providied
861 if (inputProps.placeholder != null) {
862 return inputProps.placeholder;
863 } else if (isStartBoundary) {
864 return isInputFocused ? this.state.formattedMinDateString : "Start date";
865 } else if (isEndBoundary) {
866 return isInputFocused ? this.state.formattedMaxDateString : "End date";
867 } else {
868 return "";
869 }
870 };
871
872 private getInputProps = (boundary: Boundary) => {
873 return boundary === Boundary.START ? this.props.startInputProps : this.props.endInputProps;
874 };
875
876 private getInputRef = (boundary: Boundary) => {
877 return boundary === Boundary.START ? this.handleStartInputRef : this.handleEndInputRef;
878 };
879
880 private getStateKeysAndValuesForBoundary = (boundary: Boundary): IStateKeysAndValuesObject => {
881 const controlledRange = this.props.value;
882 if (boundary === Boundary.START) {
883 return {
884 keys: {
885 hoverString: "startHoverString",
886 inputString: "startInputString",
887 isInputFocused: "isStartInputFocused",
888 selectedValue: "selectedStart",
889 },
890 values: {
891 controlledValue: controlledRange != null ? controlledRange[0] : undefined,
892 hoverString: this.state.startHoverString,
893 inputString: this.state.startInputString,
894 isInputFocused: this.state.isStartInputFocused,
895 selectedValue: this.state.selectedStart,
896 },
897 };
898 } else {
899 return {
900 keys: {
901 hoverString: "endHoverString",
902 inputString: "endInputString",
903 isInputFocused: "isEndInputFocused",
904 selectedValue: "selectedEnd",
905 },
906 values: {
907 controlledValue: controlledRange != null ? controlledRange[1] : undefined,
908 hoverString: this.state.endHoverString,
909 inputString: this.state.endInputString,
910 isInputFocused: this.state.isEndInputFocused,
911 selectedValue: this.state.selectedEnd,
912 },
913 };
914 }
915 };
916
917 private getDateRangeForCallback = (currDate: Date | null, currBoundary?: Boundary): DateRange => {
918 const otherBoundary = this.getOtherBoundary(currBoundary);
919 const otherDate = this.getStateKeysAndValuesForBoundary(otherBoundary).values.selectedValue;
920
921 return currBoundary === Boundary.START ? [currDate, otherDate] : [otherDate, currDate];
922 };
923
924 private getOtherBoundary = (boundary?: Boundary) => {
925 return boundary === Boundary.START ? Boundary.END : Boundary.START;
926 };
927
928 private doBoundaryDatesOverlap = (date: Date | null, boundary: Boundary) => {
929 const { allowSingleDayRange } = this.props;
930 const otherBoundary = this.getOtherBoundary(boundary);
931 const otherBoundaryDate = this.getStateKeysAndValuesForBoundary(otherBoundary).values.selectedValue;
932 if (date == null || otherBoundaryDate == null) {
933 return false;
934 }
935
936 if (boundary === Boundary.START) {
937 const isAfter = date > otherBoundaryDate;
938 return isAfter || (!allowSingleDayRange && DayPicker.DateUtils.isSameDay(date, otherBoundaryDate));
939 } else {
940 const isBefore = date < otherBoundaryDate;
941 return isBefore || (!allowSingleDayRange && DayPicker.DateUtils.isSameDay(date, otherBoundaryDate));
942 }
943 };
944
945 /**
946 * Returns true if the provided boundary is an END boundary overlapping the
947 * selected start date. (If the boundaries overlap, we consider the END
948 * boundary to be erroneous.)
949 */
950 private doesEndBoundaryOverlapStartBoundary = (boundaryDate: Date, boundary: Boundary) => {
951 return boundary === Boundary.START ? false : this.doBoundaryDatesOverlap(boundaryDate, boundary);
952 };
953
954 private isControlled = () => this.props.value !== undefined;
955
956 private isInputEmpty = (inputString: string) => inputString == null || inputString.length === 0;
957
958 private isInputInErrorState = (boundary: Boundary) => {
959 const values = this.getStateKeysAndValuesForBoundary(boundary).values;
960 const { isInputFocused, hoverString, inputString, selectedValue } = values;
961 if (hoverString != null || this.isInputEmpty(inputString)) {
962 // don't show an error state while we're hovering over a valid date.
963 return false;
964 }
965
966 const boundaryValue = isInputFocused ? this.parseDate(inputString) : selectedValue;
967 return (
968 boundaryValue != null &&
969 (!this.isDateValidAndInRange(boundaryValue) ||
970 this.doesEndBoundaryOverlapStartBoundary(boundaryValue, boundary))
971 );
972 };
973
974 private isDateValidAndInRange = (date: Date | false | null): date is Date => {
975 return isDateValid(date) && isDayInRange(date, [this.props.minDate, this.props.maxDate]);
976 };
977
978 private isNextDateRangeValid(nextDate: Date | false | null, boundary: Boundary): nextDate is Date {
979 return this.isDateValidAndInRange(nextDate) && !this.doBoundaryDatesOverlap(nextDate, boundary);
980 }
981
982 // this is a slightly kludgy function, but it saves us a good amount of repeated code between
983 // the constructor and componentDidUpdate.
984 private getFormattedMinMaxDateString(props: IDateRangeInputProps, propName: "minDate" | "maxDate") {
985 const date = props[propName];
986 const defaultDate = DateRangeInput.defaultProps[propName];
987 // default values are applied only if a prop is strictly `undefined`
988 // See: https://facebook.github.io/react/docs/react-component.html#defaultprops
989 return getFormattedDateString(date === undefined ? defaultDate : date, this.props);
990 }
991
992 private parseDate(dateString: string): Date | null {
993 if (dateString === this.props.outOfRangeMessage || dateString === this.props.invalidDateMessage) {
994 return null;
995 }
996 const { locale, parseDate } = this.props;
997 const newDate = parseDate(dateString, locale);
998 return newDate === false ? new Date(undefined) : newDate;
999 }
1000
1001 private formatDate(date: Date): string {
1002 if (!this.isDateValidAndInRange(date)) {
1003 return "";
1004 }
1005 const { locale, formatDate } = this.props;
1006 return formatDate(date, locale);
1007 }
1008}