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()} 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/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..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"); @@ -1105,6 +1140,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 +1182,14 @@ 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("isYearInRange", () => { @@ -1580,14 +1631,33 @@ 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); + it("returns false when isWithinInterval throws", async () => { + jest.doMock("date-fns", () => { + const actual = jest.requireActual("date-fns"); + return { + ...actual, + isWithinInterval: () => { + throw new Error("boom"); + }, + }; + }); - expect(result).toBe(false); + try { + const { isDayInRange: mockedIsDayInRange } = await import( + "../date_utils" + ); + + expect( + mockedIsDayInRange( + new Date("2024-01-15"), + new Date("2024-01-10"), + new Date("2024-01-20"), + ), + ).toBe(false); + } finally { + 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..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"; @@ -36,6 +37,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 +5031,209 @@ 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(1); + + 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 { instance } = renderDatePickerWithRef({ + selected: null, + onChange: () => {}, + onInputError, + }); + + act(() => { + instance?.setState({ + preSelection: "invalid-date" as unknown as Date, + open: true, + }); + }); + + 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 { instance } = renderDatePickerWithRef({ + selected: null, + onChange: () => {}, + onInputError, + }); + + act(() => { + instance?.setState({ + preSelection: "invalid-date" as unknown as Date, + open: true, + }); + }); + + act(() => { + instance?.onInputKeyDown({ + key: "Tab", + preventDefault: jest.fn(), + target: document.createElement("input"), + } as unknown as React.KeyboardEvent); + }); + + 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/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"; 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); + }); });