UNPKG

@blueprintjs/datetime

Version:

Components for interacting with dates and times

380 lines 20.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DateInput = void 0; const tslib_1 = require("tslib"); const jsx_runtime_1 = require("react/jsx-runtime"); /* * 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. */ const classnames_1 = tslib_1.__importDefault(require("classnames")); const react_1 = require("react"); const core_1 = require("@blueprintjs/core"); const common_1 = require("../../common"); const dateFnsFormatUtils_1 = require("../../common/dateFnsFormatUtils"); const dateFnsLocaleUtils_1 = require("../../common/dateFnsLocaleUtils"); const datePicker_1 = require("../date-picker/datePicker"); const dateConstants_1 = require("../dateConstants"); const timezoneSelect_1 = require("../timezone-select/timezoneSelect"); const useDateFormatter_1 = require("./useDateFormatter"); const useDateParser_1 = require("./useDateParser"); const timezoneSelectButtonProps = { fill: false, minimal: true, outlined: true, }; /** * Date input component. * * @see https://blueprintjs.com/docs/#datetime/date-input */ exports.DateInput = (0, react_1.memo)(function DateInput(props) { const { closeOnSelection = true, dateFnsFormat, dateFnsLocaleLoader, defaultTimezone, defaultValue, disabled = false, disableTimezoneSelect, fill, inputProps = {}, invalidDateMessage = dateConstants_1.INVALID_DATE_MESSAGE, locale: localeOrCode = dateConstants_1.LOCALE, maxDate = dateConstants_1.MAX_DATE, minDate = dateConstants_1.MIN_DATE, onChange, onError, onTimezoneChange, outOfRangeMessage = dateConstants_1.OUT_OF_RANGE_MESSAGE, popoverProps = {}, popoverRef, rightElement, reverseMonthAndYearMenus = false, showTimezoneSelect, timePrecision, timezone: controlledTimezone, value, ...datePickerProps } = props; const locale = (0, dateFnsLocaleUtils_1.useDateFnsLocale)(localeOrCode, dateFnsLocaleLoader); const placeholder = getPlaceholder(props); const formatDateString = (0, useDateFormatter_1.useDateFormatter)(props, locale); const parseDateString = (0, useDateParser_1.useDateParser)(props, locale); // Refs // ------------------------------------------------------------------------ const inputRef = (0, react_1.useRef)(null); const popoverContentRef = (0, react_1.useRef)(null); const popoverId = core_1.Utils.uniqueId("date-picker"); // State // ------------------------------------------------------------------------ const [isOpen, setIsOpen] = (0, react_1.useState)(false); const [timezoneValue, setTimezoneValue] = (0, react_1.useState)(getInitialTimezoneValue(props)); const valueFromProps = (0, react_1.useMemo)(() => common_1.TimezoneUtils.getDateObjectFromIsoString(value, timezoneValue), [timezoneValue, value]); const isControlled = valueFromProps !== undefined; const defaultValueFromProps = (0, react_1.useMemo)(() => common_1.TimezoneUtils.getDateObjectFromIsoString(defaultValue, timezoneValue), [defaultValue, timezoneValue]); const [valueAsDate, setValue] = (0, react_1.useState)(isControlled ? valueFromProps : defaultValueFromProps); const [selectedShortcutIndex, setSelectedShortcutIndex] = (0, react_1.useState)(undefined); const [isInputFocused, setIsInputFocused] = (0, react_1.useState)(false); // rendered as the text input's value const formattedDateString = (0, react_1.useMemo)(() => (valueAsDate === null ? undefined : formatDateString(valueAsDate)), [valueAsDate, formatDateString]); const [inputValue, setInputValue] = (0, react_1.useState)(formattedDateString ?? undefined); const isErrorState = valueAsDate != null && (!common_1.DateUtils.isDateValid(valueAsDate) || !common_1.DateUtils.isDayInRange(valueAsDate, [minDate, maxDate])); // Effects // ------------------------------------------------------------------------ (0, react_1.useEffect)(() => { if (isControlled) { setValue(valueFromProps); } }, [isControlled, valueFromProps]); (0, react_1.useEffect)(() => { // uncontrolled mode, updating initial timezone value if (defaultTimezone !== undefined && common_1.TimezoneNameUtils.isValidTimezone(defaultTimezone)) { setTimezoneValue(defaultTimezone); } }, [defaultTimezone]); (0, react_1.useEffect)(() => { // controlled mode, updating timezone value if (controlledTimezone !== undefined && common_1.TimezoneNameUtils.isValidTimezone(controlledTimezone)) { setTimezoneValue(controlledTimezone); } }, [controlledTimezone]); (0, react_1.useEffect)(() => { if (isControlled && !isInputFocused) { setInputValue(formattedDateString); } }, [isControlled, isInputFocused, formattedDateString]); // Popover contents (date picker) // ------------------------------------------------------------------------ const handlePopoverClose = (0, react_1.useCallback)((e) => { popoverProps.onClose?.(e); setIsOpen(false); }, [popoverProps]); const handleDateChange = (0, react_1.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, 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 && (common_1.DateUtils.hasMonthChanged(prevDate, newDate) || (timePrecision !== undefined && common_1.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 = common_1.TimezoneUtils.getIsoEquivalentWithUpdatedTimezone(newDate, timezoneValue, timePrecision); onChange?.(newIsoDateString, isUserChange); }, [closeOnSelection, isControlled, formatDateString, onChange, timezoneValue, timePrecision, valueAsDate]); const dayPickerProps = { ...props.dayPickerProps, onDayKeyDown: (day, modifiers, e) => { props.dayPickerProps?.onDayKeyDown?.(day, modifiers, e); }, onMonthChange: (month) => { props.dayPickerProps?.onMonthChange?.(month); }, }; const handleShortcutChange = (0, react_1.useCallback)((_, index) => { setSelectedShortcutIndex(index); }, []); const handleStartFocusBoundaryFocusIn = (0, react_1.useCallback)((e) => { 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 = (0, react_1.useCallback)((e) => { 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 = ((0, jsx_runtime_1.jsxs)("div", { ref: popoverContentRef, role: "dialog", "aria-label": "date picker", id: popoverId, children: [(0, jsx_runtime_1.jsx)("div", { onFocus: handleStartFocusBoundaryFocusIn, tabIndex: 0 }), (0, jsx_runtime_1.jsx)(datePicker_1.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 }), (0, jsx_runtime_1.jsx)("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 = (0, react_1.useMemo)(() => valueAsDate != null && common_1.DateUtils.isDateValid(valueAsDate) ? valueAsDate : common_1.TimezoneUtils.convertLocalDateToTimezoneTime(new Date(), timezoneValue), [timezoneValue, valueAsDate]); const isTimezoneSelectHidden = timePrecision === undefined || showTimezoneSelect === false; const isTimezoneSelectDisabled = disabled || disableTimezoneSelect; const handleTimezoneChange = (0, react_1.useCallback)((newTimezone) => { if (controlledTimezone === undefined) { // uncontrolled timezone setTimezoneValue(newTimezone); } onTimezoneChange?.(newTimezone); if (valueAsDate != null) { const newDateString = common_1.TimezoneUtils.getIsoEquivalentWithUpdatedTimezone(valueAsDate, newTimezone, timePrecision); onChange?.(newDateString, true); } }, [onChange, onTimezoneChange, valueAsDate, timePrecision, controlledTimezone]); const maybeTimezonePicker = (0, react_1.useMemo)(() => isTimezoneSelectHidden ? undefined : ((0, jsx_runtime_1.jsx)(timezoneSelect_1.TimezoneSelect, { buttonProps: timezoneSelectButtonProps, className: common_1.Classes.DATE_INPUT_TIMEZONE_SELECT, date: tzSelectDate, disabled: isTimezoneSelectDisabled, onChange: handleTimezoneChange, value: timezoneValue, children: (0, jsx_runtime_1.jsx)(core_1.Tag, { endIcon: isTimezoneSelectDisabled ? undefined : "caret-down", interactive: !isTimezoneSelectDisabled, minimal: true, children: common_1.TimezoneNameUtils.getTimezoneShortName(timezoneValue, tzSelectDate) }) })), [handleTimezoneChange, isTimezoneSelectDisabled, isTimezoneSelectHidden, timezoneValue, tzSelectDate]); // Text input // ------------------------------------------------------------------------ const handleInputFocus = (0, react_1.useCallback)((e) => { setIsInputFocused(true); setIsOpen(true); setInputValue(formattedDateString); inputProps?.onFocus?.(e); }, [formattedDateString, inputProps]); const handleInputBlur = (0, react_1.useCallback)((e) => { if (inputValue == null || valueAsDate == null) { setIsInputFocused(false); return; } const date = parseDateString(inputValue); if (inputValue.length > 0 && inputValue !== formattedDateString && (!common_1.DateUtils.isDateValid(date) || !common_1.DateUtils.isDayInRange(date, [minDate, maxDate]))) { if (isControlled) { setIsInputFocused(false); } else { setIsInputFocused(false); setValue(date); setInputValue(undefined); } if (date === null) { onChange?.(null, true); } else { onError?.(date); } } else { if (inputValue.length === 0) { setIsInputFocused(false); setValue(null); setInputValue(undefined); } else { setIsInputFocused(false); } } inputProps?.onBlur?.(e); }, [ formattedDateString, inputProps, inputValue, isControlled, maxDate, minDate, onChange, onError, parseDateString, valueAsDate, ]); const handleInputChange = (0, react_1.useCallback)((e) => { const valueString = e.target.value; const inputValueAsDate = parseDateString(valueString); if (common_1.DateUtils.isDateValid(inputValueAsDate) && common_1.DateUtils.isDayInRange(inputValueAsDate, [minDate, maxDate])) { if (isControlled) { setInputValue(valueString); } else { setValue(inputValueAsDate); setInputValue(valueString); } const newIsoDateString = common_1.TimezoneUtils.getIsoEquivalentWithUpdatedTimezone(inputValueAsDate, timezoneValue, timePrecision); onChange?.(newIsoDateString, true); } else { if (valueString.length === 0) { onChange?.(null, true); } setValue(inputValueAsDate); setInputValue(valueString); } inputProps?.onChange?.(e); }, [isControlled, minDate, maxDate, timezoneValue, timePrecision, parseDateString, onChange, inputProps]); const handleInputClick = (0, react_1.useCallback)((e) => { // stop propagation to the Popover's internal handleTargetClick handler; // otherwise, the popover will flicker closed as soon as it opens. e.stopPropagation(); inputProps?.onClick?.(e); }, [inputProps]); const handleInputKeyDown = (0, react_1.useCallback)((e) => { if (e.key === "Tab" && e.shiftKey) { // close popover on SHIFT+TAB key press handlePopoverClose(e); } else if (e.key === "Tab" && isOpen) { getKeyboardFocusableElements(popoverContentRef).shift()?.focus(); // necessary to prevent focusing the second focusable element e.preventDefault(); } else if (e.key === "Escape") { setIsOpen(false); inputRef.current?.blur(); } else if (e.key === "Enter" && inputValue != null) { const nextDate = parseDateString(inputValue); if (common_1.DateUtils.isDateValid(nextDate)) { handleDateChange(nextDate, true, true); } } inputProps?.onKeyDown?.(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 = (0, react_1.useCallback)(({ isOpen: targetIsOpen, ref, ...targetProps }) => { return ((0, jsx_runtime_1.jsx)(core_1.InputGroup, { autoComplete: "off", className: (0, classnames_1.default)(targetProps.className, inputProps.className), intent: shouldShowErrorStyling && isErrorState ? core_1.Intent.DANGER : core_1.Intent.NONE, placeholder: placeholder, rightElement: (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [rightElement, maybeTimezonePicker] }), tagName: popoverProps.targetTagName, type: "text", role: "combobox", ...targetProps, ...inputProps, "aria-controls": popoverId, "aria-expanded": targetIsOpen, disabled: disabled, fill: fill, inputRef: (0, core_1.mergeRefs)(ref, inputRef, inputProps?.inputRef), onBlur: handleInputBlur, onChange: handleInputChange, onClick: handleInputClick, onFocus: handleInputFocus, onKeyDown: handleInputKeyDown, value: (isInputFocused ? inputValue : formattedDateString) ?? "" })); }, [ 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 ((0, jsx_runtime_1.jsx)(core_1.Popover, { isOpen: isOpen && !disabled, ...popoverProps, autoFocus: false, className: (0, classnames_1.default)(common_1.Classes.DATE_INPUT, popoverProps.className, props.className), content: popoverContent, enforceFocus: false, onClose: handlePopoverClose, popoverClassName: (0, classnames_1.default)(common_1.Classes.DATE_INPUT_POPOVER, popoverProps.popoverClassName), ref: popoverRef, renderTarget: renderTarget })); }); exports.DateInput.displayName = `${core_1.DISPLAYNAME_PREFIX}.DateInput`; /** Gets the input `placeholder` value from props, using default values if undefined */ function getPlaceholder(props) { if (props.placeholder !== undefined || (props.formatDate !== undefined && props.parseDate !== undefined)) { return props.placeholder; } else { return props.dateFnsFormat ?? (0, dateFnsFormatUtils_1.getDefaultDateFnsFormat)(props); } } function getInitialTimezoneValue({ defaultTimezone, timezone }) { if (timezone !== undefined) { // controlled mode if (common_1.TimezoneNameUtils.isValidTimezone(timezone)) { return timezone; } else { console.error(common_1.Errors.DATEINPUT_INVALID_TIMEZONE); return common_1.TimezoneUtils.UTC_TIME.ianaCode; } } else if (defaultTimezone !== undefined) { // uncontrolled mode with initial value if (common_1.TimezoneNameUtils.isValidTimezone(defaultTimezone)) { return defaultTimezone; } else { console.error(common_1.Errors.DATEINPUT_INVALID_DEFAULT_TIMEZONE); return common_1.TimezoneUtils.UTC_TIME.ianaCode; } } else { // uncontrolled mode return common_1.TimezoneUtils.getCurrentTimezone(); } } function getRelatedTargetWithFallback(e) { return e.relatedTarget ?? core_1.Utils.getActiveElement(e.currentTarget); } function getKeyboardFocusableElements(popoverContentRef) { if (popoverContentRef.current === null) { return []; } const elements = core_1.Utils.getFocusableElements(popoverContentRef.current); // Remove focus boundary div elements elements.pop(); elements.shift(); return elements; } //# sourceMappingURL=dateInput.js.map