Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
72 changes: 69 additions & 3 deletions src/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Modal from './'
import React, { useCallback, useRef } from 'react'
import Button from '../Button'
import Modal from './'
import './dialog.mock'

const TestModal = ({ state }: { state: boolean }) => {
const [open, setOpen] = React.useState(state)
Expand Down Expand Up @@ -84,4 +85,69 @@ describe('Modal', () => {
screen.queryByRole('button', { name: 'Do not click me' })
).not.toBeInTheDocument()
})

describe('dialog ref handlers', () => {
const TestDialogWithHandlers = () => {
const ref = useRef<HTMLDialogElement>(null)
const handleShow = useCallback(() => {
ref.current?.showModal()
}, [ref])
const handleClose = useCallback(() => {
ref.current?.close()
}, [ref])
return (
<div className="font-sans">
<Button onClick={handleShow}>Open Modal</Button>
<Modal ref={ref}>
<Modal.Header className="font-bold">Hello!</Modal.Header>
<Modal.Body>
Press ESC key or click the button below to close
</Modal.Body>
<Modal.Actions>
<Button onClick={handleClose}>Close</Button>
</Modal.Actions>
</Modal>
</div>
)
}

it('should show modal with button', async () => {
const user = userEvent.setup()
render(<TestDialogWithHandlers />)

expect(screen.queryByRole('dialog')).not.toBeInTheDocument()

const openButton = screen.getByRole('button', { name: 'Open Modal' })
expect(openButton).toBeInTheDocument()

await user.click(openButton)

const dialog = await screen.findByRole('dialog')
expect(dialog).toBeInTheDocument()

const closeButton = screen.getByRole('button', { name: 'Close' })
expect(closeButton).toBeInTheDocument()
await user.click(closeButton)

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})

it('should handle cancel event when triggered manually', async () => {
const user = userEvent.setup()
render(<TestDialogWithHandlers />)

const openButton = screen.getByRole('button', { name: 'Open Modal' })
await user.click(openButton)

const dialog = await screen.findByRole<HTMLDialogElement>('dialog')
expect(dialog).toBeInTheDocument()

// Manually trigger the cancel event (simulating ESC key)
await dialog.triggerCancel()

expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
})
25 changes: 14 additions & 11 deletions src/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React, { forwardRef, useCallback, useRef } from 'react'
import clsx from 'clsx'
import React, { forwardRef, useImperativeHandle, useRef } from 'react'
import { twMerge } from 'tailwind-merge'

import { IComponentBaseProps, ComponentPosition } from '../types'
import { ComponentPosition, IComponentBaseProps } from '../types'

import ModalActions from './ModalActions'
import ModalBody from './ModalBody'
import ModalHeader from './ModalHeader'
import ModalLegacy from './ModalLegacy'
import { useAriaHidden } from './useAriaHidden'

export type ModalProps = React.DialogHTMLAttributes<HTMLDialogElement> &
IComponentBaseProps & {
Expand All @@ -33,32 +33,35 @@ const Modal = forwardRef<HTMLDialogElement, ModalProps>(
},
ref
): JSX.Element => {
const internalRef = useRef<HTMLDialogElement>(null)
useImperativeHandle(ref, () => internalRef.current as HTMLDialogElement)
const isAriaHidden = useAriaHidden(internalRef, open, ariaHidden)

const containerClasses = twMerge(
'modal',
clsx({
'modal-open': open,
'modal-end': position === 'end',
'modal-start': position === 'start',
'modal-top': position === 'top',
'modal-middle': position === 'middle',
'modal-bottom': position === 'bottom',
'modal-middle': position === undefined,
'modal-bottom sm:modal-middle': responsive,
})
)

ariaHidden = ariaHidden ?? !open
const bodyClasses = twMerge('modal-box', className)

return (
<dialog
{...props}
aria-label="Modal"
aria-hidden={ariaHidden}
aria-hidden={isAriaHidden}
open={open}
aria-modal={open}
data-theme={dataTheme}
className={containerClasses}
ref={ref}
ref={internalRef}
>
<div data-theme={dataTheme} className={bodyClasses}>
{children}
Expand All @@ -79,13 +82,13 @@ export type DialogProps = Omit<ModalProps, 'ref'>
const useDialog = () => {
const dialogRef = useRef<HTMLDialogElement>(null)

const handleShow = useCallback(() => {
const handleShow = () => {
dialogRef.current?.showModal()
}, [dialogRef])
}

const handleHide = useCallback(() => {
const handleHide = () => {
dialogRef.current?.close()
}, [dialogRef])
}

const Dialog = ({ children, ...props }: DialogProps) => {
return (
Expand Down
54 changes: 54 additions & 0 deletions src/Modal/dialog.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Mock implementation for HTMLDialogElement methods in Jest tests.
*
* This mock provides:
* - show() and showModal() methods that set open=true
* - close() method that sets open=false and fires 'close' event
* - triggerCancel() method for testing cancel events (simulates ESC key)
*
* Usage in tests:
* ```tsx
* const dialog = screen.getByRole('dialog') as HTMLDialogElement
* dialog.triggerCancel() // Simulates ESC key press
* ```
*/

// Extend the HTMLDialogElement interface to include our custom method
declare global {
interface HTMLDialogElement {
triggerCancel(): void
}
}

HTMLDialogElement.prototype.show = jest.fn(function mock(
this: HTMLDialogElement
) {
this.open = true
})

HTMLDialogElement.prototype.showModal = jest.fn(function mock(
this: HTMLDialogElement
) {
this.open = true
})

HTMLDialogElement.prototype.close = jest.fn(function mock(
this: HTMLDialogElement
) {
this.open = false
// Fire the 'close' event
this.dispatchEvent(new Event('close', { bubbles: true }))
})

// Helper function to manually trigger cancel event for testing
// This simulates what happens when ESC key is pressed
HTMLDialogElement.prototype.triggerCancel = jest.fn(function mock(
this: HTMLDialogElement
) {
this.open = false
// Fire the 'cancel' event
this.dispatchEvent(new Event('cancel', { bubbles: true }))
})

// Export to make this a module
export {}
41 changes: 41 additions & 0 deletions src/Modal/useAriaHidden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'

export const useAriaHidden = (
ref: React.RefObject<HTMLDialogElement>,
open?: boolean,
ariaHidden?: boolean
) => {
const [iAriaHidden, setIAriaHidden] = useState(ariaHidden ?? !open)

useEffect(() => {
setIAriaHidden(ariaHidden ?? !open)
}, [ariaHidden, open])

useEffect(() => {
if (ref.current) {
ref.current.addEventListener('close', () => {
setIAriaHidden(true)
})
ref.current.addEventListener('cancel', () => {
setIAriaHidden(true)
})
}
}, [ref.current])

useEffect(() => {
if (ref.current) {
const originalShowModal = ref.current.showModal
ref.current.showModal = () => {
originalShowModal.call(ref.current)
setIAriaHidden(false)
}
const originalClose = ref.current.close
ref.current.close = () => {
originalClose.call(ref.current)
setIAriaHidden(true)
}
}
}, [ref.current])

return iAriaHidden
}