/* * Copyright 2023 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import classNames from "classnames"; import { format } from "date-fns"; import { type ActiveModifiers, type DateFormatter, DayPicker } from "react-day-picker"; import { Button, DISPLAYNAME_PREFIX, Divider } from "@blueprintjs/core"; import { Classes, type DateRange, DateUtils, Errors, TimezoneUtils } from "../../common"; import { dayPickerClassNameOverrides } from "../../common/classes"; import { LOCALE, MAX_DATE, MIN_DATE } from "../dateConstants"; import { DateFnsLocalizedComponent } from "../dateFnsLocalizedComponent"; import { DatePickerDropdown } from "../react-day-picker/datePickerDropdown"; import { IconLeft, IconRight } from "../react-day-picker/datePickerNavIcons"; import { DatePickerShortcutMenu, type DateRangeShortcut } from "../shortcuts/shortcuts"; import { TimePicker } from "../time-picker/timePicker"; import { DatePickerProvider } from "./datePickerContext"; import type { DatePickerProps } from "./datePickerProps"; import type { DatePickerState } from "./datePickerState"; export type { DatePickerProps }; /** * Date picker component. * * @see https://blueprintjs.com/docs/#datetime/date-picker */ export class DatePicker extends DateFnsLocalizedComponent { public static defaultProps: DatePickerProps = { canClearSelection: true, clearButtonText: "Clear", dayPickerProps: {}, highlightCurrentDay: false, locale: LOCALE, maxDate: MAX_DATE, minDate: MIN_DATE, reverseMonthAndYearMenus: false, shortcuts: false, showActionsBar: false, todayButtonText: "Today", }; public static displayName = `${DISPLAYNAME_PREFIX}.DatePicker`; private ignoreNextMonthChange = false; public constructor(props: DatePickerProps) { super(props); const value = getInitialValue(props); const initialMonth = getInitialMonth(props, value); this.state = { displayMonth: initialMonth.getMonth(), displayYear: initialMonth.getFullYear(), locale: undefined, selectedDay: value == null ? null : value.getDate(), selectedShortcutIndex: this.props.selectedShortcutIndex !== undefined ? this.props.selectedShortcutIndex : -1, value, }; } public render() { const { className, dayPickerProps, footerElement, maxDate, minDate, showActionsBar } = this.props; const { displayMonth, displayYear, locale } = this.state; return (
{this.maybeRenderShortcuts()}
{this.maybeRenderTimePicker()} {showActionsBar && this.renderOptionsBar()} {footerElement}
); } public async componentDidMount() { await super.componentDidMount(); } public async componentDidUpdate(prevProps: DatePickerProps) { super.componentDidUpdate(prevProps); if (this.props.value !== prevProps.value) { if (this.props.value == null) { // clear the value this.setState({ value: null }); } else { this.setState({ displayMonth: this.props.value.getMonth(), displayYear: this.props.value.getFullYear(), selectedDay: this.props.value.getDate(), value: this.props.value, }); } } if (this.props.selectedShortcutIndex !== prevProps.selectedShortcutIndex) { this.setState({ selectedShortcutIndex: this.props.selectedShortcutIndex }); } } protected validateProps(props: DatePickerProps) { const { defaultValue, initialMonth, maxDate, minDate, value } = props; if (defaultValue != null && !DateUtils.isDayInRange(defaultValue, [minDate!, maxDate!])) { console.error(Errors.DATEPICKER_DEFAULT_VALUE_INVALID); } if (initialMonth != null && !DateUtils.isMonthInRange(initialMonth, [minDate!, maxDate!])) { console.error(Errors.DATEPICKER_INITIAL_MONTH_INVALID); } if (maxDate != null && minDate != null && maxDate < minDate && !DateUtils.isSameDay(maxDate, minDate)) { console.error(Errors.DATEPICKER_MAX_DATE_INVALID); } if (value != null && !DateUtils.isDayInRange(value, [minDate!, maxDate!])) { console.error(Errors.DATEPICKER_VALUE_INVALID); } } /** * Custom formatter to render weekday names in the calendar header. The default formatter generally works fine, * but it was returning CAPITALIZED strings for some reason, while we prefer Title Case. */ private renderWeekdayName: DateFormatter = date => { return format(date, "EEEEEE", { locale: this.state.locale }); }; private renderOptionsBar() { const { clearButtonText, todayButtonText, minDate, maxDate, canClearSelection } = this.props; const todayEnabled = isTodayEnabled(minDate!, maxDate!); return [ ,
, ]; } private maybeRenderTimePicker() { const { timePrecision, timePickerProps, minDate, maxDate } = this.props; if (timePrecision == null && timePickerProps === undefined) { return null; } const applyMin = this.state.value != null && DateUtils.isSameDay(this.state.value, minDate!); const applyMax = this.state.value != null && DateUtils.isSameDay(this.state.value, maxDate!); return (
); } private maybeRenderShortcuts() { const { shortcuts } = this.props; if (shortcuts == null || shortcuts === false) { return null; } const { selectedShortcutIndex } = this.state; const { maxDate = MAX_DATE, minDate = MIN_DATE, timePrecision } = this.props; // Reuse the existing date range shortcuts and only care about start date const dateRangeShortcuts: DateRangeShortcut[] | true = shortcuts === true ? true : shortcuts.map(shortcut => ({ ...shortcut, // TODO: Remove cast after setting "strictNullChecks: true" dateRange: [shortcut.date, null] as [Date, null], })); return [ , , ]; } private handleDaySelect = ( day: Date | undefined, selectedDay: Date, activeModifiers: ActiveModifiers, e: React.MouseEvent, ) => { if (activeModifiers.disabled) { return; } else if (day === undefined) { this.handleClearClick(); return; } this.updateDay(day); this.props.dayPickerProps?.onSelect?.(day, selectedDay, activeModifiers, e); // allow toggling selected date by clicking it again (if prop enabled) const newValue = this.props.canClearSelection && activeModifiers.selected ? null : DateUtils.getDateTime(day, this.state.value); this.updateValue(newValue, true); }; private handleShortcutClick = (shortcut: DateRangeShortcut, selectedShortcutIndex: number) => { const { onShortcutChange, selectedShortcutIndex: currentShortcutIndex } = this.props; const { dateRange, includeTime } = shortcut; const newDate = dateRange[0]; const newValue = includeTime ? newDate : DateUtils.getDateTime(newDate, this.state.value); if (newDate == null) { return; } this.updateDay(newDate); this.updateValue(newValue, true); if (currentShortcutIndex === undefined) { this.setState({ selectedShortcutIndex }); } const datePickerShortcut = { ...shortcut, date: newDate }; onShortcutChange?.(datePickerShortcut, selectedShortcutIndex); }; private updateDay = (day: Date) => { if (this.props.value === undefined) { // set now if uncontrolled, otherwise they'll be updated in `componentDidUpdate` this.setState({ displayMonth: day.getMonth(), displayYear: day.getFullYear(), selectedDay: day.getDate(), }); } if (this.state.value != null && this.state.value.getMonth() !== day.getMonth()) { this.ignoreNextMonthChange = true; } }; private computeValidDateInSpecifiedMonthYear(displayYear: number, displayMonth: number): Date | null { const { minDate, maxDate } = this.props; const { selectedDay } = this.state; // month is 0-based, date is 1-based. date 0 is last day of previous month. const maxDaysInMonth = new Date(displayYear, displayMonth + 1, 0).getDate(); const displayDate = selectedDay == null ? 1 : Math.min(selectedDay, maxDaysInMonth); // 12:00 matches the underlying react-day-picker timestamp behavior const value = DateUtils.getDateTime(new Date(displayYear, displayMonth, displayDate, 12), this.state.value); // clamp between min and max dates if (value != null && value < minDate!) { return minDate!; } else if (value != null && value > maxDate!) { return maxDate!; } return value; } private handleClearClick = () => this.updateValue(null, true); private handleMonthChange = (newDate: Date) => { const date = this.computeValidDateInSpecifiedMonthYear(newDate.getFullYear(), newDate.getMonth()); if (date != null) { this.setState({ displayMonth: date.getMonth(), displayYear: date.getFullYear() }); this.props.dayPickerProps?.onMonthChange?.(date); } if (this.state.value !== null) { // if handleDayClick just got run (so this flag is set), then the // user selected a date in a new month, so don't invoke onChange a // second time this.updateValue(date, false, this.ignoreNextMonthChange); this.ignoreNextMonthChange = false; } }; private handleTodayClick = () => { const { timezone } = this.props; const today = new Date(); const value = timezone != null ? TimezoneUtils.convertLocalDateToTimezoneTime(today, timezone) : today; const displayMonth = value.getMonth(); const displayYear = value.getFullYear(); const selectedDay = value.getDate(); this.setState({ displayMonth, displayYear, selectedDay }); this.updateValue(value, true); }; private handleTimeChange = (time: Date) => { this.props.timePickerProps?.onChange?.(time); const { value } = this.state; const newValue = DateUtils.getDateTime(value != null ? value : new Date(), time); this.updateValue(newValue, true); }; /** * Update `value` by invoking `onChange` (always) and setting state (if uncontrolled). */ private updateValue(value: Date | null, isUserChange: boolean, skipOnChange = false) { if (!skipOnChange) { this.props.onChange?.(value, isUserChange); } if (this.props.value === undefined) { this.setState({ value }); } } } function getInitialValue(props: DatePickerProps): Date | null { // !== because `null` is a valid value (no date) if (props.value !== undefined) { return props.value; } if (props.defaultValue !== undefined) { return props.defaultValue; } return null; } function getInitialMonth(props: DatePickerProps, value: Date | null): Date { const rangeFromProps: DateRange = [props.minDate ?? null, props.maxDate ?? null]; const today = new Date(); // != because we must have a real `Date` to begin the calendar on. if (props.initialMonth != null) { return props.initialMonth; } else if (value != null) { return value; } else if (DateUtils.isDayInRange(today, rangeFromProps)) { return today; } else { return DateUtils.getDateBetween(rangeFromProps); } } function isTodayEnabled(minDate: Date, maxDate: Date): boolean { const today = new Date(); return DateUtils.isDayInRange(today, [minDate, maxDate]); }