Skip to content

Add improved day validation workaround for leap year date input bug #695

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions packages/react-date-picker/src/DateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down
90 changes: 89 additions & 1 deletion packages/react-date-picker/src/DateInput/DayInput.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
});
});
});
27 changes: 26 additions & 1 deletion packages/react-date-picker/src/DateInput/DayInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export default function DayInput({
year,
...otherProps
}: DayInputProps): React.ReactElement {
const { maxDay, minDay } = getMinMaxDays({ minDate, maxDate, month, year });

return <Input max={maxDay} min={minDay} name="day" {...otherProps} />;
}

export function getMinMaxDays(args: DayInputProps): {
maxDay: number;
minDay: number;
} {
const { minDate, maxDate, month, year } = args;

const currentMonthMaxDays = (() => {
if (!month) {
return 31;
Expand All @@ -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 <Input max={maxDay} min={minDay} name="day" {...otherProps} />;
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();
}
147 changes: 146 additions & 1 deletion packages/react-date-picker/src/DatePicker.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
<DatePicker
onChange={onChange}
onInvalidChange={onInvalidChange}
format="dd.MM.yyyy"
minDate={new Date(1000, 0, 1)}
/>,
);

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(
<DatePicker
onChange={onChange}
onInvalidChange={onInvalidChange}
format="dd.MM.yyyy"
minDate={new Date('0010-01-01')}
/>,
);

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