Repository navigation
Button uses native disabled for busy state, which drops focus #4871
Description
Activity
Both #4885 and #4879 close the focus-loss correctly (busy no longer uses the native
disabled), but they land the fix at different depths, and I think the difference matters for thehrefcase and for the announcement story. I went through both diffs against the currentButton.tsxso these are code-checked, not theoretical.What both do right. A busy button stays focusable,
aria-busy+aria-disabledare set,isDisabledkeeps nativedisabled. That's the core of #4871 and neither regresses it.Where they diverge - the
href(anchor) case.- fix(Button): use aria-disabled instead of native disabled while busy-only #4885:
renderAsLink = href != null && !buttonDisabledwithbuttonDisabledincluding the busy-only flag means a busy anchor withhrefflips to a<button>for the duration of the action. It does stay focusable (via the aria-disabled path), so the focus drop is fixed - but the element still changes mid-action. Any consumer styling or testing against the anchor tag sees it become a<button>and back. That's a behavioral surface change that exists only because the busy state madebuttonDisabledtrue. - fix(core): keep busy Button focusable, recover from failed clickAction #4879: keeps the busy anchor an anchor, and documents it ("With
href, a busy button now stays an anchor instead of swapping to a disabled<button>mid-action"). That preserves element identity, which I'd argue is the more correct fix - busy should only change the announced/interaction state, not the element type.
So for the
hrefcase specifically, #4879 is the more complete fix. #4885 is smaller and fixes the focus loss, but leaves the element-flip in place.One thing I'd want verified in whichever lands - the keyboard re-entry guard.
Both rely on the existing
actionInFlightRef/handler guard to keep Enter/Space from re-firing while busy, sincedisabledno longer blocks it. #4879 tests this explicitly ("Enter/Space on the focused busy button must not re-fire"); I'd want that same explicit test on the merged result, because the guard is now the only thing preventing double-submit - the native attribute was a silent backstop that's gone.On screen-reader behaviour:
aria-busy+aria-disabledon a focused control is the right pair, but it's worth one real pass with NVDA before merge. In my experience NVDA announces thearia-busystate change more reliably than a focused element being "disabled" - and since the button stays focused, the SR should announce "busy" when the spinner appears and clear it when it settles. That live announcement is the part that a jsdom test can't assert, and it's the actual payoff for the user who lost their place today.Either direction is a real improvement over the native-disabled drop; I'd push for #4879's element-identity handling on
hrefplus the explicit keyboard-guard test.- fix(Button): use aria-disabled instead of native disabled while busy-only #4885:
- added a commit that references this issue
on Aug 31, 2026 - added a commit that references this issue
on Sep 10, 2026 - added 4 commits that reference this issue
on Sep 21, 2026 - added a commit that references this issue
on Oct 10, 2026
What happens
While a
clickActionis pending,Buttonsets the nativedisabledattribute on the<button>. A natively disabled element cannot hold focus, so the browser moves focus to<body>the moment the action starts, and it is not restored when the action settles.In
packages/core/src/Button/Button.tsx:useAriaDisabledis only true when atooltipis set, so in the common case the busy button is natively disabled.Why it's a problem
A keyboard user who activates "Save" with Enter loses their place: focus falls to
<body>, so the next Tab restarts from the top of the document, and screen readers announce nothing when the action completes. This is the classic reason busy state should never be expressed asdisabled.It also contradicts the documented rule in the API Conventions wiki page ("Disabled vs Busy"), which says busy is visual-only —
aria-busyplus a spinner — and that onlyisDisableduses the native attribute. Input components already follow that rule: they renderaria-busyand keep the control focusable, guarding re-entry in the change handler.Why the native disable looks redundant
Buttonalready guards re-entry in the click handler with a ref:That guard is what actually makes a fire-once action fire once — it dedupes even a same-tick double click, which
disableddoes not reliably do. The native attribute adds the focus loss without adding the safety.Suggested fix
While busy (and not otherwise disabled):
disabledaria-busy="true"and the spinneraria-disabled="true"so AT announces the statearia-disabled+ tooltip branch already doesisDisabledbehavior unchanged: that stays nativedisabledisInterruptiblecan stay as-is; it would simply become the variant that also lets activation through.Notes
Changing this affects every consumer that relies on a busy button being unclickable, so the re-entry guard needs to cover the keyboard path too before the attribute comes off. Worth a changeset and a note in the release notes.