fix(ui): a closing dialog's focus restore reaches the dialog left standing above it, and survives a menu
A dialog closing on its own under a standing modal skipped its restore, and the standing modal's own target was a control inside the closed one: closing it left the focus on <body>. The skipped target is now handed to the standing dialog, which falls back to it. Focus on a row of one of the dialog's own menus (the popover engine portals them out of the dialog root) read as the user walking away, so Back with a menu open left the focus on <body>. A focus in a popover portal now counts as held, and one still standing is not taken from.
This commit is contained in:
@@ -23,7 +23,7 @@ vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
import Modal from "./Modal";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createPortal, flushSync } from "react-dom";
|
||||
|
||||
// Fire a keydown on window (where Modal installs its handler), inside act.
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
@@ -311,6 +311,70 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("hands its trigger to the dialog left standing above it, which restores it on close", () => {
|
||||
// The standing upper dialog captured ITS restore target while the focus was inside the lower
|
||||
// one — a control that is gone once the lower closes. So the lower skipping its own restore
|
||||
// (above) handed the trigger to no one, and closing the upper left the focus on <body>.
|
||||
// The arrangement keyboard help or the error dialog opened over a dialog actually has.
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
function Siblings() {
|
||||
const [lower, setLower] = useState(true);
|
||||
const [upper, setUpper] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{lower && (
|
||||
<Modal initialFocus="close" title="lower" onClose={() => {}}>
|
||||
<button onClick={() => setUpper(true)}>open upper</button>
|
||||
<button onClick={() => setLower(false)}>close lower</button>
|
||||
</Modal>
|
||||
)}
|
||||
{upper && (
|
||||
<Modal initialFocus="close" title="upper" onClose={() => {}}>
|
||||
<button onClick={() => setUpper(false)}>close upper</button>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Siblings />);
|
||||
screen.getByText("open upper").focus();
|
||||
act(() => void screen.getByText("open upper").click()); // opened from inside the lower dialog
|
||||
const inUpper = screen.getByText("close upper");
|
||||
inUpper.focus();
|
||||
// The lower closes on its own under the standing upper (`.click()` moves no focus).
|
||||
act(() => void screen.getByText("close lower").click());
|
||||
expect(screen.queryByText("close lower")).toBeNull();
|
||||
expect(document.activeElement).toBe(inUpper);
|
||||
act(() => void inUpper.click());
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("restores the trigger when it closes with the focus in one of its menus", () => {
|
||||
// The popover engine portals a menu out of the dialog's root (it carries no aria-modal), so a
|
||||
// focus on a menu row read as "the user walked away": Back with a menu open inside a dialog
|
||||
// left the focus on <body>. The portal marker is the one `ui/AnchoredPopover` reads too.
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
const portal = document.createElement("div");
|
||||
portal.setAttribute("data-floating-ui-portal", "");
|
||||
document.body.appendChild(portal);
|
||||
const { unmount } = render(
|
||||
<Modal initialFocus="close" title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
{createPortal(<button>menu-row</button>, portal)}
|
||||
</Modal>,
|
||||
);
|
||||
screen.getByText("menu-row").focus();
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
|
||||
it("leaves the focus alone when the user moved it out of the dialog themselves", () => {
|
||||
// The other half of the same rule, and the branch nothing drove: taking focus back from where
|
||||
// the user deliberately put it is the rudest thing a closing dialog can do.
|
||||
|
||||
Reference in New Issue
Block a user