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.
54 lines
1.8 KiB
TypeScript
54 lines
1.8 KiB
TypeScript
// @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();
|
|
});
|
|
});
|