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); + }); });