diff --git a/src/components/forms/DatePicker/DatePicker.tsx b/src/components/forms/DatePicker/DatePicker.tsx index af13a3e040..2b3f3056c2 100644 --- a/src/components/forms/DatePicker/DatePicker.tsx +++ b/src/components/forms/DatePicker/DatePicker.tsx @@ -24,14 +24,14 @@ import { } from './utils' import { Calendar } from './Calendar' -interface DatePickerProps { +interface BaseDatePickerProps { id: string name: string className?: string disabled?: boolean required?: boolean defaultValue?: string - minDate: string + minDate?: string maxDate?: string rangeDate?: string onChange?: (val?: string) => void @@ -40,6 +40,8 @@ interface DatePickerProps { ) => void } +export type DatePickerProps = BaseDatePickerProps & Omit + export enum FocusMode { None, Input, @@ -48,6 +50,7 @@ export enum FocusMode { export const DatePicker = ({ id, name, + className, defaultValue, disabled, required, @@ -57,7 +60,7 @@ export const DatePicker = ({ onChange, onBlur, ...inputProps -}: DatePickerProps & JSX.IntrinsicElements['input']): React.ReactElement => { +}: DatePickerProps): React.ReactElement => { const datePickerEl = useRef(null) const externalInputEl = useRef(null) @@ -236,7 +239,8 @@ export const DatePicker = ({ 'usa-date-picker--initialized', { 'usa-date-picker--active': showCalendar, - } + }, + className ) return ( diff --git a/src/components/forms/DateRangePicker/DateRangePicker.stories.tsx b/src/components/forms/DateRangePicker/DateRangePicker.stories.tsx new file mode 100644 index 0000000000..330aa98b8c --- /dev/null +++ b/src/components/forms/DateRangePicker/DateRangePicker.stories.tsx @@ -0,0 +1,102 @@ +import React from 'react' + +import { DateRangePicker } from './DateRangePicker' +import { Form } from '../Form/Form' +import { addDays, formatDate } from '../DatePicker/utils' + +export default { + title: 'Components/Form controls/Date range picker', + component: DateRangePicker, + argTypes: { + onSubmit: { + action: 'submitted' + }, + startDatePickerDisabled: { + control: { type: 'boolean' }, + defaultValue: false + }, + endDatePickerDisabled: { + control: { type: 'boolean' }, + defaultValue: false + }, + }, + parameters: { + docs: { + description: { + component: ` + ### USWDS 2.0 Date Range Picker component + Source: https://designsystem.digital.gov/components/form-controls/#date-range-picker + ` + }, + }, + }, +} + +export const completeDateRangePicker = (argTypes): React.ReactElement => ( +
+ + +) + +export const withDefaultValues = (argTypes): React.ReactElement => ( +
+ + +) + +export const withAllowableRanges = (argTypes): React.ReactElement => ( +
+ + +) diff --git a/src/components/forms/DateRangePicker/DateRangePicker.test.tsx b/src/components/forms/DateRangePicker/DateRangePicker.test.tsx new file mode 100644 index 0000000000..ba4369aa41 --- /dev/null +++ b/src/components/forms/DateRangePicker/DateRangePicker.test.tsx @@ -0,0 +1,402 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { DateRangePicker } from './DateRangePicker' +import userEvent from '@testing-library/user-event' + +const startDatePickerTestProps = { + id: 'start-date', + name: 'start-date' +} + +const endDatePickerTestProps = { + id: 'end-date', + name: 'end-date' +} + +describe("DateRangePicker component", () => { + it('renders without errors', () => { + const { getByTestId, getAllByTestId } = render( + + ) + + const dateRangePicker = getByTestId('date-range-picker') + expect(dateRangePicker).toBeInTheDocument() + expect(dateRangePicker).toHaveClass('usa-date-range-picker') + + const datePickers = getAllByTestId('date-picker') + expect(datePickers).toHaveLength(2) + + const startDatePicker = datePickers[0] + const endDatePicker = datePickers[1] + expect(startDatePicker).toHaveClass('usa-date-range-picker__range-start') + expect(endDatePicker).toHaveClass('usa-date-range-picker__range-end') + + const internalInputs = getAllByTestId('date-picker-internal-input') + expect(internalInputs).toHaveLength(2) + + const startDatePickerInternalInput = internalInputs[0] + const endDatePickerInternalInput = internalInputs[1] + expect(startDatePickerInternalInput).not.toHaveAttribute("aria-describedby") + expect(endDatePickerInternalInput).not.toHaveAttribute("aria-describedby") + + const externalInputs = getAllByTestId('date-picker-external-input') + expect(externalInputs).toHaveLength(2) + + const startDatePickerExternalInput = externalInputs[0] + const endDatePickerExternalInput = externalInputs[1] + expect(startDatePickerExternalInput).not.toHaveAttribute("aria-describedby") + expect(endDatePickerExternalInput).not.toHaveAttribute("aria-describedby") + }) + + it('renders labels when specified', () => { + const { getByTestId, getAllByTestId, queryByText } = render( + + ) + + const dateRangePicker = getByTestId('date-range-picker') + expect(dateRangePicker).toBeInTheDocument() + expect(dateRangePicker).toHaveClass('usa-date-range-picker') + + const datePickers = getAllByTestId('date-picker') + expect(datePickers).toHaveLength(2) + + const startDatePicker = datePickers[0] + const endDatePicker = datePickers[1] + expect(startDatePicker).toHaveClass('usa-date-range-picker__range-start') + expect(endDatePicker).toHaveClass('usa-date-range-picker__range-end') + + const startDateLabel = queryByText("Start Date") + expect(startDateLabel).toBeInTheDocument() + expect(startDateLabel).toHaveClass('usa-label') + + const endDateLabel = queryByText("End Date") + expect(endDateLabel).toBeInTheDocument() + expect(endDateLabel).toHaveClass('usa-label') + + const internalInputs = getAllByTestId('date-picker-internal-input') + expect(internalInputs).toHaveLength(2) + + const startDatePickerInternalInput = internalInputs[0] + const endDatePickerInternalInput = internalInputs[1] + expect(startDatePickerInternalInput).toHaveAttribute("aria-describedby", "start-date-label") + expect(endDatePickerInternalInput).toHaveAttribute("aria-describedby", "end-date-label") + + const externalInputs = getAllByTestId('date-picker-external-input') + expect(externalInputs).toHaveLength(2) + + const startDatePickerExternalInput = externalInputs[0] + const endDatePickerExternalInput = externalInputs[1] + expect(startDatePickerExternalInput).toHaveAttribute("aria-describedby", "start-date-label") + expect(endDatePickerExternalInput).toHaveAttribute("aria-describedby", "end-date-label") + }) + + it('renders hints when specified', () => { + const { getByTestId, getAllByTestId, queryByText } = render( + + ) + + const dateRangePicker = getByTestId('date-range-picker') + expect(dateRangePicker).toBeInTheDocument() + expect(dateRangePicker).toHaveClass('usa-date-range-picker') + expect(getAllByTestId('date-picker')).toHaveLength(2) + + const startDateHint = queryByText("start date format: mm/dd/yyyy") + expect(startDateHint).toBeInTheDocument() + expect(startDateHint).toHaveClass('usa-hint') + + const endDateHint = queryByText("end date format: mm/dd/yyyy") + expect(endDateHint).toBeInTheDocument() + expect(endDateHint).toHaveClass('usa-hint') + + const internalInputs = getAllByTestId('date-picker-internal-input') + expect(internalInputs).toHaveLength(2) + + const startDatePickerInternalInput = internalInputs[0] + const endDatePickerInternalInput = internalInputs[1] + expect(startDatePickerInternalInput).toHaveAttribute("aria-describedby", "start-date-hint") + expect(endDatePickerInternalInput).toHaveAttribute("aria-describedby", "end-date-hint") + + const externalInputs = getAllByTestId('date-picker-external-input') + expect(externalInputs).toHaveLength(2) + + const startDatePickerExternalInput = externalInputs[0] + const endDatePickerExternalInput = externalInputs[1] + expect(startDatePickerExternalInput).toHaveAttribute("aria-describedby", "start-date-hint") + expect(endDatePickerExternalInput).toHaveAttribute("aria-describedby", "end-date-hint") + }) + + it('renders labels and hints simultaneously, properly populating the aria-describedby property on each DatePicker', () => { + const { getByTestId, getAllByTestId, queryByText } = render( + + ) + + const dateRangePicker = getByTestId('date-range-picker') + expect(dateRangePicker).toBeInTheDocument() + expect(dateRangePicker).toHaveClass('usa-date-range-picker') + expect(getAllByTestId('date-picker')).toHaveLength(2) + + const startDateLabel = queryByText("Start Date") + expect(startDateLabel).toBeInTheDocument() + expect(startDateLabel).toHaveClass('usa-label') + + const endDateLabel = queryByText("End Date") + expect(endDateLabel).toBeInTheDocument() + expect(endDateLabel).toHaveClass('usa-label') + + const startDateHint = queryByText("start date format: mm/dd/yyyy") + expect(startDateHint).toBeInTheDocument() + expect(startDateHint).toHaveClass('usa-hint') + + const endDateHint = queryByText("end date format: mm/dd/yyyy") + expect(endDateHint).toBeInTheDocument() + expect(endDateHint).toHaveClass('usa-hint') + + const internalInputs = getAllByTestId('date-picker-internal-input') + expect(internalInputs).toHaveLength(2) + + const startDatePickerInternalInput = internalInputs[0] + const endDatePickerInternalInput = internalInputs[1] + expect(startDatePickerInternalInput).toHaveAttribute("aria-describedby", "start-date-label start-date-hint") + expect(endDatePickerInternalInput).toHaveAttribute("aria-describedby", "end-date-label end-date-hint") + + const externalInputs = getAllByTestId('date-picker-external-input') + expect(externalInputs).toHaveLength(2) + + const startDatePickerExternalInput = externalInputs[0] + const endDatePickerExternalInput = externalInputs[1] + expect(startDatePickerExternalInput).toHaveAttribute("aria-describedby", "start-date-label start-date-hint") + expect(endDatePickerExternalInput).toHaveAttribute("aria-describedby", "end-date-label end-date-hint") + }) + + it('allows a date range to be selected by using both date pickers to pick start and end dates', () => { + const mockStartDatePickerOnChange = jest.fn() + const mockEndDatePickerOnChange = jest.fn() + const { getAllByTestId, getByText } = render( + + ) + + const datePickerButtons = getAllByTestId('date-picker-button') + const startDatePickerButton = datePickerButtons[0] + const endDatePickerButton = datePickerButtons[1] + + const calendars = getAllByTestId('date-picker-calendar') + const startDatePickerCalendar = calendars[0] + const endDatePickerCalendar = calendars[1] + + const internalInputs = getAllByTestId('date-picker-internal-input') + const startDatePickerInternalInput = internalInputs[0] + const endDatePickerInternalInput = internalInputs[1] + + const externalInputs = getAllByTestId('date-picker-external-input') + const startDatePickerExternalInput = externalInputs[0] + const endDatePickerExternalInput = externalInputs[1] + + // Select the start date from the first date picker: + userEvent.click(startDatePickerButton) + expect(startDatePickerCalendar).toBeVisible() + const defaultSelectedStartDate = getByText('20') + expect(defaultSelectedStartDate).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--selected usa-date-picker__calendar__date--range-date-start' + ) + const newStartDateButton = getByText('21') + expect(newStartDateButton).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--within-range' + ) + userEvent.click(newStartDateButton) + expect(startDatePickerExternalInput).toHaveValue('01/21/2021') + expect(startDatePickerInternalInput).toHaveValue('2021-01-21') + expect(startDatePickerExternalInput).toHaveFocus() + expect(startDatePickerCalendar).not.toBeVisible() + expect(mockStartDatePickerOnChange).toHaveBeenCalledWith('01/21/2021') + + // Select the end date from the second date picker: + userEvent.click(endDatePickerButton) + expect(endDatePickerCalendar).toBeVisible() + const defaultSelectedEndDate = getByText('25') + expect(defaultSelectedEndDate).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--selected usa-date-picker__calendar__date--range-date-end' + ) + const newEndDateButton = getByText('24') + expect(newEndDateButton).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--within-range' + ) + userEvent.click(newEndDateButton) + expect(endDatePickerExternalInput).toHaveValue('01/24/2021') + expect(endDatePickerInternalInput).toHaveValue('2021-01-24') + expect(endDatePickerExternalInput).toHaveFocus() + expect(endDatePickerCalendar).not.toBeVisible() + expect(mockEndDatePickerOnChange).toHaveBeenCalledWith('01/24/2021') + }) + + it('prevents the selection of a date range where the end date is before the start date', () => { + const mockStartDatePickerOnChange = jest.fn() + const mockEndDatePickerOnChange = jest.fn() + const { getAllByTestId, getByText } = render( + + ) + + const datePickerButtons = getAllByTestId('date-picker-button') + const startDatePickerButton = datePickerButtons[0] + const endDatePickerButton = datePickerButtons[1] + + const calendars = getAllByTestId('date-picker-calendar') + const startDatePickerCalendar = calendars[0] + const endDatePickerCalendar = calendars[1] + + // Try to select an start date before the end date: + userEvent.click(startDatePickerButton) + expect(startDatePickerCalendar).toBeVisible() + const defaultSelectedStartDate = getByText('20') + expect(defaultSelectedStartDate).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--selected usa-date-picker__calendar__date--range-date-start' + ) + const startDatePickerRangeEnd = getByText('25') + expect(startDatePickerRangeEnd).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--range-date-end' + ) + const invalidStartDateButton = getByText('26') + expect(invalidStartDateButton).toHaveClass( + 'usa-date-picker__calendar__date' + ) + expect(invalidStartDateButton).toBeDisabled() + userEvent.click(invalidStartDateButton) + expect(startDatePickerCalendar).toBeVisible() + expect(mockEndDatePickerOnChange).not.toHaveBeenCalledWith("01/26/2021") + + // Try to select an end date before the start date: + userEvent.click(endDatePickerButton) + expect(endDatePickerCalendar).toBeVisible() + const endDatePickerRangeStart = getByText('20') + expect(endDatePickerRangeStart).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--range-date-start' + ) + const defaultSelectedEndDate = getByText('25') + expect(defaultSelectedEndDate).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--selected usa-date-picker__calendar__date--range-date-end' + ) + const invalidEndDateButton = getByText('19') + expect(invalidEndDateButton).toHaveClass( + 'usa-date-picker__calendar__date' + ) + expect(invalidEndDateButton).toBeDisabled() + userEvent.click(invalidEndDateButton) + expect(endDatePickerCalendar).toBeVisible() + expect(mockEndDatePickerOnChange).not.toHaveBeenCalledWith("01/19/2021") + }) + + it('clears the range-determining date of the opposite DatePicker when the input is cleared', () => { + const mockStartDatePickerOnChange = jest.fn() + const mockEndDatePickerOnChange = jest.fn() + const { getAllByTestId, getByText } = render( + + ) + + const datePickerExternalInputs = getAllByTestId('date-picker-external-input') + const startDatePickerExternalInput = datePickerExternalInputs[0] + + const datePickerButtons = getAllByTestId('date-picker-button') + const endDatePickerButton = datePickerButtons[1] + + const calendars = getAllByTestId('date-picker-calendar') + const endDatePickerCalendar = calendars[1] + + // Verify the end date cannot be selected before the default-selected start date: + userEvent.click(endDatePickerButton) + expect(endDatePickerCalendar).toBeVisible() + const endDatePickerRangeStart = getByText('20') + expect(endDatePickerRangeStart).toHaveClass( + 'usa-date-picker__calendar__date usa-date-picker__calendar__date--range-date-start' + ) + const invalidEndDateButton = getByText('19') + expect(invalidEndDateButton).toHaveClass( + 'usa-date-picker__calendar__date' + ) + expect(invalidEndDateButton).toBeDisabled() + userEvent.click(invalidEndDateButton) + expect(endDatePickerCalendar).toBeVisible() + expect(mockEndDatePickerOnChange).not.toHaveBeenCalledWith("01/19/2021") + + // Close the end date picker calendar: + userEvent.click(endDatePickerButton) + expect(endDatePickerCalendar).not.toBeVisible() + + // Clear the start picker input: + userEvent.clear(startDatePickerExternalInput) + + // Verify an end date before the previously selected start date can be selected: + userEvent.click(endDatePickerButton) + expect(endDatePickerCalendar).toBeVisible() + const noLongerRangeStart = getByText('20') + expect(noLongerRangeStart).toHaveClass( + 'usa-date-picker__calendar__date' + ) + const previouslyInvalidEndDateButton = getByText('19') + expect(previouslyInvalidEndDateButton).toHaveClass( + 'usa-date-picker__calendar__date' + ) + expect(previouslyInvalidEndDateButton).not.toBeDisabled() + userEvent.click(previouslyInvalidEndDateButton) + expect(endDatePickerCalendar).not.toBeVisible() + expect(mockEndDatePickerOnChange).toHaveBeenCalledWith("01/19/2021") + }) +}) diff --git a/src/components/forms/DateRangePicker/DateRangePicker.tsx b/src/components/forms/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 0000000000..e18f948e33 --- /dev/null +++ b/src/components/forms/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,179 @@ +import classnames from 'classnames' +import React, { useState } from 'react' +import { DEFAULT_EXTERNAL_DATE_FORMAT } from '../DatePicker/constants' +import { DatePicker, DatePickerProps } from '../DatePicker/DatePicker' +import { formatDate, parseDateString } from '../DatePicker/utils' +import { FormGroup } from '../FormGroup/FormGroup' +import { Label } from '../Label/Label' + +interface DateRangePickerProps { + startDateLabel?: string + startDateHint?: string + startDatePickerProps: Omit + endDateLabel?: string + endDateHint?: string + endDatePickerProps: Omit + className?: string +} + +export const DateRangePicker = ( + props: DateRangePickerProps & JSX.IntrinsicElements['div'] +): React.ReactElement => { + const { + startDateLabel, + startDateHint, + startDatePickerProps, + endDateLabel, + endDateHint, + endDatePickerProps, + className + } = props + + const [ + startDateInternalValue, + setStartDateInternalValue + ] = useState(startDatePickerProps.defaultValue) + const [ + endDateInternalValue, + setEndDateInternalValue + ] = useState(endDatePickerProps.defaultValue) + + const getMaxStartDate = (): string | undefined => { + const { maxDate: maxStartDate } = startDatePickerProps + const parsedMaxStartDate = maxStartDate && parseDateString(maxStartDate) + const parsedCurrentEndDate = endDateInternalValue && parseDateString(endDateInternalValue) + + if (parsedCurrentEndDate && parsedMaxStartDate) { + if (parsedCurrentEndDate.getTime() < parsedMaxStartDate.getTime()) { + return formatDate(parsedCurrentEndDate) + } else { + return formatDate(parsedMaxStartDate) + } + } else { + return (parsedCurrentEndDate && formatDate(parsedCurrentEndDate)) + || (parsedMaxStartDate && formatDate(parsedMaxStartDate)) + || undefined + } + } + + const getMinEndDate = (): string | undefined => { + const { minDate: minEndDate } = endDatePickerProps + const parsedMinEndDate = minEndDate && parseDateString(minEndDate) + const parsedCurrentStartDate = startDateInternalValue && parseDateString(startDateInternalValue) + + if (parsedCurrentStartDate && parsedMinEndDate) { + if (parsedCurrentStartDate.getTime() > parsedMinEndDate.getTime()) { + return formatDate(parsedCurrentStartDate) + } else { + return formatDate(parsedMinEndDate) + } + } else { + return (parsedCurrentStartDate && formatDate(parsedCurrentStartDate)) + || (parsedMinEndDate && formatDate(parsedMinEndDate)) + || undefined + } + } + + const getDatePickerOnChangeFn = ( + originalOnChangeFn: ((val?: string) => void) | undefined, + setStateInternalValueFn: React.Dispatch> + ): (val?: string) => void => { + return (externallyFormattedValue?: string | undefined): void => { + const parsedValue = + externallyFormattedValue && parseDateString(externallyFormattedValue, DEFAULT_EXTERNAL_DATE_FORMAT) + + if (parsedValue) { + // The externally input and formatted value is a valid date. + // Convert to internal format and set the internal state to + // the selected date. + const internallyFormattedValue = formatDate(parsedValue) + setStateInternalValueFn(internallyFormattedValue) + } else { + // Externally input and formatted value is not a valid date. + // Do not attempt to convert to internal date format. + // Simply update internal state with the input value as received. + setStateInternalValueFn(externallyFormattedValue) + } + + if (originalOnChangeFn) originalOnChangeFn(externallyFormattedValue) + } + } + + const startDatePickerOnChange = getDatePickerOnChangeFn( + startDatePickerProps.onChange, + setStartDateInternalValue + ) + + const endDatePickerOnChange = getDatePickerOnChangeFn( + endDatePickerProps.onChange, + setEndDateInternalValue + ) + + const classes = classnames(className, 'usa-date-range-picker') + const startDatePickerClasses = classnames(startDatePickerProps.className, 'usa-date-range-picker__range-start') + const endDatePickerClasses = classnames(endDatePickerProps.className, 'usa-date-range-picker__range-end') + + const startDatePickerLabelId = `${startDatePickerProps.id}-label` + const startDatePickerHintId = `${startDatePickerProps.id}-hint` + const startDatePickerAriaDescribedBy = [ + startDateLabel && startDatePickerLabelId, + startDateHint && startDatePickerHintId + ].join(" ").trim() || undefined + + const endDatePickerLabelId = `${endDatePickerProps.id}-label` + const endDatePickerHintId = `${endDatePickerProps.id}-hint` + const endDatePickerAriaDescribedBy = [ + endDateLabel && endDatePickerLabelId, + endDateHint && endDatePickerHintId + ].join(" ").trim() || undefined + + return ( +
+ + {startDateLabel && + } + {startDateHint && +
+ {startDateHint} +
} + +
+ + + {endDateLabel && + } + {endDateHint && +
+ {endDateHint} +
} + +
+
+ ) +} diff --git a/src/index.ts b/src/index.ts index 5110a40091..b1238f2c85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export { ComboBox } from './components/forms/ComboBox/ComboBox' export { DateInput } from './components/forms/DateInput/DateInput' export { DateInputGroup } from './components/forms/DateInputGroup/DateInputGroup' export { DatePicker } from './components/forms/DatePicker/DatePicker' +export { DateRangePicker } from './components/forms/DateRangePicker/DateRangePicker' export { Dropdown } from './components/forms/Dropdown/Dropdown' export { ErrorMessage } from './components/forms/ErrorMessage/ErrorMessage' export { Fieldset } from './components/forms/Fieldset/Fieldset'