Validate date format and min/max in date range picker component (#18006)

* add validation behaviour

* add specs

* refactor into a hook
This commit is contained in:
Suzanne Aitchison 2022-06-30 10:08:06 +01:00 committed by GitHub
parent 3c00226428
commit d806393642
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 252 additions and 4 deletions

View file

@ -318,6 +318,12 @@ textarea.crayons-textfield.crayons-textfield--ghost {
}
}
&--error {
.DateRangePickerInput {
border-color: var(--accent-danger);
}
}
.DateRangePicker:focus-within {
.DateRangePickerInput {
border-color: var(--focus);
@ -430,4 +436,8 @@ textarea.crayons-textfield.crayons-textfield--ghost {
}
}
}
&__errors {
color: var(--accent-danger);
}
}

View file

@ -1,6 +1,6 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { useState } from 'preact/hooks';
import { useState, useLayoutEffect, useEffect } from 'preact/hooks';
import 'react-dates/initialize';
import moment from 'moment';
import { DateRangePicker as ReactDateRangePicker } from 'react-dates';
@ -24,6 +24,12 @@ const PICKER_PHRASES = {
chooseAvailableStartDate: ({ date }) => `Choose ${date} as start date`,
chooseAvailableEndDate: ({ date }) => `Choose ${date} as end date`,
focusStartDate: 'Interact with the calendar and add your start date',
invalidDateFormat: (format, errorPrefix) =>
`${errorPrefix} must be in the format ${format}`,
dateTooLate: (latestDate, errorPrefix) =>
`${errorPrefix} must be on or before ${latestDate}`,
dateTooEarly: (earliestDate, errorPrefix) =>
`${errorPrefix} must be on or after ${earliestDate}`,
};
const MONTH_NAMES = [...Array(12).keys()].map((key) =>
@ -152,6 +158,87 @@ const PresetDateRangeOptions = ({
);
};
/**
* Hook that will attach listeners to date range inputs and return the correct error message for each.
* @param {Object} props
* @param {Object} props.earliesMoment Moment object reflecting the earliest date that may be selected
* @param {Object} props.latestMoment Moment object reflecting the latest date that may be selected
* @param {string} props.dateFormat The expected date format, e.g. "MM/DD/YYYY"
*/
const useDateRangeValidation = ({
earliestMoment,
latestMoment,
dateFormat,
}) => {
const [startDateInput, setStartDateInput] = useState(null);
const [endDateInput, setEndDateInput] = useState(null);
const [startDateError, setStartDateError] = useState('');
const [endDateError, setEndDateError] = useState('');
useEffect(() => {
const setError = (error, inputType) =>
inputType === 'start'
? setStartDateError(error ?? '')
: setEndDateError(error ?? '');
const handleBlur = ({ target: { value } }, inputType) => {
if (value === '') {
setError('', inputType);
return;
}
const errorPrefix = `${inputType === 'start' ? 'Start' : 'End'} date`;
const valueMoment = moment(value, dateFormat);
if (!valueMoment.isValid()) {
setError(
PICKER_PHRASES.invalidDateFormat(dateFormat, errorPrefix),
inputType,
);
return;
}
if (valueMoment.isBefore(earliestMoment)) {
setError(
PICKER_PHRASES.dateTooEarly(
earliestMoment.format(dateFormat),
errorPrefix,
),
inputType,
);
return;
}
if (valueMoment.isAfter(latestMoment)) {
setError(
PICKER_PHRASES.dateTooLate(
latestMoment.format(dateFormat),
errorPrefix,
),
inputType,
);
return;
}
// Date is valid
setError('', inputType);
};
const handleStartBlur = (e) => handleBlur(e, 'start');
const handleEndBlur = (e) => handleBlur(e, 'end');
startDateInput?.addEventListener('blur', handleStartBlur);
endDateInput?.addEventListener('blur', handleEndBlur);
return () => {
startDateInput?.removeEventListener('blur', handleStartBlur);
endDateInput?.removeEventListener('blur', handleEndBlur);
};
}, [startDateInput, endDateInput, dateFormat, earliestMoment, latestMoment]);
return { setStartDateInput, setEndDateInput, startDateError, endDateError };
};
/**
* Used to facilitate picking a date range. This component is a wrapper around the one provided from react-dates.
*
@ -186,6 +273,9 @@ export const DateRangePicker = ({
defaultEndDate ? moment(defaultEndDate) : null,
);
const dateFormat =
getCurrentLocale().toLowerCase() === 'en-us' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const useCompactLayout = useMediaQuery(
`(max-width: ${BREAKPOINTS.Medium - 1}px)`,
);
@ -199,12 +289,26 @@ export const DateRangePicker = ({
const today = moment(todaysDate);
const dateFormat =
getCurrentLocale().toLowerCase() === 'en-us' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const { setStartDateInput, setEndDateInput, startDateError, endDateError } =
useDateRangeValidation({ earliestMoment, latestMoment, dateFormat });
useLayoutEffect(() => {
const startDateInput = document.getElementById(startDateId);
startDateInput.setAttribute('aria-describedby', 'start-date-error');
setStartDateInput(startDateInput);
const endDateInput = document.getElementById(endDateId);
endDateInput.setAttribute('aria-describedby', 'end-date-error');
setEndDateInput(endDateInput);
}, [startDateId, endDateId, setStartDateInput, setEndDateInput]);
return (
// We wrap in a span to assist with scoping CSS selectors & overriding styles from react-dates
<span className="c-date-picker">
<span
className={`c-date-picker${
startDateError || endDateError ? ' c-date-picker--error' : ''
}`}
>
<ReactDateRangePicker
startDateId={startDateId}
startDate={startMoment}
@ -277,6 +381,13 @@ export const DateRangePicker = ({
/>
)}
/>
<div
className="c-date-picker__errors crayons-field__description"
aria-live="assertive"
>
<div id="start-date-error">{startDateError}</div>
<div id="end-date-error">{endDateError}</div>
</div>
</span>
);
};

View file

@ -148,6 +148,133 @@ describe('<DateRangePicker />', () => {
expect(endDate.getFullYear()).toEqual(2022);
});
it('displays errors on blur if an invalid date is typed', async () => {
const { getByRole } = render(
<DateRangePicker
startDateId="start-date"
endDateId="end-date"
todaysDate={todayMock}
minStartDate={new Date('2022-01-01')}
maxEndDate={new Date('2022-01-31')}
/>,
);
const startDateInput = getByRole('textbox', {
name: 'Start date (MM/DD/YYYY)',
});
const endDateInput = getByRole('textbox', {
name: 'End date (MM/DD/YYYY)',
});
userEvent.type(startDateInput, 'something');
await waitFor(() => expect(startDateInput).toHaveDisplayValue('something'));
// Move away from the input to trigger the blur event
userEvent.tab();
await waitFor(() =>
expect(startDateInput).toHaveAccessibleDescription(
'Start date must be in the format MM/DD/YYYY',
),
);
userEvent.type(endDateInput, '12345');
await waitFor(() => expect(endDateInput).toHaveDisplayValue('12345'));
userEvent.tab();
await waitFor(() =>
expect(endDateInput).toHaveAccessibleDescription(
'End date must be in the format MM/DD/YYYY',
),
);
});
it('displays errors on blur if date is before minimum date', async () => {
const { getByRole } = render(
<DateRangePicker
startDateId="start-date"
endDateId="end-date"
todaysDate={todayMock}
minStartDate={new Date('2022-01-01')}
maxEndDate={new Date('2022-01-31')}
/>,
);
const startDateInput = getByRole('textbox', {
name: 'Start date (MM/DD/YYYY)',
});
const endDateInput = getByRole('textbox', {
name: 'End date (MM/DD/YYYY)',
});
userEvent.type(startDateInput, '01/22/2020');
await waitFor(() =>
expect(startDateInput).toHaveDisplayValue('01/22/2020'),
);
// Move away from the input to trigger the blur event
userEvent.tab();
await waitFor(() =>
expect(startDateInput).toHaveAccessibleDescription(
'Start date must be on or after 01/01/2022',
),
);
userEvent.type(endDateInput, '01/22/2020');
await waitFor(() => expect(endDateInput).toHaveDisplayValue('01/22/2020'));
userEvent.tab();
await waitFor(() =>
expect(endDateInput).toHaveAccessibleDescription(
'End date must be on or after 01/01/2022',
),
);
});
it('displays errors on blur if date is after maximum date', async () => {
const { getByRole } = render(
<DateRangePicker
startDateId="start-date"
endDateId="end-date"
todaysDate={todayMock}
minStartDate={new Date('2022-01-01')}
maxEndDate={new Date('2022-01-31')}
/>,
);
const startDateInput = getByRole('textbox', {
name: 'Start date (MM/DD/YYYY)',
});
const endDateInput = getByRole('textbox', {
name: 'End date (MM/DD/YYYY)',
});
userEvent.type(startDateInput, '05/22/2022');
await waitFor(() =>
expect(startDateInput).toHaveDisplayValue('05/22/2022'),
);
// Move away from the input to trigger the blur event
userEvent.tab();
await waitFor(() =>
expect(startDateInput).toHaveAccessibleDescription(
'Start date must be on or before 01/31/2022',
),
);
userEvent.type(endDateInput, '05/22/2022');
await waitFor(() => expect(endDateInput).toHaveDisplayValue('05/22/2022'));
userEvent.tab();
await waitFor(() =>
expect(endDateInput).toHaveAccessibleDescription(
'End date must be on or before 01/31/2022',
),
);
});
it('skips to a selected year', async () => {
const { getByRole, getAllByRole } = render(
<DateRangePicker