11import React from "react" ;
2- import { render , screen , within } from "@testing-library/react" ;
2+ import { render , screen , within , act } from "@testing-library/react" ;
33import userEvent from "@testing-library/user-event" ;
44
55import { Button } from "../button" ;
@@ -25,6 +25,7 @@ describe("<Modal />", () => {
2525
2626 afterEach ( ( ) => {
2727 jest . clearAllMocks ( ) ;
28+ jest . useRealTimers ( ) ;
2829 } ) ;
2930
3031 afterAll ( ( ) => {
@@ -201,4 +202,51 @@ describe("<Modal />", () => {
201202 const closeButton = within ( dialog ) . getByRole ( "button" , { name : "Close" } ) ;
202203 expect ( closeButton ) . toHaveFocus ( ) ;
203204 } ) ;
205+
206+ it ( "should show a focus ring on the initially focused element after the modal opens" , async ( ) => {
207+ // headlessui only fires afterEnter when `show` transitions false → true (not on
208+ // initial mount). Use fake timers so requestAnimationFrame callbacks — which
209+ // headlessui uses to detect when the CSS enter transition finishes — are
210+ // controlled by jest and fire synchronously via jest.runAllTimers().
211+ jest . useFakeTimers ( ) ;
212+
213+ const initialFocusRef = React . createRef < HTMLButtonElement > ( ) ;
214+
215+ const modal = ( open : boolean ) => (
216+ < Modal open = { open } onClose = { ( ) => { } } initialFocus = { initialFocusRef } >
217+ < Modal . Header > Lorem ipsum</ Modal . Header >
218+ < Modal . Body >
219+ < p > Laboriosam autem non et nisi.</ p >
220+ </ Modal . Body >
221+ < Modal . Footer >
222+ < Button ref = { initialFocusRef } block size = "large" >
223+ Submit
224+ </ Button >
225+ </ Modal . Footer >
226+ </ Modal >
227+ ) ;
228+
229+ const { rerender } = render ( modal ( false ) ) ;
230+
231+ // Trigger the enter transition (false → true).
232+ rerender ( modal ( true ) ) ;
233+
234+ // headlessui's d.nextFrame() fires afterEnter via two nested RAFs.
235+ // Advance fake timers twice to flush both frames.
236+ act ( ( ) => jest . runAllTimers ( ) ) ; // outer RAF fires, schedules inner RAF
237+ act ( ( ) => jest . runAllTimers ( ) ) ; // inner RAF fires → afterEnter
238+
239+ // headlessui's nesting.onStop calls afterEnter via a Promise chain
240+ // (.then().then()). Flush the microtask queue so those callbacks run.
241+ // eslint-disable-next-line testing-library/no-unnecessary-act
242+ await act ( async ( ) => { } ) ;
243+
244+ const dialog = screen . getByRole ( "dialog" , { name : "Lorem ipsum" } ) ;
245+ const submitButton = within ( dialog ) . getByRole ( "button" , { name : "Submit" } ) ;
246+
247+ expect ( submitButton ) . toHaveStyle (
248+ "outline: 3px solid var(--focus-outline-color)" ,
249+ ) ;
250+ expect ( submitButton ) . toHaveStyle ( "outline-offset: 0px" ) ;
251+ } ) ;
204252} ) ;
0 commit comments