/* * 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 { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ButtonProps, DISPLAYNAME_PREFIX, InputGroup, Intent, mergeRefs, Popover, type PopoverClickTargetHandlers, type PopoverTargetProps, Tag, Utils, } from "@blueprintjs/core"; import { Classes, DateUtils, Errors, TimezoneNameUtils, TimezoneUtils } from "../../common"; import { getDefaultDateFnsFormat } from "../../common/dateFnsFormatUtils"; import { useDateFnsLocale } from "../../common/dateFnsLocaleUtils"; import type { ReactDayPickerSingleProps } from "../../common/reactDayPickerProps"; import { DatePicker } from "../date-picker/datePicker"; import { INVALID_DATE_MESSAGE, LOCALE, MAX_DATE, MIN_DATE, OUT_OF_RANGE_MESSAGE } from "../dateConstants"; import type { DatePickerShortcut } from "../shortcuts/shortcuts"; import { TimezoneSelect } from "../timezone-select/timezoneSelect"; import type { DateInputProps } from "./dateInputProps"; import { useDateFormatter } from "./useDateFormatter"; import { useDateParser } from "./useDateParser"; export type { DateInputProps }; const timezoneSelectButtonProps: Partial = { fill: false, minimal: true, outlined: true, }; /** * Date input component. * * @see https://blueprintjs.com/docs/#datetime/date-input */ export const DateInput: React.FC = memo(function DateInput(props) { const { closeOnSelection = true, dateFnsFormat, dateFnsLocaleLoader, defaultTimezone, defaultValue, disabled = false, disableTimezoneSelect, fill, inputProps = {}, invalidDateMessage = INVALID_DATE_MESSAGE, locale: localeOrCode = LOCALE, maxDate = MAX_DATE, minDate = MIN_DATE, onChange, onError, onTimezoneChange, outOfRangeMessage = OUT_OF_RANGE_MESSAGE, popoverProps = {}, popoverRef, rightElement, reverseMonthAndYearMenus = false, showTimezoneSelect, timePrecision, timezone: controlledTimezone, value, ...datePickerProps } = props; const locale = useDateFnsLocale(localeOrCode, dateFnsLocaleLoader); const placeholder = getPlaceholder(props); const formatDateString = useDateFormatter(props, locale); const parseDateString = useDateParser(props, locale); // Refs // ------------------------------------------------------------------------ const inputRef = useRef(null); const popoverContentRef = useRef(null); const popoverId = Utils.uniqueId("date-picker"); // State // ------------------------------------------------------------------------ const [isOpen, setIsOpen] = useState(false); const [timezoneValue, setTimezoneValue] = useState(getInitialTimezoneValue(props)); const valueFromProps = useMemo( () => TimezoneUtils.getDateObjectFromIsoString(value, timezoneValue), [timezoneValue, value], ); const isControlled = valueFromProps !== undefined; const defaultValueFromProps = useMemo( () => TimezoneUtils.getDateObjectFromIsoString(defaultValue, timezoneValue), [defaultValue, timezoneValue], ); const [valueAsDate, setValue] = useState( isControlled ? valueFromProps : defaultValueFromProps, ); const [selectedShortcutIndex, setSelectedShortcutIndex] = useState(undefined); const [isInputFocused, setIsInputFocused] = useState(false); // rendered as the text input's value const formattedDateString = useMemo( () => (valueAsDate === null ? undefined : formatDateString(valueAsDate)), [valueAsDate, formatDateString], ); const [inputValue, setInputValue] = useState(formattedDateString ?? undefined); const isErrorState = valueAsDate != null && (!DateUtils.isDateValid(valueAsDate) || !DateUtils.isDayInRange(valueAsDate, [minDate, maxDate])); // Effects // ------------------------------------------------------------------------ useEffect(() => { if (isControlled) { setValue(valueFromProps); } }, [isControlled, valueFromProps]); useEffect(() => { // uncontrolled mode, updating initial timezone value if (defaultTimezone !== undefined && TimezoneNameUtils.isValidTimezone(defaultTimezone)) { setTimezoneValue(defaultTimezone); } }, [defaultTimezone]); useEffect(() => { // controlled mode, updating timezone value if (controlledTimezone !== undefined && TimezoneNameUtils.isValidTimezone(controlledTimezone)) { setTimezoneValue(controlledTimezone); } }, [controlledTimezone]); useEffect(() => { if (isControlled && !isInputFocused) { setInputValue(formattedDateString); } }, [isControlled, isInputFocused, formattedDateString]); // Popover contents (date picker) // ------------------------------------------------------------------------ const handlePopoverClose = useCallback( (e: React.SyntheticEvent) => { popoverProps.onClose?.(e); setIsOpen(false); }, [popoverProps], ); const handleDateChange = useCallback( (newDate: Date | null, isUserChange: boolean, didSubmitWithEnter = false) => { const prevDate = valueAsDate; if (newDate === null) { if (!isControlled && !didSubmitWithEnter) { // user clicked on current day in the calendar, so we should clear the input when uncontrolled setInputValue(""); } onChange?.(null, isUserChange); return; } // this change handler was triggered by a change in month, day, or (if // enabled) time. for UX purposes, we want to close the popover only if // the user explicitly clicked a day within the current month. const newIsOpen = !isUserChange || !closeOnSelection || (prevDate != null && (DateUtils.hasMonthChanged(prevDate, newDate) || (timePrecision !== undefined && DateUtils.hasTimeChanged(prevDate, newDate)))); // if selecting a date via click or Tab, the input will already be // blurred by now, so sync isInputFocused to false. if selecting via // Enter, setting isInputFocused to false won't do anything by itself, // plus we want the field to retain focus anyway. // (note: spelling out the ternary explicitly reads more clearly.) const newIsInputFocused = didSubmitWithEnter ? true : false; if (isControlled) { setIsInputFocused(newIsInputFocused); setIsOpen(newIsOpen); } else { const newFormattedDateString = formatDateString(newDate); setIsInputFocused(newIsInputFocused); setIsOpen(newIsOpen); setValue(newDate); setInputValue(newFormattedDateString); } const newIsoDateString = TimezoneUtils.getIsoEquivalentWithUpdatedTimezone( newDate, timezoneValue, timePrecision, ); onChange?.(newIsoDateString, isUserChange); }, [closeOnSelection, isControlled, formatDateString, onChange, timezoneValue, timePrecision, valueAsDate], ); const dayPickerProps: ReactDayPickerSingleProps["dayPickerProps"] = { ...props.dayPickerProps, onDayKeyDown: (day, modifiers, e) => { props.dayPickerProps?.onDayKeyDown?.(day, modifiers, e); }, onMonthChange: (month: Date) => { props.dayPickerProps?.onMonthChange?.(month); }, }; const handleShortcutChange = useCallback((_: DatePickerShortcut, index: number) => { setSelectedShortcutIndex(index); }, []); const handleStartFocusBoundaryFocusIn = useCallback((e: React.FocusEvent) => { if (popoverContentRef.current?.contains(getRelatedTargetWithFallback(e))) { // Not closing Popover to allow user to freely switch between manually entering a date // string in the input and selecting one via the Popover inputRef.current?.focus(); } else { getKeyboardFocusableElements(popoverContentRef).shift()?.focus(); } }, []); const handleEndFocusBoundaryFocusIn = useCallback( (e: React.FocusEvent) => { if (popoverContentRef.current?.contains(getRelatedTargetWithFallback(e))) { inputRef.current?.focus(); handlePopoverClose(e); } else { getKeyboardFocusableElements(popoverContentRef).pop()?.focus(); } }, [handlePopoverClose], ); // React's onFocus prop listens to the focusin browser event under the hood, so it's safe to // provide it the focusIn event handlers instead of using a ref and manually adding the // event listeners ourselves. const popoverContent = (