From 014f05ff22dea1bc687f7bf3c8d7b86b0bc4dc23 Mon Sep 17 00:00:00 2001 From: balajis-qb Date: Mon, 13 Jul 2026 11:59:59 +0530 Subject: [PATCH] fix: keep time panel in normal flow when todayButton is used with showTimeSelect The time panel relied on an absolute-positioning hack whenever a todayButton was rendered alongside showTimeSelect, since the button was rendered before the time section in the DOM, pushing the right-floated time panel below it. That detached the panel from the popper's layout box, causing it to misalign and get clipped near the viewport edge. Render the time section before the today-button so both floats stay in normal flow, and let the today-button clear only the month view's column instead of spanning full width. The time select's height now stretches to cover the date picker + today button combined height, instead of just the date picker as before. Closes #6237 --- src/calendar.tsx | 26 +++++++++++----- src/stylesheets/datepicker.scss | 28 ++++++++---------- src/stylesheets/variables.scss | 1 + src/test/calendar_test.test.tsx | 44 +++++++++++++++++++++++++++ src/test/timepicker_test.test.tsx | 49 ++++++++++++++++++++++++++++++- src/time.tsx | 29 ++++++++++-------- 6 files changed, 141 insertions(+), 36 deletions(-) diff --git a/src/calendar.tsx b/src/calendar.tsx index 4bedbdc06..6e6664348 100644 --- a/src/calendar.tsx +++ b/src/calendar.tsx @@ -167,6 +167,7 @@ type CalendarProps = React.PropsWithChildren< showQuarterYearPicker?: boolean; showTimeSelect?: boolean; showTimeInput?: boolean; + todayButton?: React.ReactNode; showYearDropdown?: boolean; showMonthDropdown?: boolean; yearItemNumber?: number; @@ -239,6 +240,7 @@ interface CalendarState extends Pick, Pick { date: Required["date"]; monthContainer: TimeProps["monthRef"]; + todayButtonContainer: TimeProps["todayButtonRef"]; isRenderAriaLiveMessage: boolean; } @@ -267,18 +269,22 @@ export default class Calendar extends Component { date: this.getDateInView(), selectingDate: undefined, monthContainer: undefined, + todayButtonContainer: undefined, isRenderAriaLiveMessage: false, }; } componentDidMount() { - // monthContainer height is needed in time component + // monthContainer/todayButtonContainer height is needed in time component // to determine the height for the ul in the time component // setState here so height is given after final component // layout is rendered if (this.props.showTimeSelect) { this.assignMonthContainer = ((): void => { - this.setState({ monthContainer: this.monthContainer }); + this.setState({ + monthContainer: this.monthContainer, + todayButtonContainer: this.todayButtonContainer, + }); })(); } } @@ -314,6 +320,8 @@ export default class Calendar extends Component { monthContainer: CalendarState["monthContainer"] = undefined; + todayButtonContainer: CalendarState["todayButtonContainer"] = undefined; + assignMonthContainer: void | undefined; handleClickOutside = (event: MouseEvent): void => { @@ -773,9 +781,6 @@ export default class Calendar extends Component { if (this.props.showTimeSelect) { classes.push("react-datepicker__navigation--next--with-time"); } - if (this.props.todayButton) { - classes.push("react-datepicker__navigation--next--with-today-button"); - } let clickHandler: React.MouseEventHandler | undefined = this.increaseMonth; @@ -907,7 +912,13 @@ export default class Calendar extends Component { } return (
{ + this.todayButtonContainer = div ?? undefined; + }} + className={clsx("react-datepicker__today-button", { + "react-datepicker__today-button--with-time-select": + this.props.showTimeSelect, + })} onClick={this.handleTodayButtonClick} > {this.props.todayButton} @@ -1174,6 +1185,7 @@ export default class Calendar extends Component { format={this.props.timeFormat} intervals={this.props.timeIntervals} monthRef={this.state.monthContainer} + todayButtonRef={this.state.todayButtonContainer} /> ); } @@ -1327,8 +1339,8 @@ export default class Calendar extends Component { this.renderNextButton()} {this.renderMonths()} {this.renderYears()} - {this.renderTodayButton()} {this.renderTimeSection()} + {this.renderTodayButton()} {this.renderInputTimeSection()} {this.renderChildren()} diff --git a/src/stylesheets/datepicker.scss b/src/stylesheets/datepicker.scss index 57b8445f9..fcdb6799f 100644 --- a/src/stylesheets/datepicker.scss +++ b/src/stylesheets/datepicker.scss @@ -132,9 +132,7 @@ .react-datepicker__header-wrapper { position: relative; - .react-datepicker__navigation--next--with-time:not( - .react-datepicker__navigation--next--with-today-button - ) { + .react-datepicker__navigation--next--with-time { right: 2px; } } @@ -211,8 +209,8 @@ h2.react-datepicker__current-month { &--next { right: 2px; - &--with-time:not(&--with-today-button) { - right: 85px; + &--with-time { + right: $datepicker__time-container-width; } } @@ -344,16 +342,7 @@ h2.react-datepicker__current-month { .react-datepicker__time-container { float: right; border-left: $datepicker__border; - width: 85px; - - &--with-today-button { - display: inline; - border: 1px solid #aeaeae; - border-radius: 0.375em; - position: absolute; - right: -87px; - top: 0; - } + width: $datepicker__time-container-width; .react-datepicker__time { position: relative; @@ -361,7 +350,7 @@ h2.react-datepicker__current-month { border-bottom-right-radius: 0.375em; .react-datepicker__time-box { - width: 85px; + width: $datepicker__time-container-width; overflow-x: hidden; margin: 0 auto; text-align: center; @@ -770,6 +759,13 @@ h2.react-datepicker__current-month { font-weight: bold; padding: 5px 0; clear: left; + + // Leave room for the time panel so it isn't painted over by the + // time-container float, which stretches to cover this row's height. + // The extra 1px accounts for the time-container's border-left. + &--with-time-select { + width: calc(100% - #{$datepicker__time-container-width} - 1px); + } } .react-datepicker__portal { diff --git a/src/stylesheets/variables.scss b/src/stylesheets/variables.scss index 53866477f..a65b932c9 100644 --- a/src/stylesheets/variables.scss +++ b/src/stylesheets/variables.scss @@ -23,3 +23,4 @@ $datepicker__font-family: $datepicker__item-size: 2.125em !default; $datepicker__margin: 0.5em !default; $datepicker__navigation-button-size: 32px !default; +$datepicker__time-container-width: 85px !default; diff --git a/src/test/calendar_test.test.tsx b/src/test/calendar_test.test.tsx index 721ee0523..5522c3181 100644 --- a/src/test/calendar_test.test.tsx +++ b/src/test/calendar_test.test.tsx @@ -1226,6 +1226,50 @@ describe("Calendar", () => { expect(isSameDay(instance?.state.date, newDate())).toBeTruthy(); }); + it("should render the time container before the today button so the time panel stays in normal flow next to the month view", () => { + const { calendar } = getCalendar({ + todayButton: "Vandaag", + showTimeSelect: true, + }); + const todayButton = safeQuerySelector( + calendar, + ".react-datepicker__today-button", + ); + const timeContainer = safeQuerySelector( + calendar, + ".react-datepicker__time-container", + ); + expect( + todayButton.compareDocumentPosition(timeContainer) & + Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + }); + + it("should narrow the today button's width to leave room for the time panel when showTimeSelect is enabled", () => { + const { calendar } = getCalendar({ + todayButton: "Vandaag", + showTimeSelect: true, + }); + const todayButton = safeQuerySelector( + calendar, + ".react-datepicker__today-button", + ); + expect(todayButton.classList).toContain( + "react-datepicker__today-button--with-time-select", + ); + }); + + it("should not narrow the today button's width when showTimeSelect is disabled", () => { + const { calendar } = getCalendar({ todayButton: "Vandaag" }); + const todayButton = safeQuerySelector( + calendar, + ".react-datepicker__today-button", + ); + expect(todayButton.classList).not.toContain( + "react-datepicker__today-button--with-time-select", + ); + }); + it("should use a hash for week label if weekLabel is NOT provided", () => { const { calendar } = getCalendar({ showWeekNumbers: true }); const weekLabel = calendar.querySelectorAll( diff --git a/src/test/timepicker_test.test.tsx b/src/test/timepicker_test.test.tsx index 5cb56f36d..0b82843f7 100644 --- a/src/test/timepicker_test.test.tsx +++ b/src/test/timepicker_test.test.tsx @@ -1,5 +1,5 @@ import { render, fireEvent, waitFor } from "@testing-library/react"; -import React from "react"; +import React, { act } from "react"; import { formatDate, KeyType, newDate } from "../date_utils"; import DatePicker from "../index"; @@ -118,6 +118,53 @@ describe("TimePicker", () => { // The fix ensures setState is only called when height actually changes expect(timeList.style.height).toBe(initialHeight); }); + + it("should grow the time list to also cover the today button's height", async () => { + const { container } = render( + , + ); + + await waitFor(() => { + expect(mockObserve).toHaveBeenCalledTimes(2); + }); + + const monthContainer = safeQuerySelector( + container, + ".react-datepicker__month-container", + ); + const timeHeader = safeQuerySelector( + container, + ".react-datepicker__header--time", + ); + const todayButton = safeQuerySelector( + container, + ".react-datepicker__today-button", + ); + const timeList = safeQuerySelector( + container, + ".react-datepicker__time-list", + ); + + Object.defineProperty(monthContainer, "clientHeight", { value: 300 }); + Object.defineProperty(timeHeader, "clientHeight", { value: 30 }); + Object.defineProperty(todayButton, "clientHeight", { value: 40 }); + + const resizeObserverCallback = getResizeObserverCallback(); + expect(typeof resizeObserverCallback).toBe("function"); + act(() => { + resizeObserverCallback?.([], mockObserve.mock.calls[0][0]); + }); + + await waitFor(() => { + expect(timeList.style.height).toBe("310px"); + }); + }); }); it("should update on input time change", () => { diff --git a/src/time.tsx b/src/time.tsx index c02dc2369..25b0f0a9b 100644 --- a/src/time.tsx +++ b/src/time.tsx @@ -29,8 +29,8 @@ interface TimeProps extends Pick< openToDate?: Date; onChange?: (time: Date) => void; timeClassName?: (time: Date) => string; - todayButton?: React.ReactNode; monthRef?: HTMLDivElement; + todayButtonRef?: HTMLDivElement; timeCaption?: string; injectTimes?: Date[]; handleOnKeyDown?: React.KeyboardEventHandler; @@ -47,7 +47,6 @@ export default class Time extends Component { static get defaultProps() { return { intervals: 30, - todayButton: null, timeCaption: "Time", showTimeCaption: true, }; @@ -84,7 +83,7 @@ export default class Time extends Component { private centerLi?: HTMLLIElement; private observeDatePickerHeightChanges(): void { - const { monthRef } = this.props; + const { monthRef, todayButtonRef } = this.props; this.updateContainerHeight(); if (monthRef) { @@ -93,13 +92,24 @@ export default class Time extends Component { }); this.resizeObserver.observe(monthRef); + if (todayButtonRef) { + this.resizeObserver.observe(todayButtonRef); + } } } + // Height contributed by the today-button, so the time list can extend + // to cover the full height of the month view plus the button below it. + private getTodayButtonHeight(): number { + return this.props.todayButtonRef?.clientHeight ?? 0; + } + private updateContainerHeight(): void { if (this.props.monthRef && this.header) { const newHeight = - this.props.monthRef.clientHeight - this.header.clientHeight; + this.props.monthRef.clientHeight - + this.header.clientHeight + + this.getTodayButtonHeight(); // Only update state if height actually changed to prevent infinite resize loops if (this.state.height !== newHeight) { this.setState({ @@ -118,7 +128,8 @@ export default class Time extends Component { Time.calcCenterPosition( this.props.monthRef ? this.props.monthRef.clientHeight - - (this.header?.clientHeight ?? 0) + (this.header?.clientHeight ?? 0) + + this.getTodayButtonHeight() : this.list.clientHeight, this.centerLi, )) ?? @@ -311,13 +322,7 @@ export default class Time extends Component { const { height } = this.state; return ( -
+
{this.renderTimeCaption()}