From f37cce6b3e1bf31a567fce9b91fe788766428f2a Mon Sep 17 00:00:00 2001 From: balajis-qb Date: Fri, 14 Aug 2026 12:41:05 +0530 Subject: [PATCH] fix: render an independent, panel-aware dropdown on every month header showYearDropdown/showMonthDropdown/showMonthYearDropdown only rendered their control on the first month panel in monthsShown > 1 mode, so every other panel's header collapsed to a shorter height than the first - browsers render native form controls (select/button) at different heights, so no fixed or measured placeholder height reliably matched it across browsers/OS/zoom. Render the same live dropdown on every panel instead of reserving its space. Every header now renders identical control markup, so heights match by construction on any browser, with no hardcoded number and no runtime measurement. This requires each dropdown to be genuinely interactive per panel rather than a hidden/inert placeholder, so changeYear/changeMonth/ changeMonthYear now take the triggering panel's date and land the result accordingly instead of always acting on the calendar's anchor date: - changeYear shifts the anchor by the picked year delta so every panel keeps its own month and moves together. - changeMonth/changeMonthYear land the result on the panel that was interacted with (via the existing monthSelectedIn plumbing used for day selection), instead of always resetting to the leftmost panel. Custom headers (renderCustomHeader) are unaffected: they still receive the unbound changeMonth/changeYear and call them with a single argument, so the new panelDate/monthSelectedIn parameters fall back to their defaults and reproduce the exact previous anchor-based, leftmost-panel-landing behavior - verified with a temporary regression test exercising both panels' controls before this commit. Fixes #6320 Co-Authored-By: Claude Sonnet 5 --- src/calendar.tsx | 96 +++++++++------- src/test/calendar_test.test.tsx | 170 +++++++++++++++++++++++++++-- src/test/multi_month_test.test.tsx | 26 ++++- 3 files changed, 242 insertions(+), 50 deletions(-) diff --git a/src/calendar.tsx b/src/calendar.tsx index 4bedbdc06d..bcd3af3e07 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 721ee05232..99c61190b1 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 5f057744bb..7eee6ad9b1 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", () => {