UNPKG

@carbon/react

Version:

React components for the Carbon Design System

488 lines (486 loc) 20.1 kB
/** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ import { usePrefix } from "../../internal/usePrefix.js"; import { Enter, Escape, Tab } from "../../internal/keyboard/keys.js"; import { match } from "../../internal/keyboard/match.js"; import { deprecate } from "../../prop-types/deprecate.js"; import { isComponentElement } from "../../internal/utils.js"; import DatePickerInput_default from "../DatePickerInput/index.js"; import { appendToPlugin } from "./plugins/appendToPlugin.js"; import fixEventsPlugin from "./plugins/fixEventsPlugin.js"; import { isEmptyDateValue } from "./utils.js"; import { rangePlugin } from "./plugins/rangePlugin.js"; import { useSavedCallback } from "../../internal/useSavedCallback.js"; import { SUPPORTED_LOCALES } from "./DatePickerLocales.js"; import classNames from "classnames"; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import PropTypes from "prop-types"; import { jsx, jsxs } from "react/jsx-runtime"; import flatpickr from "flatpickr"; import l10n from "flatpickr/dist/l10n/index"; import { datePartsOrder } from "@carbon/utilities"; //#region src/components/DatePicker/DatePicker.tsx /** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ function initializeWeekdayShorthand() { if (l10n?.en?.weekdays?.shorthand) l10n.en.weekdays.shorthand.forEach((_day, index) => { const currentDay = l10n.en.weekdays.shorthand; if (currentDay[index] === "Thu" || currentDay[index] === "Th") currentDay[index] = "Th"; else currentDay[index] = currentDay[index].charAt(0); }); } const forEach = Array.prototype.forEach; /** * @param {number} monthNumber The month number. * @param {boolean} shorthand `true` to use shorthand month. * @param {Locale} locale The Flatpickr locale data. * @returns {string} The month string. */ const monthToStr = (monthNumber, shorthand, locale) => locale.months[shorthand ? "shorthand" : "longhand"][monthNumber]; /** * @param {object} config Plugin configuration. * @param {boolean} [config.shorthand] `true` to use shorthand month. * @param {string} config.selectorFlatpickrMonthYearContainer The CSS selector for the container of month/year selection UI. * @param {string} config.selectorFlatpickrYearContainer The CSS selector for the container of year selection UI. * @param {string} config.selectorFlatpickrCurrentMonth The CSS selector for the text-based month selection UI. * @param {string} config.classFlatpickrCurrentMonth The CSS class for the text-based month selection UI. * @param {string} config.locale The locale code. * @returns {Plugin} A Flatpickr plugin to use text instead of `<select>` for month picker. */ const carbonFlatpickrMonthSelectPlugin = (config) => (fp) => { const setupElements = () => { if (!fp.monthElements) return; fp.monthElements.forEach((elem) => { if (!elem.parentNode) return; elem.parentNode.removeChild(elem); }); fp.monthElements.splice(0, fp.monthElements.length, ...fp.monthElements.map(() => { const monthElement = fp._createElement("span", config.classFlatpickrCurrentMonth); monthElement.textContent = monthToStr(fp.currentMonth, config.shorthand === true, fp.l10n); if (datePartsOrder.isMonthFirst(config.locale)) fp.yearElements[0].closest(config.selectorFlatpickrMonthYearContainer).insertBefore(monthElement, fp.yearElements[0].closest(config.selectorFlatpickrYearContainer)); else fp.yearElements[0].closest(config.selectorFlatpickrMonthYearContainer).insertAdjacentElement("beforeend", monthElement); return monthElement; })); }; const updateCurrentMonth = () => { if (fp.monthElements) { const monthStr = monthToStr(fp.currentMonth, config.shorthand === true, fp.l10n); fp.yearElements.forEach((elem) => { const currentMonthContainer = elem.closest(config.selectorFlatpickrMonthYearContainer); Array.prototype.forEach.call(currentMonthContainer.querySelectorAll(".cur-month"), (monthElement) => { monthElement.textContent = monthStr; }); }); } }; const register = () => { fp.loadedPlugins.push("carbonFlatpickrMonthSelectPlugin"); }; return { onMonthChange: updateCurrentMonth, onValueUpdate: updateCurrentMonth, onOpen: updateCurrentMonth, onReady: [ setupElements, updateCurrentMonth, register ] }; }; /** * Determine if every child in a list of children has no label specified * @param {Array<ReactElement>} children * @returns {boolean} */ function isLabelTextEmpty(children) { return children.every((child) => !child.props.labelText); } function updateClassNames(calendar, prefix) { const calendarContainer = calendar.calendarContainer; const daysContainer = calendar.days; if (calendarContainer && daysContainer) { calendarContainer.classList.add(`${prefix}--date-picker__calendar`); calendarContainer.querySelector(".flatpickr-month").classList.add(`${prefix}--date-picker__month`); calendarContainer.querySelector(".flatpickr-weekdays").classList.add(`${prefix}--date-picker__weekdays`); calendarContainer.querySelector(".flatpickr-days").classList.add(`${prefix}--date-picker__days`); forEach.call(calendarContainer.querySelectorAll(".flatpickr-weekday"), (item) => { const currentItem = item; currentItem.innerHTML = currentItem.innerHTML.replace(/\s+/g, ""); currentItem.classList.add(`${prefix}--date-picker__weekday`); }); forEach.call(daysContainer.querySelectorAll(".flatpickr-day"), (item) => { item.classList.add(`${prefix}--date-picker__day`); item.setAttribute("role", "button"); if (item.classList.contains("today") && calendar.selectedDates.length > 0) item.classList.add("no-border"); else if (item.classList.contains("today") && calendar.selectedDates.length === 0) item.classList.remove("no-border"); }); } } const DatePicker = forwardRef((props, ref) => { const { allowInput, appendTo, children, className, closeOnSelect = true, dateFormat = "m/d/Y", datePickerType, disable, enable, inline, invalid, warn, light = false, locale = "en", maxDate, minDate, onChange, onClose, onOpen, readOnly = false, short = false, value, parseDate: parseDateProp, nextMonthAriaLabel = "Next month", prevMonthAriaLabel = "Previous month", ...rest } = props; const prefix = usePrefix(); const [hasInput, setHasInput] = useState(false); const startInputField = useCallback((node) => { if (node !== null) { startInputField.current = node; setHasInput(true); } }, []); const lastStartValue = useRef(""); const calendarRef = useRef(null); const [calendarCloseEvent, setCalendarCloseEvent] = useState(null); const handleCalendarClose = useCallback((selectedDates, dateStr, instance) => { if (lastStartValue.current && selectedDates[0] && !startInputField.current.value) { startInputField.current.value = lastStartValue.current; calendarRef.current?.setDate([startInputField.current.value, endInputField?.current?.value], true, calendarRef.current.config.dateFormat); } if (onClose) onClose(selectedDates, dateStr, instance); }, [onClose]); const onCalendarClose = (selectedDates, dateStr, instance, e) => { if (e && e.type === "clickOutside") return; setCalendarCloseEvent({ selectedDates, dateStr, instance }); }; useEffect(() => { if (calendarCloseEvent) { const { selectedDates, dateStr, instance } = calendarCloseEvent; handleCalendarClose(selectedDates, dateStr, instance); setCalendarCloseEvent(null); } }, [calendarCloseEvent, handleCalendarClose]); const endInputField = useRef(null); const lastFocusedField = useRef(null); const savedOnChange = useSavedCallback(onChange); const savedOnOpen = useSavedCallback(onOpen); const effectiveWarn = warn && !invalid; const wrapperRef = useRef(null); const datePickerClasses = classNames(`${prefix}--date-picker`, { [`${prefix}--date-picker--short`]: short, [`${prefix}--date-picker--light`]: light, [`${prefix}--date-picker--simple`]: datePickerType === "simple", [`${prefix}--date-picker--single`]: datePickerType === "single", [`${prefix}--date-picker--range`]: datePickerType === "range", [`${prefix}--date-picker--nolabel`]: datePickerType === "range" && isLabelTextEmpty(children) }); const wrapperClasses = classNames(`${prefix}--form-item`, { [String(className)]: className }); const childrenWithProps = React.Children.toArray(children).map((child, index) => { if (index === 0 && isComponentElement(child, DatePickerInput_default)) return React.cloneElement(child, { datePickerType, ref: startInputField, readOnly, invalid, warn: effectiveWarn }); if (index === 1 && isComponentElement(child, DatePickerInput_default)) return React.cloneElement(child, { datePickerType, ref: endInputField, readOnly, invalid, warn: effectiveWarn }); if (index === 0) return React.cloneElement(child, { ref: startInputField, readOnly, invalid, warn: effectiveWarn }); if (index === 1) return React.cloneElement(child, { ref: endInputField, readOnly, invalid, warn: effectiveWarn }); }); useEffect(() => { initializeWeekdayShorthand(); }, []); useEffect(() => { if (datePickerType !== "single" && datePickerType !== "range") return; if (!startInputField.current) return; const onHook = (_electedDates, _dateStr, instance) => { updateClassNames(instance, prefix); if (startInputField?.current) startInputField.current.readOnly = readOnly; if (endInputField?.current) endInputField.current.readOnly = readOnly; }; const enableOrDisable = enable ? "enable" : "disable"; let enableOrDisableArr; if (!enable && !disable) enableOrDisableArr = []; else if (enable) enableOrDisableArr = enable; else enableOrDisableArr = disable; let localeData; if (typeof locale === "object") localeData = { ...l10n[locale.locale ? locale.locale : "en"], ...locale }; else localeData = l10n[locale]; /** * parseDate is called before the date is actually set. * It attempts to parse the input value and return a valid date string. * Flatpickr's default parser results in odd dates when given invalid * values, so instead here we normalize the month/day to `1` if given * a value outside the acceptable range. */ let parseDate; if (!parseDateProp && dateFormat === "m/d/Y") parseDate = (date) => { const month = date.split("/")[0] <= 12 && date.split("/")[0] > 0 ? parseInt(date.split("/")[0]) : 1; const year = parseInt(date.split("/")[2]); if (month && year) { const daysInMonth = new Date(year, month, 0).getDate(); const day = date.split("/")[1] <= daysInMonth && date.split("/")[1] > 0 ? parseInt(date.split("/")[1]) : 1; return /* @__PURE__ */ new Date(`${year}/${month}/${day}`); } else return false; }; else if (parseDateProp) parseDate = parseDateProp; const rightArrowHTML = `<svg aria-label="${nextMonthAriaLabel}" role="img" width="16px" height="16px" viewBox="0 0 16 16"> <polygon points="11,8 6,13 5.3,12.3 9.6,8 5.3,3.7 6,3 "/> </svg>`; const leftArrowHTML = `<svg aria-label="${prevMonthAriaLabel}" role="img" width="16px" height="16px" viewBox="0 0 16 16"> <polygon points="5,8 10,3 10.7,3.7 6.4,8 10.7,12.3 10,13 "/> </svg>`; const { current: start } = startInputField; const { current: end } = endInputField; const calendar = flatpickr(start, { inline: inline ?? false, onClose: onCalendarClose, disableMobile: true, defaultDate: value, closeOnSelect, mode: datePickerType, allowInput: allowInput ?? true, dateFormat, locale: localeData, [enableOrDisable]: enableOrDisableArr, minDate, maxDate, parseDate, plugins: [ datePickerType === "range" ? rangePlugin({ input: endInputField.current ?? void 0 }) : (() => {}), appendTo ? appendToPlugin({ appendTo }) : (() => {}), carbonFlatpickrMonthSelectPlugin({ selectorFlatpickrMonthYearContainer: ".flatpickr-current-month", selectorFlatpickrYearContainer: ".numInputWrapper", selectorFlatpickrCurrentMonth: ".cur-month", classFlatpickrCurrentMonth: "cur-month", locale }), fixEventsPlugin({ inputFrom: startInputField.current, inputTo: endInputField.current, lastStartValue, container: wrapperRef.current }) ], clickOpens: !readOnly, noCalendar: readOnly, nextArrow: rightArrowHTML, prevArrow: leftArrowHTML, onChange: (...args) => { if (!readOnly) savedOnChange(...args); }, onReady: onHook, onMonthChange: onHook, onYearChange: onHook, onOpen: (...args) => { onHook(...args); savedOnOpen(...args); }, onValueUpdate: onHook }); calendarRef.current = calendar; const handleInputFieldKeyDown = (event) => { if (readOnly && match(event, Tab)) return; const { calendarContainer, selectedDateElem: fpSelectedDateElem, todayDateElem: fpTodayDateElem } = calendar; if (match(event, Escape)) calendarContainer.classList.remove("open"); if (match(event, Tab)) { if (!event.shiftKey) { event.preventDefault(); calendarContainer.classList.add("open"); const selectedDateElem = calendarContainer.querySelector(".selected") && fpSelectedDateElem; const todayDateElem = calendarContainer.querySelector(".today") && fpTodayDateElem; const focusTarget = selectedDateElem || todayDateElem || calendarContainer.querySelector(".flatpickr-day[tabindex]") || calendarContainer; if (focusTarget instanceof HTMLElement) focusTarget.focus(); if (event.target === startInputField.current) lastFocusedField.current = startInputField.current; else if (event.target === endInputField.current) lastFocusedField.current = endInputField.current; } else if (calendarRef.current?.isOpen && event.target === startInputField.current) { calendarRef.current.close(); onCalendarClose(calendarRef.current.selectedDates, "", calendarRef.current, event); } } }; const handleCalendarKeyDown = (event) => { if (!calendarRef.current || !startInputField.current) return; const lastInputField = datePickerType == "range" ? endInputField.current : startInputField.current; if (match(event, Tab)) if (!event.shiftKey) if (lastFocusedField.current === lastInputField) { lastInputField.focus(); calendarRef.current.close(); onCalendarClose(calendarRef.current.selectedDates, "", calendarRef.current, event); } else { event.preventDefault(); lastInputField.focus(); } else { event.preventDefault(); (lastFocusedField.current || startInputField.current).focus(); } }; function handleOnChange(event) { const { target } = event; if (target === start) lastStartValue.current = start.value; if (start.value !== "") return; if (!calendar.selectedDates) return; if (calendar.selectedDates.length === 0) return; } function handleKeyPress(event) { if (match(event, Enter) && closeOnSelect && datePickerType == "single") calendar.calendarContainer.classList.remove("open"); } if (start) { start.addEventListener("keydown", handleInputFieldKeyDown); start.addEventListener("change", handleOnChange); start.addEventListener("keypress", handleKeyPress); if (calendar && calendar.calendarContainer) { calendar.calendarContainer.setAttribute("role", "application"); calendar.calendarContainer.setAttribute("aria-label", "calendar-container"); } } if (end) { end.addEventListener("keydown", handleInputFieldKeyDown); end.addEventListener("change", handleOnChange); end.addEventListener("keypress", handleKeyPress); } if (calendar.calendarContainer) calendar.calendarContainer.addEventListener("keydown", handleCalendarKeyDown); return () => { if (calendar && calendar.destroy) calendar.destroy(); if (value) { if (start) start.value = ""; if (end) end.value = ""; } if (start) { start.removeEventListener("keydown", handleInputFieldKeyDown); start.removeEventListener("change", handleOnChange); start.removeEventListener("keypress", handleKeyPress); } if (end) { end.removeEventListener("keydown", handleInputFieldKeyDown); end.removeEventListener("change", handleOnChange); end.removeEventListener("keypress", handleKeyPress); } if (calendar.calendarContainer) calendar.calendarContainer.removeEventListener("keydown", handleCalendarKeyDown); }; }, [ savedOnChange, savedOnOpen, readOnly, closeOnSelect, hasInput, datePickerType, nextMonthAriaLabel, prevMonthAriaLabel ]); useImperativeHandle(ref, () => ({ get calendar() { return calendarRef.current; } })); useEffect(() => { if (calendarRef.current?.set) calendarRef.current.set({ dateFormat }); }, [dateFormat]); useEffect(() => { if (calendarRef.current?.set) calendarRef.current.set("minDate", minDate); }, [minDate]); useEffect(() => { if (calendarRef.current?.set) calendarRef.current.set("allowInput", allowInput); }, [allowInput]); useEffect(() => { if (calendarRef.current?.set) calendarRef.current.set("maxDate", maxDate); }, [maxDate]); useEffect(() => { if (calendarRef.current?.set && disable) calendarRef.current.set("disable", disable); }, [disable]); useEffect(() => { if (calendarRef.current?.set && enable) calendarRef.current.set("enable", enable); }, [enable]); useEffect(() => { if (calendarRef.current?.set && inline) calendarRef.current.set("inline", inline); }, [inline]); useEffect(() => { if ((isEmptyDateValue(value) || Array.isArray(value) && value.every(isEmptyDateValue)) && calendarRef.current?.selectedDates.length) { calendarRef.current?.clear(); if (startInputField.current) startInputField.current.value = ""; if (endInputField.current) endInputField.current.value = ""; } }, [value, startInputField]); useEffect(() => { if (calendarRef.current?.set) { if (value !== void 0) if (value === "" || value === null || Array.isArray(value) && (value.length === 0 || value.every(isEmptyDateValue))) { if (calendarRef.current.selectedDates.length > 0) calendarRef.current.clear(); } else calendarRef.current.setDate(value); updateClassNames(calendarRef.current, prefix); } else if (!calendarRef.current && typeof value !== "undefined" && value !== null) startInputField.current.value = value; }, [ value, prefix, startInputField ]); let fluidError; return /* @__PURE__ */ jsxs("div", { className: wrapperClasses, ref, ...rest, children: [/* @__PURE__ */ jsx("div", { className: datePickerClasses, ref: wrapperRef, children: childrenWithProps }), fluidError] }); }); DatePicker.propTypes = { allowInput: PropTypes.bool, appendTo: PropTypes.object, children: PropTypes.node, className: PropTypes.string, closeOnSelect: PropTypes.bool, dateFormat: PropTypes.string, datePickerType: PropTypes.oneOf([ "simple", "single", "range" ]), disable: PropTypes.array, enable: PropTypes.array, inline: PropTypes.bool, invalid: PropTypes.bool, light: deprecate(PropTypes.bool, "The `light` prop for `DatePicker` has been deprecated in favor of the new `Layer` component. It will be removed in the next major release."), locale: PropTypes.oneOfType([PropTypes.object, PropTypes.oneOf(SUPPORTED_LOCALES)]), maxDate: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), minDate: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), onChange: PropTypes.func, onClose: PropTypes.func, onOpen: PropTypes.func, parseDate: PropTypes.func, readOnly: PropTypes.oneOfType([PropTypes.bool, PropTypes.array]), short: PropTypes.bool, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([ PropTypes.string, PropTypes.number, PropTypes.object ])), PropTypes.object, PropTypes.number ]), warn: PropTypes.bool, nextMonthAriaLabel: PropTypes.string, prevMonthAriaLabel: PropTypes.string }; //#endregion export { DatePicker as default };