From 76ef47a5c046c60cce684945d282635404c36832 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:47:37 +0700 Subject: [PATCH] fix(modal): prevent focus from being placed on modal container --- src/modal/modal.test.tsx | 54 ++++++++++++++++++++++++++++++++++++++++ src/modal/modal.tsx | 12 ++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/modal/modal.test.tsx b/src/modal/modal.test.tsx index c9715a13..2543d934 100644 --- a/src/modal/modal.test.tsx +++ b/src/modal/modal.test.tsx @@ -206,6 +206,60 @@ describe("", () => { expect(closeButton).toHaveFocus(); }); + it("should not apply a focus ring to the dialog container", async () => { + vi.useFakeTimers(); + + // Simulate the race where document.activeElement is the dialog root at + // the moment afterEnter fires (HeadlessUI's transient focus state). + const dialogContainer = document.createElement("div"); + dialogContainer.setAttribute("role", "dialog"); + const activeElementSpy = vi + .spyOn(document, "activeElement", "get") + .mockReturnValue(dialogContainer); + + const { rerender } = render( + {}}> + Lorem ipsum + + Laboriosam autem non et nisi. + + + + Submit + + + , + ); + + rerender( + {}}> + Lorem ipsum + + Laboriosam autem non et nisi. + + + + Submit + + + , + ); + + act(() => { + vi.runAllTimers(); + }); + act(() => { + vi.runAllTimers(); + }); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => {}); + + activeElementSpy.mockRestore(); + + expect(dialogContainer.style.outline).toBe(""); + expect(dialogContainer.style.outlineOffset).toBe(""); + }); + it("should show a focus ring on the initially focused element after the modal opens", async () => { // headlessui only fires afterEnter when `show` transitions false → true (not on // initial mount). Use fake timers so requestAnimationFrame callbacks — which diff --git a/src/modal/modal.tsx b/src/modal/modal.tsx index 1dea3fee..3aeca95c 100644 --- a/src/modal/modal.tsx +++ b/src/modal/modal.tsx @@ -118,8 +118,18 @@ const Modal = ({ } const showFocusRingOnInitialFocus = () => { + // Guard against container elements (body, html, the dialog root) that + // HeadlessUI may temporarily focus during its enter transition. Those + // elements are fixed inset-0, so an outline on them renders as blue + // lines at the viewport edges. const el = document.activeElement as HTMLElement | null; - if (!el) return; + if ( + !el || + el === document.body || + el === document.documentElement || + el.getAttribute("role") === "dialog" + ) + return; el.style.outline = "3px solid var(--focus-outline-color)"; el.style.outlineOffset = "0px";
Laboriosam autem non et nisi.