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:
@@ -372,8 +372,8 @@ describe("Home", () => {
|
||||
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
|
||||
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
|
||||
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
|
||||
// so binding the button's `loading` to raw `isFetching` made it spin and, since `ui/Button` turns
|
||||
// `loading` into `disabled`, swallow clicks after actions the user never associated with a
|
||||
// so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
|
||||
// `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
|
||||
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
|
||||
const rerollBtn = () =>
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
@@ -391,7 +391,6 @@ describe("Home", () => {
|
||||
// The positive control, and it is the half most easily broken while fixing the other: this is the
|
||||
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
|
||||
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
|
||||
expect(rerollBtn().disabled).toBe(true);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
@@ -409,7 +408,6 @@ describe("Home", () => {
|
||||
});
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(rerollBtn().disabled).toBe(false);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -434,7 +432,7 @@ describe("Home", () => {
|
||||
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
|
||||
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
|
||||
failPick(new Error("boom"));
|
||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button
|
||||
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
|
||||
|
||||
// Now an unrelated action refetches that errored key in the background.
|
||||
discoverPick.mockReturnValue(new Promise(() => {}));
|
||||
@@ -442,7 +440,6 @@ describe("Home", () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
expect(rerollBtn().disabled).toBe(false);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -479,14 +476,14 @@ describe("Home", () => {
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
expect(rerollBtn().disabled).toBe(true); // the press is outstanding
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBe("true"); // the press is outstanding
|
||||
|
||||
failPick(new Error("boom"));
|
||||
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
|
||||
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
|
||||
// the press is in flight — so asserting it straight away would pass with or without the fix.
|
||||
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
|
||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false));
|
||||
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull());
|
||||
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
|
||||
expect(screen.queryByText("Hero v1")).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user