Skip to content

Instantly share code, notes, and snippets.

@drikusroor
Last active November 22, 2023 13:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save drikusroor/1efe8321ca45a12219ed0e7432cc0b45 to your computer and use it in GitHub Desktop.
Save drikusroor/1efe8321ca45a12219ed0e7432cc0b45 to your computer and use it in GitHub Desktop.
React modal component styled with TailwindCSS in TypeScript
import { fireEvent, render, screen, waitFor } from 'from '@testing-library/react'
import Modal from './Modal'
describe('Modal', () => {
it('renders successfully', () => {
const onCancel = jest.fn()
expect(() => {
render(<Modal isOpen onCancel={onCancel} />)
}).not.toThrow()
})
it('renders the modal when `isOpen` is true', () => {
render(<Modal isOpen onCancel={() => {}} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('does not render the modal when `isOpen` is false', () => {
render(<Modal isOpen={false} onCancel={() => {}} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('renders the title when provided', () => {
const title = 'Test Modal Title'
render(<Modal isOpen title={title} onCancel={() => {}} />)
expect(screen.getByText(title)).toBeInTheDocument()
})
it('calls `onCancel` when the backdrop is clicked', () => {
const onCancel = jest.fn()
render(<Modal isOpen onCancel={onCancel} />)
fireEvent.click(screen.getByRole('presentation'))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('calls `onCancel` when the escape key is pressed', () => {
const onCancel = jest.fn()
render(<Modal isOpen onCancel={onCancel} />)
fireEvent.keyDown(screen.getByRole('presentation'), {
key: 'Escape',
code: 'Escape',
})
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('does not call `onCancel` when the modal content is clicked', () => {
const onCancel = jest.fn()
render(<Modal isOpen onCancel={onCancel} />)
fireEvent.click(screen.getByRole('dialog'))
expect(onCancel).not.toHaveBeenCalled()
})
it('calls `onConfirm` when the confirm button is clicked', () => {
const onConfirm = jest.fn()
render(<Modal isOpen onConfirm={onConfirm} onCancel={() => {}} />)
fireEvent.click(screen.getByText('Confirm'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('displays custom button texts when provided', () => {
const confirmText = 'Yes, I’m sure'
const cancelText = 'No, cancel'
render(
<Modal
isOpen
confirmButtonText={confirmText}
cancelButtonText={cancelText}
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>
)
expect(screen.getByText(confirmText)).toBeInTheDocument()
expect(screen.getByText(cancelText)).toBeInTheDocument()
})
it('transitions to closed state after a delay when `isOpen` is set to false', async () => {
jest.useFakeTimers()
const { rerender } = render(<Modal isOpen onCancel={() => {}} />)
rerender(<Modal isOpen={false} onCancel={() => {}} />)
fireEvent.transitionEnd(screen.getByRole('presentation'))
jest.advanceTimersByTime(300) // Advance timers by the length of your transition
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
jest.useRealTimers()
})
it('adds an "-translate-y-4" class to the modal when it is in the process of opening', () => {
render(<Modal isOpen onCancel={() => {}} />)
expect(screen.getByRole('dialog')).toHaveClass('-translate-y-4')
})
it('adds a "-translate-y-4" class to the modal when it is in the process of closing', () => {
jest.useFakeTimers()
const { rerender } = render(<Modal isOpen onCancel={() => {}} />)
rerender(<Modal isOpen={false} onCancel={() => {}} />)
jest.advanceTimersByTime(50)
expect(screen.getByRole('dialog')).toHaveClass('-translate-y-4')
jest.useRealTimers()
})
})
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import { useEffect, useState } from 'react'
import classNames from 'src/lib/class-names'
interface ModalProps {
isOpen: boolean
children?: React.ReactNode
title?: string
confirmButtonText?: string
cancelButtonText?: string
onConfirm?: () => void
onCancel: () => void
hideCloseButton?: boolean
}
type RenderState = 'open' | 'opening' | 'closed' | 'closing'
const Modal = ({
children,
isOpen,
title = '',
confirmButtonText = 'Confirm',
cancelButtonText = 'Cancel',
onConfirm,
onCancel,
hideCloseButton = false,
}: ModalProps) => {
const [renderState, setRenderState] = useState<RenderState>('closed')
const showButtonBar = onConfirm || (onCancel && !hideCloseButton)
useEffect(() => {
if (isOpen) {
if (renderState !== 'closed') {
return
}
setRenderState('opening')
setTimeout(() => setRenderState('open'), 50)
} else {
if (renderState !== 'open') {
return
}
setRenderState('closing')
setTimeout(() => setRenderState('closed'), 300)
}
}, [isOpen, renderState])
if (renderState === 'closed') {
return null
}
return (
<div
className={classNames(
'fixed inset-0 z-10 flex h-screen w-screen items-center justify-center overflow-y-auto bg-gray-600/50 transition-opacity',
renderState === 'open' ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
onClick={onCancel}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onCancel()
}
}}
role="presentation"
>
<div
className={classNames(
'relative w-96 rounded-md border bg-white p-5 shadow-lg transition-transform',
renderState === 'open' ? 'translate-y-0' : '-translate-y-4'
)}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
onClick={(e) => e.stopPropagation()}
>
<div>
{title && (
<h3 className="text-lg font-medium leading-6 text-gray-900">
{title}
</h3>
)}
{children && <p className="text-sm text-gray-500">{children}</p>}
</div>
{showButtonBar && (
<div className="mt-4 flex items-center justify-end gap-2">
{onCancel && !hideCloseButton && (
<button
onClick={onCancel}
className="mb-1 mr-1 rounded bg-red-500 px-4 py-2 text-xs font-bold uppercase text-white shadow outline-none hover:shadow-md focus:outline-none active:bg-red-600"
style={{ transition: 'all .15s ease' }}
>
{cancelButtonText || 'Cancel'}
</button>
)}
{onConfirm && (
<button
onClick={onConfirm}
className="mb-1 mr-1 rounded bg-green-500 px-4 py-2 text-xs font-bold uppercase text-white shadow outline-none hover:shadow-md focus:outline-none active:bg-green-600"
style={{ transition: 'all .15s ease' }}
>
{confirmButtonText}
</button>
)}
</div>
)}
</div>
</div>
)
}
export default Modal
@drikusroor
Copy link
Author

Not sure what to do with the a11y suggestions:

jsx-a11y/click-events-have-key-events
jsx-a11y/no-noninteractive-element-interactions

If anyone ever encounters this in the future and you have a nice solution, please let me know. :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment