-
Notifications
You must be signed in to change notification settings - Fork 64
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(store-ui): Add Modal molecule (#957)
* 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
1 parent
a3aa66a
commit 530b308
Showing
14 changed files
with
704 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.