From 0f42c8ea5300c1bef0c8dec086183f15a56bab28 Mon Sep 17 00:00:00 2001 From: Andrew Brough Date: Wed, 30 Jul 2025 01:16:16 -0400 Subject: [PATCH 1/3] working ariaHidden toggle using useDialog --- src/Modal/Modal.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Modal/Modal.tsx b/src/Modal/Modal.tsx index 6b703ac1..7c9da95b 100644 --- a/src/Modal/Modal.tsx +++ b/src/Modal/Modal.tsx @@ -1,8 +1,7 @@ -import React, { forwardRef, useCallback, useRef } from 'react' import clsx from 'clsx' +import React, { forwardRef, useCallback, useRef, useState } 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' @@ -40,8 +39,8 @@ 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, }) ) @@ -78,18 +77,21 @@ Modal.displayName = 'Modal' export type DialogProps = Omit const useDialog = () => { const dialogRef = useRef(null) + const [ariaHidden, setAriaHidden] = useState(true) const handleShow = useCallback(() => { dialogRef.current?.showModal() + setAriaHidden(false) }, [dialogRef]) const handleHide = useCallback(() => { dialogRef.current?.close() + setAriaHidden(true) }, [dialogRef]) const Dialog = ({ children, ...props }: DialogProps) => { return ( - + {children} ) From e4672e4a57216ef47d666522edda39c8b9aff8b4 Mon Sep 17 00:00:00 2001 From: Andrew Brough Date: Wed, 30 Jul 2025 01:48:52 -0400 Subject: [PATCH 2/3] useAriaHidden to handle events when ref dialog showModal and close functions and events are used --- src/Modal/Modal.tsx | 25 +++++++++++++------------ src/Modal/useAriaHidden.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 src/Modal/useAriaHidden.ts diff --git a/src/Modal/Modal.tsx b/src/Modal/Modal.tsx index 7c9da95b..f2f92c6c 100644 --- a/src/Modal/Modal.tsx +++ b/src/Modal/Modal.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import React, { forwardRef, useCallback, useRef, useState } from 'react' +import React, { forwardRef, useImperativeHandle, useRef } from 'react' import { twMerge } from 'tailwind-merge' import { ComponentPosition, IComponentBaseProps } from '../types' @@ -7,6 +7,7 @@ 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 & { @@ -32,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({ @@ -45,19 +50,18 @@ const Modal = forwardRef( }) ) - ariaHidden = ariaHidden ?? !open const bodyClasses = twMerge('modal-box', className) return (
{children} @@ -77,21 +81,18 @@ Modal.displayName = 'Modal' export type DialogProps = Omit const useDialog = () => { const dialogRef = useRef(null) - const [ariaHidden, setAriaHidden] = useState(true) - const handleShow = useCallback(() => { + const handleShow = () => { dialogRef.current?.showModal() - setAriaHidden(false) - }, [dialogRef]) + } - const handleHide = useCallback(() => { + const handleHide = () => { dialogRef.current?.close() - setAriaHidden(true) - }, [dialogRef]) + } const Dialog = ({ children, ...props }: DialogProps) => { return ( - + {children} ) diff --git a/src/Modal/useAriaHidden.ts b/src/Modal/useAriaHidden.ts new file mode 100644 index 00000000..7b975dd2 --- /dev/null +++ b/src/Modal/useAriaHidden.ts @@ -0,0 +1,38 @@ +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]) + + 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 +} From 6dade66ac412ca53f9127bb049d180ba418e2a16 Mon Sep 17 00:00:00 2001 From: Andrew Brough Date: Wed, 30 Jul 2025 02:28:07 -0400 Subject: [PATCH 3/3] added test for ref handlers opening strategy - which includes a mock modal implementation covering the basic handlers. Doesn't cover testing for form submission. --- src/Modal/Modal.test.tsx | 72 ++++++++++++++++++++++++++++++++++++-- src/Modal/dialog.mock.ts | 54 ++++++++++++++++++++++++++++ src/Modal/useAriaHidden.ts | 3 ++ 3 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/Modal/dialog.mock.ts 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/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 index 7b975dd2..91271673 100644 --- a/src/Modal/useAriaHidden.ts +++ b/src/Modal/useAriaHidden.ts @@ -16,6 +16,9 @@ export const useAriaHidden = ( ref.current.addEventListener('close', () => { setIAriaHidden(true) }) + ref.current.addEventListener('cancel', () => { + setIAriaHidden(true) + }) } }, [ref.current])