@carbon/react
Version:
React components for the Carbon Design System
488 lines (486 loc) • 20.1 kB
JavaScript
/**
* 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 };