diff --git a/packages/react-component-library/cypress/specs/DatePickerE/index.spec.ts b/packages/react-component-library/cypress/specs/DatePickerE/index.spec.ts index fa61df146f..4b069ac75c 100644 --- a/packages/react-component-library/cypress/specs/DatePickerE/index.spec.ts +++ b/packages/react-component-library/cypress/specs/DatePickerE/index.spec.ts @@ -51,7 +51,7 @@ describe('DatePickerE', () => { before(() => { cy.visit('/iframe.html?id=date-picker-experimental--range&viewMode=story') - cy.get(selectors.datePicker.input).focus() + cy.get(selectors.datePicker.input).click() }) it('should show the days', () => { diff --git a/packages/react-component-library/package.json b/packages/react-component-library/package.json index d2f1e16daa..bea6d669d2 100644 --- a/packages/react-component-library/package.json +++ b/packages/react-component-library/package.json @@ -169,6 +169,7 @@ "date-fns": "^2.9.0", "decimal.js": "^10.3.1", "downshift": "^6.1.7", + "focus-trap-react": "^8.9.1", "polished": "^4.0.3", "postcss-flexbugs-fixes": "^5.0.2", "react-compound-slider": "^3.3.1", @@ -182,7 +183,8 @@ "react-toast-notifications": "^2.4.0", "react-transition-group": "^4.4.1", "styled-normalize": "^8.0.7", - "styled-theming": "^2.2.0" + "styled-theming": "^2.2.0", + "usehooks-ts": "^2.2.1" }, "peerDependencies": { "formik": "^2 || ^3", diff --git a/packages/react-component-library/src/components/DatePickerE/DatePickerE.test.tsx b/packages/react-component-library/src/components/DatePickerE/DatePickerE.test.tsx index e3c536f15c..8baed4b4a8 100644 --- a/packages/react-component-library/src/components/DatePickerE/DatePickerE.test.tsx +++ b/packages/react-component-library/src/components/DatePickerE/DatePickerE.test.tsx @@ -602,6 +602,38 @@ describe('DatePickerE', () => { expect(wrapper.getByText('December 2019')).toBeInTheDocument() }) + it('focuses the previous month button', () => { + return waitFor(() => + expect(wrapper.getByLabelText('Previous Month')).toHaveFocus() + ) + }) + + describe('when the day picker is focused and Shift-Tab is pressed', () => { + let dayPicker: Element + + beforeEach(() => { + dayPicker = wrapper.container.querySelectorAll('.DayPicker-wrapper')[0] + + fireEvent.focus(dayPicker) + + userEvent.tab({ shift: true }) + }) + + it('traps the focus within the picker', () => { + expect(wrapper.getByText('1')).toHaveFocus() + }) + + describe('and Tab is then pressed', () => { + beforeEach(() => { + userEvent.tab() + }) + + it('still traps the focus within the picker', () => { + expect(dayPicker).toHaveFocus() + }) + }) + }) + describe.each([ { name: 'day picker container', @@ -711,9 +743,9 @@ describe('DatePickerE', () => { ) }) - describe('when the end user focuses on the Input', () => { + describe('when the end user clicks on the Input', () => { beforeEach(() => { - wrapper.getByTestId('datepicker-input').focus() + userEvent.click(wrapper.getByTestId('datepicker-input')) }) it('opens the day picker', () => { @@ -907,9 +939,9 @@ describe('DatePickerE', () => { }) }) - describe('when the isOpen prop is provided', () => { + describe('when the initialIsOpen prop is provided', () => { beforeEach(() => { - wrapper = render() + wrapper = render() }) it('displays the picker as open on initial render', () => { @@ -954,7 +986,7 @@ describe('DatePickerE', () => { beforeEach(() => { wrapper = render( { describe('when the `initialMonth` prop is provided and no `startDate`', () => { beforeEach(() => { - wrapper = render() + wrapper = render( + + ) }) it('displays the correct month initially', () => { diff --git a/packages/react-component-library/src/components/DatePickerE/DatePickerE.tsx b/packages/react-component-library/src/components/DatePickerE/DatePickerE.tsx index ac8120dd9d..ec9ead25ad 100644 --- a/packages/react-component-library/src/components/DatePickerE/DatePickerE.tsx +++ b/packages/react-component-library/src/components/DatePickerE/DatePickerE.tsx @@ -1,8 +1,10 @@ import { IconEvent } from '@defencedigital/icon-library' import { isValid } from 'date-fns' -import React, { useState } from 'react' +import FocusTrap from 'focus-trap-react' +import React, { useRef, useState } from 'react' import { Placement } from '@popperjs/core' import { DayModifiers, DayPickerProps } from 'react-day-picker' +import { useBoolean } from 'usehooks-ts' import { ComponentWithClass } from '../../common/ComponentWithClass' import { DATE_FORMAT } from '../../constants' @@ -19,10 +21,9 @@ import { StyledInlineButtons } from '../InlineButtons/partials/StyledInlineButto import { StyledInput } from '../TextInputE/partials/StyledInput' import { StyledInputWrapper } from './partials/StyledInputWrapper' import { StyledOuterWrapper } from './partials/StyledOuterWrapper' -import { useCloseOnEscape } from './useCloseOnEscape' -import { useDatePickerEOpenClose } from './useDatePickerEOpenClose' import { useExternalId } from '../../hooks/useExternalId' import { useFocus } from '../../hooks/useFocus' +import { useFocusTrapOptions } from './useFocusTrapOptions' import { useInput } from './useInput' import { useRangeHoverOrFocusDate } from './useRangeHoverOrFocusDate' import { useSelection } from './useSelection' @@ -110,9 +111,9 @@ export interface DatePickerEProps */ startDate?: Date /** - * Toggles whether or not the picker is open. + * Toggles whether the picker is open on first render. */ - isOpen?: boolean + initialIsOpen?: boolean /** * An array of dates to disabled within the picker, preventing them from * being selected in the date picker calendar. @@ -152,7 +153,7 @@ export const DatePickerE: React.FC = ({ onChange, onCalendarFocus, startDate, - isOpen, + initialIsOpen, disabledDays, initialMonth, placement = 'bottom-start', @@ -164,16 +165,19 @@ export const DatePickerE: React.FC = ({ const id = useExternalId(externalId) const titleId = `datepicker-title-${useExternalId()}` const contentId = `datepicker-contentId-${useExternalId()}` + const buttonRef = useRef() + const inputRef = useRef() const { hasFocus, onLocalBlur, onLocalFocus } = useFocus() const { - floatingBoxChildrenRef, - handleOnClose, - handleOnOpen, - inputButtonRef, - open, - } = useDatePickerEOpenClose(isOpen) - const { handleDayPickerKeyDown } = useCloseOnEscape(handleOnClose) + setFalse: close, + value: isOpen, + toggle: toggleIsOpen, + } = useBoolean(initialIsOpen) + const focusTrapOptions = useFocusTrapOptions( + close, + isRange ? [buttonRef, inputRef] : [buttonRef] + ) const [inputValue, setInputValue] = useState( formatDatesForInput(startDate, endDate, datePickerFormat) @@ -245,6 +249,7 @@ export const DatePickerE: React.FC = ({ {placeholder && ` (${placeholder})`} = ({ onFocus={() => { onLocalFocus() if (isRange) { - handleOnOpen() + buttonRef.current?.focus() + } + }} + onClick={() => { + if (isRange) { + toggleIsOpen() } }} placeholder={placeholder} @@ -274,13 +284,13 @@ export const DatePickerE: React.FC = ({ @@ -288,7 +298,7 @@ export const DatePickerE: React.FC = ({ = ({ aria-labelledby={titleId} aria-live="polite" > -
+ = ({ ) ) if (newState.to || !isRange) { - setTimeout(() => handleOnClose()) + setTimeout(() => close()) } }} - onKeyDown={handleDayPickerKeyDown} initialMonth={replaceInvalidDate(from) || initialMonth} disabledDays={disabledDays} $isRange={isRange} - $isVisible={open} + $isVisible={isOpen} onFocus={onCalendarFocus} onDayMouseEnter={handleDayMouseEnter} onDayMouseLeave={handleDayMouseLeave} onDayFocus={handleDayFocus} /> -
+
) diff --git a/packages/react-component-library/src/components/DatePickerE/useCloseOnEscape.ts b/packages/react-component-library/src/components/DatePickerE/useCloseOnEscape.ts deleted file mode 100644 index ee882fb2dc..0000000000 --- a/packages/react-component-library/src/components/DatePickerE/useCloseOnEscape.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useCallback } from 'react' - -import { ESCAPE } from '../../utils/keyCodes' - -export function useCloseOnEscape(handleOnClose: () => void): { - handleDayPickerKeyDown: React.KeyboardEventHandler -} { - const handleDayPickerKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.keyCode === ESCAPE) { - handleOnClose() - event.stopPropagation() - event.preventDefault() - } - }, - [handleOnClose] - ) - - return { handleDayPickerKeyDown } -} diff --git a/packages/react-component-library/src/components/DatePickerE/useDatePickerEOpenClose.ts b/packages/react-component-library/src/components/DatePickerE/useDatePickerEOpenClose.ts deleted file mode 100644 index fa924e9699..0000000000 --- a/packages/react-component-library/src/components/DatePickerE/useDatePickerEOpenClose.ts +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useCallback, useRef } from 'react' - -import { useDocumentClick, useOpenClose } from '../../hooks' - -export function useDatePickerEOpenClose(isOpen = false): { - floatingBoxChildrenRef: React.RefObject - handleOnClose: () => void - inputButtonRef: React.RefObject - handleOnOpen: () => void - open: boolean -} { - const { open, handleOnClose, handleOnFocus } = useOpenClose(isOpen) - const floatingBoxChildrenRef = useRef() - const inputButtonRef = useRef() - - const handleDatePickerOnClose = useCallback(() => { - if (!open) { - return - } - - handleOnClose(null) - inputButtonRef.current?.focus() - }, [handleOnClose, open]) - - useDocumentClick( - [floatingBoxChildrenRef, inputButtonRef], - handleDatePickerOnClose - ) - - return { - floatingBoxChildrenRef, - handleOnOpen: handleOnFocus, - inputButtonRef, - open, - handleOnClose: handleDatePickerOnClose, - } -} diff --git a/packages/react-component-library/src/components/DatePickerE/useFocusTrapOptions.ts b/packages/react-component-library/src/components/DatePickerE/useFocusTrapOptions.ts new file mode 100644 index 0000000000..ee4d1bec99 --- /dev/null +++ b/packages/react-component-library/src/components/DatePickerE/useFocusTrapOptions.ts @@ -0,0 +1,31 @@ +import { Options as FocusTrapOptions } from 'focus-trap' +import React, { useMemo } from 'react' + +function isEventTargetDescendantOf( + event: Event, + refs: React.RefObject[] +): boolean { + return ( + event.target instanceof Node && + refs.some((ref) => ref.current?.contains(event.target as Node)) + ) +} + +export function useFocusTrapOptions( + close: () => void, + clickAllowedElementRefs: React.RefObject[] +): FocusTrapOptions { + return useMemo( + () => ({ + allowOutsideClick: (event) => + 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/yarn.lock b/yarn.lock index 3e78da2d00..f34c1dd2c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9645,6 +9645,20 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +focus-trap-react@^8.9.1: + version "8.9.1" + resolved "https://registry.yarnpkg.com/focus-trap-react/-/focus-trap-react-8.9.1.tgz#5f2b3bce6a7c4de6485fd6f4c5e8c0cbaa6e9ad3" + integrity sha512-QGVw4ch1ZfT2IcCL1ehBgrwnFuPA0nP7hXcrZzC8SThbktt/jiXCWQSKhA2WL0nVbxTiZ7YpIu/foTekp+dcqA== + dependencies: + focus-trap "^6.7.2" + +focus-trap@^6.7.2: + version "6.7.2" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.7.2.tgz#02e63b12f4d4b3d00bfac4309cfd223e9b4ed44e" + integrity sha512-mRVv9QPCXITaDreu+pNXiPk1Rpn0WQtGvGrDo3Z/s2kdwtzFw/WOPfbLkdxWWvcahoInm9eRztuQOr1RNyQGrw== + dependencies: + tabbable "^5.2.1" + follow-redirects@^1.14.0: version "1.14.7" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685" @@ -17910,6 +17924,11 @@ synchronous-promise@^2.0.15: resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e" integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg== +tabbable@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.2.1.tgz#e3fda7367ddbb172dcda9f871c0fdb36d1c4cd9c" + integrity sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ== + table@^6.7.5: version "6.7.5" resolved "https://registry.yarnpkg.com/table/-/table-6.7.5.tgz#f04478c351ef3d8c7904f0e8be90a1b62417d238" @@ -18775,6 +18794,11 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +usehooks-ts@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.2.1.tgz#9bb9af81d4680cf9cdb9234331a37e4be39d1183" + integrity sha512-n6Rat52iF+Tz9CFU2IIuaFsm9uCXX4dU565lKaAKMEXLLGTg9qYwzDSpXkCPRnx9lTdkm8M+W+YJ36Lb13zjEA== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"