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:
@@ -290,3 +290,63 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
|
||||
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
|
||||
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
|
||||
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
|
||||
describe("PlayerModal — the dialog contract", () => {
|
||||
const open = (onClose = () => {}) => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
return renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
);
|
||||
};
|
||||
|
||||
it("is named by the video's title", () => {
|
||||
open();
|
||||
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
|
||||
open();
|
||||
const active = document.activeElement as HTMLElement;
|
||||
expect(active.tagName).not.toBe("BUTTON");
|
||||
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives the focus back to what opened it when it closes", () => {
|
||||
const opener = document.createElement("button");
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
try {
|
||||
const view = open();
|
||||
expect(document.activeElement).not.toBe(opener);
|
||||
view.unmount();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
} finally {
|
||||
opener.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Tab inside the player", () => {
|
||||
open();
|
||||
const dialog = screen.getByTestId("player-modal");
|
||||
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
|
||||
const last = tabbables[tabbables.length - 1]!;
|
||||
last.focus();
|
||||
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
|
||||
// element an offsetParent for the duration of this check.
|
||||
const spy = vi
|
||||
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||
.mockReturnValue(document.body);
|
||||
try {
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
expect(document.activeElement).not.toBe(last);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user