fix(ui): dialogs, menus and busy buttons keep the keyboard focus where it belongs
Measured in Chrome before each change: - A dialog opened from a menu lost its focus: the menu row ran its action and closed in one batch, and floating-ui's focus return (a microtask) moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after it opened, where Enter activated the row. MenuItemList now closes first and runs the action a task later; the dialog also captures the row, not a dying menu item, as its restore target. - A side panel opened as a flyout collapsed the moment a dialog opened from inside it, unmounting the row the dialog hands the focus back to. useHoverFocusWithin holds while the focus is inside an aria-modal dialog. - ui/Button's loading state set native disabled; Chrome moved the focus of the pressed button to <body> and never gave it back. Loading is now aria-disabled with a click guard that also cancels implicit submission. - Escape/Enter in the playlist rail's rename field dropped the focus on <body>. The row refocuses from its ref callback when it re-attaches (a post-commit focus sticks; the earlier attempt ran before the button existed). - PlayerModal was the one aria-modal shell without the dialog contract: it is now named by its title, traps Tab, restores the focus to its opener and shares one counted body scroll lock with every dialog.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The parts of the shared dialog contract that are not already pinned through ui/Modal: the ONE
|
||||
// counted page scroll lock, and `focusScope` (the player opens focused on its card, not a button).
|
||||
import { useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { useDialogChrome } from "./dialogFocus";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Dialog({ focusScope = false }: { focusScope?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useDialogChrome(ref, undefined, { focusScope });
|
||||
return (
|
||||
<div ref={ref} tabIndex={-1} data-testid="scope">
|
||||
<button>first</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDialogChrome · the page scroll lock", () => {
|
||||
// Each dialog used to save and restore `overflow` itself, so the one mounted SECOND but unmounted
|
||||
// LAST put back the "hidden" it had saved — a page left locked with no dialog open.
|
||||
it("unlocks only when the last dialog closes, in whatever order they close", () => {
|
||||
document.body.style.overflow = "";
|
||||
const player = render(<Dialog />);
|
||||
const modal = render(<Dialog />);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
|
||||
player.unmount(); // the one underneath goes first
|
||||
expect(document.body.style.overflow).toBe("hidden"); // the other is still open
|
||||
|
||||
modal.unmount();
|
||||
expect(document.body.style.overflow).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDialogChrome · initial focus", () => {
|
||||
it("lands on the first control by default", () => {
|
||||
// jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable.
|
||||
const spy = vi
|
||||
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||
.mockReturnValue(document.body);
|
||||
try {
|
||||
const { getByText } = render(<Dialog />);
|
||||
expect(document.activeElement).toBe(getByText("first"));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("lands on the scope itself with focusScope", () => {
|
||||
// Same layout stub as above: without it jsdom sees no focusable child, so the scope would get
|
||||
// the focus with or without the option and this would pin nothing.
|
||||
const spy = vi
|
||||
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||
.mockReturnValue(document.body);
|
||||
try {
|
||||
const { getByTestId } = render(<Dialog focusScope />);
|
||||
expect(document.activeElement).toBe(getByTestId("scope"));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user