Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(store-ui): Add Modal molecule #957

Merged
merged 16 commits into from
Sep 28, 2021
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.11",
"@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