Skip to content

Commit

Permalink
fix(modal): handle events and focus
Browse files Browse the repository at this point in the history
  • Loading branch information
DorianMaliszewski committed Sep 7, 2023
1 parent f51ec03 commit 6067c87
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/four-lizards-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ultraviolet/ui': patch
---

fix(modal): handle events and focus
76 changes: 73 additions & 3 deletions packages/ui/src/components/Modal/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import styled from '@emotion/styled'
import type { KeyboardEventHandler, MouseEventHandler } from 'react'
import type {
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
ReactEventHandler,
} from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { MODAL_PLACEMENT, MODAL_WIDTH } from './constants'
Expand Down Expand Up @@ -75,13 +80,15 @@ export const Dialog = ({
backdropCss,
}: DialogProps) => {
const containerRef = useRef(document.createElement('div'))
const dialogRef = useRef(document.createElement('dialog'))
const onCloseRef = useRef(onClose)

// Portal to put the modal in
useEffect(() => {
const element = containerRef.current
if (open) {
document.body.appendChild(element)
dialogRef.current.focus()
}

return () => {
Expand All @@ -100,15 +107,25 @@ export const Dialog = ({
useEffect(() => {
const handleEscPress = (event: KeyboardEvent) => {
if (event.key === 'Escape' && hideOnEsc) {
event.preventDefault()
event.stopPropagation()
onCloseRef.current()
}
}
if (open) {
document.addEventListener('keyup', handleEscPress)
document.body.addEventListener('keyup', handleEscPress, { capture: true })
document.body.addEventListener('keydown', handleEscPress, {
capture: true,
})
}

return () => {
document.removeEventListener('keyup', handleEscPress)
document.body.removeEventListener('keyup', handleEscPress, {
capture: true,
})
document.body.removeEventListener('keydown', handleEscPress, {
capture: true,
})
}
}, [open, onCloseRef, hideOnEsc])

Expand All @@ -121,6 +138,11 @@ export const Dialog = ({
}
}, [preventBodyScroll, open])

// Stop focus to prevent unexpected body loose focus
const stopFocus: FocusEventHandler = useCallback(event => {
event.stopPropagation()
}, [])

// Stop click to prevent unexpected dialog close
const stopClick: MouseEventHandler = useCallback(event => {
event.stopPropagation()
Expand All @@ -131,17 +153,62 @@ export const Dialog = ({
event.stopPropagation()
}, [])

// Enable focus trap inside the modal
const handleFocusTrap: KeyboardEventHandler = useCallback(event => {
event.stopPropagation()

Check warning on line 158 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L158

Added line #L158 was not covered by tests
if (event.key === 'Escape') {
event.preventDefault()

Check warning on line 160 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L160

Added line #L160 was not covered by tests

return

Check warning on line 162 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L162

Added line #L162 was not covered by tests
}
const isTabPressed = event.key === 'Tab'

Check warning on line 164 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L164

Added line #L164 was not covered by tests

if (!isTabPressed) {
return

Check warning on line 167 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L167

Added line #L167 was not covered by tests
}

const focusableEls = dialogRef.current.querySelectorAll(

Check warning on line 170 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L170

Added line #L170 was not covered by tests
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled])',
)

// Handle case when no interactive element are within the modal (including close icon)
if (focusableEls.length === 0) {
event.preventDefault()

Check warning on line 176 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L176

Added line #L176 was not covered by tests
}

const firstFocusableEl = focusableEls[0] as HTMLElement
const lastFocusableEl = focusableEls[focusableEls.length - 1] as HTMLElement

Check warning on line 180 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L179-L180

Added lines #L179 - L180 were not covered by tests

if (event.shiftKey) {
if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus()
event.preventDefault()

Check warning on line 185 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L184-L185

Added lines #L184 - L185 were not covered by tests
}
} else if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus()
event.preventDefault()

Check warning on line 189 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L188-L189

Added lines #L188 - L189 were not covered by tests
}
}, [])

// Prevent default behaviour on Escape
const stopCancel: ReactEventHandler = event => {
event.preventDefault()
event.stopPropagation()

Check warning on line 196 in packages/ui/src/components/Modal/Dialog.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/Modal/Dialog.tsx#L195-L196

Added lines #L195 - L196 were not covered by tests
}

return createPortal(
<StyledBackdrop
data-open={open}
onClick={hideOnClickOutside ? onClose : undefined}
className={backdropClassName}
css={backdropCss}
data-testid={dataTestId ? `${dataTestId}-backdrop` : undefined}
onFocus={stopFocus}
>
<StyledDialog
css={dialogCss}
onKeyUp={stopKeyUp}
onKeyDown={handleFocusTrap}
className={className}
id={id}
data-testid={dataTestId}
Expand All @@ -150,7 +217,10 @@ export const Dialog = ({
data-size={size}
open={open}
onClick={stopClick}
onCancel={stopCancel}
onClose={stopCancel}
aria-modal
ref={dialogRef}
>
{open ? children : null}
</StyledDialog>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/components/Modal/Disclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export const Disclosure = ({
}
}, [handleOpen, disclosureRef])

useEffect(() => {
if (!visible) {
disclosureRef.current?.focus()
}
}, [visible, disclosureRef])

if (typeof disclosure === 'function') {
return disclosure({
visible,
Expand Down
5 changes: 0 additions & 5 deletions packages/ui/src/components/Modal/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
renderWithTheme,
shouldMatchEmotionSnapshotWithPortal,
} from '../../../../.jest/helpers'
import { TextInput } from '../../TextInput'

const customDialogBackdropStyles = css`
background-color: aliceblue;
Expand Down Expand Up @@ -279,12 +278,8 @@ describe('Modal', () => {
opened
>
<div> test</div>
<TextInput data-testid="input" />
</Modal>,
)
await userEvent.type(screen.getByRole('textbox'), 'test{Escape}')
await userEvent.keyboard('{Escape}')
expect(mockOnClose).toBeCalledTimes(0)
await userEvent.click(screen.getByRole('dialog'))
await userEvent.keyboard('{Escape}')

Expand Down

0 comments on commit 6067c87

Please sign in to comment.