From 350b07464db392060fb91bb147eb6271e36c0cde Mon Sep 17 00:00:00 2001 From: Dag Jomar Mersland Date: Fri, 25 Apr 2025 14:01:05 +0200 Subject: [PATCH] Add improved day validation workaround for leap year date input bug Fixes: https://github.com/wojtekmaj/react-date-picker/issues/659 There is an issue when trying to input a date such as "29 / 02 / 2024" (February 29th 2024) which is a leap year and should be valid. The validation runs into a state where the input fields are first rendered with the inputs day: 29 month: 2 year: 202 which is not a leap year, and thus the day 29 is not valid for this state. The problem occurs when running the onChangeExternal, which tries to read the validity state of the dayInput react Ref. However, that ref has last been rendered with a min/max number value calculated from the previous month and year (month 2 and year 202). Therefore the day input field still believes that the day 29 is not valid, even though we have just entered 2024 into the year field. This 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. Added a bunch of unit tests for this case, and improved the test coverage of utility methods as well --- packages/react-date-picker/src/DateInput.tsx | 29 +++- .../src/DateInput/DayInput.spec.tsx | 90 ++++++++++- .../src/DateInput/DayInput.tsx | 27 +++- .../react-date-picker/src/DatePicker.spec.tsx | 147 +++++++++++++++++- 4 files changed, 288 insertions(+), 5 deletions(-) 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); + }); });