diff --git a/packages/react-date-picker/src/DateInput.tsx b/packages/react-date-picker/src/DateInput.tsx
index c21fcdf8..a264c1dd 100644
--- a/packages/react-date-picker/src/DateInput.tsx
+++ b/packages/react-date-picker/src/DateInput.tsx
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { getYear, getMonthHuman, getDate } from '@wojtekmaj/date-utils';
import Divider from './Divider.js';
-import DayInput from './DateInput/DayInput.js';
+import DayInput, { checkDayInputValidity } from './DateInput/DayInput.js';
import MonthInput from './DateInput/MonthInput.js';
import MonthSelect from './DateInput/MonthSelect.js';
import YearInput from './DateInput/YearInput.js';
@@ -469,7 +469,32 @@ export default function DateInput({
}
const isEveryValueFilled = formElements.every((formElement) => formElement.value);
- const isEveryValueValid = formElements.every((formElement) => formElement.validity.valid);
+ const isEveryValueValid = formElements.every((formElement) => {
+ if (formElement.name === 'day') {
+ // We do this special day validation check due to this bug: https://github.com/wojtekmaj/react-date-picker/issues/659
+ // The main reason for this bug is that in the current react render cycle,
+ // the dayInput ref is still using the previous input values of the safeMin and safeMax days,
+ // which were calculated based on the previous month and year values.
+ // So for instance when the year 202 was not a leap year, the dayinput field is invalid for a value of 29,
+ // but at the time when the user inputs 2024 in the year field, the dayInput field is actually supposed to be valid,
+ // but input validity check does not yet know about the new year input value, and so it returns false.
+ // A workaround is to just use a new instance of a DOM input field that emulates what the real input field should do,
+ // and check this validity value instead, which will be correct using the current updated values.
+ // The real input field values will become valid next render cycle.
+ // Ironic to make another "shadow DOM" inside react, but at least it works.
+ const isDayInputValid = checkDayInputValidity(
+ {
+ minDate,
+ maxDate,
+ month: values.month?.toString() || null,
+ year: values.year?.toString() || null,
+ },
+ values.day?.toString() || undefined,
+ );
+ return isDayInputValid;
+ }
+ return formElement.validity.valid;
+ });
if (isEveryValueFilled && isEveryValueValid) {
const year = Number(values.year || new Date().getFullYear());
diff --git a/packages/react-date-picker/src/DateInput/DayInput.spec.tsx b/packages/react-date-picker/src/DateInput/DayInput.spec.tsx
index ac82a35f..7c4e9e3f 100644
--- a/packages/react-date-picker/src/DateInput/DayInput.spec.tsx
+++ b/packages/react-date-picker/src/DateInput/DayInput.spec.tsx
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { createRef } from 'react';
import { render } from '@testing-library/react';
-import DayInput from './DayInput.js';
+import DayInput, { checkDayInputValidity, getMinMaxDays } from './DayInput.js';
describe('DayInput', () => {
const defaultProps = {
@@ -205,3 +205,91 @@ describe('DayInput', () => {
expect(input).toHaveAttribute('max', '15');
});
});
+
+describe('getMinMaxDays', () => {
+ it('returns 1-31 by default', () => {
+ const result = getMinMaxDays({});
+ expect(result).toEqual({ minDay: 1, maxDay: 31 });
+ });
+
+ it('returns 1-29 given month is 2 and year is a leap year', () => {
+ const result = getMinMaxDays({ month: '2', year: '2020' });
+ expect(result).toEqual({ minDay: 1, maxDay: 29 });
+ });
+
+ it('returns 1-28 given month is 2 and year is not a leap year', () => {
+ const result = getMinMaxDays({ month: '2', year: '2021' });
+ expect(result).toEqual({ minDay: 1, maxDay: 28 });
+ });
+
+ it('returns 1-31 for january', () => {
+ const result = getMinMaxDays({ month: '1', year: '2021' });
+ expect(result).toEqual({ minDay: 1, maxDay: 31 });
+ });
+
+ it('returns 1-30 for november', () => {
+ const result = getMinMaxDays({ month: '11', year: '2021' });
+ expect(result).toEqual({ minDay: 1, maxDay: 30 });
+ });
+
+ it('returns minDay 15 if the given minDate fall on the same month and has a day value of 15', () => {
+ const result = getMinMaxDays({ minDate: new Date(2021, 10, 15), month: '11', year: '2021' });
+ expect(result).toEqual({ minDay: 15, maxDay: 30 });
+ });
+
+ it('returns maxDay 15 if the given maxDate fall on the same month and has a day value of 15', () => {
+ const result = getMinMaxDays({ maxDate: new Date(2021, 10, 15), month: '11', year: '2021' });
+ expect(result).toEqual({ minDay: 1, maxDay: 15 });
+ });
+});
+
+describe('checkDayInputValidity', () => {
+ const testCases = [
+ {
+ month: '1',
+ year: '2024',
+ dayValue: '1',
+ expectedValidity: true,
+ },
+
+ {
+ month: '2',
+ year: '2023',
+ dayValue: '29',
+ expectedValidity: false,
+ },
+ {
+ month: '2',
+ year: '2024',
+ dayValue: '29',
+ expectedValidity: true,
+ },
+ {
+ month: '2',
+ year: '2024',
+ dayValue: '30',
+ expectedValidity: false,
+ },
+ {
+ dayValue: '32',
+ expectedValidity: false,
+ },
+ {
+ dayValue: '31',
+ expectedValidity: true,
+ },
+ ];
+
+ testCases.forEach((testCase) => {
+ it(`returns ${testCase.expectedValidity} for day ${testCase.dayValue} in month ${testCase.month} and year ${testCase.year}`, () => {
+ const result = checkDayInputValidity(
+ {
+ month: testCase.month,
+ year: testCase.year,
+ },
+ testCase.dayValue,
+ );
+ expect(result).toBe(testCase.expectedValidity);
+ });
+ });
+});
diff --git a/packages/react-date-picker/src/DateInput/DayInput.tsx b/packages/react-date-picker/src/DateInput/DayInput.tsx
index 4a5eb264..1f28d743 100644
--- a/packages/react-date-picker/src/DateInput/DayInput.tsx
+++ b/packages/react-date-picker/src/DateInput/DayInput.tsx
@@ -18,6 +18,17 @@ export default function DayInput({
year,
...otherProps
}: DayInputProps): React.ReactElement {
+ const { maxDay, minDay } = getMinMaxDays({ minDate, maxDate, month, year });
+
+ return ;
+}
+
+export function getMinMaxDays(args: DayInputProps): {
+ maxDay: number;
+ minDay: number;
+} {
+ const { minDate, maxDate, month, year } = args;
+
const currentMonthMaxDays = (() => {
if (!month) {
return 31;
@@ -33,5 +44,19 @@ export default function DayInput({
const maxDay = safeMin(currentMonthMaxDays, maxDate && isSameMonth(maxDate) && getDate(maxDate));
const minDay = safeMax(1, minDate && isSameMonth(minDate) && getDate(minDate));
- return ;
+ return { maxDay, minDay };
+}
+
+export function checkDayInputValidity(inputProps: DayInputProps, dayValue?: string): boolean {
+ const { minDay, maxDay } = getMinMaxDays(inputProps);
+ // Create an in-memory input element
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.name = 'day';
+ input.min = minDay.toString();
+ input.max = maxDay.toString();
+ input.required = true;
+ input.value = dayValue || '';
+ // The browser will now validate the value based on min/max/required
+ return input.checkValidity();
}
diff --git a/packages/react-date-picker/src/DatePicker.spec.tsx b/packages/react-date-picker/src/DatePicker.spec.tsx
index e500611a..b247e07a 100644
--- a/packages/react-date-picker/src/DatePicker.spec.tsx
+++ b/packages/react-date-picker/src/DatePicker.spec.tsx
@@ -1,6 +1,6 @@
-import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
import DatePicker from './DatePicker.js';
@@ -590,4 +590,149 @@ describe('DatePicker', () => {
expect(onTouchStart).toHaveBeenCalled();
});
+
+ it('handles leap year date input correctly when minDate is a 4-digit year', () => {
+ const onChange = vi.fn();
+ const onInvalidChange = vi.fn();
+
+ const { container } = render(
+ ,
+ );
+
+ const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement;
+ const monthInput = container.querySelector('input[name="month"]') as HTMLInputElement;
+ const yearInput = container.querySelector('input[name="year"]') as HTMLInputElement;
+
+ // Initial state - incomplete date should call onInvalidChange
+ act(() => {
+ fireEvent.change(dayInput, { target: { value: '29' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ act(() => {
+ fireEvent.change(monthInput, { target: { value: '02' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ // Enter year digit by digit to test intermediate states
+ act(() => {
+ fireEvent.change(yearInput, { target: { value: '2' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ act(() => {
+ fireEvent.change(yearInput, { target: { value: '20' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ act(() => {
+ fireEvent.change(yearInput, { target: { value: '202' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ expect(onChange).not.toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ // Complete with leap year - should be valid
+ act(() => {
+ fireEvent.change(yearInput, { target: { value: '2024' } });
+ });
+ expect(onChange).toHaveBeenLastCalledWith(new Date(2024, 1, 29));
+ expect(onInvalidChange).not.toHaveBeenCalled();
+ onChange.mockClear();
+
+ // Try changing day to 30 in February - should be invalid
+ act(() => {
+ fireEvent.change(dayInput, { target: { value: '30' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ expect(onChange).not.toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ // Change to March
+ act(() => {
+ fireEvent.change(monthInput, { target: { value: '03' } });
+ });
+ // This now preserves the 30 day input from last step
+ expect(onChange).toHaveBeenLastCalledWith(new Date(2024, 2, 30));
+ expect(onInvalidChange).not.toHaveBeenCalled();
+ onChange.mockClear();
+
+ // Change to 2023 (non-leap year)
+ act(() => {
+ fireEvent.change(yearInput, { target: { value: '2023' } });
+ });
+ expect(onChange).toHaveBeenLastCalledWith(new Date(2023, 2, 30));
+ expect(onInvalidChange).not.toHaveBeenCalled();
+ onChange.mockClear();
+
+ // Change to day 29
+ act(() => {
+ fireEvent.change(dayInput, { target: { value: '29' } });
+ });
+ expect(onChange).toHaveBeenLastCalledWith(new Date(2023, 2, 29));
+ expect(onInvalidChange).not.toHaveBeenCalled();
+ onChange.mockClear();
+
+ // Change to February month, causing Febrary 29, 2023 to be invalid
+ act(() => {
+ fireEvent.change(monthInput, { target: { value: '02' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('handles leap year date input correctly when minDate is a 2-digit year', () => {
+ const onChange = vi.fn();
+ const onInvalidChange = vi.fn();
+
+ const { container } = render(
+ ,
+ );
+
+ const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement;
+ const monthInput = container.querySelector('input[name="month"]') as HTMLInputElement;
+ const yearInput = container.querySelector('input[name="year"]') as HTMLInputElement;
+
+ act(() => {
+ fireEvent.change(dayInput, { target: { value: '29' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ act(() => {
+ fireEvent.change(monthInput, { target: { value: '02' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ act(() => {
+ fireEvent.change(yearInput, { target: { value: '2' } });
+ });
+ expect(onInvalidChange).toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ act(() => {
+ fireEvent.change(yearInput, { target: { value: '20' } });
+ });
+ expect(onInvalidChange).not.toHaveBeenCalled();
+ onInvalidChange.mockClear();
+
+ const expectedDate = new Date(2020, 1, 29);
+ expectedDate.setFullYear(20);
+ expect(onChange).toHaveBeenLastCalledWith(expectedDate);
+ });
});