From c5923812a77d1bc2173dbb4dbe632c0e450ebe0c Mon Sep 17 00:00:00 2001 From: Martijn Russchen Date: Mon, 10 Nov 2025 19:14:19 +0100 Subject: [PATCH 1/4] Add coverage tests for calendar interactions --- src/test/click_outside_wrapper.test.tsx | 31 ++++ src/test/date_utils_test.test.ts | 83 +++++++++- src/test/datepicker_test.test.tsx | 205 ++++++++++++++++++++++++ src/test/day_test.test.tsx | 48 ++++++ src/test/filter_times_test.test.tsx | 49 ++++++ src/test/input_time.test.tsx | 25 +++ src/test/week_test.test.tsx | 43 +++++ 7 files changed, 477 insertions(+), 7 deletions(-) diff --git a/src/test/click_outside_wrapper.test.tsx b/src/test/click_outside_wrapper.test.tsx index 329c18dea..e1bd54a7a 100644 --- a/src/test/click_outside_wrapper.test.tsx +++ b/src/test/click_outside_wrapper.test.tsx @@ -194,4 +194,35 @@ describe("ClickOutsideWrapper", () => { removeEventListenerSpy.mockRestore(); }); + + it("invokes handler registered on document with composedPath target", () => { + const addEventListenerSpy = jest.spyOn(document, "addEventListener"); + const removeEventListenerSpy = jest.spyOn(document, "removeEventListener"); + + const { unmount } = render( + +
Inside
+
, + ); + + const handlerEntry = addEventListenerSpy.mock.calls.find( + ([type]) => type === "mousedown", + ); + const handler = handlerEntry?.[1] as EventListener; + + const outsideNode = document.createElement("div"); + const mockEvent = { + composed: true, + composedPath: () => [outsideNode], + target: outsideNode, + } as unknown as MouseEvent; + + handler(mockEvent); + + expect(onClickOutsideMock).toHaveBeenCalledTimes(1); + + unmount(); + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); }); diff --git a/src/test/date_utils_test.test.ts b/src/test/date_utils_test.test.ts index 6a4cee43e..e5e40e99f 100644 --- a/src/test/date_utils_test.test.ts +++ b/src/test/date_utils_test.test.ts @@ -1105,6 +1105,14 @@ describe("date_utils", () => { expect(isMonthInRange(startDate, endDate, 5, day)).toBe(true); }); + + it("should return false when the start date is after the end date", () => { + const day = newDate("2024-01-01"); + const startDate = newDate("2024-02-01"); + const endDate = newDate("2024-01-01"); + + expect(isMonthInRange(startDate, endDate, 1, day)).toBe(false); + }); }); describe("getStartOfYear", () => { @@ -1139,6 +1147,50 @@ describe("date_utils", () => { expect(isQuarterInRange(startDate, endDate, 5, day)).toBe(true); }); + + it("should return false when the start quarter is after the end quarter", () => { + const day = newDate("2024-01-01"); + const startDate = newDate("2024-10-01"); + const endDate = newDate("2024-04-01"); + + expect(isQuarterInRange(startDate, endDate, 1, day)).toBe(false); + }); + }); + + describe("isTimeInDisabledRange", () => { + it("throws when either minTime or maxTime is missing", () => { + expect(() => + isTimeInDisabledRange(newDate(), { minTime: newDate() }), + ).toThrow("Both minTime and maxTime props required"); + }); + + it("returns false when isWithinInterval throws", () => { + jest.isolateModules(() => { + jest.doMock("date-fns", () => { + const actual = jest.requireActual("date-fns"); + return { + ...actual, + isWithinInterval: () => { + throw new Error("boom"); + }, + }; + }); + + // eslint-disable-next-line @typescript-eslint/no-var-requires -- isolated mock import + const { + isTimeInDisabledRange: mockedIsTimeInDisabledRange, + } = require("../date_utils"); + expect( + mockedIsTimeInDisabledRange(new Date(), { + minTime: new Date(), + maxTime: new Date(), + }), + ).toBe(false); + + jest.resetModules(); + jest.dontMock("date-fns"); + }); + }); }); describe("isYearInRange", () => { @@ -1581,13 +1633,30 @@ describe("date_utils", () => { describe("isDayInRange error handling", () => { it("returns false when isWithinInterval throws", () => { - const testDate = new Date("2024-01-15"); - const invalidStartDate = new Date("invalid"); - const invalidEndDate = new Date("also-invalid"); - - const result = isDayInRange(testDate, invalidStartDate, invalidEndDate); - - expect(result).toBe(false); + jest.isolateModules(() => { + jest.doMock("date-fns", () => { + const actual = jest.requireActual("date-fns"); + return { + ...actual, + isWithinInterval: () => { + throw new Error("boom"); + }, + }; + }); + + // eslint-disable-next-line @typescript-eslint/no-var-requires -- isolated mock import + const { isDayInRange: mockedIsDayInRange } = require("../date_utils"); + expect( + mockedIsDayInRange( + new Date("2024-01-15"), + new Date("2024-01-10"), + new Date("2024-01-20"), + ), + ).toBe(false); + + jest.resetModules(); + jest.dontMock("date-fns"); + }); }); it("returns true for dates inside a valid range", () => { diff --git a/src/test/datepicker_test.test.tsx b/src/test/datepicker_test.test.tsx index 3d63b1626..a32a77e4d 100644 --- a/src/test/datepicker_test.test.tsx +++ b/src/test/datepicker_test.test.tsx @@ -36,6 +36,22 @@ import { setupMockResizeObserver, } from "./test_utils"; +const renderDatePickerWithRef = ( + props: React.ComponentProps, +) => { + let instance: DatePicker | null = null; + const result = render( + { + instance = node; + }} + {...props} + />, + ); + + return { ...result, instance: instance as DatePicker | null }; +}; + function getSelectedDayNode(container: HTMLElement) { return ( container.querySelector('.react-datepicker__day[tabindex="0"]') ?? undefined @@ -5014,6 +5030,195 @@ describe("DatePicker", () => { jest.useRealTimers(); }); + it("deferFocusInput cancels pending timeouts before focusing input", () => { + jest.useFakeTimers(); + const { instance } = renderDatePickerWithRef({ + selected: newDate(), + onChange: () => {}, + }); + + expect(instance).not.toBeNull(); + + const setFocusSpy = jest + .spyOn(instance as DatePicker, "setFocus") + .mockImplementation(() => undefined); + + act(() => { + instance?.deferFocusInput(); + instance?.deferFocusInput(); + }); + + jest.advanceTimersByTime(1); + + expect(setFocusSpy).toHaveBeenCalledTimes(2); + + setFocusSpy.mockRestore(); + jest.useRealTimers(); + }); + + it("clears ranges when changed date is null and start date exists", () => { + const onChange = jest.fn(); + const startDate = new Date("2024-01-15T00:00:00"); + const { instance } = renderDatePickerWithRef({ + inline: true, + selectsRange: true, + startDate, + endDate: null, + selected: null, + onChange, + }); + + act(() => { + instance?.setSelected(null); + }); + + expect(onChange).toHaveBeenCalledWith([null, null], undefined); + }); + + it("reports input errors when escaping with invalid preSelection", () => { + const onInputError = jest.fn(); + const { container, instance } = renderDatePickerWithRef({ + selected: null, + onChange: () => {}, + onInputError, + }); + + act(() => { + instance?.setState({ preSelection: "invalid-date" as unknown as Date }); + }); + + const input = safeQuerySelector(container, "input"); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onInputError).toHaveBeenCalled(); + }); + + it("reports input errors when input key down completes with invalid preSelection", () => { + const onInputError = jest.fn(); + const { container, instance } = renderDatePickerWithRef({ + selected: null, + onChange: () => {}, + onInputError, + }); + + act(() => { + instance?.setState({ preSelection: "invalid-date" as unknown as Date }); + }); + + const input = safeQuerySelector(container, "input"); + act(() => { + fireEvent.keyDown(input, { key: "Tab" }); + }); + + expect(onInputError).toHaveBeenCalled(); + }); + + it("reports input errors when unsupported key is pressed in calendar grid", () => { + const onInputError = jest.fn(); + const { instance } = renderDatePickerWithRef({ + selected: newDate(), + onChange: () => {}, + onInputError, + inline: true, + }); + + act(() => { + instance?.setState({ preSelection: newDate() }); + }); + + act(() => { + instance?.onDayKeyDown({ + key: "A", + shiftKey: false, + preventDefault: jest.fn(), + target: document.createElement("div"), + } as unknown as React.KeyboardEvent); + }); + + expect(onInputError).toHaveBeenCalled(); + }); + + it("reports input errors when escape is pressed within the calendar grid", () => { + const onInputError = jest.fn(); + const { instance } = renderDatePickerWithRef({ + selected: null, + onChange: () => {}, + onInputError, + inline: true, + }); + + act(() => { + instance?.setState({ preSelection: "invalid-date" as unknown as Date }); + }); + + act(() => { + instance?.onDayKeyDown({ + key: "Escape", + shiftKey: false, + preventDefault: jest.fn(), + target: document.createElement("div"), + } as unknown as React.KeyboardEvent); + }); + + expect(onInputError).toHaveBeenCalled(); + }); + + describe("aria-live messaging", () => { + it("describes range selections", () => { + const startDate = new Date("2024-01-01T00:00:00"); + const endDate = new Date("2024-01-02T00:00:00"); + const { instance } = renderDatePickerWithRef({ + selectsRange: true, + startDate, + endDate, + selected: endDate, + }); + + const message = instance?.renderAriaLiveRegion(); + expect(message?.props.children).toContain("Selected start date"); + }); + + it("describes time-only selections", () => { + const { instance } = renderDatePickerWithRef({ + showTimeSelectOnly: true, + selected: new Date("2024-01-01T12:00:00"), + }); + + const message = instance?.renderAriaLiveRegion(); + expect(message?.props.children).toContain("Selected time:"); + }); + + it("describes year picker selections", () => { + const { instance } = renderDatePickerWithRef({ + showYearPicker: true, + selected: new Date("2024-01-01T00:00:00"), + }); + + const message = instance?.renderAriaLiveRegion(); + expect(message?.props.children).toContain("Selected year:"); + }); + + it("describes month-year picker selections", () => { + const { instance } = renderDatePickerWithRef({ + showMonthYearPicker: true, + selected: new Date("2024-03-01T00:00:00"), + }); + + const message = instance?.renderAriaLiveRegion(); + expect(message?.props.children).toContain("Selected month:"); + }); + + it("describes quarter picker selections", () => { + const { instance } = renderDatePickerWithRef({ + showQuarterYearPicker: true, + selected: new Date("2024-06-01T00:00:00"), + }); + + const message = instance?.renderAriaLiveRegion(); + expect(message?.props.children).toContain("Selected quarter:"); + }); + }); + it("should handle focus on year dropdown", () => { const { container } = render( { }); }); + describe("interactions", () => { + it("should not trigger onMouseEnter when the day is disabled", () => { + const onMouseEnter = jest.fn(); + const day = newDate(); + const container = renderDay(day, { + disabled: true, + onMouseEnter, + }); + + const node = safeQuerySelector(container, ".react-datepicker__day"); + fireEvent.mouseEnter(node); + + expect(onMouseEnter).not.toHaveBeenCalled(); + }); + + it("should convert space key presses to enter events", () => { + const onKeyDown = jest.fn(); + const container = renderDay(newDate(), { handleOnKeyDown: onKeyDown }); + const node = safeQuerySelector(container, ".react-datepicker__day"); + + fireEvent.keyDown(node, { key: " " }); + + expect(onKeyDown).toHaveBeenCalled(); + expect(onKeyDown.mock.calls[0][0].key).toBe("Enter"); + }); + }); + + describe("holidays and titles", () => { + it("should append holiday class names when provided", () => { + const day = new Date("2024-01-01T00:00:00"); + const holidays = getHolidaysMap([{ date: day, holidayName: "New Year" }]); + const container = renderDay(day, { holidays }); + + const node = safeQuerySelector(container, ".react-datepicker__day"); + expect(node.className).toContain("react-datepicker__day--holidays"); + }); + + it("should include exclude date messages in the title overlay", () => { + const day = new Date("2024-02-05T00:00:00"); + const container = renderDay(day, { + excludeDates: [{ date: day, message: "Blocked day" }], + }); + + const node = safeQuerySelector(container, ".react-datepicker__day"); + expect(node.getAttribute("title")).toContain("Blocked day"); + }); + }); + describe("selected", () => { const className = "react-datepicker__day--selected"; let day: Date; diff --git a/src/test/filter_times_test.test.tsx b/src/test/filter_times_test.test.tsx index 687bcdcc6..978cd1610 100644 --- a/src/test/filter_times_test.test.tsx +++ b/src/test/filter_times_test.test.tsx @@ -88,4 +88,53 @@ describe("TimeComponent", () => { expect(onChange).toHaveBeenCalled(); }); + + it("should prevent clicks outside the provided min/max time range", () => { + const onChange = jest.fn(); + const minTime = new Date("2024-01-01T08:00:00"); + const maxTime = new Date("2024-01-01T10:00:00"); + const { container } = render( + , + ); + + const disabledSlot = Array.from( + container.querySelectorAll(".react-datepicker__time-list-item"), + ).find((node) => + node.classList.contains("react-datepicker__time-list-item--disabled"), + ) as HTMLElement; + + fireEvent.click(disabledSlot); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should allow clicks within the min/max time range", () => { + const onChange = jest.fn(); + const minTime = new Date("2024-01-01T08:00:00"); + const maxTime = new Date("2024-01-01T10:00:00"); + const { container } = render( + , + ); + + const enabledSlot = Array.from( + container.querySelectorAll(".react-datepicker__time-list-item"), + ).find( + (node) => + !node.classList.contains("react-datepicker__time-list-item--disabled"), + ) as HTMLElement; + + fireEvent.click(enabledSlot); + + expect(onChange).toHaveBeenCalled(); + }); }); diff --git a/src/test/input_time.test.tsx b/src/test/input_time.test.tsx index a468af74d..204905525 100644 --- a/src/test/input_time.test.tsx +++ b/src/test/input_time.test.tsx @@ -226,6 +226,31 @@ describe("InputTime", () => { expect(onTimeChange).toHaveBeenCalledWith(date); }); + it("preserves existing time when custom input emits value without colon", () => { + const onChange = jest.fn(); + const date = new Date("2023-09-30T11:00:00"); + + const { container } = render( + } + />, + ); + + const customInput = container.querySelector( + '[data-testid="partial-input"]', + ) as HTMLInputElement; + + fireEvent.change(customInput, { target: { value: "invalid" } }); + + expect(onChange).toHaveBeenCalledTimes(1); + const calledDate = onChange.mock.calls[0][0]; + expect(calledDate.getHours()).toBe(11); + expect(calledDate.getMinutes()).toBe(0); + }); + it("renders container with correct class names", () => { const { container } = render(); diff --git a/src/test/week_test.test.tsx b/src/test/week_test.test.tsx index 2c5545b45..1aef0e2e5 100644 --- a/src/test/week_test.test.tsx +++ b/src/test/week_test.test.tsx @@ -458,4 +458,47 @@ describe("Week", () => { expect(days[6]?.textContent).toBe("5"); }); }); + it("should call onDayMouseEnter when hovering over a day", () => { + const onDayMouseEnter = jest.fn(); + const weekStart = newDate("2024-01-07"); + const { container } = render( + , + ); + + const firstDay = safeQuerySelector(container, ".react-datepicker__day"); + fireEvent.mouseEnter(firstDay); + + expect(onDayMouseEnter).toHaveBeenCalledWith(weekStart); + }); + + it("should pass the first enabled day when the week starts disabled", () => { + const weekStart = newDate("2024-02-04"); + const onWeekSelect = jest.fn(); + const setOpenSpy = jest.fn(); + const { container } = render( + , + ); + + const weekNumberElement = safeQuerySelector( + container, + ".react-datepicker__week-number", + ); + fireEvent.click(weekNumberElement); + + expect(onWeekSelect).toHaveBeenCalled(); + const firstEnabled = onWeekSelect.mock.calls[0][0] as Date; + expect(firstEnabled.getDate()).toBe(weekStart.getDate() + 1); + expect(setOpenSpy).toHaveBeenCalledWith(false); + }); }); From 8389812541c986b46fff90e11a249a0d5f05b644 Mon Sep 17 00:00:00 2001 From: Martijn Russchen Date: Mon, 10 Nov 2025 19:49:03 +0100 Subject: [PATCH 2/4] Fix lint violations in date utils tests --- src/test/date_utils_test.test.ts | 105 ++++++++++++++++--------------- 1 file changed, 53 insertions(+), 52 deletions(-) diff --git a/src/test/date_utils_test.test.ts b/src/test/date_utils_test.test.ts index e5e40e99f..b9d85167e 100644 --- a/src/test/date_utils_test.test.ts +++ b/src/test/date_utils_test.test.ts @@ -926,6 +926,41 @@ describe("date_utils", () => { }); }); + describe("isTimeInDisabledRange edge cases", () => { + it("throws when either minTime or maxTime is missing", () => { + expect(() => + isTimeInDisabledRange(newDate(), { minTime: newDate() }), + ).toThrow("Both minTime and maxTime props required"); + }); + + it("returns false when isWithinInterval throws", async () => { + jest.doMock("date-fns", () => { + const actual = jest.requireActual("date-fns"); + return { + ...actual, + isWithinInterval: () => { + throw new Error("boom"); + }, + }; + }); + + try { + const { isTimeInDisabledRange: mockedIsTimeInDisabledRange } = + await import("../date_utils"); + + expect( + mockedIsTimeInDisabledRange(new Date(), { + minTime: new Date(), + maxTime: new Date(), + }), + ).toBe(false); + } finally { + jest.resetModules(); + jest.dontMock("date-fns"); + } + }); + }); + describe("isDayInRange", () => { it("should tell if day is in range", () => { const day = newDate("2016-02-15 09:40"); @@ -1157,42 +1192,6 @@ describe("date_utils", () => { }); }); - describe("isTimeInDisabledRange", () => { - it("throws when either minTime or maxTime is missing", () => { - expect(() => - isTimeInDisabledRange(newDate(), { minTime: newDate() }), - ).toThrow("Both minTime and maxTime props required"); - }); - - it("returns false when isWithinInterval throws", () => { - jest.isolateModules(() => { - jest.doMock("date-fns", () => { - const actual = jest.requireActual("date-fns"); - return { - ...actual, - isWithinInterval: () => { - throw new Error("boom"); - }, - }; - }); - - // eslint-disable-next-line @typescript-eslint/no-var-requires -- isolated mock import - const { - isTimeInDisabledRange: mockedIsTimeInDisabledRange, - } = require("../date_utils"); - expect( - mockedIsTimeInDisabledRange(new Date(), { - minTime: new Date(), - maxTime: new Date(), - }), - ).toBe(false); - - jest.resetModules(); - jest.dontMock("date-fns"); - }); - }); - }); - describe("isYearInRange", () => { it("should return true if the year passed is in range", () => { const startDate = newDate("2000-01-01"); @@ -1632,20 +1631,22 @@ describe("date_utils", () => { }); describe("isDayInRange error handling", () => { - it("returns false when isWithinInterval throws", () => { - jest.isolateModules(() => { - jest.doMock("date-fns", () => { - const actual = jest.requireActual("date-fns"); - return { - ...actual, - isWithinInterval: () => { - throw new Error("boom"); - }, - }; - }); - - // eslint-disable-next-line @typescript-eslint/no-var-requires -- isolated mock import - const { isDayInRange: mockedIsDayInRange } = require("../date_utils"); + it("returns false when isWithinInterval throws", async () => { + jest.doMock("date-fns", () => { + const actual = jest.requireActual("date-fns"); + return { + ...actual, + isWithinInterval: () => { + throw new Error("boom"); + }, + }; + }); + + try { + const { isDayInRange: mockedIsDayInRange } = await import( + "../date_utils" + ); + expect( mockedIsDayInRange( new Date("2024-01-15"), @@ -1653,10 +1654,10 @@ describe("date_utils", () => { new Date("2024-01-20"), ), ).toBe(false); - + } finally { jest.resetModules(); jest.dontMock("date-fns"); - }); + } }); it("returns true for dates inside a valid range", () => { From 51980d9b946a0865f520843d1e6b0c11bf9f1c25 Mon Sep 17 00:00:00 2001 From: Martijn Russchen Date: Mon, 10 Nov 2025 19:54:11 +0100 Subject: [PATCH 3/4] Fix React act() import error - import from react instead of @testing-library/react - Fixed src/test/show_time_test.test.tsx to import act from react - Fixed src/test/datepicker_test.test.tsx to import act from react - Resolves process.env.NODE_ENV.exports.act error for React 19 compatibility --- src/test/calendar_test.test.tsx | 3 ++- src/test/datepicker_test.test.tsx | 35 ++++++++++++++++++++++--------- src/test/show_time_test.test.tsx | 3 ++- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/test/calendar_test.test.tsx b/src/test/calendar_test.test.tsx index 8af2aa6eb..6ce8d7dfd 100644 --- a/src/test/calendar_test.test.tsx +++ b/src/test/calendar_test.test.tsx @@ -2,7 +2,8 @@ * @jest-environment jsdom */ -import { render, fireEvent, act, waitFor } from "@testing-library/react"; +import { render, fireEvent, waitFor } from "@testing-library/react"; +import { act } from "react"; import { setDate, startOfMonth, diff --git a/src/test/datepicker_test.test.tsx b/src/test/datepicker_test.test.tsx index a32a77e4d..230b9f879 100644 --- a/src/test/datepicker_test.test.tsx +++ b/src/test/datepicker_test.test.tsx @@ -1,4 +1,5 @@ -import { render, act, waitFor, fireEvent } from "@testing-library/react"; +import { render, waitFor, fireEvent } from "@testing-library/react"; +import { act } from "react"; import { userEvent } from "@testing-library/user-event"; import { enUS, enGB } from "date-fns/locale"; import React, { useState } from "react"; @@ -5050,7 +5051,7 @@ describe("DatePicker", () => { jest.advanceTimersByTime(1); - expect(setFocusSpy).toHaveBeenCalledTimes(2); + expect(setFocusSpy).toHaveBeenCalledTimes(1); setFocusSpy.mockRestore(); jest.useRealTimers(); @@ -5077,37 +5078,51 @@ describe("DatePicker", () => { it("reports input errors when escaping with invalid preSelection", () => { const onInputError = jest.fn(); - const { container, instance } = renderDatePickerWithRef({ + const { instance } = renderDatePickerWithRef({ selected: null, onChange: () => {}, onInputError, }); act(() => { - instance?.setState({ preSelection: "invalid-date" as unknown as Date }); + instance?.setState({ + preSelection: "invalid-date" as unknown as Date, + open: true, + }); }); - const input = safeQuerySelector(container, "input"); - fireEvent.keyDown(input, { key: "Escape" }); + act(() => { + instance?.onInputKeyDown({ + key: "Escape", + preventDefault: jest.fn(), + target: document.createElement("input"), + } as unknown as React.KeyboardEvent); + }); expect(onInputError).toHaveBeenCalled(); }); it("reports input errors when input key down completes with invalid preSelection", () => { const onInputError = jest.fn(); - const { container, instance } = renderDatePickerWithRef({ + const { instance } = renderDatePickerWithRef({ selected: null, onChange: () => {}, onInputError, }); act(() => { - instance?.setState({ preSelection: "invalid-date" as unknown as Date }); + instance?.setState({ + preSelection: "invalid-date" as unknown as Date, + open: true, + }); }); - const input = safeQuerySelector(container, "input"); act(() => { - fireEvent.keyDown(input, { key: "Tab" }); + instance?.onInputKeyDown({ + key: "Tab", + preventDefault: jest.fn(), + target: document.createElement("input"), + } as unknown as React.KeyboardEvent); }); expect(onInputError).toHaveBeenCalled(); diff --git a/src/test/show_time_test.test.tsx b/src/test/show_time_test.test.tsx index 1e8b6d96e..807f12c72 100644 --- a/src/test/show_time_test.test.tsx +++ b/src/test/show_time_test.test.tsx @@ -1,4 +1,5 @@ -import { act, fireEvent, render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; +import { act } from "react"; import React from "react"; import DatePicker from "../index"; From 45d778057e24a4dedcd24f214ba914437435f990 Mon Sep 17 00:00:00 2001 From: Martijn Russchen Date: Mon, 10 Nov 2025 20:42:48 +0100 Subject: [PATCH 4/4] Add date validity checks to Calendar and Month components Introduces isValid checks in Calendar and Month components to prevent rendering and formatting with invalid dates. This improves robustness and accessibility by ensuring UI elements and aria-labels are only generated for valid dates. --- src/calendar.tsx | 21 ++++++++++++++++++++- src/month.tsx | 15 +++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/calendar.tsx b/src/calendar.tsx index e04158010..ab2136893 100644 --- a/src/calendar.tsx +++ b/src/calendar.tsx @@ -266,6 +266,7 @@ export default class Calendar extends Component { componentDidUpdate(prevProps: CalendarProps) { if ( this.props.preSelection && + isValid(this.props.preSelection) && (!isSameDay(this.props.preSelection, prevProps.preSelection) || this.props.monthSelectedIn !== prevProps.monthSelectedIn) ) { @@ -468,6 +469,11 @@ export default class Calendar extends Component { }; header = (date: Date = this.state.date): React.ReactElement[] => { + // Return empty array if date is invalid + if (!isValid(date)) { + return []; + } + const disabled = this.props.disabled; const startOfWeek = getStartOfWeek( date, @@ -781,7 +787,9 @@ export default class Calendar extends Component { } return (

- {formatDate(date, this.props.dateFormat, this.props.locale)} + {isValid(date) + ? formatDate(date, this.props.dateFormat, this.props.locale) + : ""}

); }; @@ -1112,6 +1120,17 @@ export default class Calendar extends Component { }; renderAriaLiveRegion = (): React.ReactElement => { + // Don't render aria-live message if date is invalid + if (!isValid(this.state.date)) { + return ( + + ); + } + const { startPeriod, endPeriod } = getYearsPeriod( this.state.date, this.props.yearItemNumber ?? Calendar.defaultProps.yearItemNumber, diff --git a/src/month.tsx b/src/month.tsx index f68e22ee6..68d980385 100644 --- a/src/month.tsx +++ b/src/month.tsx @@ -27,6 +27,7 @@ import { isSameMonth, isSameQuarter, isSpaceKeyDown, + isValid, newDate, setMonth, setQuarter, @@ -447,6 +448,11 @@ export default class Month extends Component { }; renderWeeks = () => { + // Return empty array if day is invalid + if (!isValid(this.props.day)) { + return []; + } + const weeks = []; const isFixedHeight = this.props.fixedHeight; @@ -1138,6 +1144,11 @@ export default class Month extends Component { ? ariaLabelPrefix.trim() + " " : ""; + // Format aria-label, return empty string if date is invalid + const formattedAriaLabel = isValid(day) + ? `${formattedAriaLabelPrefix}${formatDate(day, "MMMM, yyyy", this.props.locale)}` + : ""; + const shouldUseListboxRole = showMonthYearPicker || showQuarterYearPicker; if (shouldUseListboxRole) { @@ -1150,7 +1161,7 @@ export default class Month extends Component { onPointerLeave={ this.props.usePointerEvent ? this.handleMouseLeave : undefined } - aria-label={`${formattedAriaLabelPrefix}${formatDate(day, "MMMM, yyyy", this.props.locale)}`} + aria-label={formattedAriaLabel} role="listbox" > {showMonthYearPicker ? this.renderMonths() : this.renderQuarters()} @@ -1172,7 +1183,7 @@ export default class Month extends Component { onPointerLeave={ this.props.usePointerEvent ? this.handleMouseLeave : undefined } - aria-label={`${formattedAriaLabelPrefix}${formatDate(day, "MMMM, yyyy", this.props.locale)}`} + aria-label={formattedAriaLabel} role="rowgroup" > {this.renderWeeks()}