Skip to content

Commit

Permalink
feat(modal): responds to keyboard events when modal is displayed (#574)
Browse files Browse the repository at this point in the history
  • Loading branch information
unix authored Jun 27, 2021
1 parent b861e73 commit c1d7620
Show file tree
Hide file tree
Showing 6 changed files with 83 additions and 70 deletions.
72 changes: 40 additions & 32 deletions components/modal/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import React from 'react'
import { mount } from 'enzyme'
import { Modal } from 'components'
import { KeyCode, Modal } from 'components'
import { nativeEvent, updateWrapper } from 'tests/utils'
import { expectModalIsClosed, expectModalIsOpened } from './use-modal.test'
import { act } from 'react-dom/test-utils'

const TabEvent = {
key: 'TAB',
keyCode: 9,
which: 9,
}

describe('Modal', () => {
it('should render correctly', () => {
const wrapper = mount(
<Modal open={true}>
<Modal visible={true}>
<Modal.Title>Modal</Modal.Title>
<Modal.Subtitle>This is a modal</Modal.Subtitle>
<Modal.Content>
Expand All @@ -29,19 +22,17 @@ describe('Modal', () => {
})

it('should trigger event when modal changed', async () => {
const openHandler = jest.fn()
const closeHandler = jest.fn()
const wrapper = mount(
<Modal onOpen={openHandler} onClose={closeHandler}>
<Modal onClose={closeHandler}>
<Modal.Title>Modal</Modal.Title>
</Modal>,
)
expectModalIsClosed(wrapper)

wrapper.setProps({ open: true })
wrapper.setProps({ visible: true })
await updateWrapper(wrapper, 350)
expectModalIsOpened(wrapper)
expect(openHandler).toHaveBeenCalled()

wrapper.find('.backdrop').simulate('click', nativeEvent)
await updateWrapper(wrapper, 500)
Expand All @@ -52,7 +43,7 @@ describe('Modal', () => {
it('should disable backdrop event', async () => {
const closeHandler = jest.fn()
const wrapper = mount(
<Modal open={true} disableBackdropClick onClose={closeHandler}>
<Modal visible={true} disableBackdropClick onClose={closeHandler}>
<Modal.Title>Modal</Modal.Title>
<Modal.Action>Submit</Modal.Action>
</Modal>,
Expand All @@ -66,7 +57,7 @@ describe('Modal', () => {
it('should disable backdrop even if actions missing', async () => {
const closeHandler = jest.fn()
const wrapper = mount(
<Modal open={true} disableBackdropClick onClose={closeHandler}>
<Modal visible={true} disableBackdropClick onClose={closeHandler}>
<Modal.Title>Modal</Modal.Title>
</Modal>,
)
Expand All @@ -80,7 +71,7 @@ describe('Modal', () => {
const actions1 = jest.fn()
const actions2 = jest.fn()
const wrapper = mount(
<Modal open={true}>
<Modal visible={true}>
<Modal.Title>Modal</Modal.Title>
<Modal.Action passive onClick={actions1}>
Submit
Expand All @@ -100,7 +91,7 @@ describe('Modal', () => {
it('should be close modal through action event', async () => {
const closeHandler = jest.fn()
const wrapper = mount(
<Modal open={true} onClose={closeHandler}>
<Modal visible={true} onClose={closeHandler}>
<Modal.Title>Modal</Modal.Title>
<Modal.Action passive onClick={e => e.close()}>
Close
Expand All @@ -115,7 +106,7 @@ describe('Modal', () => {

it('customization should be supported', () => {
const wrapper = mount(
<Modal open={true} width="100px" wrapClassName="test-class">
<Modal visible={true} width="100px" wrapClassName="test-class">
<Modal.Title>Modal</Modal.Title>
</Modal>,
)
Expand All @@ -127,29 +118,46 @@ describe('Modal', () => {

it('focus should only be switched within modal', () => {
const wrapper = mount(
<Modal open={true} width="100px" wrapClassName="test-class">
<Modal visible={true} width="100px" wrapClassName="test-class">
<Modal.Title>Modal</Modal.Title>
</Modal>,
)
const tabStart = wrapper.find('.hide-tab').at(0).getDOMNode()
const tabEnd = wrapper.find('.hide-tab').at(1).getDOMNode()
const eventElement = wrapper.find('.wrapper').at(0)
expect(document.activeElement).toBe(tabStart)

act(() => {
eventElement.simulate('keydown', {
...TabEvent,
shiftKey: true,
})
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.Tab }))

expect(tabEnd.outerHTML).toEqual(document.activeElement?.outerHTML)
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.Tab }))
expect(tabStart.outerHTML).toEqual(document.activeElement?.outerHTML)
})

it('should close modal when keyboard event is triggered', async () => {
const wrapper = mount(
<Modal visible={true}>
<Modal.Title>Modal</Modal.Title>
</Modal>,
)
expectModalIsOpened(wrapper)
wrapper.simulate('keydown', {
keyCode: KeyCode.Escape,
})
expect(document.activeElement).toBe(tabEnd)
await updateWrapper(wrapper, 500)
expectModalIsClosed(wrapper)
})

act(() => {
eventElement.simulate('keydown', {
...TabEvent,
shiftKey: false,
})
it('should prevent close modal when keyboard is false', async () => {
const wrapper = mount(
<Modal visible={true} keyboard={false}>
<Modal.Title>Modal</Modal.Title>
</Modal>,
)
expectModalIsOpened(wrapper)
wrapper.simulate('keydown', {
keyCode: KeyCode.Escape,
})
expect(document.activeElement).toBe(tabStart)
await updateWrapper(wrapper, 500)
expectModalIsOpened(wrapper)
})
})
1 change: 1 addition & 0 deletions components/modal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ export type { ModalTitleProps } from './modal-title'
export type { ModalSubtitleProps } from './modal-subtitle'
export type { ModalActionProps } from './modal-action'
export type { ModalContentProps } from './modal-content'
export type { ModalHooksBindings } from './use-modal'
export default Modal as ModalComponentType
42 changes: 23 additions & 19 deletions components/modal/modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { MouseEvent, useEffect, useMemo } from 'react'
import React, { MouseEvent, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import usePortal from '../utils/use-portal'
import ModalWrapper from './modal-wrapper'
Expand All @@ -8,60 +8,63 @@ import Backdrop from '../shared/backdrop'
import { ModalConfig, ModalContext } from './modal-context'
import { pickChild } from '../utils/collections'
import useBodyScroll from '../utils/use-body-scroll'
import useCurrentState from '../utils/use-current-state'
import useScaleable, { withScaleable } from '../use-scaleable'
import useKeyboard, { KeyCode } from '../use-keyboard'

interface Props {
disableBackdropClick?: boolean
onClose?: () => void
onOpen?: () => void
onContentClick?: (event: MouseEvent<HTMLElement>) => void
open?: boolean
visible?: boolean
keyboard?: boolean
wrapClassName?: string
}

const defaultProps = {
wrapClassName: '',
keyboard: true,
disableBackdropClick: false,
}

type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type ModalProps = Props & NativeAttrs

const ModalComponent: React.FC<React.PropsWithChildren<ModalProps>> = ({
open,
onOpen,
visible: customVisible,
onClose,
children,
keyboard,
wrapClassName,
onContentClick,
disableBackdropClick,
}: React.PropsWithChildren<ModalProps> & typeof defaultProps) => {
const portal = usePortal('modal')
const { SCALES } = useScaleable()
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const [visible, setVisible, visibleRef] = useCurrentState<boolean>(false)
const [visible, setVisible] = useState<boolean>(false)
const [withoutActionsChildren, ActionsChildren] = pickChild(children, ModalAction)
const hasActions = ActionsChildren && React.Children.count(ActionsChildren) > 0

const closeModal = () => {
onClose && onClose()
setVisible(false)
setBodyHidden(false)
}

useEffect(() => {
if (open === undefined) return
if (open) {
onOpen && onOpen()
}
if (!open && visibleRef.current) {
onClose && onClose()
}
if (typeof customVisible === 'undefined') return
setVisible(customVisible)
setBodyHidden(customVisible)
}, [customVisible])

setVisible(open)
setBodyHidden(open)
}, [open])
const { bindings } = useKeyboard(
() => {
keyboard && closeModal()
},
KeyCode.Escape,
{
disableGlobalEvent: true,
},
)

const closeFromBackdrop = () => {
if (disableBackdropClick) return
Expand All @@ -82,7 +85,8 @@ const ModalComponent: React.FC<React.PropsWithChildren<ModalProps>> = ({
onClick={closeFromBackdrop}
onContentClick={onContentClick}
visible={visible}
width={SCALES.width(26)}>
width={SCALES.width(26)}
{...bindings}>
<ModalWrapper visible={visible} className={wrapClassName}>
{withoutActionsChildren}
{hasActions && <ModalActions>{ActionsChildren}</ModalActions>}
Expand Down
10 changes: 5 additions & 5 deletions components/modal/use-modal.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Dispatch, MutableRefObject, SetStateAction } from 'react'
import useCurrentState from '../utils/use-current-state'
import { ModalProps } from '../modal'

export type ModalHooksBindings = Pick<ModalProps, 'visible' | 'onClose'>

const useModal = (
initialVisible: boolean = false,
): {
visible: boolean
setVisible: Dispatch<SetStateAction<boolean>>
currentRef: MutableRefObject<boolean>
bindings: {
open: boolean
onClose: () => void
}
bindings: ModalHooksBindings
} => {
const [visible, setVisible, currentRef] = useCurrentState<boolean>(initialVisible)

Expand All @@ -19,7 +19,7 @@ const useModal = (
setVisible,
currentRef,
bindings: {
open: visible,
visible,
onClose: () => setVisible(false),
},
}
Expand Down
14 changes: 7 additions & 7 deletions pages/en-us/components/modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Display popup content that requires attention or provides additional information

<Playground
title="Basic"
desc="Use `open` control whether `Modal` is displayed."
desc="Use `visible` control whether `Modal` is displayed."
scope={{ Modal, Button, useState }}
code={`
() => {
Expand All @@ -35,7 +35,7 @@ Display popup content that requires attention or provides additional information
return (
<div>
<Button auto onClick={handler}>Show Modal</Button>
<Modal open={state} onClose={closeHandler}>
<Modal visible={state} onClose={closeHandler}>
<Modal.Title>Modal</Modal.Title>
<Modal.Subtitle>This is a modal</Modal.Subtitle>
<Modal.Content>
Expand Down Expand Up @@ -94,7 +94,7 @@ Display popup content that requires attention or provides additional information
return (
<div>
<Button auto onClick={handler}>Show Modal</Button>
<Modal open={state} onClose={closeHandler}>
<Modal visible={state} onClose={closeHandler}>
<Modal.Title>Modal</Modal.Title>
<Modal.Subtitle>This is a modal</Modal.Subtitle>
<Modal.Content>
Expand Down Expand Up @@ -122,7 +122,7 @@ Display popup content that requires attention or provides additional information
return (
<>
<Button auto onClick={handler}>Show Modal</Button>
<Modal open={state} onClose={closeHandler}>
<Modal visible={state} onClose={closeHandler}>
<Modal.Title>Modal</Modal.Title>
<Modal.Subtitle>This is a modal</Modal.Subtitle>
<Modal.Content>
Expand Down Expand Up @@ -233,11 +233,11 @@ Display popup content that requires attention or provides additional information

| Attribute | Description | Type | Accepted values | Default |
| ------------------------ | -------------------------------- | ------------------------- | --------------------------------------- | ------- |
| **open** | open or close | `boolean` | - | `false` |
| **onOpen** | open event | `() => void` | - | - |
| **visible** | open or close | `boolean` | - | `false` |
| **onClose** | open event | `() => void` | - | - |
| **onContentClick** | event from modal content | `(e: MouseEvent) => void` | - | - |
| **wrapClassName** | className of the modal dialog | `string` | - | - |
| **keyboard** | press Esc to close modal | `boolean` | - | `true` |
| **disableBackdropClick** | click background and don't close | `boolean` | - | `false` |
| ... | native props | `HTMLAttributes` | `'autoFocus', 'name', 'className', ...` | - |

Expand Down Expand Up @@ -277,7 +277,7 @@ type useModal = (initialVisible: boolean) => {
setVisible: Dispatch<SetStateAction<boolean>>
currentRef: MutableRefObject<boolean>
bindings: {
open: boolean
visible: boolean
onClose: () => void
}
}
Expand Down
Loading

0 comments on commit c1d7620

Please sign in to comment.