import { isInaccessible } from '@testing-library/react'
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'
import { render } from '~/src/utils/test'
import { AutoFocus } from '~/src/components/AutoFocus'
import {
Modal,
ModalBody,
ModalClose,
ModalContent,
ModalFooter,
ModalHeader,
ModalTrigger,
} from './Modal'
import {
type ModalContentProps,
type ModalHeaderProps,
type ModalProps,
} from './Modal.types'
const TRIGGER_TEXT = 'Open'
const CLOSE_TEXT = 'Close'
const TITLE_TEXT = 'Title'
const SUBTITLE_TEXT = 'Subtitle'
const DESCRIPTION_TEXT = 'Description'
describe('Modal', () => {
const renderModal = ({
modalProps,
modalContentProps,
modalHeaderProps = {
title: TITLE_TEXT,
subtitle: SUBTITLE_TEXT,
description: DESCRIPTION_TEXT,
titleSize: 'l',
},
}: {
modalProps?: ModalProps
modalContentProps?: ModalContentProps
modalHeaderProps?: ModalHeaderProps
} = {}) =>
render(
}
rightContent={
}
/>
)
let user: ReturnType
let renderOpenedModal: typeof renderModal
beforeEach(() => {
user = userEvent.setup({
/**
* To prevent `pointer-events: none` error.
* @see https://testing-library.com/docs/user-event/options#pointereventscheck
*/
pointerEventsCheck: PointerEventsCheckLevel.Never,
})
renderOpenedModal = (props) =>
renderModal({ modalProps: { defaultShow: true }, ...props })
})
describe('Accessibility', () => {
it('should be accessible', () => {
const { container } = renderModal()
expect(isInaccessible(container)).toBe(false)
})
})
describe('Focus Management', () => {
it('should focus on the first focusable element when the modal is opened', async () => {
const { getByRole } = renderModal()
const trigger = getByRole('button', { name: TRIGGER_TEXT })
await user.click(trigger)
expect(document.activeElement).toBe(getByRole('textbox'))
})
it('should focus on the modal trigger when the modal is closed', async () => {
const { getByRole } = renderModal()
const trigger = getByRole('button', { name: TRIGGER_TEXT })
await user.click(trigger)
await user.click(getByRole('button', { name: CLOSE_TEXT }))
expect(document.activeElement).toBe(trigger)
})
it('should be trapped inside the modal when the modal is opened', async () => {
const { getByRole } = renderModal()
const trigger = getByRole('button', { name: TRIGGER_TEXT })
await user.click(trigger)
const closeButton = getByRole('button', { name: CLOSE_TEXT })
const textbox = getByRole('textbox')
await user.tab()
expect(document.activeElement).toBe(closeButton)
await user.tab()
expect(document.activeElement).toBe(textbox)
await user.tab()
expect(document.activeElement).not.toBe(trigger) /* button */
await user.tab({ shift: true })
expect(document.activeElement).toBe(textbox)
await user.tab({ shift: true })
expect(document.activeElement).toBe(closeButton)
await user.tab({ shift: true })
expect(document.activeElement).not.toBe(trigger) /* textbox */
})
it('should focus last on the close icon button', async () => {
const { getByRole, getAllByRole } = renderModal({
modalContentProps: { showCloseIcon: true },
})
const trigger = getByRole('button', { name: TRIGGER_TEXT })
await user.click(trigger)
const [closeButton, closeIconButton] = getAllByRole('button')
await user.tab()
expect(document.activeElement).toBe(closeButton)
await user.tab()
expect(document.activeElement).toBe(closeIconButton)
})
})
describe('ModalContent', () => {
describe('ARIA', () => {
it('should have \'role="dialog"\' attribute', () => {
const { getByRole } = renderOpenedModal()
expect(getByRole('dialog')).toBeInTheDocument()
})
it('should have \'aria-modal="true"\' attribute', () => {
const { getByRole } = renderOpenedModal()
expect(getByRole('dialog')).toHaveAttribute('aria-modal', 'true')
})
it("should have proper 'aria-labelledby' attribute", () => {
const { getByRole } = renderOpenedModal()
expect(
getByRole('dialog', { name: `${TITLE_TEXT} ${SUBTITLE_TEXT}` })
).toBeInTheDocument()
})
it("should have proper 'aria-labelledby' attribute (only title)", () => {
const { getByRole } = renderOpenedModal({
modalHeaderProps: { title: TITLE_TEXT, subtitle: null },
modalContentProps: { 'aria-describedby': undefined },
})
expect(getByRole('dialog', { name: TITLE_TEXT })).toBeInTheDocument()
})
it("should have proper 'aria-labelledby' attribute (hidden title)", () => {
const { getByRole } = renderOpenedModal({
modalHeaderProps: { title: TITLE_TEXT, subtitle: null, hidden: true },
modalContentProps: { 'aria-describedby': undefined },
})
expect(getByRole('dialog', { name: TITLE_TEXT })).toBeInTheDocument()
})
it("should have proper 'aria-describedby' attribute", () => {
const { getByRole } = renderOpenedModal()
expect(
getByRole('dialog', { description: DESCRIPTION_TEXT })
).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should close the modal when the user clicks outside of the modal', async () => {
const { queryByRole, container } = renderOpenedModal()
await user.click(container)
expect(queryByRole('dialog')).not.toBeInTheDocument()
})
it('should keep modal open when the user clicks outside of the modal if preventHideOnOutsideClick property is true', async () => {
const { queryByRole, container } = renderOpenedModal({
modalContentProps: {
preventHideOnOutsideClick: true,
},
})
await user.click(container)
expect(queryByRole('dialog')).toBeInTheDocument()
})
it('should close the modal when the user clicks close icon button', async () => {
const { getAllByRole, queryByRole } = renderOpenedModal()
const [, closeIconButton] = getAllByRole('button')
await user.click(closeIconButton)
expect(queryByRole('dialog')).not.toBeInTheDocument()
})
it('should close the modal when the user presses the ESC key', async () => {
const { queryByRole } = renderOpenedModal()
await user.keyboard('{Escape}')
expect(queryByRole('dialog')).not.toBeInTheDocument()
})
})
describe('Data Attributes', () => {
it("should have proper 'data-state' attribute", () => {
const { getByRole } = renderOpenedModal()
expect(getByRole('dialog')).toHaveAttribute('data-state', 'open')
})
})
})
describe('ModalHeader', () => {
describe('ARIA, Semantics', () => {
it("the heading group(title and subtitle) should have group role and 'aria-roledescription' attribute", () => {
const { getByRole } = renderOpenedModal()
const titleGroup = getByRole('group')
expect(titleGroup).toHaveAttribute(
'aria-roledescription',
'Heading group'
)
})
it('the title should be an h2 element', () => {
const { getByRole } = renderOpenedModal()
expect(
getByRole('heading', { name: TITLE_TEXT, level: 2 })
).toBeInTheDocument()
})
it('the subtitle should have \'aria-roledescription="subtitle"\' attribute', () => {
const { getByText } = renderOpenedModal()
const subtitle = getByText(SUBTITLE_TEXT)
expect(subtitle).toHaveAttribute('aria-roledescription', 'subtitle')
})
})
describe('Visually Hidden', () => {
it("should be visible when the 'hidden' prop is false", () => {
const { getByRole } = renderOpenedModal()
expect(getByRole('banner')).toBeVisible() /* HTML5 header element */
})
it("should be visually hidden when the 'hidden' prop is true", () => {
const { queryByRole } = renderOpenedModal({
modalContentProps: { 'aria-describedby': undefined },
modalHeaderProps: { title: TITLE_TEXT, hidden: true },
})
/**
* NOTE(@ed): As a `toBeVisible` matcher, it cannot be used because the `visibility` style cannot be checked.
* Instead, it tests whether the visually hidden style is applied correctly.
* @see https://github.com/testing-library/jest-dom/issues/209
*/
expect(queryByRole('banner')).toHaveStyle({
position: 'absolute',
}) /* HTML5 header element */
})
})
})
describe('ModalTrigger', () => {
describe('ARIA', () => {
it('should have \'aria-haspopup="dialog"\' attribute', () => {
const { getByRole } = renderModal()
const trigger = getByRole('button', { name: TRIGGER_TEXT })
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
})
it("should have proper 'aria-expanded' attribute", async () => {
const { getByRole } = renderModal()
const trigger = getByRole('button', { name: TRIGGER_TEXT })
expect(trigger).toHaveAttribute('aria-expanded', 'false')
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
it('should have \'aria-controls="{dialogElement.id}"\' attribute', async () => {
const { getByRole } = renderModal()
const trigger = getByRole('button', { name: TRIGGER_TEXT })
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-controls', getByRole('dialog').id)
})
})
describe('User Interactions', () => {
it('should open modal when clicked (Uncontrolled)', async () => {
const { getByRole } = renderModal()
await user.click(getByRole('button', { name: TRIGGER_TEXT }))
expect(getByRole('dialog')).toBeInTheDocument()
})
it('should open modal when clicked (Controlled)', async () => {
const onShow = jest.fn()
const { getByRole } = renderModal({
modalProps: { show: false, onShow },
})
await user.click(getByRole('button', { name: TRIGGER_TEXT }))
expect(onShow).toHaveBeenCalledTimes(1)
})
})
})
describe('ModalClose', () => {
describe('User Interactions', () => {
it('should close modal when clicked (Uncontrolled)', async () => {
const { getByRole, queryByRole } = renderOpenedModal()
await user.click(getByRole('button', { name: CLOSE_TEXT }))
expect(queryByRole('dialog')).not.toBeInTheDocument()
})
it('should close modal when clicked (Controlled)', async () => {
const onHide = jest.fn()
const { getByRole } = renderModal({
modalProps: { show: true, onHide },
})
await user.click(getByRole('button', { name: CLOSE_TEXT }))
expect(onHide).toHaveBeenCalledTimes(1)
})
})
})
describe('With AutoFocus', () => {
const renderModalWithAutoFocus = () =>
render(
}
rightContent={
}
/>
)
it('should focus the element wrapped by AutoFocus', async () => {
const { getByRole } = renderModalWithAutoFocus()
await user.click(getByRole('button', { name: TRIGGER_TEXT }))
expect(getByRole('button', { name: CLOSE_TEXT })).toHaveFocus()
})
})
})