From 4633f53868ce5ad3368c60f7bae385ecf3e84157 Mon Sep 17 00:00:00 2001 From: jpveooys <66470099+jpveooys@users.noreply.github.com> Date: Wed, 12 Jan 2022 15:31:28 +0000 Subject: [PATCH] feat(DatePickerE): Add focus trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a focus trap to DatePickerE using the react-focus-trap library. (This means that the focus stays within the picker when it is open and the tab key is pressed.) This also replaces the existing document click logic as this is handled by react-focus-trap (and focus-trap). The `isOpen` prop was also changed to affect the first render only and renamed to `initialIsOpen` (matching `SidebarE`). This is because it doesn't make much sense to have it affect subsequent renders – you never know whether the prop or the internal state is correct at any point in time. Finally the `usehooks-ts` library was added to gain access to some utility hooks it provides. --- .../cypress/specs/DatePickerE/index.spec.ts | 2 +- packages/react-component-library/package.json | 4 +- .../DatePickerE/DatePickerE.test.tsx | 46 +++++++++++++-- .../components/DatePickerE/DatePickerE.tsx | 57 +++++++++++-------- .../DatePickerE/useCloseOnEscape.ts | 20 ------- .../DatePickerE/useDatePickerEOpenClose.ts | 37 ------------ .../DatePickerE/useFocusTrapOptions.ts | 31 ++++++++++ yarn.lock | 24 ++++++++ 8 files changed, 132 insertions(+), 89 deletions(-) delete mode 100644 packages/react-component-library/src/components/DatePickerE/useCloseOnEscape.ts delete mode 100644 packages/react-component-library/src/components/DatePickerE/useDatePickerEOpenClose.ts create mode 100644 packages/react-component-library/src/components/DatePickerE/useFocusTrapOptions.ts 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"