Repository navigation
Conversation
While a clickAction is pending (or isLoading is set), Button set the native disabled attribute. A natively disabled element cannot hold focus, so the browser dropped keyboard focus to <body> the moment the action started, and it was never restored when the action settled. Busy is now expressed the way the API Conventions "Disabled vs Busy" rule specifies: no native disabled, aria-busy + aria-disabled for AT, and re-activation (click, Enter, Space) blocked through the existing handleClick/handleKeyDown guards — so fire-once actions still fire exactly once. With href, a busy button now stays an anchor instead of swapping to a disabled <button> mid-action, which dropped focus the same way. isDisabled (and ButtonGroup disabled) behavior is unchanged: still the native attribute, aria-disabled only when a tooltip needs a focusable trigger. isInterruptible is unchanged. Fixes facebook#4871
Stress-testing the busy state surfaced a second defect on the same lines: a rejected clickAction escaped the startTransition async scope, so React never settled the transition. isPending stuck true, the spinner never left, and the activation guard blocked every retry — one failed action bricked the button until remount. (Latent on main too, where it presented as disabled-forever.) The action now catches the rejection, reports it via devError (the established runtime-failure channel, see Table plugins), and settles normally; the finally still resets the in-flight guard, so the user can retry. Also adds an edge-case suite around the busy contract: tooltip+busy, isDisabled+isLoading precedence (native attribute wins), ButtonGroup disabled with and without tooltip, interruptible boundaries (aria-busy without aria-disabled; isDisabled wins over isInterruptible), form submit suppression while busy, disabled-link fallback, busy-anchor focusability, and rejected-action recovery. Refs facebook#4871
|
@AKnassa is attempting to deploy a commit to the Meta Open Source Team on Vercel. A member of the Team first needs to authorize it. |
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsButton (@astryxdesign/core) · View in Storybook
IconButton (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
A busy-only href Button still set renderAsLink = href != null && !buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a busy link swapped to a disabled <button> mid-action, dropping focus the same way the native disabled attribute the earlier commit removed did. renderAsLink now checks isTrueDisabled only; a genuinely disabled href Button still falls back (disabled links are an accessibility anti-pattern), but busy-only stays an anchor with aria-busy and aria-disabled applied directly to it, guarded by the same handleClick ref check that already suppresses activation. Carries over and credits @AKnassa's anchor-preservation work from facebook#4879. Reconciled the Chromium a11y contract's own accounting: the fix turns button.focus.reachable-and-escapable and button.unavailable.inert from known-failure into unexpected-pass for both Button and IconButton's loading state (verified against a real Chromium run before and after), so removed the four now-stale known-failure records and the two declaredNotDelivered entries they were paired with. Updated Button and IconButton's own consumer docs (isLoading no longer disables interaction; it blocks re-activation while staying focusable) and ListInput's loading-state test, whose add-item button receives isLoading directly and is now aria-disabled rather than natively disabled while busy — its remove/reorder controls map ListInput's loading state onto isDisabled instead and are unaffected.
A busy-only href Button still set renderAsLink = href != null && !buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a busy link swapped to a disabled <button> mid-action, dropping focus the same way the native disabled attribute the earlier commit removed did. renderAsLink now checks isTrueDisabled only; a genuinely disabled href Button still falls back (disabled links are an accessibility anti-pattern), but busy-only stays an anchor with aria-busy and aria-disabled applied directly to it, guarded by the same handleClick ref check that already suppresses activation. Carries over and credits @AKnassa's anchor-preservation work from facebook#4879. Reconciled the Chromium a11y contract's own accounting: the fix turns button.focus.reachable-and-escapable and button.unavailable.inert from known-failure into unexpected-pass for both Button and IconButton's loading state (verified against a real Chromium run before and after), so removed the four now-stale known-failure records and the two declaredNotDelivered entries they were paired with. Updated Button and IconButton's own consumer docs (isLoading no longer disables interaction; it blocks re-activation while staying focusable) and ListInput's loading-state test, whose add-item button receives isLoading directly and is now aria-disabled rather than natively disabled while busy — its remove/reorder controls map ListInput's loading state onto isDisabled instead and are unaffected.
There was a problem hiding this comment.
Thanks for restoring keyboard focus while a Button is busy and for making a failed action retryable — the hard-disabled/busy split, docs, and edge-case tests make that contract clear.
One fix is needed before this is ready: a busy link-rendered Button still forwards Enter/Space to a consumer onKeyDown handler while it exposes aria-disabled="true". In packages/core/src/Button/Button.tsx, the link branch spreads the consumer props and overrides only onClick; the handleKeyDown guard that suppresses activation keys (and forwards other keys) is installed only on the native <button> branch. So <Button href="/docs" isLoading onKeyDown={handler} /> can run consumer keyboard side effects in a state announced as disabled, even though click activation is suppressed.
Please apply the same activation-key guard to the link branch and add a regression test showing Enter/Space do not reach consumer onKeyDown while busy, while a non-activation key still does. The busy-link tests currently cover focus and click suppression only.
[Automated review]
A busy-only href Button still set renderAsLink = href != null && !buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a busy link swapped to a disabled <button> mid-action, dropping focus the same way the native disabled attribute the earlier commit removed did. renderAsLink now checks isTrueDisabled only; a genuinely disabled href Button still falls back (disabled links are an accessibility anti-pattern), but busy-only stays an anchor with aria-busy and aria-disabled applied directly to it, guarded by the same handleClick ref check that already suppresses activation. Carries over and credits @AKnassa's anchor-preservation work from facebook#4879. Reconciled the Chromium a11y contract's own accounting: the fix turns button.focus.reachable-and-escapable and button.unavailable.inert from known-failure into unexpected-pass for both Button and IconButton's loading state (verified against a real Chromium run before and after), so removed the four now-stale known-failure records and the two declaredNotDelivered entries they were paired with. Updated Button and IconButton's own consumer docs (isLoading no longer disables interaction; it blocks re-activation while staying focusable) and ListInput's loading-state test, whose add-item button receives isLoading directly and is now aria-disabled rather than natively disabled while busy — its remove/reorder controls map ListInput's loading state onto isDisabled instead and are unaffected.
|
Closing this in favour of #4885. @cixzhang picked it as the canonical fix for #4871, and it already carries the busy- The one open review point on this PR also applies to #4885, so I've left a note there: a busy link-rendered Button still forwards Enter/Space to a consumer The only piece #4885 doesn't cover is catching a rejected |
A busy-only href Button still set renderAsLink = href != null && !buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a busy link swapped to a disabled <button> mid-action, dropping focus the same way the native disabled attribute the earlier commit removed did. renderAsLink now checks isTrueDisabled only; a genuinely disabled href Button still falls back (disabled links are an accessibility anti-pattern), but busy-only stays an anchor with aria-busy and aria-disabled applied directly to it, guarded by the same handleClick ref check that already suppresses activation. Carries over and credits @AKnassa's anchor-preservation work from facebook#4879. Reconciled the Chromium a11y contract's own accounting: the fix turns button.focus.reachable-and-escapable and button.unavailable.inert from known-failure into unexpected-pass for both Button and IconButton's loading state (verified against a real Chromium run before and after), so removed the four now-stale known-failure records and the two declaredNotDelivered entries they were paired with. Updated Button and IconButton's own consumer docs (isLoading no longer disables interaction; it blocks re-activation while staying focusable) and ListInput's loading-state test, whose add-item button receives isLoading directly and is now aria-disabled rather than natively disabled while busy — its remove/reorder controls map ListInput's loading state onto isDisabled instead and are unaffected.
A busy-only href Button still set renderAsLink = href != null && !buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a busy link swapped to a disabled <button> mid-action, dropping focus the same way the native disabled attribute the earlier commit removed did. renderAsLink now checks isTrueDisabled only; a genuinely disabled href Button still falls back (disabled links are an accessibility anti-pattern), but busy-only stays an anchor with aria-busy and aria-disabled applied directly to it, guarded by the same handleClick ref check that already suppresses activation. Carries over and credits @AKnassa's anchor-preservation work from facebook#4879. Reconciled the Chromium a11y contract's own accounting: the fix turns button.focus.reachable-and-escapable and button.unavailable.inert from known-failure into unexpected-pass for both Button and IconButton's loading state (verified against a real Chromium run before and after), so removed the four now-stale known-failure records and the two declaredNotDelivered entries they were paired with. Updated Button and IconButton's own consumer docs (isLoading no longer disables interaction; it blocks re-activation while staying focusable) and ListInput's loading-state test, whose add-item button receives isLoading directly and is now aria-disabled rather than natively disabled while busy — its remove/reorder controls map ListInput's loading state onto isDisabled instead and are unaffected.
A busy-only href Button still set renderAsLink = href != null && !buttonDisabled, and buttonDisabled includes isBusyOnlyDisabled — so a busy link swapped to a disabled <button> mid-action, dropping focus the same way the native disabled attribute the earlier commit removed did. renderAsLink now checks isTrueDisabled only; a genuinely disabled href Button still falls back (disabled links are an accessibility anti-pattern), but busy-only stays an anchor with aria-busy and aria-disabled applied directly to it, guarded by the same handleClick ref check that already suppresses activation. Carries over and credits @AKnassa's anchor-preservation work from facebook#4879. Reconciled the Chromium a11y contract's own accounting: the fix turns button.focus.reachable-and-escapable and button.unavailable.inert from known-failure into unexpected-pass for both Button and IconButton's loading state (verified against a real Chromium run before and after), so removed the four now-stale known-failure records and the two declaredNotDelivered entries they were paired with. Updated Button and IconButton's own consumer docs (isLoading no longer disables interaction; it blocks re-activation while staying focusable) and ListInput's loading-state test, whose add-item button receives isLoading directly and is now aria-disabled rather than natively disabled while busy — its remove/reorder controls map ListInput's loading state onto isDisabled instead and are unaffected.
Fixes #4871
What happens today
While a
clickActionis pending (orisLoadingis set),Buttonsets the nativedisabledattribute. A natively disabled element cannot hold focus, so the browser drops keyboard focus to<body>the moment the action starts — the next Tab restarts from the top of the document, and nothing is announced when the action completes. This is the deviation already documented in the API Conventions wiki ("Disabled vs Busy" → Known deviation).What this PR does
Busy no longer uses the native attribute (first commit):
isDisabled/ ButtonGroup disabled) may set nativedisabled; that behavior is unchanged.aria-busy="true"+aria-disabled="true".handleClickref guard covers the pointer path, and the existingaria-disabledkeydown path (previously tooltip-only) now also suppresses Enter/Space while busy. Fire-once actions still fire exactly once — the same-tick double-click dedupe is untouched.href, a busy button now stays an anchor instead of swapping to a disabled<button>mid-action — the element swap dropped focus the same way the attribute did.isInterruptibleis unchanged (interactive while pending:aria-busywithoutaria-disabled).A rejected
clickActionno longer bricks the button (second commit, found while stress-testing the busy state):startTransitionasync scope, so the transition never settled —isPendingstucktrue, the spinner never left, and the guard blocked every retry until remount. Latent onmaintoo, where it presented as disabled-forever.devError(the established runtime-failure channel, cf. Table plugins), and settles; thefinallystill resets the in-flight guard so the user can retry.Behavior table
disabledattraria-busyaria-disabledisLoading)isInterruptibleisDisabled(no tooltip)isDisabled+ tooltipisDisabled+isLoadingTesting
isDisabled+isLoadingprecedence, ButtonGroup disabled (with/without tooltip), interruptible boundaries, form-submit suppression while busy, disabled-link fallback, busy-anchor focusability, and rejected-action recovery. Written red-first against the old code.packages/cliname-resolution failures, identical on an untouchedmainbaseline.disabledattribute, Tab lands on it, and the:focus-visiblering renders around the spinner.check:repo(incl. changesets), eslintCI=true, prettier: all green. Changeset included (@astryxdesign/corepatch).Notes for review
aria-disabled="true"on the busy anchor: valid ARIA, but if you'd rather busy links carry onlyaria-busy, that's a one-line change.not-allowedcursor) — conforms to the wiki table ("reduced opacity"); no visual delta intended.startTransition(async …)call site in core (Selector, Pagination, ComplexSelector, TimeInput, …) has the same unguardedawaitand can strandisPendingon a rejected action. Deliberately not touched here — happy to follow up separately.