diff --git a/src/Modal/Modal.test.tsx b/src/Modal/Modal.test.tsx index 60b311cb..d32a4840 100644 --- a/src/Modal/Modal.test.tsx +++ b/src/Modal/Modal.test.tsx @@ -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) @@ -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(null) + const handleShow = useCallback(() => { + ref.current?.showModal() + }, [ref]) + const handleClose = useCallback(() => { + ref.current?.close() + }, [ref]) + return ( +
+ + + Hello! + + Press ESC key or click the button below to close + + + + + +
+ ) + } + + it('should show modal with button', async () => { + const user = userEvent.setup() + render() + + 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() + + const openButton = screen.getByRole('button', { name: 'Open Modal' }) + await user.click(openButton) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + + // Manually trigger the cancel event (simulating ESC key) + await dialog.triggerCancel() + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/Modal/Modal.tsx b/src/Modal/Modal.tsx index 6b703ac1..f2f92c6c 100644 --- a/src/Modal/Modal.tsx +++ b/src/Modal/Modal.tsx @@ -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 & IComponentBaseProps & { @@ -33,6 +33,10 @@ const Modal = forwardRef( }, ref ): JSX.Element => { + const internalRef = useRef(null) + useImperativeHandle(ref, () => internalRef.current as HTMLDialogElement) + const isAriaHidden = useAriaHidden(internalRef, open, ariaHidden) + const containerClasses = twMerge( 'modal', clsx({ @@ -40,25 +44,24 @@ const Modal = forwardRef( '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 (
{children} @@ -79,13 +82,13 @@ export type DialogProps = Omit const useDialog = () => { const dialogRef = useRef(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 ( diff --git a/src/Modal/dialog.mock.ts b/src/Modal/dialog.mock.ts new file mode 100644 index 00000000..fca9759d --- /dev/null +++ b/src/Modal/dialog.mock.ts @@ -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 {} diff --git a/src/Modal/useAriaHidden.ts b/src/Modal/useAriaHidden.ts new file mode 100644 index 00000000..91271673 --- /dev/null +++ b/src/Modal/useAriaHidden.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' + +export const useAriaHidden = ( + ref: React.RefObject, + 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 +}