1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 | import classNames from "classnames";
|
18 | import * as React from "react";
|
19 | import DayPicker from "react-day-picker";
|
20 |
|
21 | import {
|
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 |
|
38 | import { DateRange } from "./common/dateRange";
|
39 | import { areSameTime, isDateValid, isDayInRange } from "./common/dateUtils";
|
40 | import * as Errors from "./common/errors";
|
41 | import { DateFormatProps, getFormattedDateString } from "./dateFormat";
|
42 | import { getDefaultMaxDate, getDefaultMinDate, IDatePickerBaseProps } from "./datePickerCore";
|
43 | import { DateRangePicker } from "./dateRangePicker";
|
44 | import { DateRangeShortcut } from "./shortcuts";
|
45 |
|
46 |
|
47 | type InputEvent =
|
48 | | React.MouseEvent<HTMLInputElement>
|
49 | | React.KeyboardEvent<HTMLInputElement>
|
50 | | React.FocusEvent<HTMLInputElement>
|
51 | | React.ChangeEvent<HTMLInputElement>;
|
52 |
|
53 |
|
54 | export type DateRangeInputProps = IDateRangeInputProps;
|
55 |
|
56 | export interface IDateRangeInputProps extends IDatePickerBaseProps, DateFormatProps, Props {
|
57 | |
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 | allowSingleDayRange?: boolean;
|
65 |
|
66 | |
67 |
|
68 |
|
69 |
|
70 |
|
71 | closeOnSelection?: boolean;
|
72 |
|
73 | |
74 |
|
75 |
|
76 |
|
77 |
|
78 |
|
79 | contiguousCalendarMonths?: boolean;
|
80 |
|
81 | |
82 |
|
83 |
|
84 |
|
85 | defaultValue?: DateRange;
|
86 |
|
87 | |
88 |
|
89 |
|
90 |
|
91 |
|
92 | disabled?: boolean;
|
93 |
|
94 | |
95 |
|
96 |
|
97 |
|
98 |
|
99 | endInputProps?: InputGroupProps2;
|
100 |
|
101 | |
102 |
|
103 |
|
104 |
|
105 |
|
106 |
|
107 | onChange?: (selectedRange: DateRange) => void;
|
108 |
|
109 | |
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 |
|
116 | onError?: (errorRange: DateRange) => void;
|
117 |
|
118 | |
119 |
|
120 |
|
121 |
|
122 |
|
123 |
|
124 | overlappingDatesMessage?: string;
|
125 |
|
126 | |
127 |
|
128 |
|
129 |
|
130 | popoverProps?: Partial<IPopoverProps>;
|
131 |
|
132 | |
133 |
|
134 |
|
135 |
|
136 |
|
137 | selectAllOnFocus?: boolean;
|
138 |
|
139 | |
140 |
|
141 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 |
|
147 | shortcuts?: boolean | DateRangeShortcut[];
|
148 |
|
149 | |
150 |
|
151 |
|
152 |
|
153 |
|
154 | singleMonthOnly?: boolean;
|
155 |
|
156 | |
157 |
|
158 |
|
159 |
|
160 |
|
161 | startInputProps?: InputGroupProps2;
|
162 |
|
163 | |
164 |
|
165 |
|
166 |
|
167 |
|
168 |
|
169 |
|
170 |
|
171 | value?: DateRange;
|
172 | }
|
173 |
|
174 | export 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 |
|
200 | interface 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 |
|
216 | export 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 |
|
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 |
|
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 |
|
343 |
|
344 | return (
|
345 |
|
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 | {}
|
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 |
|
395 |
|
396 |
|
397 | private handleDateRangePickerChange = (selectedRange: DateRange, didSubmitWithEnter = false) => {
|
398 |
|
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 |
|
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 |
|
427 | startHoverString = null;
|
428 | } else if (selectedEnd == null) {
|
429 |
|
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 |
|
446 |
|
447 |
|
448 | isEndInputFocused = true;
|
449 | } else {
|
450 | isEndInputFocused = false;
|
451 | boundaryToModify = Boundary.END;
|
452 | }
|
453 | } else if (this.state.lastFocusedField === Boundary.START) {
|
454 |
|
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 |
|
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 |
|
504 | if (!this.state.isOpen) {
|
505 | return;
|
506 | }
|
507 |
|
508 | if (hoveredRange == null) {
|
509 |
|
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 |
|
539 |
|
540 |
|
541 |
|
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 |
|
588 |
|
589 |
|
590 | private handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
591 |
|
592 |
|
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 |
|
600 |
|
601 |
|
602 |
|
603 | const wasStartFieldFocused = this.state.lastFocusedField === Boundary.START;
|
604 | const wasEndFieldFocused = this.state.lastFocusedField === Boundary.END;
|
605 |
|
606 |
|
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 |
|
617 |
|
618 | e.preventDefault();
|
619 | } else if (wasEndFieldFocused && isShiftPressed) {
|
620 | isStartInputFocused = true;
|
621 | isEndInputFocused = false;
|
622 | e.preventDefault();
|
623 | } else {
|
624 |
|
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 |
|
644 | return;
|
645 | }
|
646 | };
|
647 |
|
648 | private handleInputMouseDown = () => {
|
649 |
|
650 |
|
651 |
|
652 | this.setState({ wasLastFocusChangeDueToHover: false });
|
653 | };
|
654 |
|
655 | private handleInputClick = (e: React.MouseEvent<HTMLInputElement>) => {
|
656 |
|
657 |
|
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 |
|
666 |
|
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 |
|
729 |
|
730 |
|
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 |
|
740 |
|
741 |
|
742 |
|
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 |
|
758 | nextState = { ...nextState, [keys.inputString]: inputString, [keys.hoverString]: null };
|
759 | }
|
760 |
|
761 | this.setState(nextState);
|
762 | };
|
763 |
|
764 |
|
765 |
|
766 |
|
767 | private handlePopoverClose = (event: React.SyntheticEvent<HTMLElement>) => {
|
768 | this.setState({ isOpen: false });
|
769 | this.props.popoverProps.onClose?.(event);
|
770 | };
|
771 |
|
772 |
|
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 |
|
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 |
|
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 |
|
825 |
|
826 |
|
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 |
|
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 |
|
947 |
|
948 |
|
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 |
|
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 |
|
983 |
|
984 | private getFormattedMinMaxDateString(props: IDateRangeInputProps, propName: "minDate" | "maxDate") {
|
985 | const date = props[propName];
|
986 | const defaultDate = DateRangeInput.defaultProps[propName];
|
987 |
|
988 |
|
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 | }
|