diff --git a/docs-site/src/components/Examples/config.tsx b/docs-site/src/components/Examples/config.tsx index 2ba3f4497..914aba936 100644 --- a/docs-site/src/components/Examples/config.tsx +++ b/docs-site/src/components/Examples/config.tsx @@ -18,6 +18,7 @@ import CustomInput from "../../examples/ts/customInput?raw"; import RenderCustomHeader from "../../examples/ts/renderCustomHeader?raw"; import RenderCustomHeaderTwoMonths from "../../examples/ts/renderCustomHeaderTwoMonths?raw"; import RenderCustomDayName from "../../examples/ts/renderCustomDayName?raw"; +import MonthHeaderPosition from "../../examples/ts/monthHeaderPosition?raw"; import RenderCustomDay from "../../examples/ts/renderCustomDay?raw"; import RenderCustomMonth from "../../examples/ts/renderCustomMonth?raw"; import RenderCustomQuarter from "../../examples/ts/renderCustomQuarter?raw"; @@ -189,6 +190,10 @@ export const EXAMPLE_CONFIG: IExampleConfig[] = [ title: "Custom Day Names", component: RenderCustomDayName, }, + { + title: "Month header position", + component: MonthHeaderPosition, + }, { title: "Custom Day", component: RenderCustomDay, diff --git a/docs-site/src/examples/ts/monthHeaderPosition.tsx b/docs-site/src/examples/ts/monthHeaderPosition.tsx new file mode 100644 index 000000000..d2447fc8e --- /dev/null +++ b/docs-site/src/examples/ts/monthHeaderPosition.tsx @@ -0,0 +1,54 @@ +type Position = "top" | "middle" | "bottom"; + +const MonthHeaderPositionExample = () => { + const [selectedDate, setSelectedDate] = useState(new Date()); + const [position, setPosition] = useState("middle"); + + return ( + <> +
+ + + +
+ + + ); +}; + +render(MonthHeaderPositionExample); diff --git a/docs/month_header_position.md b/docs/month_header_position.md new file mode 100644 index 000000000..b7740b132 --- /dev/null +++ b/docs/month_header_position.md @@ -0,0 +1,51 @@ +# monthHeaderPosition + +## Description + +The `monthHeaderPosition` prop allows you to control where the month header (e.g., "December 2025") is displayed in the calendar. By default, it appears in the standard header section above the day names. You can reposition the header to appear between the day names and calendar days ("middle") or at the bottom of the calendar ("bottom"). + +## Type + +```typescript +monthHeaderPosition?: "top" | "middle" | "bottom"; +``` + +## Values + +- `"top"` (or undefined) - Month header appears in the standard position at the top of the calendar (default) +- `"middle"` - Month header appears between day names and calendar days +- `"bottom"` - Month header appears at the bottom of the calendar + +## Usage + +```tsx +import React, { useState } from "react"; +import DatePicker from "react-datepicker"; + +// Example 1: Header in the middle (between day names and days) +const MiddlePositionExample = () => { + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ; +}; + +// Example 2: Header at the bottom +const BottomPositionExample = () => { + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ; +}; + +// Example 3: Default position (top) +const DefaultPositionExample = () => { + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ; +}; +``` + +## Notes + +- When `monthHeaderPosition` is set to `"middle"` or `"bottom"`, the month header (including navigation buttons and dropdowns) is removed from the default header section +- Works with multiple months (`monthsShown` prop) - each month's header will be positioned accordingly +- Navigation buttons are included and properly positioned in all three position options diff --git a/src/calendar.tsx b/src/calendar.tsx index 228c33f45..2193acc92 100644 --- a/src/calendar.tsx +++ b/src/calendar.tsx @@ -206,6 +206,7 @@ type CalendarProps = React.PropsWithChildren< renderCustomDayName?: ( props: ReactDatePickerCustomDayNameProps, ) => React.ReactNode; + monthHeaderPosition?: "top" | "middle" | "bottom"; onYearMouseEnter?: YearProps["onYearMouseEnter"]; onYearMouseLeave?: YearProps["onYearMouseLeave"]; monthAriaLabelPrefix?: MonthProps["ariaLabelPrefix"]; @@ -250,6 +251,7 @@ export default class Calendar extends Component { previousMonthButtonLabel: "Previous Month", nextMonthButtonLabel: "Next Month", yearItemNumber: DEFAULT_YEAR_ITEM_NUMBER, + monthHeaderPosition: "top", }; } @@ -916,25 +918,44 @@ export default class Calendar extends Component { ); - renderDefaultHeader = ({ monthDate, i }: { monthDate: Date; i: number }) => ( -
- {this.renderCurrentMonth(monthDate)} + renderDefaultHeader = ({ monthDate, i }: { monthDate: Date; i: number }) => { + const headerContent = (
- {this.renderMonthDropdown(i !== 0)} - {this.renderMonthYearDropdown(i !== 0)} - {this.renderYearDropdown(i !== 0)} + {this.renderCurrentMonth(monthDate)} +
+ {this.renderMonthDropdown(i !== 0)} + {this.renderMonthYearDropdown(i !== 0)} + {this.renderYearDropdown(i !== 0)} +
-
- ); + ); + + // Top position: render header directly in default location + if (this.props.monthHeaderPosition === "top") { + return headerContent; + } + + // Middle/bottom positions: wrap with navigation buttons + return ( +
+ {this.renderPreviousButton() || null} + {this.renderNextButton() || null} + {headerContent} +
+ ); + }; renderCustomHeader = (headerArgs: { monthDate: Date; i: number }) => { const { monthDate, i } = headerArgs; @@ -1078,7 +1099,8 @@ export default class Calendar extends Component { }} className="react-datepicker__month-container" > - {this.renderHeader({ monthDate, i })} + {this.props.monthHeaderPosition === "top" && + this.renderHeader({ monthDate, i })} { monthShowsDuplicateDaysEnd={monthShowsDuplicateDaysEnd} monthShowsDuplicateDaysStart={monthShowsDuplicateDaysStart} dayNamesHeader={this.renderDayNamesHeader(monthDate, i)} + monthHeader={ + this.props.monthHeaderPosition === "middle" + ? this.renderHeader({ monthDate, i }) + : undefined + } + monthFooter={ + this.props.monthHeaderPosition === "bottom" + ? this.renderHeader({ monthDate, i }) + : undefined + } /> , ); @@ -1239,8 +1271,10 @@ export default class Calendar extends Component { inline={this.props.inline} > {this.renderAriaLiveRegion()} - {this.renderPreviousButton()} - {this.renderNextButton()} + {this.props.monthHeaderPosition === "top" && + this.renderPreviousButton()} + {this.props.monthHeaderPosition === "top" && + this.renderNextButton()} {this.renderMonths()} {this.renderYears()} {this.renderTodayButton()} diff --git a/src/index.tsx b/src/index.tsx index 011e93fa1..43f2fbbb5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1822,6 +1822,7 @@ export class DatePicker extends Component { popperComponent={calendar} popperOnKeyDown={this.onPopperKeyDown} showArrow={this.props.showPopperArrow} + monthHeaderPosition={this.props.monthHeaderPosition} /> ); } diff --git a/src/month.tsx b/src/month.tsx index b4eb1e7e3..6eee82deb 100644 --- a/src/month.tsx +++ b/src/month.tsx @@ -141,6 +141,8 @@ interface MonthProps extends Omit< chooseDayAriaLabelPrefix?: WeekProps["chooseDayAriaLabelPrefix"]; disabledDayAriaLabelPrefix?: WeekProps["disabledDayAriaLabelPrefix"]; dayNamesHeader?: React.ReactNode; + monthHeader?: React.ReactNode; + monthFooter?: React.ReactNode; } /** @@ -1174,6 +1176,9 @@ export default class Month extends Component { {this.props.dayNamesHeader && (
{this.props.dayNamesHeader}
)} + {this.props.monthHeader && ( +
{this.props.monthHeader}
+ )}
{ > {this.renderWeeks()}
+ {this.props.monthFooter && ( +
{this.props.monthFooter}
+ )} ); } diff --git a/src/popper_component.tsx b/src/popper_component.tsx index e5d81a325..36c769bec 100644 --- a/src/popper_component.tsx +++ b/src/popper_component.tsx @@ -28,6 +28,7 @@ interface PopperComponentProps popperOnKeyDown: React.KeyboardEventHandler; showArrow?: boolean; portalId?: PortalProps["portalId"]; + monthHeaderPosition?: "top" | "middle" | "bottom"; } // Exported for testing purposes @@ -44,6 +45,7 @@ export const PopperComponent: React.FC = (props) => { portalHost, popperProps, showArrow, + monthHeaderPosition, } = props; let popper: React.ReactElement | undefined = undefined; @@ -52,6 +54,10 @@ export const PopperComponent: React.FC = (props) => { const classes = clsx( "react-datepicker-popper", !showArrow && "react-datepicker-popper-offset", + monthHeaderPosition === "middle" && + "react-datepicker-popper--header-middle", + monthHeaderPosition === "bottom" && + "react-datepicker-popper--header-bottom", className, ); popper = ( diff --git a/src/stylesheets/datepicker.scss b/src/stylesheets/datepicker.scss index 2c87fe2f7..24ce46f8f 100644 --- a/src/stylesheets/datepicker.scss +++ b/src/stylesheets/datepicker.scss @@ -70,6 +70,25 @@ color: #fff; } } + + &--header-middle, + &--header-bottom { + &[data-placement^="bottom"] { + .react-datepicker__triangle { + fill: #fff; + color: #fff; + } + } + } + + &--header-bottom { + &[data-placement^="top"] { + .react-datepicker__triangle { + fill: $datepicker__background-color; + color: $datepicker__background-color; + } + } + } } .react-datepicker__header { @@ -90,9 +109,34 @@ } } - &:not(&--has-time-select) { + &:not(&--has-time-select, &--middle, &--bottom) { border-top-right-radius: $datepicker__border-radius; } + + // Header in middle position (between day names and days) + &--middle { + border-top: $datepicker__border; + border-radius: 0; + margin-top: 4px; + } + + // Header in bottom position (at calendar bottom) + &--bottom { + border-bottom: none; + border-top: $datepicker__border; + border-radius: 0 0 $datepicker__border-radius $datepicker__border-radius; + } +} + +// Wrapper for header in middle/bottom positions +.react-datepicker__header-wrapper { + position: relative; + + .react-datepicker__navigation--next--with-time:not( + .react-datepicker__navigation--next--with-today-button + ) { + right: 2px; + } } .react-datepicker__year-dropdown-container--select, diff --git a/src/test/calendar_test.test.tsx b/src/test/calendar_test.test.tsx index db0cdb832..721ee0523 100644 --- a/src/test/calendar_test.test.tsx +++ b/src/test/calendar_test.test.tsx @@ -2002,6 +2002,104 @@ describe("Calendar", () => { }); }); + describe("monthHeaderPosition with default header", () => { + it("should render navigation buttons at top when monthHeaderPosition is 'top'", () => { + const { container } = render( + {}} + onClickOutside={() => {}} + dropdownMode="scroll" + monthHeaderPosition="top" + />, + ); + + // Navigation buttons should be direct children of calendar container + const prevButton = container.querySelector( + ".react-datepicker > .react-datepicker__navigation--previous", + ); + const nextButton = container.querySelector( + ".react-datepicker > .react-datepicker__navigation--next", + ); + expect(prevButton).not.toBeNull(); + expect(nextButton).not.toBeNull(); + + // Should not have header-wrapper + const headerWrapper = container.querySelector( + ".react-datepicker__header-wrapper", + ); + expect(headerWrapper).toBeNull(); + }); + + it("should wrap header with navigation buttons when monthHeaderPosition is 'middle'", () => { + const { container } = render( + {}} + onClickOutside={() => {}} + dropdownMode="scroll" + monthHeaderPosition="middle" + />, + ); + + // Should have header-wrapper + const headerWrapper = container.querySelector( + ".react-datepicker__header-wrapper", + ); + expect(headerWrapper).not.toBeNull(); + + // Navigation buttons should be inside header-wrapper + const prevButton = headerWrapper?.querySelector( + ".react-datepicker__navigation--previous", + ); + const nextButton = headerWrapper?.querySelector( + ".react-datepicker__navigation--next", + ); + expect(prevButton).not.toBeNull(); + expect(nextButton).not.toBeNull(); + + // Header should have middle class + const header = container.querySelector( + ".react-datepicker__header--middle", + ); + expect(header).not.toBeNull(); + }); + + it("should wrap header with navigation buttons when monthHeaderPosition is 'bottom'", () => { + const { container } = render( + {}} + onClickOutside={() => {}} + dropdownMode="scroll" + monthHeaderPosition="bottom" + />, + ); + + // Should have header-wrapper + const headerWrapper = container.querySelector( + ".react-datepicker__header-wrapper", + ); + expect(headerWrapper).not.toBeNull(); + + // Navigation buttons should be inside header-wrapper + const prevButton = headerWrapper?.querySelector( + ".react-datepicker__navigation--previous", + ); + const nextButton = headerWrapper?.querySelector( + ".react-datepicker__navigation--next", + ); + expect(prevButton).not.toBeNull(); + expect(nextButton).not.toBeNull(); + + // Header should have bottom class + const header = container.querySelector( + ".react-datepicker__header--bottom", + ); + expect(header).not.toBeNull(); + }); + }); + describe("when showMonthYearPicker is enabled", () => { it("should change the next and previous labels", () => { const { container } = render( diff --git a/src/test/month_header_position.test.tsx b/src/test/month_header_position.test.tsx new file mode 100644 index 000000000..40c589ca3 --- /dev/null +++ b/src/test/month_header_position.test.tsx @@ -0,0 +1,219 @@ +/** + * @jest-environment jsdom + */ + +import { render } from "@testing-library/react"; +import React from "react"; + +import Calendar from "../calendar"; +import { newDate, formatDate } from "../date_utils"; + +const dateFormat = "MMMM yyyy"; + +describe("monthHeaderPosition", () => { + it("should render month header in top position by default", () => { + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + />, + ); + + // Header should be in the default header section + const header = container.querySelector(".react-datepicker__header"); + const currentMonth = header?.querySelector( + ".react-datepicker__current-month", + ); + expect(currentMonth).not.toBeNull(); + expect(currentMonth?.textContent).toContain( + formatDate(newDate(), dateFormat), + ); + }); + + it("should render month header in middle position when monthHeaderPosition is 'middle'", () => { + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + monthHeaderPosition="middle" + />, + ); + + // Header should be within the header-wrapper (not at top of calendar) + const topHeaderOutsideMonths = container.querySelectorAll( + ".react-datepicker__month-container > .react-datepicker__header", + ); + expect(topHeaderOutsideMonths.length).toBe(0); + + // Should be within the month container (middle position) + const monthContainer = container.querySelector( + ".react-datepicker__month-container", + ); + const headerInMonth = monthContainer?.querySelector( + ".react-datepicker__header .react-datepicker__current-month", + ); + expect(headerInMonth).not.toBeNull(); + expect(headerInMonth?.textContent).toContain( + formatDate(newDate(), dateFormat), + ); + + // Should have wrapper with navigation buttons + const headerWrapper = container.querySelector( + ".react-datepicker__header-wrapper", + ); + expect(headerWrapper).not.toBeNull(); + }); + + it("should render month header in bottom position when monthHeaderPosition is 'bottom'", () => { + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + monthHeaderPosition="bottom" + />, + ); + + // Header should be within the header-wrapper (not at top of calendar) + const topHeaderOutsideMonths = container.querySelectorAll( + ".react-datepicker__month-container > .react-datepicker__header", + ); + expect(topHeaderOutsideMonths.length).toBe(0); + + // Should be within the month container (bottom position) + const monthContainer = container.querySelector( + ".react-datepicker__month-container", + ); + const headerInMonth = monthContainer?.querySelector( + ".react-datepicker__header .react-datepicker__current-month", + ); + expect(headerInMonth).not.toBeNull(); + expect(headerInMonth?.textContent).toContain( + formatDate(newDate(), dateFormat), + ); + + // Should have wrapper with navigation buttons + const headerWrapper = container.querySelector( + ".react-datepicker__header-wrapper", + ); + expect(headerWrapper).not.toBeNull(); + }); + + it("should render month header for each month when multiple months shown with middle position", () => { + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + monthHeaderPosition="middle" + monthsShown={2} + />, + ); + + // Should find headers within header-wrappers + const headerWrappers = container.querySelectorAll( + ".react-datepicker__header-wrapper", + ); + expect(headerWrappers.length).toBe(2); + + const monthHeaders = container.querySelectorAll( + ".react-datepicker__header-wrapper .react-datepicker__header .react-datepicker__current-month", + ); + expect(monthHeaders.length).toBe(2); + }); + + it("should render month header for each month when multiple months shown with bottom position", () => { + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + monthHeaderPosition="bottom" + monthsShown={2} + />, + ); + + // Should find headers within header-wrappers + const headerWrappers = container.querySelectorAll( + ".react-datepicker__header-wrapper", + ); + expect(headerWrappers.length).toBe(2); + + const monthHeaders = container.querySelectorAll( + ".react-datepicker__header-wrapper .react-datepicker__header .react-datepicker__current-month", + ); + expect(monthHeaders.length).toBe(2); + }); + + it("should use top position when monthHeaderPosition is 'top'", () => { + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + monthHeaderPosition="top" + />, + ); + + // Header should be in the default header section + const header = container.querySelector(".react-datepicker__header"); + const currentMonth = header?.querySelector( + ".react-datepicker__current-month", + ); + expect(currentMonth).not.toBeNull(); + }); + + it("should render month header with middle position when navigation buttons might be hidden", () => { + const minDate = newDate(); + const maxDate = newDate(); + + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + monthHeaderPosition="middle" + minDate={minDate} + maxDate={maxDate} + showDisabledMonthNavigation={false} + />, + ); + + // Should still render the wrapper with header + const headerWrapper = container.querySelector( + ".react-datepicker__header-wrapper", + ); + expect(headerWrapper).not.toBeNull(); + + const header = container.querySelector(".react-datepicker__header"); + expect(header).not.toBeNull(); + }); + + it("should render month header with bottom position when renderCustomHeader is provided", () => { + const { container } = render( + {}} + onSelect={() => {}} + dropdownMode="scroll" + monthHeaderPosition="bottom" + renderCustomHeader={() =>
Custom Header
} + />, + ); + + // Should render custom header + const customHeader = container.querySelector( + ".react-datepicker__header--custom", + ); + expect(customHeader).not.toBeNull(); + }); +}); diff --git a/src/test/popper_component.test.tsx b/src/test/popper_component.test.tsx index be3ddbb13..651aea849 100644 --- a/src/test/popper_component.test.tsx +++ b/src/test/popper_component.test.tsx @@ -291,4 +291,74 @@ describe("PopperComponent", () => { shadowHost.remove(); }); + + describe("monthHeaderPosition", () => { + it("should not add header position classes by default", () => { + const { container } = render( + , + ); + + const popper = container.querySelector(".react-datepicker-popper"); + expect( + popper?.classList.contains("react-datepicker-popper--header-middle"), + ).toBe(false); + expect( + popper?.classList.contains("react-datepicker-popper--header-bottom"), + ).toBe(false); + }); + + it("should add header-middle class when monthHeaderPosition is 'middle'", () => { + const { container } = render( + , + ); + + const popper = container.querySelector(".react-datepicker-popper"); + expect( + popper?.classList.contains("react-datepicker-popper--header-middle"), + ).toBe(true); + expect( + popper?.classList.contains("react-datepicker-popper--header-bottom"), + ).toBe(false); + }); + + it("should add header-bottom class when monthHeaderPosition is 'bottom'", () => { + const { container } = render( + , + ); + + const popper = container.querySelector(".react-datepicker-popper"); + expect( + popper?.classList.contains("react-datepicker-popper--header-bottom"), + ).toBe(true); + expect( + popper?.classList.contains("react-datepicker-popper--header-middle"), + ).toBe(false); + }); + + it("should not add header position classes when monthHeaderPosition is 'top'", () => { + const { container } = render( + , + ); + + const popper = container.querySelector(".react-datepicker-popper"); + expect( + popper?.classList.contains("react-datepicker-popper--header-middle"), + ).toBe(false); + expect( + popper?.classList.contains("react-datepicker-popper--header-bottom"), + ).toBe(false); + }); + }); });