diff --git a/src/calendar.tsx b/src/calendar.tsx index 4bedbdc06..bcd3af3e0 100644 --- a/src/calendar.tsx +++ b/src/calendar.tsx @@ -461,35 +461,48 @@ export default class Calendar extends Component { this.handleMonthChange(date); }; - changeYear = (year: number): void => { + // `panelDate` is the month/year the panel that triggered this change was + // itself displaying; it defaults to the anchor date (`state.date`) for + // callers that don't track a specific panel, e.g. custom headers - which + // is what keeps their documented "always lands in the leftmost panel" + // behavior unchanged (see `renderCustomHeader`). + changeYear = (year: number, panelDate: Date = this.state.date): void => { this.setState( - ({ date }) => ({ - date: setYear(date, Number(year)), - }), + // Shift the anchor by the picked delta rather than overwriting its + // year outright, so every panel keeps its own month and moves by the + // same amount instead of collapsing onto whichever panel is the anchor. + ({ date }) => ({ date: addYears(date, year - getYear(panelDate)) }), () => this.handleYearChange(this.state.date), ); }; - changeMonth = (month: number): void => { - this.setState( - ({ date }) => ({ - date: setMonth(date, Number(month)), - }), - () => { - this.handleMonthChange(this.state.date); - // Reset monthSelectedIn to 0 so the target month appears in the leftmost position - // This ensures consistent behavior when using changeMonth in custom headers - this.props.onMonthSelectedInChange?.(0); - }, - ); + changeMonth = ( + month: number, + panelDate: Date = this.state.date, + monthSelectedIn: number = 0, + ): void => { + this.setState({ date: setMonth(panelDate, month) }, () => { + this.handleMonthChange(this.state.date); + this.props.onMonthSelectedInChange?.(monthSelectedIn); + }); }; - changeMonthYear = (monthYear: Date): void => { + changeMonthYear = ( + monthYear: Date, + panelDate: Date, + monthSelectedIn: number, + ): void => { this.setState( - ({ date }) => ({ - date: setYear(setMonth(date, getMonth(monthYear)), getYear(monthYear)), - }), - () => this.handleMonthYearChange(this.state.date), + { + date: setYear( + setMonth(panelDate, getMonth(monthYear)), + getYear(monthYear), + ), + }, + () => { + this.handleMonthYearChange(this.state.date); + this.props.onMonthSelectedInChange?.(monthSelectedIn); + }, ); }; @@ -847,51 +860,53 @@ export default class Calendar extends Component { ); }; - renderYearDropdown = ( - overrideHide: boolean = false, - ): React.ReactElement | undefined => { - if (!this.props.showYearDropdown || overrideHide) { + renderYearDropdown = (monthDate: Date): React.ReactElement | undefined => { + if (!this.props.showYearDropdown) { return; } return ( this.changeYear(year, monthDate)} + year={getYear(monthDate)} /> ); }; renderMonthDropdown = ( - overrideHide: boolean = false, + monthDate: Date, + i: number, ): React.ReactElement | undefined => { - if (!this.props.showMonthDropdown || overrideHide) { + if (!this.props.showMonthDropdown) { return; } return ( this.changeMonth(month, monthDate, i)} /> ); }; renderMonthYearDropdown = ( - overrideHide: boolean = false, + monthDate: Date, + i: number, ): React.ReactElement | undefined => { - if (!this.props.showMonthYearDropdown || overrideHide) { + if (!this.props.showMonthYearDropdown) { return; } return ( + this.changeMonthYear(monthYear, monthDate, i) + } /> ); }; @@ -922,6 +937,9 @@ export default class Calendar extends Component { ); renderDefaultHeader = ({ monthDate, i }: { monthDate: Date; i: number }) => { + // Every panel renders its own dropdown, bound to monthDate/i (see + // changeYear above) - keeps header heights equal by construction (#6320) + // and lets each panel be changed independently. const headerContent = (
{ className={`react-datepicker__header__dropdown react-datepicker__header__dropdown--${this.props.dropdownMode}`} onFocus={this.handleDropdownFocus} > - {this.renderMonthDropdown(i !== 0)} - {this.renderMonthYearDropdown(i !== 0)} - {this.renderYearDropdown(i !== 0)} + {this.renderMonthDropdown(monthDate, i)} + {this.renderMonthYearDropdown(monthDate, i)} + {this.renderYearDropdown(monthDate)}
); diff --git a/src/test/calendar_test.test.tsx b/src/test/calendar_test.test.tsx index 721ee0523..99c61190b 100644 --- a/src/test/calendar_test.test.tsx +++ b/src/test/calendar_test.test.tsx @@ -324,15 +324,59 @@ describe("Calendar", () => { expect(yearReadView).toHaveLength(1); }); - it("should show only one year dropdown menu if toggled on and multiple month mode on", () => { + it("should show an independent year dropdown on every month when toggled on in multiple month mode", () => { const { calendar } = getCalendar({ showYearDropdown: true, monthsShown: 2, }); - const monthReadView = calendar.querySelectorAll( + const yearDropdowns = calendar.querySelectorAll( ".react-datepicker__year-dropdown-container", ); - expect(monthReadView).toHaveLength(1); + expect(yearDropdowns).toHaveLength(2); + }); + + it("should update only the month whose year dropdown was used, shifting the others by the same year delta", () => { + // Pinned to January so the three shown panels (Jan/Feb/Mar) never cross + // a year boundary - avoids flakiness depending on when the suite runs. + const { calendar, instance } = getCalendar({ + selected: new Date(2023, 0, 15), + showYearDropdown: true, + monthsShown: 3, + }); + const initialDate = instance!.state.date; + + const readViews = safeQuerySelectorAll( + calendar, + ".react-datepicker__year-read-view", + ); + expect(readViews).toHaveLength(3); + + // Open the *third* month's year dropdown and pick a different year. + fireEvent.click(readViews[2]!); + const options = safeQuerySelectorAll( + calendar, + ".react-datepicker__year-option", + ); + const targetYear = getYear(initialDate) + 5; + const targetOption = Array.from(options).find( + (option) => option.textContent === String(targetYear), + ); + expect(targetOption).not.toBeUndefined(); + fireEvent.click(targetOption!); + + // The whole picker shifts by the same year delta - every month keeps its + // own relative offset, it doesn't jump to the leftmost panel. + const currentMonths = safeQuerySelectorAll( + calendar, + ".react-datepicker__current-month", + ); + expect(currentMonths).toHaveLength(3); + [0, 1, 2].forEach((i) => { + expect(currentMonths[i]?.textContent).toBe( + formatDate(addMonths(addYears(initialDate, 5), i), dateFormat), + ); + }); + expect(getYear(instance!.state.date)).toBe(getYear(initialDate) + 5); }); it("should show month navigation if toggled on", () => { @@ -1155,15 +1199,66 @@ describe("Calendar", () => { expect(monthReadView).toHaveLength(1); }); - it("should show only one month dropdown menu if toggled on and multiple month mode on", () => { + it("should show an independent month dropdown on every month when toggled on in multiple month mode", () => { const { calendar } = getCalendar({ showMonthDropdown: true, monthsShown: 2, }); - const monthReadView = calendar.querySelectorAll( + const monthDropdowns = calendar.querySelectorAll( ".react-datepicker__month-dropdown-container", ); - expect(monthReadView).toHaveLength(1); + expect(monthDropdowns).toHaveLength(2); + }); + + it("should land the picked month on the panel whose month dropdown was used, not the leftmost panel", () => { + const onMonthSelectedInChangeSpy = jest.fn(); + // Pinned to January so the two shown panels (Jan/Feb) and the picked + // month (June) never cross a year boundary - avoids flakiness if the + // suite runs in November/December. + const { calendar, instance, rerender } = getCalendar({ + selected: new Date(2023, 0, 15), + showMonthDropdown: true, + monthsShown: 2, + onMonthSelectedInChange: onMonthSelectedInChangeSpy, + }); + + const readViews = safeQuerySelectorAll( + calendar, + ".react-datepicker__month-read-view", + ); + expect(readViews).toHaveLength(2); + + // Open the *second* month's month dropdown (currently February) and pick June. + fireEvent.click(readViews[1]!); + const options = safeQuerySelectorAll( + calendar, + ".react-datepicker__month-option", + ); + fireEvent.click(options[5]!); // June + + expect(onMonthSelectedInChangeSpy).toHaveBeenCalledWith(1); + expect(getMonth(instance!.state.date)).toBe(5); + expect(getYear(instance!.state.date)).toBe(2023); + + // Calendar itself is a controlled component for `monthSelectedIn` - a + // real parent (DatePicker) feeds the callback's value back in as a prop + // (see index.tsx's handleMonthSelectedInChange), which is what actually + // lands the result on the interacted-with panel. Simulate that here. + rerender({ monthSelectedIn: 1 }); + + const currentMonths = safeQuerySelectorAll( + calendar, + ".react-datepicker__current-month", + ); + expect(currentMonths).toHaveLength(2); + // The second panel (the one the user interacted with) now shows June... + expect(currentMonths[1]?.textContent).toBe( + formatDate(new Date(2023, 5), dateFormat), + ); + // ...and the first panel shows the preceding month, May. + expect(currentMonths[0]?.textContent).toBe( + formatDate(new Date(2023, 4), dateFormat), + ); }); it("should not show the month-year dropdown menu by default", () => { @@ -1186,17 +1281,74 @@ describe("Calendar", () => { expect(monthYearReadView).toHaveLength(1); }); - it("should show only one month-year dropdown menu if toggled on and multiple month mode on", () => { + it("should show an independent month-year dropdown on every month when toggled on in multiple month mode", () => { const { calendar } = getCalendar({ showMonthYearDropdown: true, minDate: subYears(newDate(), 1), maxDate: addYears(newDate(), 1), monthsShown: 2, }); - const monthReadView = calendar.querySelectorAll( + const monthYearDropdowns = calendar.querySelectorAll( ".react-datepicker__month-year-dropdown-container", ); - expect(monthReadView).toHaveLength(1); + expect(monthYearDropdowns).toHaveLength(2); + }); + + it("should land the picked month-year on the panel whose dropdown was used, not the leftmost panel", () => { + const onMonthSelectedInChangeSpy = jest.fn(); + // Pinned to January so the two shown panels (Jan/Feb 2023) never cross a + // year boundary - avoids flakiness depending on when the suite runs. + const { calendar, instance, rerender } = getCalendar({ + selected: new Date(2023, 0, 15), + showMonthYearDropdown: true, + minDate: new Date(2020, 0, 1), + maxDate: new Date(2026, 0, 1), + monthsShown: 2, + onMonthSelectedInChange: onMonthSelectedInChangeSpy, + }); + + const readViews = safeQuerySelectorAll( + calendar, + ".react-datepicker__month-year-read-view", + ); + expect(readViews).toHaveLength(2); + + // Open the *second* month's month-year dropdown (currently February + // 2023) and pick "June 2024". + fireEvent.click(readViews[1]!); + const target = safeQuerySelectorAll( + calendar, + ".react-datepicker__month-year-option", + ).find( + (option) => + option.textContent === formatDate(new Date(2024, 5), dateFormat), + ); + expect(target).not.toBeUndefined(); + fireEvent.click(target!); + + expect(onMonthSelectedInChangeSpy).toHaveBeenCalledWith(1); + expect(getMonth(instance!.state.date)).toBe(5); + expect(getYear(instance!.state.date)).toBe(2024); + + // Calendar itself is a controlled component for `monthSelectedIn` - a + // real parent (DatePicker) feeds the callback's value back in as a prop + // (see index.tsx's handleMonthSelectedInChange), which is what actually + // lands the result on the interacted-with panel. Simulate that here. + rerender({ monthSelectedIn: 1 }); + + const currentMonths = safeQuerySelectorAll( + calendar, + ".react-datepicker__current-month", + ); + expect(currentMonths).toHaveLength(2); + // The second panel (the one the user interacted with) now shows June 2024... + expect(currentMonths[1]?.textContent).toBe( + formatDate(new Date(2024, 5), dateFormat), + ); + // ...and the first panel shows the preceding month, May 2024. + expect(currentMonths[0]?.textContent).toBe( + formatDate(new Date(2024, 4), dateFormat), + ); }); it("should not show the today button by default", () => { diff --git a/src/test/multi_month_test.test.tsx b/src/test/multi_month_test.test.tsx index 5f057744b..7eee6ad9b 100644 --- a/src/test/multi_month_test.test.tsx +++ b/src/test/multi_month_test.test.tsx @@ -42,12 +42,34 @@ describe("Multi month calendar", function () { expect(months).toHaveLength(2); }); - it("should render dropdown only on first month", () => { + it("should render an independent dropdown on every month (see #6320)", () => { const calendar = getCalendar({ monthsShown: 2, showYearDropdown: true }); const datepickers = calendar.querySelectorAll( ".react-datepicker__year-dropdown-container", ); - expect(datepickers).toHaveLength(1); + // Every month renders the same real dropdown, rather than one being + // reserved with a hardcoded/measured height, since native form controls + // render at different heights across browsers/operating systems (#6320). + expect(datepickers).toHaveLength(2); + }); + + it("should render the same header dropdown markup on every month, so header heights stay consistent (#6320)", () => { + const calendar = getCalendar({ monthsShown: 3, showYearDropdown: true }); + const headerDropdowns = calendar.querySelectorAll( + ".react-datepicker__header__dropdown", + ); + expect(headerDropdowns).toHaveLength(3); + + // Every month's header dropdown wrapper renders the same + // year-dropdown-container control, so their layout boxes match exactly + // without reserving space via a hardcoded or measured height. + headerDropdowns.forEach((headerDropdown) => { + expect( + headerDropdown.querySelectorAll( + ".react-datepicker__year-dropdown-container", + ), + ).toHaveLength(1); + }); }); it("should render previous months", () => {