diff --git a/src/calendar.tsx b/src/calendar.tsx index 4bedbdc06..0fea10576 100644 --- a/src/calendar.tsx +++ b/src/calendar.tsx @@ -127,7 +127,7 @@ type CalendarProps = React.PropsWithChildren< YearDropdownProps, "date" | "onChange" | "year" | "minDate" | "maxDate" > & - Omit & + Omit & Omit & Omit< YearProps, @@ -875,6 +875,7 @@ export default class Calendar extends Component { {...Calendar.defaultProps} {...this.props} month={getMonth(this.state.date)} + date={this.state.date} onChange={this.changeMonth} /> ); diff --git a/src/month_dropdown.tsx b/src/month_dropdown.tsx index 9efd3c727..78f79e129 100644 --- a/src/month_dropdown.tsx +++ b/src/month_dropdown.tsx @@ -3,6 +3,8 @@ import React, { Component } from "react"; import { getMonthShortInLocale, getMonthInLocale, + isMonthDisabled, + setMonth, type Locale, } from "./date_utils"; import MonthDropdownOptions from "./month_dropdown_options"; @@ -36,7 +38,11 @@ export default class MonthDropdown extends Component< renderSelectOptions = (monthNames: string[]): React.ReactElement[] => monthNames.map( (m: string, i: number): React.ReactElement => ( - ), @@ -91,7 +97,10 @@ export default class MonthDropdown extends Component< onChange = (month: number): void => { this.toggleDropdown(); - if (month !== this.props.month) { + if ( + month !== this.props.month && + !isMonthDisabled(setMonth(this.props.date, month), this.props) + ) { this.props.onChange(month); } }; diff --git a/src/month_dropdown_options.tsx b/src/month_dropdown_options.tsx index e9d2cf12c..19c79fd4d 100644 --- a/src/month_dropdown_options.tsx +++ b/src/month_dropdown_options.tsx @@ -1,12 +1,22 @@ +import { clsx } from "clsx"; import React, { Component } from "react"; import { ClickOutsideWrapper } from "./click_outside_wrapper"; +import { + isMonthDisabled, + setMonth, + type DateFilterOptions, +} from "./date_utils"; -interface MonthDropdownOptionsProps { +interface MonthDropdownOptionsProps extends Pick< + DateFilterOptions, + "minDate" | "maxDate" | "excludeDates" | "includeDates" | "filterDate" +> { onCancel: VoidFunction; onChange: (month: number) => void; month: number; monthNames: string[]; + date: Date; } export default class MonthDropdownOptions extends Component { @@ -14,6 +24,9 @@ export default class MonthDropdownOptions extends Component this.props.month === i; + isDisabledMonth = (i: number): boolean => + isMonthDisabled(setMonth(this.props.date, i), this.props); + handleOptionKeyDown = (i: number, e: React.KeyboardEvent): void => { switch (e.key) { case "Enter": @@ -41,38 +54,49 @@ export default class MonthDropdownOptions extends Component( - (month: string, i: number): React.ReactElement => ( -
{ - this.monthOptionButtonsRef[i] = el; - if (this.isSelectedMonth(i)) { - el?.focus(); - } - }} - role="button" - tabIndex={0} - className={ - this.isSelectedMonth(i) - ? "react-datepicker__month-option react-datepicker__month-option--selected_month" - : "react-datepicker__month-option" - } - key={month} - onClick={this.onChange.bind(this, i)} - onKeyDown={this.handleOptionKeyDown.bind(this, i)} - aria-selected={this.isSelectedMonth(i) ? "true" : undefined} - > - {this.isSelectedMonth(i) ? ( - - ) : ( - "" - )} - {month} -
- ), + (month: string, i: number): React.ReactElement => { + const isDisabled = this.isDisabledMonth(i); + return ( +
{ + this.monthOptionButtonsRef[i] = el; + if (this.isSelectedMonth(i)) { + el?.focus(); + } + }} + role="button" + tabIndex={0} + className={clsx("react-datepicker__month-option", { + "react-datepicker__month-option--selected_month": + this.isSelectedMonth(i), + "react-datepicker__month-option--disabled": isDisabled, + })} + key={month} + onClick={this.onChange.bind(this, i)} + onKeyDown={this.handleOptionKeyDown.bind(this, i)} + aria-selected={this.isSelectedMonth(i) ? "true" : undefined} + aria-disabled={isDisabled ? "true" : undefined} + > + {this.isSelectedMonth(i) ? ( + + ✓ + + ) : ( + "" + )} + {month} +
+ ); + }, ); }; - onChange = (month: number): void => this.props.onChange(month); + onChange = (month: number): void => { + if (this.isDisabledMonth(month)) { + return; + } + this.props.onChange(month); + }; handleClickOutside = (): void => this.props.onCancel(); diff --git a/src/stylesheets/datepicker.scss b/src/stylesheets/datepicker.scss index 57b8445f9..0dea77076 100644 --- a/src/stylesheets/datepicker.scss +++ b/src/stylesheets/datepicker.scss @@ -723,6 +723,16 @@ h2.react-datepicker__current-month { } } +.react-datepicker__month-option--disabled { + cursor: default; + color: $datepicker__muted-color; + + &:hover { + cursor: default; + background-color: transparent; + } +} + .react-datepicker__close-icon { cursor: pointer; background-color: transparent; diff --git a/src/test/month_dropdown_test.test.tsx b/src/test/month_dropdown_test.test.tsx index 0fb5f4aba..e700335b6 100644 --- a/src/test/month_dropdown_test.test.tsx +++ b/src/test/month_dropdown_test.test.tsx @@ -21,14 +21,15 @@ describe("MonthDropdown", () => { function getMonthDropdown( overrideProps?: Partial< - Pick + Pick > & - Omit, + Omit, ) { return render( , @@ -138,6 +139,7 @@ describe("MonthDropdown", () => { onChange={onCancelSpy} month={11} monthNames={monthNames} + date={new Date(2025, 11, 1)} />, ); fireEvent.mouseDown(document.body); @@ -291,6 +293,58 @@ describe("MonthDropdown", () => { const firstMonthOption = monthOptions[0]; expect(document.activeElement).toEqual(firstMonthOption); }); + + it("does not call onChange when clicking a month outside of minDate/maxDate range", () => { + monthDropdown = getMonthDropdown({ + month: 3, + date: new Date(2025, 3, 1), + minDate: new Date(2025, 0, 1), + maxDate: new Date(2025, 5, 30), + }); + const monthReadView = safeQuerySelector( + monthDropdown, + ".react-datepicker__month-read-view", + ); + fireEvent.click(monthReadView); + + const monthOptions = safeQuerySelectorAll( + monthDropdown, + ".react-datepicker__month-option", + ); + + // August (index 7) is outside the Jan-Jun 2025 range + const disabledMonthOption = monthOptions[7]!; + expect(disabledMonthOption.getAttribute("aria-disabled")).toEqual("true"); + + fireEvent.click(disabledMonthOption); + expect(handleChangeResult).toBeNull(); + }); + + it("calls onChange when clicking a month inside of minDate/maxDate range", () => { + monthDropdown = getMonthDropdown({ + month: 3, + date: new Date(2025, 3, 1), + minDate: new Date(2025, 0, 1), + maxDate: new Date(2025, 5, 30), + }); + const monthReadView = safeQuerySelector( + monthDropdown, + ".react-datepicker__month-read-view", + ); + fireEvent.click(monthReadView); + + const monthOptions = safeQuerySelectorAll( + monthDropdown, + ".react-datepicker__month-option", + ); + + // May (index 4) is inside the Jan-Jun 2025 range + const enabledMonthOption = monthOptions[4]!; + expect(enabledMonthOption.getAttribute("aria-disabled")).toBeNull(); + + fireEvent.click(enabledMonthOption); + expect(handleChangeResult).toEqual(4); + }); }); describe("select mode", () => { @@ -381,5 +435,32 @@ describe("MonthDropdown", () => { }); expect(handleChangeResult).toEqual(9); }); + + it("disables options for months outside of minDate/maxDate range", () => { + monthDropdown = getMonthDropdown({ + dropdownMode: "select", + month: 3, + date: new Date(2025, 3, 1), + minDate: new Date(2025, 0, 1), + maxDate: new Date(2025, 5, 30), + }); + const options = Array.from( + monthDropdown.querySelectorAll("option"), + ); + expect(options.map((o) => o.disabled)).toEqual([ + false, // Jan + false, // Feb + false, // Mar + false, // Apr + false, // May + false, // Jun + true, // Jul + true, // Aug + true, // Sep + true, // Oct + true, // Nov + true, // Dec + ]); + }); }); });