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,53 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The focus half of the side panels' peek-open model. What is pinned: a dialog opened from inside
|
||||
// the panel (rendered at the app root, so outside it in both the DOM and the React tree) does not
|
||||
// collapse the panel — collapsing unmounted the row the dialog hands the focus back to, stranding a
|
||||
// keyboard user on <body> after Cancel (measured in Chrome, E11 S2).
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useHoverFocusWithin } from "./useHoverFocusWithin";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Panel() {
|
||||
const peek = useHoverFocusWithin();
|
||||
return (
|
||||
<>
|
||||
<div {...peek.focusHandlers} data-testid="panel">
|
||||
<button>row</button>
|
||||
{peek.active && <span>expanded</span>}
|
||||
</div>
|
||||
<div role="dialog" aria-modal="true">
|
||||
<button>cancel</button>
|
||||
</div>
|
||||
<button>elsewhere</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const moveFocus = (from: HTMLElement, to: HTMLElement) =>
|
||||
act(() => {
|
||||
fireEvent.blur(from, { relatedTarget: to });
|
||||
to.focus();
|
||||
});
|
||||
|
||||
describe("useHoverFocusWithin · focus", () => {
|
||||
it("stays open while the focus is in a dialog opened over the page", () => {
|
||||
render(<Panel />);
|
||||
const row = screen.getByText("row");
|
||||
act(() => row.focus());
|
||||
expect(screen.queryByText("expanded")).not.toBeNull();
|
||||
|
||||
moveFocus(row, screen.getByText("cancel"));
|
||||
expect(screen.queryByText("expanded")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still closes when the focus leaves for the page itself", () => {
|
||||
render(<Panel />);
|
||||
const row = screen.getByText("row");
|
||||
act(() => row.focus());
|
||||
moveFocus(row, screen.getByText("elsewhere"));
|
||||
expect(screen.queryByText("expanded")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,16 @@ export function useHoverFocusWithin() {
|
||||
const focusHandlers = {
|
||||
onFocus: () => setFocused(true),
|
||||
onBlur: (e: FocusEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
|
||||
const to = e.relatedTarget as Element | null;
|
||||
if (e.currentTarget.contains(to)) return;
|
||||
// A dialog opened FROM inside the panel (the rail row's "Delete?" confirm) renders at the app
|
||||
// root, so neither the DOM nor the React tree sees it as inside — and collapsing the panel under
|
||||
// it unmounted the very row the dialog hands the focus back to, leaving a keyboard user on
|
||||
// <body> after Cancel (measured in Chrome, E11 S2). Hold while the focus is in a dialog: when it
|
||||
// closes, the focus either comes back in here (onFocus) or lands nowhere, which the
|
||||
// document-level net below clears.
|
||||
if (to?.closest('[aria-modal="true"]')) return;
|
||||
setFocused(false);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user