UNPKG

@blueprintjs/datetime

Version:

Components for interacting with dates and times

407 lines 21.9 kB
/* * 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 * as React from "react"; import { DISPLAYNAME_PREFIX, InputGroup, Intent, mergeRefs, Popover, Tag, Utils, } from "@blueprintjs/core"; import { Classes, DateUtils, Errors, TimezoneNameUtils, TimezoneUtils } from "../../common"; import { getDefaultDateFnsFormat } from "../../common/dateFnsFormatUtils"; import { useDateFnsLocale } from "../../common/dateFnsLocaleUtils"; import { DatePicker } from "../date-picker/datePicker"; import { DatePickerUtils } from "../date-picker/datePickerUtils"; import { TimezoneSelect } from "../timezone-select/timezoneSelect"; import { useDateFormatter } from "./useDateFormatter"; import { useDateParser } from "./useDateParser"; const timezoneSelectButtonProps = { fill: false, minimal: true, outlined: true, }; export const DATEINPUT_DEFAULT_PROPS = { closeOnSelection: true, disabled: false, invalidDateMessage: "Invalid date", locale: "en-US", maxDate: DatePickerUtils.getDefaultMaxDate(), minDate: DatePickerUtils.getDefaultMinDate(), outOfRangeMessage: "Out of range", reverseMonthAndYearMenus: false, }; /** * Date input component. * * @see https://blueprintjs.com/docs/#datetime/date-input */ export const DateInput = React.memo(function DateInput(props) { const { closeOnSelection, dateFnsFormat, dateFnsLocaleLoader, defaultTimezone, defaultValue, disabled, disableTimezoneSelect, fill, inputProps = {}, invalidDateMessage, locale: localeOrCode, maxDate, minDate, onChange, onError, onTimezoneChange, outOfRangeMessage, popoverProps = {}, popoverRef, rightElement, 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 = React.useRef(null); const popoverContentRef = React.useRef(null); const popoverId = Utils.uniqueId("date-picker"); // State // ------------------------------------------------------------------------ const [isOpen, setIsOpen] = React.useState(false); const [timezoneValue, setTimezoneValue] = React.useState(getInitialTimezoneValue(props)); const valueFromProps = React.useMemo(() => TimezoneUtils.getDateObjectFromIsoString(value, timezoneValue), [timezoneValue, value]); const isControlled = valueFromProps !== undefined; const defaultValueFromProps = React.useMemo(() => TimezoneUtils.getDateObjectFromIsoString(defaultValue, timezoneValue), [defaultValue, timezoneValue]); const [valueAsDate, setValue] = React.useState(isControlled ? valueFromProps : defaultValueFromProps); const [selectedShortcutIndex, setSelectedShortcutIndex] = React.useState(undefined); const [isInputFocused, setIsInputFocused] = React.useState(false); // rendered as the text input's value const formattedDateString = React.useMemo(() => (valueAsDate === null ? undefined : formatDateString(valueAsDate)), [valueAsDate, formatDateString]); const [inputValue, setInputValue] = React.useState(formattedDateString !== null && formattedDateString !== void 0 ? formattedDateString : undefined); const isErrorState = valueAsDate != null && (!DateUtils.isDateValid(valueAsDate) || !DateUtils.isDayInRange(valueAsDate, [minDate, maxDate])); // Effects // ------------------------------------------------------------------------ React.useEffect(() => { if (isControlled) { setValue(valueFromProps); } }, [isControlled, valueFromProps]); React.useEffect(() => { // uncontrolled mode, updating initial timezone value if (defaultTimezone !== undefined && TimezoneNameUtils.isValidTimezone(defaultTimezone)) { setTimezoneValue(defaultTimezone); } }, [defaultTimezone]); React.useEffect(() => { // controlled mode, updating timezone value if (controlledTimezone !== undefined && TimezoneNameUtils.isValidTimezone(controlledTimezone)) { setTimezoneValue(controlledTimezone); } }, [controlledTimezone]); React.useEffect(() => { if (isControlled && !isInputFocused) { setInputValue(formattedDateString); } }, [isControlled, isInputFocused, formattedDateString]); // Popover contents (date picker) // ------------------------------------------------------------------------ const handlePopoverClose = React.useCallback((e) => { var _a; (_a = popoverProps.onClose) === null || _a === void 0 ? void 0 : _a.call(popoverProps, e); setIsOpen(false); }, [popoverProps]); const handleDateChange = React.useCallback((newDate, isUserChange, 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 || onChange === void 0 ? void 0 : 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 === null || onChange === void 0 ? void 0 : onChange(newIsoDateString, isUserChange); }, [closeOnSelection, isControlled, formatDateString, onChange, timezoneValue, timePrecision, valueAsDate]); const dayPickerProps = { ...props.dayPickerProps, onDayKeyDown: (day, modifiers, e) => { var _a, _b; (_b = (_a = props.dayPickerProps) === null || _a === void 0 ? void 0 : _a.onDayKeyDown) === null || _b === void 0 ? void 0 : _b.call(_a, day, modifiers, e); }, onMonthChange: (month) => { var _a, _b; (_b = (_a = props.dayPickerProps) === null || _a === void 0 ? void 0 : _a.onMonthChange) === null || _b === void 0 ? void 0 : _b.call(_a, month); }, }; const handleShortcutChange = React.useCallback((_, index) => { setSelectedShortcutIndex(index); }, []); const handleStartFocusBoundaryFocusIn = React.useCallback((e) => { var _a, _b, _c; if ((_a = popoverContentRef.current) === null || _a === void 0 ? void 0 : _a.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 (_b = inputRef.current) === null || _b === void 0 ? void 0 : _b.focus(); } else { (_c = getKeyboardFocusableElements(popoverContentRef).shift()) === null || _c === void 0 ? void 0 : _c.focus(); } }, []); const handleEndFocusBoundaryFocusIn = React.useCallback((e) => { var _a, _b, _c; if ((_a = popoverContentRef.current) === null || _a === void 0 ? void 0 : _a.contains(getRelatedTargetWithFallback(e))) { (_b = inputRef.current) === null || _b === void 0 ? void 0 : _b.focus(); handlePopoverClose(e); } else { (_c = getKeyboardFocusableElements(popoverContentRef).pop()) === null || _c === void 0 ? void 0 : _c.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 = (React.createElement("div", { ref: popoverContentRef, role: "dialog", "aria-label": "date picker", id: popoverId }, React.createElement("div", { onFocus: handleStartFocusBoundaryFocusIn, tabIndex: 0 }), React.createElement(DatePicker, { ...datePickerProps, dateFnsLocaleLoader: dateFnsLocaleLoader, dayPickerProps: dayPickerProps, locale: locale, maxDate: maxDate, minDate: minDate, onChange: handleDateChange, onShortcutChange: handleShortcutChange, selectedShortcutIndex: selectedShortcutIndex, timePrecision: timePrecision, timezone: timezoneValue, // the rest of this component handles invalid dates gracefully (to show error messages), // but DatePicker does not, so we must take care to filter those out value: isErrorState ? null : valueAsDate }), React.createElement("div", { onFocus: handleEndFocusBoundaryFocusIn, tabIndex: 0 }))); // Timezone select // ------------------------------------------------------------------------ // we need a date which is guaranteed to be non-null here; if necessary, // we use today's date and shift it to the desired/current timezone const tzSelectDate = React.useMemo(() => valueAsDate != null && DateUtils.isDateValid(valueAsDate) ? valueAsDate : TimezoneUtils.convertLocalDateToTimezoneTime(new Date(), timezoneValue), [timezoneValue, valueAsDate]); const isTimezoneSelectHidden = timePrecision === undefined || showTimezoneSelect === false; const isTimezoneSelectDisabled = disabled || disableTimezoneSelect; const handleTimezoneChange = React.useCallback((newTimezone) => { if (controlledTimezone === undefined) { // uncontrolled timezone setTimezoneValue(newTimezone); } onTimezoneChange === null || onTimezoneChange === void 0 ? void 0 : onTimezoneChange(newTimezone); if (valueAsDate != null) { const newDateString = TimezoneUtils.getIsoEquivalentWithUpdatedTimezone(valueAsDate, newTimezone, timePrecision); onChange === null || onChange === void 0 ? void 0 : onChange(newDateString, true); } }, [onChange, onTimezoneChange, valueAsDate, timePrecision, controlledTimezone]); const maybeTimezonePicker = React.useMemo(() => isTimezoneSelectHidden ? undefined : (React.createElement(TimezoneSelect, { buttonProps: timezoneSelectButtonProps, className: Classes.DATE_INPUT_TIMEZONE_SELECT, date: tzSelectDate, disabled: isTimezoneSelectDisabled, onChange: handleTimezoneChange, value: timezoneValue }, React.createElement(Tag, { endIcon: isTimezoneSelectDisabled ? undefined : "caret-down", interactive: !isTimezoneSelectDisabled, minimal: true }, TimezoneNameUtils.getTimezoneShortName(timezoneValue, tzSelectDate)))), [handleTimezoneChange, isTimezoneSelectDisabled, isTimezoneSelectHidden, timezoneValue, tzSelectDate]); // Text input // ------------------------------------------------------------------------ const handleInputFocus = React.useCallback((e) => { var _a; setIsInputFocused(true); setIsOpen(true); setInputValue(formattedDateString); (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onFocus) === null || _a === void 0 ? void 0 : _a.call(inputProps, e); }, [formattedDateString, inputProps]); const handleInputBlur = React.useCallback((e) => { var _a; if (inputValue == null || valueAsDate == null) { setIsInputFocused(false); return; } const date = parseDateString(inputValue); if (inputValue.length > 0 && inputValue !== formattedDateString && (!DateUtils.isDateValid(date) || !DateUtils.isDayInRange(date, [minDate, maxDate]))) { if (isControlled) { setIsInputFocused(false); } else { setIsInputFocused(false); setValue(date); setInputValue(undefined); } if (date === null) { onChange === null || onChange === void 0 ? void 0 : onChange(null, true); } else { onError === null || onError === void 0 ? void 0 : onError(date); } } else { if (inputValue.length === 0) { setIsInputFocused(false); setValue(null); setInputValue(undefined); } else { setIsInputFocused(false); } } (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onBlur) === null || _a === void 0 ? void 0 : _a.call(inputProps, e); }, [ formattedDateString, inputProps, inputValue, isControlled, maxDate, minDate, onChange, onError, parseDateString, valueAsDate, ]); const handleInputChange = React.useCallback((e) => { var _a; const valueString = e.target.value; const inputValueAsDate = parseDateString(valueString); if (DateUtils.isDateValid(inputValueAsDate) && DateUtils.isDayInRange(inputValueAsDate, [minDate, maxDate])) { if (isControlled) { setInputValue(valueString); } else { setValue(inputValueAsDate); setInputValue(valueString); } const newIsoDateString = TimezoneUtils.getIsoEquivalentWithUpdatedTimezone(inputValueAsDate, timezoneValue, timePrecision); onChange === null || onChange === void 0 ? void 0 : onChange(newIsoDateString, true); } else { if (valueString.length === 0) { onChange === null || onChange === void 0 ? void 0 : onChange(null, true); } setValue(inputValueAsDate); setInputValue(valueString); } (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onChange) === null || _a === void 0 ? void 0 : _a.call(inputProps, e); }, [isControlled, minDate, maxDate, timezoneValue, timePrecision, parseDateString, onChange, inputProps]); const handleInputClick = React.useCallback((e) => { var _a; // stop propagation to the Popover's internal handleTargetClick handler; // otherwise, the popover will flicker closed as soon as it opens. e.stopPropagation(); (_a = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onClick) === null || _a === void 0 ? void 0 : _a.call(inputProps, e); }, [inputProps]); const handleInputKeyDown = React.useCallback((e) => { var _a, _b, _c; if (e.key === "Tab" && e.shiftKey) { // close popover on SHIFT+TAB key press handlePopoverClose(e); } else if (e.key === "Tab" && isOpen) { (_a = getKeyboardFocusableElements(popoverContentRef).shift()) === null || _a === void 0 ? void 0 : _a.focus(); // necessary to prevent focusing the second focusable element e.preventDefault(); } else if (e.key === "Escape") { setIsOpen(false); (_b = inputRef.current) === null || _b === void 0 ? void 0 : _b.blur(); } else if (e.key === "Enter" && inputValue != null) { const nextDate = parseDateString(inputValue); if (DateUtils.isDateValid(nextDate)) { handleDateChange(nextDate, true, true); } } (_c = inputProps === null || inputProps === void 0 ? void 0 : inputProps.onKeyDown) === null || _c === void 0 ? void 0 : _c.call(inputProps, e); }, [handleDateChange, handlePopoverClose, inputProps, inputValue, isOpen, parseDateString]); // Main render // ------------------------------------------------------------------------ const shouldShowErrorStyling = !isInputFocused || inputValue === outOfRangeMessage || inputValue === invalidDateMessage; // We use the renderTarget API to flatten the rendered DOM and make it easier to implement features like the "fill" prop. const renderTarget = React.useCallback(({ isOpen: targetIsOpen, ref, ...targetProps }) => { var _a; return (React.createElement(InputGroup, { autoComplete: "off", className: classNames(targetProps.className, inputProps.className), intent: shouldShowErrorStyling && isErrorState ? Intent.DANGER : Intent.NONE, placeholder: placeholder, rightElement: React.createElement(React.Fragment, null, rightElement, maybeTimezonePicker), tagName: popoverProps.targetTagName, type: "text", role: "combobox", ...targetProps, ...inputProps, "aria-controls": popoverId, "aria-expanded": targetIsOpen, disabled: disabled, fill: fill, inputRef: mergeRefs(ref, inputRef, inputProps === null || inputProps === void 0 ? void 0 : inputProps.inputRef), onBlur: handleInputBlur, onChange: handleInputChange, onClick: handleInputClick, onFocus: handleInputFocus, onKeyDown: handleInputKeyDown, value: (_a = (isInputFocused ? inputValue : formattedDateString)) !== null && _a !== void 0 ? _a : "" })); }, [ disabled, fill, formattedDateString, handleInputBlur, handleInputChange, handleInputClick, handleInputFocus, handleInputKeyDown, inputProps, inputValue, isErrorState, isInputFocused, maybeTimezonePicker, placeholder, popoverId, popoverProps.targetTagName, rightElement, shouldShowErrorStyling, ]); // N.B. no need to set `fill` since that is unused with the `renderTarget` API return (React.createElement(Popover, { isOpen: isOpen && !disabled, ...popoverProps, autoFocus: false, className: classNames(Classes.DATE_INPUT, popoverProps.className, props.className), content: popoverContent, enforceFocus: false, onClose: handlePopoverClose, popoverClassName: classNames(Classes.DATE_INPUT_POPOVER, popoverProps.popoverClassName), ref: popoverRef, renderTarget: renderTarget })); }); DateInput.displayName = `${DISPLAYNAME_PREFIX}.DateInput`; // TODO: Removing `defaultProps` here breaks tests. Investigate why. // eslint-disable-next-line @typescript-eslint/no-deprecated DateInput.defaultProps = DATEINPUT_DEFAULT_PROPS; /** Gets the input `placeholder` value from props, using default values if undefined */ function getPlaceholder(props) { var _a; if (props.placeholder !== undefined || (props.formatDate !== undefined && props.parseDate !== undefined)) { return props.placeholder; } else { return (_a = props.dateFnsFormat) !== null && _a !== void 0 ? _a : getDefaultDateFnsFormat(props); } } function getInitialTimezoneValue({ defaultTimezone, timezone }) { if (timezone !== undefined) { // controlled mode if (TimezoneNameUtils.isValidTimezone(timezone)) { return timezone; } else { console.error(Errors.DATEINPUT_INVALID_TIMEZONE); return TimezoneUtils.UTC_TIME.ianaCode; } } else if (defaultTimezone !== undefined) { // uncontrolled mode with initial value if (TimezoneNameUtils.isValidTimezone(defaultTimezone)) { return defaultTimezone; } else { console.error(Errors.DATEINPUT_INVALID_DEFAULT_TIMEZONE); return TimezoneUtils.UTC_TIME.ianaCode; } } else { // uncontrolled mode return TimezoneUtils.getCurrentTimezone(); } } function getRelatedTargetWithFallback(e) { var _a; return (_a = e.relatedTarget) !== null && _a !== void 0 ? _a : Utils.getActiveElement(e.currentTarget); } function getKeyboardFocusableElements(popoverContentRef) { if (popoverContentRef.current === null) { return []; } const elements = Utils.getFocusableElements(popoverContentRef.current); // Remove focus boundary div elements elements.pop(); elements.shift(); return elements; } //# sourceMappingURL=dateInput.js.map