From 92f37e4ded4f248215dba6a379999b711525bf01 Mon Sep 17 00:00:00 2001 From: jpveooys <66470099+jpveooys@users.noreply.github.com> Date: Mon, 20 Dec 2021 17:10:10 +0000 Subject: [PATCH 1/3] feat(DatePicker): Migrate to react-day-picker v8 This migrates DatePicker to version 8 of react-day-picker, which includes various important accessibility and keyboard navigation fixes. --- .../cypress/e2e/DatePicker/index.cy.ts | 12 +- .../cypress/selectors/DatePicker.ts | 4 +- packages/react-component-library/package.json | 2 +- .../components/DatePicker/DatePicker.test.tsx | 73 ++++++---- .../src/components/DatePicker/DatePicker.tsx | 100 ++++++------- .../src/components/DatePicker/constants.ts | 8 +- .../DatePicker/partials/StyledDayPicker.tsx | 132 ++++++++---------- .../DatePicker/useFocusTrapOptions.ts | 3 - .../DatePicker/useHandleDayClick.ts | 28 +++- .../src/components/DatePicker/useInput.ts | 2 +- yarn.lock | 28 +++- 11 files changed, 199 insertions(+), 193 deletions(-) diff --git a/packages/react-component-library/cypress/e2e/DatePicker/index.cy.ts b/packages/react-component-library/cypress/e2e/DatePicker/index.cy.ts index ce3086966a..c917be11fe 100644 --- a/packages/react-component-library/cypress/e2e/DatePicker/index.cy.ts +++ b/packages/react-component-library/cypress/e2e/DatePicker/index.cy.ts @@ -2,10 +2,10 @@ import { describe, cy, it, before } from 'local-cypress' import { addDays, startOfMonth, format } from 'date-fns' import { ColorNeutral200 } from '@defencedigital/design-tokens' +import { formatDatesForInput } from '../../../src/components/DatePicker/utils' import { DATE_FORMAT } from '../../../src/constants' import { hexToRgb } from '../../helpers' import selectors from '../../selectors' -import { formatDatesForInput } from '../../../src/components/DatePicker/utils/formatDatesForInput' describe('DatePicker', () => { describe('when a day is selected', () => { @@ -23,9 +23,7 @@ describe('DatePicker', () => { describe('and the first day is clicked', () => { before(() => { - cy.get(selectors.datePicker.day.inside) - .contains('1') - .click({ force: true }) + cy.get(selectors.datePicker.day).contains('1').click({ force: true }) }) it('should set the value of the input to the date', () => { @@ -47,7 +45,7 @@ describe('DatePicker', () => { describe('when a range is selected', () => { before(() => { - cy.visit('/iframe.html?id=date-picker--range&viewMode=story') + cy.visit('/iframe.html?id=date-picker-experimental--range&viewMode=story') cy.get(selectors.datePicker.input).click() }) @@ -58,7 +56,7 @@ describe('DatePicker', () => { describe('and the `from` day is clicked', () => { before(() => { - cy.get(selectors.datePicker.day.inside).contains('1').click() + cy.get(selectors.datePicker.day).contains('1').click() }) it('should set the value of the input to the date', () => { @@ -72,7 +70,7 @@ describe('DatePicker', () => { describe('and the `to` day is clicked', () => { before(() => { - cy.get(selectors.datePicker.day.inside).contains('10').click() + cy.get(selectors.datePicker.day).contains('10').click() }) it('should set the value of the input to the range', () => { diff --git a/packages/react-component-library/cypress/selectors/DatePicker.ts b/packages/react-component-library/cypress/selectors/DatePicker.ts index f36bdcc6d6..48d157903e 100644 --- a/packages/react-component-library/cypress/selectors/DatePicker.ts +++ b/packages/react-component-library/cypress/selectors/DatePicker.ts @@ -1,8 +1,6 @@ export default { button: '[data-testid="datepicker-input-button"]', - day: { - inside: '.DayPicker-Day:not(.DayPicker-Day--outside)', - }, + day: '.rdp_day', floatingBox: '[data-testid="floating-box"]', input: '[data-testid="datepicker-input"]', outerWrapper: '[data-testid="datepicker-outer-wrapper"]', diff --git a/packages/react-component-library/package.json b/packages/react-component-library/package.json index 34f94998ea..b7f0fdca85 100644 --- a/packages/react-component-library/package.json +++ b/packages/react-component-library/package.json @@ -157,7 +157,7 @@ "lodash": "^4.17.21", "polished": "^4.0.3", "react-compound-slider": "^3.3.1", - "react-day-picker": "^7.4.8", + "react-day-picker": "^8.0.4", "react-merge-refs": "^1.1.0", "react-popper": "^2.2.5", "react-select": "^4.1.0", diff --git a/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx b/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx index efb9c12012..3960568a6f 100644 --- a/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx +++ b/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx @@ -21,6 +21,8 @@ const NOW = '2019-12-05T11:00:00.000Z' const ERROR_BOX_SHADOW = `0 0 0 ${ BORDER_WIDTH[COMPONENT_SIZE.FORMS] } ${ColorDanger800.toUpperCase()}` +const PREVIOUS_MONTH_BUTTON_LABEL = 'Go to previous month' +const NEXT_MONTH_BUTTON_LABEL = 'Go to next month' function formatDate(date: Date | null) { return date && isValid(date) ? format(date, 'dd/MM/yyyy') : '' @@ -32,7 +34,6 @@ describe('DatePicker', () => { let initialStartDate: Date let label: string let onBlur: (e: React.FormEvent) => void - let onCalendarFocus: jest.Mock let days: string[] let onSubmitSpy: (e: React.FormEvent) => void const onChange = jest.fn() @@ -75,7 +76,6 @@ describe('DatePicker', () => { beforeEach(() => { initialStartDate = new Date(2019, 11, 1) onBlur = jest.fn() - onCalendarFocus = jest.fn() wrapper = render( <> @@ -83,7 +83,6 @@ describe('DatePicker', () => { initialStartDate={initialStartDate} onChange={onChange} onBlur={onBlur} - onCalendarFocus={onCalendarFocus} />
@@ -166,7 +165,9 @@ describe('DatePicker', () => { it('colours the current date', () => { return waitFor(() => { - expect(wrapper.getByText(/^5$/)).toHaveStyle({ + expect( + wrapper.getByRole('button', { name: '5th December (Thursday)' }) + ).toHaveStyle({ color: ColorWarning800, }) }) @@ -296,13 +297,13 @@ describe('DatePicker', () => { describe('and the tab key is pressed again', () => { beforeEach(() => { - onCalendarFocus.mockClear() return user.tab() }) - it('focuses the picker container', () => { - expect(onCalendarFocus).toHaveBeenCalledTimes(1) - // NOTE: `react-day-picker` internals from here on down + it('focuses the previous month button', () => { + expect( + wrapper.getByLabelText(PREVIOUS_MONTH_BUTTON_LABEL) + ).toHaveFocus() }) }) @@ -624,18 +625,21 @@ describe('DatePicker', () => { it('focuses the previous month button', () => { return waitFor(() => - expect(wrapper.getByLabelText('Previous Month')).toHaveFocus() + expect( + wrapper.getByLabelText(PREVIOUS_MONTH_BUTTON_LABEL) + ).toHaveFocus() ) }) - describe('when Shift-Tab is pressed twice', () => { + describe('when Shift-Tab is pressed once', () => { beforeEach(async () => { await user.tab({ shift: true }) - await user.tab({ shift: true }) }) it('traps the focus within the picker', () => { - expect(wrapper.getByText('1')).toHaveFocus() + expect( + wrapper.getByRole('button', { name: '5th December (Thursday)' }) + ).toHaveFocus() }) describe('and Tab is then pressed once', () => { @@ -644,26 +648,22 @@ describe('DatePicker', () => { }) it('still traps the focus within the picker', () => { - const dayPicker = - wrapper.container.querySelectorAll('.DayPicker-wrapper')[0] - expect(dayPicker).toHaveFocus() + expect( + wrapper.getByLabelText(PREVIOUS_MONTH_BUTTON_LABEL) + ).toHaveFocus() }) }) }) describe.each([ - { - name: 'day picker container', - selector: () => - wrapper.container.querySelectorAll('.DayPicker-wrapper')[0], - }, { name: 'previous month button', - selector: () => wrapper.getByLabelText('Previous Month'), + selector: () => wrapper.getByLabelText(PREVIOUS_MONTH_BUTTON_LABEL), }, { name: 'day picker day', - selector: () => wrapper.getByText(10), + selector: () => + wrapper.getByRole('button', { name: '10th December (Tuesday)' }), }, ])('when the escape key is pressed in $name', ({ selector }) => { beforeEach(() => { @@ -685,7 +685,7 @@ describe('DatePicker', () => { describe('when the next month button is clicked', () => { beforeEach(() => { - return user.click(wrapper.getByLabelText('Next Month')) + return user.click(wrapper.getByLabelText(NEXT_MONTH_BUTTON_LABEL)) }) it('displays the next month', () => { @@ -782,7 +782,8 @@ describe('DatePicker', () => { it('shades the date range', () => { expect( wrapper.getAllByText(/^10|11|12|13$/, { - selector: '.DayPicker-Day--selected', + selector: '.rdp-day_selected span', + ignore: '[class=rdp-vhidden]', }) ).toHaveLength(4) }) @@ -790,7 +791,7 @@ describe('DatePicker', () => { it("doesn't shade dates outside the range", () => { expect( wrapper.queryAllByText(/^(?!(10|11|12|13))\d\d$/, { - selector: '.DayPicker-Day--selected', + selector: '.rdp-day_selected span', }) ).toHaveLength(0) }) @@ -816,7 +817,8 @@ describe('DatePicker', () => { it('shades the date range', () => { expect( wrapper.getAllByText(/^10|11|12|13$/, { - selector: '.DayPicker-Day--selected', + selector: '.rdp-day_selected span', + ignore: '[class=rdp-vhidden]', }) ).toHaveLength(4) }) @@ -824,7 +826,7 @@ describe('DatePicker', () => { it("doesn't shade dates outside the range", () => { expect( wrapper.queryAllByText(/^(?!(10|11|12|13))\d\d$/, { - selector: '.DayPicker-Day--selected', + selector: '.rdp-day_selected span', }) ).toHaveLength(0) }) @@ -977,13 +979,17 @@ describe('DatePicker', () => { }) it('colours the override date', () => { - expect(wrapper.getByText(/^15$/)).toHaveStyle({ + expect( + wrapper.getByRole('button', { name: '15th December (Sunday)' }) + ).toHaveStyle({ color: ColorWarning800, }) }) it('does not colour the actual current date', () => { - expect(wrapper.getByText(/^5$/)).not.toHaveStyle({ + expect( + wrapper.getByRole('button', { name: '5th December (Thursday)' }) + ).not.toHaveStyle({ color: ColorWarning800, }) }) @@ -1035,12 +1041,17 @@ describe('DatePicker', () => { }) it('applies the disabled modifier class to the correct days', () => { - expect(wrapper.getByText('12')).toHaveClass('DayPicker-Day--disabled') + expect( + wrapper.getByRole('button', { name: '12th April (Sunday)' }) + ).toHaveClass('rdp-day_disabled') }) describe('and a disabled day is clicked', () => { beforeEach(() => { - return userEvent.click(wrapper.getByText('12'), { + const button = wrapper.getByRole('button', { + name: '12th April (Sunday)', + }) + return userEvent.click(button, { pointerEventsCheck: PointerEventsCheckLevel.Never, advanceTimers: jest.advanceTimersByTime, }) diff --git a/packages/react-component-library/src/components/DatePicker/DatePicker.tsx b/packages/react-component-library/src/components/DatePicker/DatePicker.tsx index 054b595605..37cda168bd 100644 --- a/packages/react-component-library/src/components/DatePicker/DatePicker.tsx +++ b/packages/react-component-library/src/components/DatePicker/DatePicker.tsx @@ -1,12 +1,14 @@ import { IconEvent } from '@defencedigital/icon-library' +import { format, startOfToday } from 'date-fns' +import { enGB } from 'date-fns/locale' import FocusTrap from 'focus-trap-react' import React, { useCallback, useRef, useState } from 'react' import { Placement } from '@popperjs/core' -import { DayModifiers, DayPickerProps } from 'react-day-picker' +import { DayPickerProps } from 'react-day-picker' import { ComponentWithClass } from '../../common/ComponentWithClass' import { DATE_FORMAT } from '../../constants' -import { DATE_VALIDITY, WEEKDAY_TITLES } from './constants' +import { DATE_VALIDITY } from './constants' import { DATEPICKER_ACTION } from './types' import { hasClass, ValueOf } from '../../helpers' import { InlineButton } from '../InlineButtons/InlineButton' @@ -29,18 +31,6 @@ import { useRangeHoverOrFocusDate } from './useRangeHoverOrFocusDate' import { useStatefulRef } from '../../hooks/useStatefulRef' import { useDatePickerReducer } from './useDatePickerReducer' -declare module 'react-day-picker' { - // eslint-disable-next-line no-shadow - interface DayPickerProps { - // This prop is currently missing from the react-day-picker types - onDayFocus?: ( - day: Date, - modifiers: DayModifiers, - e: React.FocusEvent - ) => void - } -} - export type DatePickerDateValidityType = ValueOf export interface DatePickerOnChangeData { @@ -102,10 +92,6 @@ export interface DatePickerProps * appropriate error message is displayed. */ onChange?: (data: DatePickerOnChangeData) => void - /** - * Optional handler to be invoked when the calendar is focussed. - */ - onCalendarFocus?: (e: React.SyntheticEvent) => void /** * The selected date, or the start of the selected date range if `isRange` * is set. @@ -127,11 +113,11 @@ export interface DatePickerProps * error message if they are received. See the `onChange` prop for more * information. */ - disabledDays?: DayPickerProps['disabledDays'] + disabledDays?: DayPickerProps['disabled'] /** * Optional month from which to display the picker calendar on first render. */ - initialMonth?: DayPickerProps['initialMonth'] + initialMonth?: DayPickerProps['defaultMonth'] /** * Initial value for `startDate`. Only used when the `startDate` prop is not set. */ @@ -169,7 +155,6 @@ export const DatePicker: React.FC = ({ isRange = false, label = 'Date', onChange, - onCalendarFocus, startDate: externalStartDate, initialIsOpen = false, disabledDays, @@ -237,9 +222,16 @@ export const DatePicker: React.FC = ({ ) const modifiers = { - start: replaceInvalidDate(startDate), - end: replaceInvalidDate(endDate), - ...(today ? { today } : {}), + start: replaceInvalidDate(startDate) || false, + end: replaceInvalidDate(endDate) || false, + } + const modifiersClassNames = { + start: 'rdp-day_start', + end: 'rdp-day_end', + } + const selected = { + from: replaceInvalidDate(startDate), + to: replaceInvalidDate(endDate) || rangeHoverOrFocusDate || undefined, } const hasContent = Boolean(startDate) @@ -331,39 +323,39 @@ export const DatePicker: React.FC = ({ aria-live="polite" > - { - if (disabled) { - return - } +
+ + format(date, 'ccc', options), + }} + mode="default" + modifiers={modifiers} + modifiersClassNames={modifiersClassNames} + selected={selected} + today={today} + onDayClick={(day, { disabled }) => { + if (disabled) { + return + } - const newState = handleDayClick(day) + const newState = handleDayClick(day) - dispatch({ type: DATEPICKER_ACTION.REFRESH_HAS_ERROR }) - dispatch({ type: DATEPICKER_ACTION.REFRESH_INPUT_VALUE }) + dispatch({ type: DATEPICKER_ACTION.REFRESH_HAS_ERROR }) + dispatch({ type: DATEPICKER_ACTION.REFRESH_INPUT_VALUE }) - if (newState.endDate || !isRange) { - setTimeout(() => close()) - } - }} - initialMonth={replaceInvalidDate(startDate) || initialMonth} - disabledDays={disabledDays} - $isRange={isRange} - $isVisible={isOpen} - onFocus={onCalendarFocus} - onDayMouseEnter={handleDayMouseEnter} - onDayMouseLeave={handleDayMouseLeave} - onDayFocus={handleDayFocus} - /> + if (newState.endDate || !isRange) { + setTimeout(() => close()) + } + }} + defaultMonth={replaceInvalidDate(startDate) || initialMonth} + disabled={disabledDays} + onDayMouseEnter={handleDayMouseEnter} + onDayMouseLeave={handleDayMouseLeave} + onDayFocus={handleDayFocus} + /> +
diff --git a/packages/react-component-library/src/components/DatePicker/constants.ts b/packages/react-component-library/src/components/DatePicker/constants.ts index af1afbba71..b5eee5faa5 100644 --- a/packages/react-component-library/src/components/DatePicker/constants.ts +++ b/packages/react-component-library/src/components/DatePicker/constants.ts @@ -1,13 +1,7 @@ -const LOCALE = { - UK: 'en-GB', -} - -const WEEKDAY_TITLES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] - const DATE_VALIDITY = { VALID: 'valid', INVALID: 'invalid', DISABLED: 'disabled', } as const -export { DATE_VALIDITY, LOCALE, WEEKDAY_TITLES } +export { DATE_VALIDITY } diff --git a/packages/react-component-library/src/components/DatePicker/partials/StyledDayPicker.tsx b/packages/react-component-library/src/components/DatePicker/partials/StyledDayPicker.tsx index ece99ede53..d1e0bc4749 100644 --- a/packages/react-component-library/src/components/DatePicker/partials/StyledDayPicker.tsx +++ b/packages/react-component-library/src/components/DatePicker/partials/StyledDayPicker.tsx @@ -1,4 +1,4 @@ -import DayPicker from 'react-day-picker' +import { DayPicker } from 'react-day-picker' import { selectors } from '@defencedigital/design-tokens' import styled, { css } from 'styled-components' @@ -9,27 +9,25 @@ import { isIE11 } from '../../../helpers' const { color, spacing, fontSize } = selectors -interface StyledDayPickerProps { - $isRange: boolean - $isVisible: boolean -} - const DAY_SIZE = '34px' -export const StyledDayPicker = styled(DayPicker)` - pointer-events: none; - - ${({ $isVisible }) => - $isVisible && - css` - pointer-events: all; - `} - - .DayPicker { - display: inline-block; +export const StyledDayPicker = styled(DayPicker)` + .rdp-vhidden { + appearance: none; + background: transparent; + border: 0; + box-sizing: border-box; + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + overflow: hidden; + margin: 0; + padding: 0; + position: absolute; + top: 0; + width: 1px; } - .DayPicker-wrapper { + .rdp-months { position: relative; flex-direction: row; padding-bottom: ${spacing('8')}; @@ -38,22 +36,19 @@ export const StyledDayPicker = styled(DayPicker)` outline: none; } - .DayPicker-Months { + .rdp-months { display: flex; flex-wrap: wrap; justify-content: center; } - .DayPicker-Month { - display: table; + .rdp-month { margin: 0 ${spacing('6')}; margin-top: ${spacing('8')}; - border-spacing: 0; - border-collapse: collapse; user-select: none; } - .DayPicker-NavButton { + .rdp-nav_button { ${getButtonStyles({ $size: COMPONENT_SIZE.SMALL, $variant: BUTTON_VARIANT.TERTIARY, @@ -64,43 +59,40 @@ export const StyledDayPicker = styled(DayPicker)` background-position: center; background-size: 18px; background-repeat: no-repeat; + + svg { + display: none; + } } - .DayPicker-NavButton--prev { + .rdp-nav_button_previous { left: ${spacing('6')}; background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='16px' height='16px' viewBox='0 0 16 16' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3EIcons/Navigation/chevron-left%3C/title%3E%3Cdefs%3E%3Cpolygon id='path-1' points='10.2755556 4.9422222 9.33333334 3.99999998 5.33333332 8 9.33333334 12 10.2755556 11.0577778 7.21777777 8'%3E%3C/polygon%3E%3C/defs%3E%3Cg id='Icons/Navigation/chevron-left' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cmask id='mask-2' fill='white'%3E%3Cuse xlink:href='%23path-1'%3E%3C/use%3E%3C/mask%3E%3Cuse id='Icons/Navigation/ic_chevron_left_18px' fill='%23233745' fill-rule='nonzero' xlink:href='%23path-1'%3E%3C/use%3E%3C/g%3E%3C/svg%3E"); } - .DayPicker-NavButton--next { + .rdp-nav_button_next { right: ${spacing('6')}; background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='16px' height='16px' viewBox='0 0 16 16' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3EIcons/Navigation/chevron-right%3C/title%3E%3Cdefs%3E%3Cpolygon id='path-1' points='6.66666666 3.99999998 5.72444443 4.9422222 8.78222223 8 5.72444443 11.0577778 6.66666666 12 10.6666667 8'%3E%3C/polygon%3E%3C/defs%3E%3Cg id='Icons/Navigation/chevron-right' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cmask id='mask-2' fill='white'%3E%3Cuse xlink:href='%23path-1'%3E%3C/use%3E%3C/mask%3E%3Cuse id='Icons/Navigation/ic_chevron_right_18px' fill='%23233745' fill-rule='nonzero' xlink:href='%23path-1'%3E%3C/use%3E%3C/g%3E%3C/svg%3E"); } - .DayPicker-NavButton--interactionDisabled { - display: none; - } - - .DayPicker-Caption { - display: table-caption; + .rdp-caption_label { + width: 100%; margin-bottom: ${spacing('8')}; height: 1.5rem; color: ${color('neutral', '600')}; font-size: ${fontSize('base')}; + font-weight: 400; line-height: 1.5rem; text-align: center; white-space: nowrap; } - .DayPicker-Weekdays { - display: table-header-group; - margin-top: ${spacing('8')}; - } - - .DayPicker-WeekdaysRow { - display: table-row; + .rdp-table { + border-spacing: 0; + border-collapse: collapse; } - .DayPicker-Weekday { + .rdp-head_cell { display: table-cell; padding: 0 ${spacing('2')} ${spacing('4')}; text-align: center; @@ -110,32 +102,36 @@ export const StyledDayPicker = styled(DayPicker)` font-weight: 600; } - .DayPicker-Weekday abbr[title] { - border-bottom: none; - text-decoration: none; - } - - .DayPicker-Body { - display: table-row-group; - } + .rdp-cell { + padding: 0; + height: ${DAY_SIZE}; + width: ${DAY_SIZE}; - .DayPicker-Week { - display: table-row; + &:not(:empty) { + border: 1px solid ${color('neutral', '000')}; + } } - .DayPicker-Day { + .rdp-day { position: relative; display: table-cell; - border: 1px solid ${color('neutral', '000')}; + border: none; font-size: ${fontSize('m')}; font-weight: 400; + background: transparent; color: ${color('neutral', '500')}; text-align: center; vertical-align: middle; - height: ${DAY_SIZE}; - width: ${DAY_SIZE}; + height: 100%; + width: 100%; cursor: pointer; + ${isIE11() && + css` + height: ${`math(${DAY_SIZE} - 1px)`}; + width: ${`math(${DAY_SIZE} - 1px)`}; + `} + &:focus { outline: none; @@ -161,39 +157,27 @@ export const StyledDayPicker = styled(DayPicker)` } } - .DayPicker-Day--outside { - border: none; - visibility: hidden; - } - - &.DayPicker--interactionDisabled .DayPicker-Day, - .DayPicker-Day--outside { - cursor: default; - } - - .DayPicker-Day:not(.DayPicker-Day--outside) { - &.DayPicker-Day--today { + .rdp-day { + &.rdp-day_today { color: ${color('warning', '800')}; } - &.DayPicker-Day--selected { + &.rdp-day_selected { background-color: ${color('action', '000')}; } - &.DayPicker-Day--start, - &.DayPicker-Day--end { + &.rdp-day_start, + &.rdp-day_end { color: ${color('neutral', 'white')}; background-color: ${color('action', '700')}; } } - &:not(.DayPicker--interactionDisabled) { - .DayPicker-Day:not(.DayPicker-Day--disabled):not(.DayPicker-Day--start):not(.DayPicker-Day--end):not(.DayPicker-Day--outside):hover { - background-color: ${color('neutral', '100')}; - } + .rdp-day:not(.rdp-day_disabled):not(.rdp-day_start):not(.rdp-day_end):hover { + background-color: ${color('neutral', '100')}; } - .DayPicker-Day--disabled { + .rdp-day_disabled { color: ${color('neutral', '200')}; pointer-events: none; } diff --git a/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts b/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts index ee4d1bec99..8485cbee05 100644 --- a/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts +++ b/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts @@ -21,9 +21,6 @@ export function useFocusTrapOptions( isEventTargetDescendantOf(event, clickAllowedElementRefs), clickOutsideDeactivates: (event) => !isEventTargetDescendantOf(event, clickAllowedElementRefs), - // Temporary workaround until we update to react-day-picker v8, which has a way - // to set the initial focus to the selected date (or today if no date selected) - initialFocus: '[role="button"]', onDeactivate: close, }), [clickAllowedElementRefs, close] diff --git a/packages/react-component-library/src/components/DatePicker/useHandleDayClick.ts b/packages/react-component-library/src/components/DatePicker/useHandleDayClick.ts index c3da381ab1..ad7c66800d 100644 --- a/packages/react-component-library/src/components/DatePicker/useHandleDayClick.ts +++ b/packages/react-component-library/src/components/DatePicker/useHandleDayClick.ts @@ -1,6 +1,6 @@ -import { isValid, max, min } from 'date-fns' +import { addHours, isValid, max, min, startOfDay } from 'date-fns' import React from 'react' -import { DayPickerProps, ModifiersUtils } from 'react-day-picker' +import { DayPickerProps, isMatch, Matcher } from 'react-day-picker' import { DatePickerDateValidityType, @@ -28,9 +28,17 @@ function getNewState( return { startDate: day, endDate: null } } +export function isDateInMatcher( + date: Date, + matcher: Matcher | Matcher[] +): boolean { + const matchersArray = Array.isArray(matcher) ? matcher : [matcher] + return isMatch(date, matchersArray) +} + function calculateDateValidity( date: Date | null, - disabledDays: DayPickerProps['disabledDays'] + disabledDays: DayPickerProps['disabled'] ): DatePickerDateValidityType | null { if (!date) { return null @@ -40,22 +48,30 @@ function calculateDateValidity( return DATE_VALIDITY.INVALID } - if (ModifiersUtils.dayMatchesModifier(date, disabledDays)) { + if (disabledDays && isDateInMatcher(date, disabledDays)) { return DATE_VALIDITY.DISABLED } return DATE_VALIDITY.VALID } +function normaliseDate(date: Date | null): Date | null { + if (!date) { + return date + } + + return addHours(startOfDay(date), 12) +} + export const useHandleDayClick = ( state: DatePickerState, dispatch: React.Dispatch, isRange: boolean, - disabledDays: DayPickerProps['disabledDays'], + disabledDays: DayPickerProps['disabled'], onChange?: (data: DatePickerOnChangeData) => void ): ((day: Date | null) => { startDate: Date | null; endDate: Date | null }) => { function handleDayClick(day: Date | null) { - const newState = getNewState(isRange, day, state) + const newState = getNewState(isRange, normaliseDate(day), state) dispatch({ type: DATEPICKER_ACTION.UPDATE, diff --git a/packages/react-component-library/src/components/DatePicker/useInput.ts b/packages/react-component-library/src/components/DatePicker/useInput.ts index 871173c391..31481739c5 100644 --- a/packages/react-component-library/src/components/DatePicker/useInput.ts +++ b/packages/react-component-library/src/components/DatePicker/useInput.ts @@ -14,7 +14,7 @@ function parseDate(datePickerFormat: string, value: string) { return new Date(NaN) } - return addHours(date, 12) + return date } export function useInput( diff --git a/yarn.lock b/yarn.lock index f7a1f6cf67..c8af26631b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3203,6 +3203,22 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== +"@reach/auto-id@^0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.16.0.tgz#dfabc3227844e8c04f8e6e45203a8e14a8edbaed" + integrity sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg== + dependencies: + "@reach/utils" "0.16.0" + tslib "^2.3.0" + +"@reach/utils@0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.16.0.tgz#5b0777cf16a7cab1ddd4728d5d02762df0ba84ce" + integrity sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q== + dependencies: + tiny-warning "^1.0.3" + tslib "^2.3.0" + "@sinclair/typebox@^0.23.3": version "0.23.4" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.23.4.tgz#6ff93fd2585ce44f7481c9ff6af610fbb5de98a4" @@ -14224,12 +14240,12 @@ react-compound-slider@^3.3.1: d3-array "^2.8.0" warning "^4.0.3" -react-day-picker@^7.4.8: - version "7.4.10" - resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-7.4.10.tgz#d3928fa65c04379ad28c76de22aa85374a8361e1" - integrity sha512-/QkK75qLKdyLmv0kcVzhL7HoJPazoZXS8a6HixbVoK6vWey1Od1WRLcxfyEiUsRfccAlIlf6oKHShqY2SM82rA== +react-day-picker@^8.0.4: + version "8.0.4" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.0.4.tgz#1d6f2627ecec30752bbfd706edd6e664b1a04ed1" + integrity sha512-RaGClmZjCgDl0O8OlgMnhtUBZgmSWNTv6p5TUXo8E9VudMPL9azAR9UCqF0JQxcDPLbjLgeMZnm53K0rWtRLCQ== dependencies: - prop-types "^15.6.2" + "@reach/auto-id" "^0.16.0" react-docgen-typescript@^2.1.1: version "2.2.2" @@ -16231,7 +16247,7 @@ tiny-invariant@^1.1.0: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== -tiny-warning@^1.0.2: +tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== From 4b302c0b1a58e45abdd8e91376189ec7f1a3cd95 Mon Sep 17 00:00:00 2001 From: jpveooys <66470099+jpveooys@users.noreply.github.com> Date: Mon, 17 Jan 2022 17:03:05 +0000 Subject: [PATCH 2/3] fix(DatePicker): Clear range shading after second date is blurred This fixes a small bug where when a second date of a range was being selected using the keyboard, the range would stay shading after tabbing from the second date to a different element. --- .../src/components/DatePicker/DatePicker.test.tsx | 13 +++++++++++++ .../src/components/DatePicker/DatePicker.tsx | 12 ++++++------ .../DatePicker/useRangeHoverOrFocusDate.ts | 14 +++++--------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx b/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx index 3960568a6f..e98be0bdf7 100644 --- a/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx +++ b/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx @@ -830,6 +830,19 @@ describe('DatePicker', () => { }) ).toHaveLength(0) }) + + describe('and then presses the tab key', () => { + beforeEach(() => { + userEvent.tab() + }) + + it('clears the range and removes the shading', () => { + // The start date still has the modifier + expect( + wrapper.container.querySelectorAll('.rdp-day_selected') + ).toHaveLength(1) + }) + }) }) describe('and clicks on a second date', () => { diff --git a/packages/react-component-library/src/components/DatePicker/DatePicker.tsx b/packages/react-component-library/src/components/DatePicker/DatePicker.tsx index 37cda168bd..9a45db9408 100644 --- a/packages/react-component-library/src/components/DatePicker/DatePicker.tsx +++ b/packages/react-component-library/src/components/DatePicker/DatePicker.tsx @@ -209,9 +209,8 @@ export const DatePicker: React.FC = ({ const { rangeHoverOrFocusDate, - handleDayFocus, - handleDayMouseEnter, - handleDayMouseLeave, + handleDayFocusOrMouseEnter, + handleDayBlurOrMouseLeave, } = useRangeHoverOrFocusDate(isRange) const { handleKeyDown, handleInputBlur, handleInputChange } = useInput( @@ -351,9 +350,10 @@ export const DatePicker: React.FC = ({ }} defaultMonth={replaceInvalidDate(startDate) || initialMonth} disabled={disabledDays} - onDayMouseEnter={handleDayMouseEnter} - onDayMouseLeave={handleDayMouseLeave} - onDayFocus={handleDayFocus} + onDayMouseEnter={handleDayFocusOrMouseEnter} + onDayMouseLeave={handleDayBlurOrMouseLeave} + onDayFocus={handleDayFocusOrMouseEnter} + onDayBlur={handleDayBlurOrMouseLeave} />
diff --git a/packages/react-component-library/src/components/DatePicker/useRangeHoverOrFocusDate.ts b/packages/react-component-library/src/components/DatePicker/useRangeHoverOrFocusDate.ts index eb5547102d..cb86d8b427 100644 --- a/packages/react-component-library/src/components/DatePicker/useRangeHoverOrFocusDate.ts +++ b/packages/react-component-library/src/components/DatePicker/useRangeHoverOrFocusDate.ts @@ -3,14 +3,11 @@ import { useCallback, useState } from 'react' /** * Hook to keep track of the date being hovered over or focused * when in range mode. - * - * @todo Add handleDayBlur after upgrading to react-day-picker v8. */ export function useRangeHoverOrFocusDate(isRange: boolean): { rangeHoverOrFocusDate: Date | null - handleDayFocus: (date: Date) => void - handleDayMouseEnter: (date: Date) => void - handleDayMouseLeave: () => void + handleDayFocusOrMouseEnter: (date: Date) => void + handleDayBlurOrMouseLeave: () => void } { const [rangeHoverOrFocusDate, setRangeHoverOrFocusDate] = useState(null) @@ -24,7 +21,7 @@ export function useRangeHoverOrFocusDate(isRange: boolean): { [isRange] ) - const handleDayMouseLeave = useCallback(() => { + const handleDayBlurOrMouseLeave = useCallback(() => { if (isRange) { setRangeHoverOrFocusDate(null) } @@ -32,8 +29,7 @@ export function useRangeHoverOrFocusDate(isRange: boolean): { return { rangeHoverOrFocusDate, - handleDayFocus: handleDayFocusOrMouseEnter, - handleDayMouseEnter: handleDayFocusOrMouseEnter, - handleDayMouseLeave, + handleDayFocusOrMouseEnter, + handleDayBlurOrMouseLeave, } } From 9eb0eb2597b4cbcba88d0e1c2a158ec0e3a9c3ba Mon Sep 17 00:00:00 2001 From: jpveooys <66470099+jpveooys@users.noreply.github.com> Date: Mon, 17 Jan 2022 17:06:06 +0000 Subject: [PATCH 3/3] fix(DatePicker): Focus selected date or today when picker opens This makes focusing consistent with the W3 WAI-ARIA date picker example. --- .../components/DatePicker/DatePicker.test.tsx | 30 ++++++++++++++----- .../src/components/DatePicker/DatePicker.tsx | 1 + .../DatePicker/useFocusTrapOptions.ts | 1 + 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx b/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx index e98be0bdf7..23ee995ec3 100644 --- a/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx +++ b/packages/react-component-library/src/components/DatePicker/DatePicker.test.tsx @@ -612,6 +612,22 @@ describe('DatePicker', () => { }) }) + describe('when a single date picker with a value is rendered and the picker is opened', () => { + beforeEach(() => { + wrapper = render( + + ) + + return user.click(wrapper.getByTestId('datepicker-input-button')) + }) + + it('focuses the current date', () => { + expect( + wrapper.getByRole('button', { name: '18th January (Tuesday)' }) + ).toHaveFocus() + }) + }) + describe('when a single date picker is rendered and the picker is opened', () => { beforeEach(() => { wrapper = render() @@ -623,12 +639,10 @@ describe('DatePicker', () => { expect(wrapper.getByText('December 2019')).toBeInTheDocument() }) - it('focuses the previous month button', () => { - return waitFor(() => - expect( - wrapper.getByLabelText(PREVIOUS_MONTH_BUTTON_LABEL) - ).toHaveFocus() - ) + it('focuses the current date', () => { + expect( + wrapper.getByRole('button', { name: '5th December (Thursday)' }) + ).toHaveFocus() }) describe('when Shift-Tab is pressed once', () => { @@ -638,7 +652,7 @@ describe('DatePicker', () => { it('traps the focus within the picker', () => { expect( - wrapper.getByRole('button', { name: '5th December (Thursday)' }) + wrapper.getByLabelText(PREVIOUS_MONTH_BUTTON_LABEL) ).toHaveFocus() }) @@ -649,7 +663,7 @@ describe('DatePicker', () => { it('still traps the focus within the picker', () => { expect( - wrapper.getByLabelText(PREVIOUS_MONTH_BUTTON_LABEL) + wrapper.getByRole('button', { name: '5th December (Thursday)' }) ).toHaveFocus() }) }) diff --git a/packages/react-component-library/src/components/DatePicker/DatePicker.tsx b/packages/react-component-library/src/components/DatePicker/DatePicker.tsx index 9a45db9408..c55a1741c0 100644 --- a/packages/react-component-library/src/components/DatePicker/DatePicker.tsx +++ b/packages/react-component-library/src/components/DatePicker/DatePicker.tsx @@ -354,6 +354,7 @@ export const DatePicker: React.FC = ({ onDayMouseLeave={handleDayBlurOrMouseLeave} onDayFocus={handleDayFocusOrMouseEnter} onDayBlur={handleDayBlurOrMouseLeave} + initialFocus /> diff --git a/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts b/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts index 8485cbee05..4f6d9bbed5 100644 --- a/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts +++ b/packages/react-component-library/src/components/DatePicker/useFocusTrapOptions.ts @@ -21,6 +21,7 @@ export function useFocusTrapOptions( isEventTargetDescendantOf(event, clickAllowedElementRefs), clickOutsideDeactivates: (event) => !isEventTargetDescendantOf(event, clickAllowedElementRefs), + initialFocus: false, onDeactivate: close, }), [clickAllowedElementRefs, close]