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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user