Skip to content

Commit dbb613f

Browse files
fix(Modal): add focus ring to initially focused element (#758)
1 parent 3d14772 commit dbb613f

File tree

2 files changed

+70
-2
lines changed

2 files changed

+70
-2
lines changed

src/modal/modal.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { render, screen, within } from "@testing-library/react";
2+
import { render, screen, within, act } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44

55
import { 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
});

src/modal/modal.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,29 @@ const Modal = ({
117117
panelClasses = panelClasses.concat(" ", "text-background-danger");
118118
}
119119

120+
const showFocusRingOnInitialFocus = () => {
121+
const el = document.activeElement as HTMLElement | null;
122+
if (!el) return;
123+
124+
el.style.outline = "3px solid var(--focus-outline-color)";
125+
el.style.outlineOffset = "0px";
126+
el.addEventListener(
127+
"blur",
128+
() => {
129+
el.style.outline = "";
130+
el.style.outlineOffset = "";
131+
},
132+
{ once: true },
133+
);
134+
};
135+
120136
return (
121137
<ModalContext.Provider value={{ onClose, variant }}>
122-
<Transition.Root show={open} as={Fragment}>
138+
<Transition.Root
139+
show={open}
140+
as={Fragment}
141+
afterEnter={showFocusRingOnInitialFocus}
142+
>
123143
<Dialog
124144
onClose={onClose}
125145
className="fixed inset-0 z-1050"

0 commit comments

Comments
 (0)