Skip to content

Commit

Permalink
feat(DatePickerE): Add focus trap
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jpveooys committed Jan 18, 2022
1 parent 544318b commit 4633f53
Show file tree
Hide file tree
Showing 8 changed files with 132 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/react-component-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -907,9 +939,9 @@ describe('DatePickerE', () => {
})
})

describe('when the isOpen prop is provided', () => {
describe('when the initialIsOpen prop is provided', () => {
beforeEach(() => {
wrapper = render(<DatePickerE isOpen />)
wrapper = render(<DatePickerE initialIsOpen />)
})

it('displays the picker as open on initial render', () => {
Expand Down Expand Up @@ -954,7 +986,7 @@ describe('DatePickerE', () => {
beforeEach(() => {
wrapper = render(
<DatePickerE
isOpen
initialIsOpen
onChange={onChange}
startDate={new Date(2020, 3, 1)}
disabledDays={[new Date(2020, 3, 12)]}
Expand Down Expand Up @@ -996,7 +1028,9 @@ describe('DatePickerE', () => {

describe('when the `initialMonth` prop is provided and no `startDate`', () => {
beforeEach(() => {
wrapper = render(<DatePickerE isOpen initialMonth={new Date(2020, 1)} />)
wrapper = render(
<DatePickerE initialIsOpen initialMonth={new Date(2020, 1)} />
)
})

it('displays the correct month initially', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -152,7 +153,7 @@ export const DatePickerE: React.FC<DatePickerEProps> = ({
onChange,
onCalendarFocus,
startDate,
isOpen,
initialIsOpen,
disabledDays,
initialMonth,
placement = 'bottom-start',
Expand All @@ -164,16 +165,19 @@ export const DatePickerE: React.FC<DatePickerEProps> = ({
const id = useExternalId(externalId)
const titleId = `datepicker-title-${useExternalId()}`
const contentId = `datepicker-contentId-${useExternalId()}`
const buttonRef = useRef<HTMLButtonElement>()
const inputRef = useRef<HTMLInputElement>()

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<string>(
formatDatesForInput(startDate, endDate, datePickerFormat)
Expand Down Expand Up @@ -245,6 +249,7 @@ export const DatePickerE: React.FC<DatePickerEProps> = ({
{placeholder && ` (${placeholder})`}
</StyledLabel>
<StyledInput
ref={inputRef}
$hasLabel={Boolean(label)}
aria-label="Choose date"
data-testid="datepicker-input"
Expand All @@ -264,7 +269,12 @@ export const DatePickerE: React.FC<DatePickerEProps> = ({
onFocus={() => {
onLocalFocus()
if (isRange) {
handleOnOpen()
buttonRef.current?.focus()
}
}}
onClick={() => {
if (isRange) {
toggleIsOpen()
}
}}
placeholder={placeholder}
Expand All @@ -274,29 +284,29 @@ export const DatePickerE: React.FC<DatePickerEProps> = ({
</StyledInputWrapper>
<StyledInlineButtons>
<InlineButton
aria-expanded={!!open}
aria-label={`${open ? 'Hide' : 'Show'} day picker`}
aria-expanded={!!isOpen}
aria-label={`${isOpen ? 'Hide' : 'Show'} day picker`}
aria-owns={contentId}
data-testid="datepicker-input-button"
isDisabled={isDisabled}
onClick={open ? handleOnClose : handleOnOpen}
ref={inputButtonRef}
onClick={toggleIsOpen}
ref={buttonRef}
>
<IconEvent size={18} />
</InlineButton>
</StyledInlineButtons>
</StyledOuterWrapper>
</StyledDatePickerEInput>
<StyledFloatingBox
isVisible={open}
isVisible={isOpen}
placement={placement}
targetElement={floatingBoxTarget}
role="dialog"
aria-modal
aria-labelledby={titleId}
aria-live="polite"
>
<div ref={floatingBoxChildrenRef}>
<FocusTrap focusTrapOptions={focusTrapOptions}>
<StyledDayPicker
firstDayOfWeek={1}
weekdaysShort={WEEKDAY_TITLES}
Expand All @@ -322,20 +332,19 @@ export const DatePickerE: React.FC<DatePickerEProps> = ({
)
)
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}
/>
</div>
</FocusTrap>
</StyledFloatingBox>
</>
)
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Options as FocusTrapOptions } from 'focus-trap'
import React, { useMemo } from 'react'

function isEventTargetDescendantOf(
event: Event,
refs: React.RefObject<Element>[]
): boolean {
return (
event.target instanceof Node &&
refs.some((ref) => ref.current?.contains(event.target as Node))
)
}

export function useFocusTrapOptions(
close: () => void,
clickAllowedElementRefs: React.RefObject<Element>[]
): 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]
)
}
24 changes: 24 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 4633f53

Please sign in to comment.