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:
@@ -39,7 +39,7 @@ describe("Button primitive (S5)", () => {
|
||||
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("loading disables the button, marks aria-busy, and blocks clicks", () => {
|
||||
it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
|
||||
@@ -47,30 +47,58 @@ describe("Button primitive (S5)", () => {
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.getAttribute("aria-busy")).toBe("true");
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
// the leading icon is swapped for a spinner
|
||||
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
||||
fireEvent.click(btn);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loading disables even when an explicit disabled={false} is passed", () => {
|
||||
// E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to
|
||||
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
|
||||
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
|
||||
it("loading keeps the button natively enabled, so it keeps the focus", () => {
|
||||
render(<Button loading>Reroll</Button>);
|
||||
const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("loading blocks clicks even when an explicit disabled={false} is passed", () => {
|
||||
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
|
||||
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
|
||||
// clickable mid-mutation and double-submitted. The contract is: loading always disables.
|
||||
// clickable mid-mutation and double-submitted. The contract is: loading always blocks.
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button disabled={false} loading onClick={onClick}>
|
||||
Create
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
fireEvent.click(btn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a busy submit button cancels the form's submission", () => {
|
||||
// Enter in a field submits through a click on the default button; the guard cancels that click.
|
||||
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Button type="submit" loading>
|
||||
Send
|
||||
</Button>
|
||||
</form>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a non-busy button still calls onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Go</Button>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Go" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a link gets no button padding, and an explicit size still wins", () => {
|
||||
// The size default is variant-dependent (link → "none"). Locking it because the failure is
|
||||
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
|
||||
|
||||
Reference in New Issue
Block a user