Last active
November 22, 2023 13:25
-
-
Save drikusroor/1efe8321ca45a12219ed0e7432cc0b45 to your computer and use it in GitHub Desktop.
React modal component styled with TailwindCSS in TypeScript
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
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() | |
}) | |
}) |
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
/* 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Not sure what to do with the
a11y
suggestions:If anyone ever encounters this in the future and you have a nice solution, please let me know. :-)