Repository navigation
fix(ItemAction,Button): disable loading actions; show focus and stay inert while disabled - #1470
Merged
tenphi merged 9 commits intoOct 6, 2026
Conversation
A loading ItemAction showed a spinner but stayed pressable, so a second
press re-ran the action. Loading now always disables it, matching Button;
isDisabled={false} still overrides a disabled row but not loading.
A disabled or loading action with a tooltip is now marked aria-disabled
and kept inert instead of natively disabled, so its tooltip still opens
(getDisabledElementProps, as Button and Item use). The spinner on a
label-only action gets the icon padding.
Refs CUB-5275
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A loading action now stays focusable (aria-disabled) so a keyboard
press keeps its place. An action disabled by its host row or field
keeps the native attribute, so it stays out of the tab order as in a
disabled fieldset. On the aria-disabled path, handlers a parent passed
in (MenuTrigger's onKeyDown) are dropped, so a disabled trigger can't
open its menu. A loading action with isDisabled={false} in a disabled
row no longer fades twice. Docs gain a loading example.
Refs CUB-5275
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ring
A loading action keeps focus it already holds but gets `tabIndex={-1}`, so
Tab skips it as it skips a loading `Button`. An action disabled by its own
prop inside a disabled row now counts as inherited: it stays natively
disabled and fades once.
The shared `useFocus` now reports focus again when a control is re-enabled
while it still holds focus. React Aria fires no focus event in that case, so
an `aria-disabled` control (a loading ItemAction, a Button with a tooltip)
lost its focus ring until focus moved away and back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The re-enable check only knew elements focused while enabled, so a control that took focus while aria-disabled (a disabled tooltip Tab stop) got no ring once enabled. Keep React Aria's focus handlers attached while disabled and hide the ring until enabled. React Aria reports the blur itself when an element is natively disabled, so no stale ring is left behind; the render-time reset is no longer needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…enus `useFocus` reports whether its element holds focus, disabled or not, and drops a focus the element lost without a blur React sees: the element replaced (a tooltip wrapper added or dropped), disabled through a fieldset, or stripped of its tabIndex. Its unused `isDisabled` option goes, and `useAction` no longer hides `focused` while disabled, so a disabled Button, ItemButton or ItemAction that keeps focus (aria-disabled with a tooltip, or a link) shows its focus ring. Button drops activation handlers while inert, as Item and ItemAction do, so a disabled or loading Button with a tooltip no longer opens its MenuTrigger menu from the keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keyboard rules Round-5 review follow-ups: - Chromium test for the useFocus guard on a fieldset disable and a removed tabIndex, which jsdom cannot reproduce; it fails without the guard. - Ring tests for a disabled ItemButton with a tooltip, a disabled interactive InfoBadge, a disabled ItemAction link and a re-enabled ItemAction Tab stop; loading rows for Button's menu keys. - ItemAction docs gain an Accessibility section for the Tab, focus ring, menu key and aria-disabled rules, and the prop bullets shrink to what each prop means. InfoBadge docs mention the ring. - The guard comment and focus changeset say the guard runs when the control re-renders; the isInert JSDoc points at omitActivationEventProps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cking its container Dropping a Button's activation handlers while inert also dropped React Aria's keydown preventDefault, so Enter or Space on a focused aria-disabled Button made the browser click it and the click reached an ancestor's onClick. ItemButton and ItemAction already did this through the same inert props. INERT_PROPS now cancels both keys on the element itself, which covers all three; keys from a focused descendant are left alone. The useFocus guard reads the active element the way React Aria does, so it also looks inside a shadow root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Now that a disabled control can show focus, `current.primary` was the one flavour whose focus overlay had no disabled guard, so a focused disabled chip darkened. The overlay now skips disabled, like every other theme's fill. The focus changeset names the ItemButton types that have a ring: the default `item` type shows focus with its fill and gets no ring, by design, so the ItemButton ring test uses `type="outline"`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: d127716 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
📦 NPM canary releaseDeployed canary version 0.0.0-canary-35a6e75. |
Contributor
🧪 Storybook is successfully deployed!
|
Contributor
🏋️ Size limit report
Compared against main at 05f03e8 — run 37345053487, 2026-10-05T16:59:36Z.To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html. |
… not re-render The guard that drops a focus its element no longer holds only ran after the control's own render. A focus lost to a change elsewhere stayed: a stateful parent disabling the `fieldset` around a control, or a child restructuring itself (ItemButton's auto tooltip mounting once its label overflows). No blur reaches React there, so the stale ring stayed until focus came back, and after a Tab two rings showed at once. The same check now also runs from React Aria's focus-visible listener, which every `useFocus` already registers, on each key or pointer press it reports (keys typed in a text field don't count, nor does a focus moved by code). The element ref is cleared on blur, so an unfocused control does no more than a null check. Chromium tests cover both cases and a memoized row moved by a key press, which React refocuses after the commit and keeps its ring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tenphi
deleted the
andrew/cub-5275-itemactions-isloading-shows-a-spinner-but-leaves-the-action
branch
October 6, 2026 09:07
Merged
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Describe changes
Fixes CUB-5275. A loading
ItemAction(Item.Action) showed a spinner but stayed pressable, so moving a loadingButtonintoItem.Actionlost its double-press guard. Consumers had to pairisDisabledwithisLoadingby hand.Related keyboard gaps are fixed in the same layers:
Buttonwith a tooltip still opened itsMenuTriggermenu from the keyboard;ItemButtonorItemActionclicked the element around it.ItemAction
Button:isDisabled = isLoading || (isDisabledProp ?? contextIsDisabled).isDisabled={false}still overrides a disabled row, but not a loading action. A computedisDisabled={!x}can't tell "explicit false" from "not disabled", so letting it win would quietly bring the double press back. No consumer relies on the override.getDisabledElementProps, likeButtonandItem:aria-disabledand inert instead of natively disabled. It keeps the focus it already holds, and its focus ring, so a keyboard press doesn't drop the user's place to<body>. It also keeps its tooltip, and Tab skips it (tabIndex={-1}) until loading ends, tooltip or not. That differs from a loadingButtonwith a tooltip, which stays a Tab stop; ItemAction keeps one rule: a loading action is never a Tab stop.Button. A disabled link action (to) is alwaysaria-disabledand stays a Tab stop, as onButton.ItemActionProvider) is disabled too, a button action that isn't loading stays natively disabled, as a disabled fieldset would leave it.omitActivationEventProps, asItemdoes). Without that, aMenuTrigger's ownonKeyDownwould still open the menu from an aria-disabled trigger.inherit-disablednow covers every disabled action inside a disabled row. Before, an action disabled by its own prop inside a disabled row faded twice. That was already true on main.has-icon), likeButton'shasLeftIcon, so a label-only action keeps its size while loading.Button
While inert (disabled or loading with a tooltip, or rendered as a link), Button now drops activation handlers the same way. A
MenuTriggerhands its trigger anonKeyDownthat ignores the trigger's disabled state, so Enter, Space and ArrowDown used to open the menu from a disabled Button with a tooltip.Inert elements (
getDisabledElementProps)The shared inert props now cancel Enter and Space on the element itself. Otherwise the browser turns them into a click on the focused element, and that click reaches a clickable row's or card's
onClick. On main,ItemButtonandItemActionalready let that click through. Button didn't, because React Aria's own key handler prevented it, and dropping that handler would have brought the problem to Button too. Keys from a focused descendant are left alone.Focus ring on disabled controls (shared
useFocus,useAction)On main,
useFocuspassedisDisabledto React Aria, which detaches the focus handlers. It then reset its own state during render, anduseActionhidfocusedwhile disabled. So a control that keeps focus while disabled (a disabledButtonorItemActionwith a tooltip or rendered as a link, anItemButtonof a type with a ring, and an interactiveInfoBadge) showed no ring. It also stayed without one after being enabled, until focus left and came back.useFocusnow reports whether its element holds focus, disabled or not, anduseActionpasses that through. ItsisDisabledoption is gone, which simplifies all 13 call sites. No blur reaches React when, in a render of the control:tooltip={off ? reason : undefined});fieldsetis disabled;So the hook drops a focus its element no longer holds, and never sets one: after each render of the control and, for a loss the control doesn't re-render for, on the next key or pointer press React Aria reports, through the focus-visible listener every
useFocusalready registers. Keys typed in a text field don't count, and neither does a focus moved by code, so a stale ring can last until the next press. The second case covers ItemButton's auto tooltip mounting on overflow while the row is focused, and a stateful parent disabling thefieldsetaround a control. So a Tab no longer leaves two rings. It reads the active element the way React Aria does, so a shadow root is covered too. That also fixes stale rings main shows in all these cases, for enabled controls too.The ring is the standard one.
ItemButton's defaultitemtype gets none: its rows show focus with their fill, and a ring would get in the way of menu navigation and selection, so a disabled row shows no focus, as before.disabledstill wins for fill and color in every theme;currentprimarylacked that guard for its focus overlay and now has it.ActiveZone, which isn't an aria-disabled control, keeps its own mask.Entropy change
useFocus: Slightly Decreased. One option and a render-time reset are removed, along with the flag every caller passed. One invariant is added: the hook reports focus only while its element holds it, checked after the control renders and from the focus-visible listener it already had, with no new subscription. It lives in the layer that owns the bug instead of in its 13 callers. No public API changes (useFocusisn't exported).useActionfocusedmod: Slightly Decreased. A second disabled mask goes, and focus on a disabled Tab stop becomes visible.Button: Neutral. It adopts the inert-handler line thatItemandItemActionalready use, instead of being the one aria-disabled trigger that still acts.getDisabledElementProps: Neutral. One key handler in the shared inert props makes "an inert element does nothing on Enter or Space" true for every component that uses it, instead of each one guarding it.ItemAction: Moderately Increased for maintainers, and justified. The precedence rule, two derived flags, a two-termkeepEventsandtabIndex={-1}while loading give a disabled action four forms (native; aria-disabled Tab stop; aria-disabled, skipped by Tab; native under a disabled host). Each maps to an acceptance criterion and reuses thegetDisabledElementPropscontractButtonandItemalready have.isLoadingalone is enough; no hand-writtenisDisabled={isLoading}, and no API added.ButtonandItem.The ring and menu fixes belong here: this PR makes a disabled icon-only action with a tooltip a Tab stop, so without the ring it would itself add focus keyboard users can't see. The menu fix is the same inert-handler line this PR adds to
ItemAction.Chromatic: expect diffs only in the ItemAction "States" story's Loading row and the InsideItem story's "With Loading States": loading actions fade like disabled ones, and the label-only spinner gets icon padding. No story focuses a disabled or loading control, so the ring changes no snapshot.
Verified
ItemAction.test.tsx(new),interactions.test.tsx,button.test.tsx, and ring tests inItemButton.test.tsxandInfoBadge.test.tsx.ButtonorItemButtonwith a tooltip don't click the element around it.currentprimarychip keeps its resting fill (temporary Chromium check: fails before, passes after).interactions.browser.test.tsx(Chromium; jsdom can't show these):fieldsetis disabled or whose tabIndex is removed drops its focus;fieldsetaround{children}: one ring after Tab, not two;isDisabled(Button, ItemButton) leaves no stale ring in either direction.fieldset) or a removed tabIndex leaves no ring, during or after.cursor: default; the tooltip opens on hover.diagnostics:compilerpass (334 compiled functions, 158 diagnostics, as on main), as doesaudit-docsfor ItemAction and InfoBadge. The ItemButtonskipActionsWidthTransitionaudit note is the same on main.tscshows the same 22 errors as main. The full jsdom suite passes (3281 tests). Under heavy machine load a few unrelated tests hit the 5 s timeout and pass when rerun alone.Follow-up after release: bump in cubejs-enterprise. Drop
isDisabled={isAuthenticating}(MCP auth Connect) and|| isCheckingAccess(report delivery refresh), and switchToolMessage.browser.test.tsx'stoBeDisabled()to anaria-disabledassertion.Checklist
Closes: CUB-5275
🤖 Generated with Claude Code