Skip to content

Commit

Permalink
feat(store-ui): Add Modal molecule (#957)
Browse files Browse the repository at this point in the history
* Add Modal component

* Modal Molecule, tests and story

* Refactor to apply pure component pattern

* Add tests for modal inside another modal

* Apply suggestions from code review

Co-authored-by: Ícaro Azevedo <icaro.azevedo@vtex.com.br>

* Remove composeEventHandler

* Pass overlay as ModalPure props

* Fix handleBackdropKeyDown

* Remove ModalPure

* Add jest-axe test

* Change test name

Co-authored-by: Larícia Mota <laricia.mota@vtex.com.br>

* Fix modal close when has no tabbable children

* Change useTrapFocus API

* Remove unnecessary comment

Co-authored-by: Larícia Mota <laricia.mota@vtex.com.br>

* Fix typo

Co-authored-by: Ícaro Azevedo <icaro.azevedo@vtex.com.br>

* Receive before after ref element on useTrapFocus

Co-authored-by: Ícaro Azevedo <icaro.azevedo@vtex.com.br>
Co-authored-by: Larícia Mota <laricia.mota@vtex.com.br>
  • Loading branch information
3 people authored Sep 28, 2021
1 parent a3aa66a commit 530b308
Show file tree
Hide file tree
Showing 14 changed files with 704 additions and 12 deletions.
6 changes: 5 additions & 1 deletion packages/store-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
],
"dependencies": {
"@reach/popover": "^0.16.0",
"react-swipeable": "^6.1.2"
"react-swipeable": "^6.1.2",
"tabbable": "^5.2.1"
},
"peerDependencies": {
"react": "^17.0.2",
Expand All @@ -70,9 +71,12 @@
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^13.1.8",
"@types/jest-axe": "^3.5.3",
"@types/tabbable": "^3.1.1",
"@types/testing-library__jest-dom": "^5.9.5",
"@vtex/theme-b2c-tailwind": "^1.1.13",
"@vtex/tsconfig": "^0.5.0",
"jest-axe": "^5.0.1",
"react": "^17.0.2",
"react-docgen-typescript-loader": "^3.7.2",
"react-dom": "^17.0.2",
Expand Down
3 changes: 3 additions & 0 deletions packages/store-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export type { CarouselProps } from './molecules/Carousel'
export { default as IconButton } from './molecules/IconButton'
export type { IconButtonProps } from './molecules/IconButton'

export { default as Modal } from './molecules/Modal'
export type { ModalProps } from './molecules/Modal'

// Hooks
export { default as useSlider } from './hooks/useSlider'
export type {
Expand Down
233 changes: 233 additions & 0 deletions packages/store-ui/src/molecules/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import type { ReactNode } from 'react'
import React, { useState } from 'react'
import { fireEvent, render } from '@testing-library/react'
import { axe } from 'jest-axe'

import Modal from './Modal'
import Button from '../../atoms/Button'
import Input from '../../atoms/Input'

const modalTestId = 'store-modal'

const TestModal = ({
children,
onDismiss: mockOnDismiss,
}: {
children?: ReactNode
onDismiss?: () => void
}) => {
const [isOpen, setIsOpen] = useState(false)
const handleOpen = () => {
setIsOpen(true)
}

const onDismiss = () => {
setIsOpen(false)
mockOnDismiss?.()
}

return (
<>
<Button testId="trigger" onClick={handleOpen}>
OpenModal
</Button>
<Modal isOpen={isOpen} testId={modalTestId} onDismiss={onDismiss}>
<Input testId="first-input" />
<Button testId="first-button" />
{children}
</Modal>
</>
)
}

describe('Modal', () => {
it('The attribute data-store-modal-content should be present', () => {
const { getByTestId } = render(
<Modal aria-label="test modal" testId="store-modal" isOpen>
Foo
</Modal>
)

expect(getByTestId('store-modal')).toHaveAttribute(
'data-store-modal-content'
)
})

it('Modal should only be rendered if isOpen is true', () => {
// Check that modal won't be rendered
const { getByTestId } = render(<TestModal />)

expect(document.querySelector(`[data-testid="${modalTestId}"]`)).toBeNull()

fireEvent.click(getByTestId('trigger'))

expect(getByTestId('store-modal')).toBeInTheDocument()
})
})

describe('Modal WAI-ARIA Specifications', () => {
// WAI-ARIA tests
// https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal
it('AXE Test', async () => {
render(
<Modal aria-label="test modal" testId="store-modal" isOpen>
Foo
</Modal>
)

expect(await axe(document.body)).toHaveNoViolations()
})

it('Focus first element', () => {
const { getByTestId } = render(<TestModal />)

// Open the modal
fireEvent.click(getByTestId('trigger'))

// Check if the first tabbable is focused
expect(getByTestId('first-input')).toHaveFocus()
})

it('Loop focus', () => {
const { getByTestId } = render(<TestModal />)

// Open the modal
fireEvent.click(getByTestId('trigger'))

expect(getByTestId('first-input')).toHaveFocus()

// Simulate loop back: from first to last element
fireEvent.keyDown(document.activeElement!, {
key: 'Tab',
shiftKey: true,
})

fireEvent.focus(getByTestId('beforeElement'))
expect(getByTestId('first-button')).toHaveFocus()

// Simulate loop back: from last to first element
fireEvent.keyDown(document.activeElement!, {
key: 'Tab',
})

fireEvent.focus(getByTestId('afterElement'))
expect(getByTestId('first-input')).toHaveFocus()
})

it('Loop focus inside the child modal', () => {
const { getByTestId, getAllByTestId } = render(
<TestModal>
<TestModal />
</TestModal>
)

// Open the first modal
fireEvent.click(getByTestId('trigger'))

const [, secondTrigger] = getAllByTestId('trigger')

// Open the internal modal
fireEvent.click(secondTrigger)

// Check if the first input of the internal modal is focused
expect(secondTrigger).not.toHaveFocus()
expect(getAllByTestId('first-input')[1]).toHaveFocus()

// Simulate loop back: from first to last element of the internal modal
fireEvent.keyDown(document.activeElement!, {
key: 'Tab',
shiftKey: true,
})

fireEvent.focus(getAllByTestId('beforeElement')[1])
const [firstButton, secondButton] = getAllByTestId('first-button')

expect(secondButton).toHaveFocus()
expect(firstButton).not.toHaveFocus()
})

it('Focus last element before the modal was opened', () => {
const { getByTestId } = render(<TestModal />)
const triggerModalButton = getByTestId('trigger')

// Focus the trigger button that's outside the modal
triggerModalButton.focus()
expect(triggerModalButton).toHaveFocus()

fireEvent.click(triggerModalButton)

// Modal focused something inside, so make sure that's not focused
expect(triggerModalButton).not.toHaveFocus()

// Close the modal
fireEvent.click(getByTestId('store-overlay'))

// Make sure that modal focused back the trigger button after close.
expect(triggerModalButton).toHaveFocus()
})

it('Call onDismiss when press escape without tabbable children', () => {
const mockDismiss = jest.fn()
const { getByTestId } = render(
<Modal isOpen testId="store-modal" onDismiss={mockDismiss}>
Not focable content
</Modal>
)

fireEvent.keyDown(getByTestId('store-modal'), { key: 'Escape' })
expect(mockDismiss).toHaveBeenCalled()
})

it('Call onDismiss when press escape with tabbable children', () => {
const mockDismiss = jest.fn()
const { getByTestId } = render(<TestModal onDismiss={mockDismiss} />)

fireEvent.click(getByTestId('trigger'))

// Pressing any key other than 'Escape' won't close the modal
fireEvent.keyPress(getByTestId('store-modal'), { key: 'j' })
expect(mockDismiss).not.toHaveBeenCalled()

// Press Escape
fireEvent.keyDown(getByTestId('store-modal'), {
key: 'Escape',
})

expect(mockDismiss).toHaveBeenCalled()
})

it('Call only the onDismiss from internal modal when press escape', () => {
const mockExternalDismiss = jest.fn()
const mockInternalDismiss = jest.fn()
const { getByTestId, getAllByTestId } = render(
<TestModal onDismiss={mockExternalDismiss}>
<TestModal onDismiss={mockInternalDismiss} />
</TestModal>
)

fireEvent.click(getByTestId('trigger'))
fireEvent.click(getAllByTestId('trigger')[1])

expect(getAllByTestId('store-modal')[1]).toBeInTheDocument()

// Press Escape on internal modal. Only the internal modal should close
fireEvent.keyDown(getAllByTestId('store-modal')[1], {
key: 'Escape',
})

expect(mockExternalDismiss).not.toHaveBeenCalled()
expect(mockInternalDismiss).toHaveBeenCalled()
})

it('Call onDismiss when click outside the modal', () => {
const mockDismiss = jest.fn()
const { getByTestId } = render(<TestModal onDismiss={mockDismiss} />)

fireEvent.click(getByTestId('trigger'))

// Close the modal
fireEvent.click(getByTestId('store-overlay'))

expect(mockDismiss).toHaveBeenCalled()
})
})
83 changes: 83 additions & 0 deletions packages/store-ui/src/molecules/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type {
AriaAttributes,
KeyboardEvent,
MouseEvent,
PropsWithChildren,
} from 'react'
import React from 'react'
import { createPortal } from 'react-dom'

import Overlay from '../../atoms/Overlay'
import ModalContent from './ModalContent'
import type { ModalContentProps } from './ModalContent'

export interface ModalProps extends ModalContentProps {
/**
* ID to find this component in testing tools (e.g.: cypress, testing library, and jest).
*/
testId?: string
/**
* Identifies the element (or elements) that labels the current element.
* @see aria-labelledby https://www.w3.org/TR/wai-aria-1.1/#aria-labelledby
*/
'aria-labelledby'?: AriaAttributes['aria-label']

/**
* This function is called whenever the user hits "Escape" or clicks outside
* the dialog.
*/
onDismiss?: (event: MouseEvent | KeyboardEvent) => void
/**
* Controls whether or not the dialog is open.
*/
isOpen: boolean
}

/*
* This component is based on @reach/dialog.
* https://github.com/reach/reach-ui/blob/main/packages/dialog/src/index.tsx
* https://reach.tech/dialog
*/

const Modal = ({
isOpen,
children,
onDismiss,
testId = 'store-modal',
...props
}: PropsWithChildren<ModalProps>) => {
const handleBackdropClick = (event: MouseEvent) => {
if (event.defaultPrevented) {
return
}

event.stopPropagation()
onDismiss?.(event)
}

const handleBackdropKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) {
return
}

event.stopPropagation()
onDismiss?.(event)
}

return isOpen
? createPortal(
<Overlay
data-modal-overlay
onClick={handleBackdropClick}
onKeyDown={handleBackdropKeyDown}
>
<ModalContent {...props} testId={testId}>
{children}
</ModalContent>
</Overlay>,
document.body
)
: null
}

export default Modal
Loading

0 comments on commit 530b308

Please sign in to comment.