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:
@@ -54,13 +54,18 @@ describe("ActionMenu", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("selecting an item fires its onSelect and closes the menu", () => {
|
||||
// Close first, act on a later task: floating-ui hands the focus back to the trigger from a microtask
|
||||
// as the menu unmounts, and an action run in that same batch (a confirm dialog) had its focus taken
|
||||
// back to the trigger behind the dialog's scrim — measured in Chrome, E11 S2. jsdom does not run
|
||||
// that focus race, so the ORDER is what is pinned: the menu is gone before the action runs.
|
||||
it("selecting an item closes the menu first, then fires its onSelect once", async () => {
|
||||
const onSelect = vi.fn();
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
fireEvent.click(screen.getByText("Sync"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
|
||||
|
||||
@@ -83,8 +83,15 @@ export function MenuItemList({
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
// Close FIRST, act once the menu has handed the focus back. floating-ui returns the
|
||||
// focus to the trigger from a microtask queued as the menu unmounts, so an action run
|
||||
// in the same batch had its focus taken back by it: measured in Chrome (E11 S2), a
|
||||
// confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
|
||||
// 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task
|
||||
// runs after that microtask — and a dialog opened there captures the trigger, not a
|
||||
// dying menu item, as the element to restore the focus to.
|
||||
onClose();
|
||||
setTimeout(it.onSelect, 0);
|
||||
}}
|
||||
className={`${ROW}${it.danger ? " text-danger" : ""}`}
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -44,28 +44,32 @@ const VARIANTS: Record<ButtonVariant, string> = {
|
||||
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
|
||||
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
|
||||
outline:
|
||||
"font-medium border border-border text-muted enabled:hover:text-accent enabled:hover:border-accent",
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent enabled:hover:bg-accent/10",
|
||||
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-accent enabled:not-aria-disabled:hover:border-accent",
|
||||
"outline-accent":
|
||||
"font-medium border border-accent/40 text-accent enabled:not-aria-disabled:hover:bg-accent/10",
|
||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||
// is whether the button should advertise the destruction before the pointer arrives.
|
||||
"outline-danger":
|
||||
"font-medium border border-border text-muted enabled:hover:text-danger enabled:hover:border-danger/40",
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn enabled:hover:bg-warn/10",
|
||||
ghost: "font-medium text-muted enabled:hover:text-fg enabled:hover:bg-card",
|
||||
glass: "font-medium glass-card glass-hover enabled:hover:text-accent",
|
||||
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-danger enabled:not-aria-disabled:hover:border-danger/40",
|
||||
"outline-warn":
|
||||
"font-medium border border-warn/40 text-warn enabled:not-aria-disabled:hover:bg-warn/10",
|
||||
ghost:
|
||||
"font-medium text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:bg-card",
|
||||
glass: "font-medium glass-card glass-hover enabled:not-aria-disabled:hover:text-accent",
|
||||
"danger-soft":
|
||||
"font-medium border border-danger/60 text-danger enabled:hover:bg-danger/10 enabled:hover:border-danger",
|
||||
"font-medium border border-danger/60 text-danger enabled:not-aria-disabled:hover:bg-danger/10 enabled:not-aria-disabled:hover:border-danger",
|
||||
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
|
||||
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
|
||||
// the winner is decided by the generated stylesheet order, not by the class list order).
|
||||
link: "font-semibold text-accent enabled:hover:underline",
|
||||
link: "font-semibold text-accent enabled:not-aria-disabled:hover:underline",
|
||||
};
|
||||
|
||||
// Why every hover above is `enabled:hover:` — Tailwind's `:not(:disabled)`. A disabled button that
|
||||
// still lights up on hover advertises a click that does nothing, and the ONE migrated call site that
|
||||
// had guarded its own hover (TagManager's Add) silently lost the guard when it moved onto this
|
||||
// primitive (E10 review round 1). The contract belongs here, not in ~160 call sites: BASE dims a
|
||||
// disabled button, and the variant must not paint over that dimming.
|
||||
// Why every hover above is `enabled:not-aria-disabled:hover:` — neither natively disabled nor busy. A
|
||||
// disabled button that still lights up on hover advertises a click that does nothing, and the ONE
|
||||
// migrated call site that had guarded its own hover (TagManager's Add) silently lost the guard when it
|
||||
// moved onto this primitive (E10 review round 1). `not-aria-disabled` is the `loading` half: a busy
|
||||
// button stays natively enabled (see `loading` below) and must not light up either. The contract
|
||||
// belongs here, not in ~160 call sites: BASE dims both, and the variant must not paint over it.
|
||||
|
||||
// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
|
||||
// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
|
||||
@@ -86,7 +90,7 @@ const SIZES: Record<ButtonSize, string> = {
|
||||
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
|
||||
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed";
|
||||
|
||||
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
@@ -98,7 +102,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
iconOnly?: boolean;
|
||||
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
|
||||
label?: string;
|
||||
/** Disable + swap the icon for a spinner + mark aria-busy. */
|
||||
/** Busy: swap the icon for a spinner, mark aria-busy + aria-disabled, and swallow activation — but
|
||||
* NOT native `disabled`, which would throw the focus out of the page (see the render below). */
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
@@ -131,6 +136,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
children,
|
||||
disabled,
|
||||
type,
|
||||
onClick,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@@ -149,11 +155,23 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
// `loading` must always disable (see the prop's contract above), even when a call-site also
|
||||
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
|
||||
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
|
||||
disabled={disabled || loading}
|
||||
// `loading` blocks activation WITHOUT native `disabled`. A focused button that turns disabled
|
||||
// loses the focus: Chrome moved it to <body> ~60 ms after the reroll click on Home and nothing
|
||||
// handed it back when the button re-enabled (measured, E11 S2) — so a keyboard user was dropped
|
||||
// out of the page on every press. `aria-disabled` keeps the button focusable and announced as
|
||||
// unavailable; the click guard is what actually stops the double-submit, whatever `disabled`
|
||||
// says, and its preventDefault also cancels a form's implicit submission (which is a click on
|
||||
// the default button).
|
||||
disabled={disabled}
|
||||
aria-disabled={loading || undefined}
|
||||
aria-busy={loading || undefined}
|
||||
onClick={(e) => {
|
||||
if (loading) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(e);
|
||||
}}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={clsx(
|
||||
|
||||
@@ -108,13 +108,13 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("selecting an item fires it and closes the menu", () => {
|
||||
it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => {
|
||||
const onSelect = vi.fn();
|
||||
renderList(() => [{ id: "a", label: "Remove", onSelect }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Remove"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("Shift+F10 opens it from the keyboard", () => {
|
||||
@@ -217,7 +217,7 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
|
||||
});
|
||||
|
||||
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", () => {
|
||||
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", async () => {
|
||||
const chosen = vi.fn();
|
||||
renderList(() => [
|
||||
{ id: "play", label: "Play", onSelect: vi.fn() },
|
||||
@@ -226,8 +226,8 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Move to"));
|
||||
fireEvent.click(item("Homelab"));
|
||||
expect(chosen).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
await vi.waitFor(() => expect(chosen).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The parts of the shared dialog contract that are not already pinned through ui/Modal: the ONE
|
||||
// counted page scroll lock, and `focusScope` (the player opens focused on its card, not a button).
|
||||
import { useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { useDialogChrome } from "./dialogFocus";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Dialog({ focusScope = false }: { focusScope?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useDialogChrome(ref, undefined, { focusScope });
|
||||
return (
|
||||
<div ref={ref} tabIndex={-1} data-testid="scope">
|
||||
<button>first</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDialogChrome · the page scroll lock", () => {
|
||||
// Each dialog used to save and restore `overflow` itself, so the one mounted SECOND but unmounted
|
||||
// LAST put back the "hidden" it had saved — a page left locked with no dialog open.
|
||||
it("unlocks only when the last dialog closes, in whatever order they close", () => {
|
||||
document.body.style.overflow = "";
|
||||
const player = render(<Dialog />);
|
||||
const modal = render(<Dialog />);
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
|
||||
player.unmount(); // the one underneath goes first
|
||||
expect(document.body.style.overflow).toBe("hidden"); // the other is still open
|
||||
|
||||
modal.unmount();
|
||||
expect(document.body.style.overflow).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDialogChrome · initial focus", () => {
|
||||
it("lands on the first control by default", () => {
|
||||
// jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable.
|
||||
const spy = vi
|
||||
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||
.mockReturnValue(document.body);
|
||||
try {
|
||||
const { getByText } = render(<Dialog />);
|
||||
expect(document.activeElement).toBe(getByText("first"));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("lands on the scope itself with focusScope", () => {
|
||||
// Same layout stub as above: without it jsdom sees no focusable child, so the scope would get
|
||||
// the focus with or without the option and this would pin nothing.
|
||||
const spy = vi
|
||||
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||
.mockReturnValue(document.body);
|
||||
try {
|
||||
const { getByTestId } = render(<Dialog focusScope />);
|
||||
expect(document.activeElement).toBe(getByTestId("scope"));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -46,13 +46,36 @@ export function trapTab(
|
||||
}
|
||||
}
|
||||
|
||||
// ONE body scroll lock for every open dialog, counted. Each dialog used to save and restore
|
||||
// `overflow` on its own, so two unmounting out of order (the player's cleanup running before a Modal
|
||||
// opened above it) put back a stale "hidden". Invisible today — the app scrolls inside <main>, not
|
||||
// the document (measured, E11 S2) — but a count cannot be restored out of order, and PlayerModal's
|
||||
// second copy of the lock is gone.
|
||||
let scrollLocks = 0;
|
||||
let unlockedOverflow = "";
|
||||
function lockBodyScroll(): () => void {
|
||||
if (scrollLocks++ === 0) {
|
||||
unlockedOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (--scrollLocks === 0) document.body.style.overflow = unlockedOverflow;
|
||||
};
|
||||
}
|
||||
|
||||
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
||||
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
|
||||
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
||||
* disagree about what counts as "inside" (see `heldRef`). */
|
||||
* disagree about what counts as "inside" (see `heldRef`). `focusScope` opens the dialog focused on
|
||||
* the scope element itself rather than its first control: the player keeps the focus on its card, so
|
||||
* Space and the arrows reach its shortcuts instead of activating a button. */
|
||||
export function useDialogChrome(
|
||||
scopeRef: RefObject<HTMLElement | null>,
|
||||
ownerRef?: RefObject<HTMLElement | null>,
|
||||
{ focusScope = false }: { focusScope?: boolean } = {},
|
||||
): void {
|
||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||
// dialog's autoFocus (or our own initial focus) and moves activeElement into the dialog. Reading it
|
||||
@@ -79,18 +102,17 @@ export function useDialogChrome(
|
||||
// this hook existed the restore was unconditional (deep-review round 6).
|
||||
const heldRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const unlockScroll = lockBodyScroll();
|
||||
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||
// focus to where this dialog was focused before — or, the first time, to the first control, so
|
||||
// the dialog opens focused rather than stranded on the background.
|
||||
// focus to where this dialog was focused before — or, the first time, to the first control (or
|
||||
// the scope itself, with `focusScope`), so the dialog opens focused rather than stranded on the
|
||||
// background.
|
||||
const scope = scopeRef.current;
|
||||
if (scope && !scope.contains(document.activeElement)) {
|
||||
const back = focusedRef.current;
|
||||
const target =
|
||||
(back?.isConnected && scope.contains(back) ? back : null) ??
|
||||
focusablesIn(scope)[0] ??
|
||||
scope;
|
||||
(focusScope ? scope : (focusablesIn(scope)[0] ?? scope));
|
||||
target.focus();
|
||||
}
|
||||
const onFocusIn = () => {
|
||||
@@ -114,7 +136,7 @@ export function useDialogChrome(
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", onFocusIn);
|
||||
document.body.style.overflow = prev;
|
||||
unlockScroll();
|
||||
// Restore focus to the trigger, if it's still in the document and focusable — and only if the
|
||||
// dialog still held the focus. Once the user has moved it elsewhere, taking it back is the
|
||||
// rudest thing a closing dialog can do.
|
||||
@@ -140,5 +162,5 @@ export function useDialogChrome(
|
||||
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
||||
restoreTo.focus();
|
||||
};
|
||||
}, [restoreTo, scopeRef, ownerRef]);
|
||||
}, [restoreTo, scopeRef, ownerRef, focusScope]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user