[button] Enable buttons to remain focusable when disabled#48613
[button] Enable buttons to remain focusable when disabled#48613mj12albert wants to merge 5 commits into
Conversation
Deploy preview
Bundle size
Check out the code infra dashboard for more information about this PR. |
af55452 to
4cf24e7
Compare
4cf24e7 to
e18f541
Compare
| For disabled `Button` and `IconButton` components that need to trigger a Tooltip, use the `focusableWhenDisabled` prop instead: | ||
|
|
||
| ```jsx | ||
| <Tooltip title="You don't have permission to do this"> | ||
| <Button disabled focusableWhenDisabled> | ||
| Disabled | ||
| </Button> | ||
| </Tooltip> | ||
| ``` |
There was a problem hiding this comment.
I think this section should be outside of "Cursor not-allowed" or just link to a new section that explain the focusableWhenDisabled prop.
siriwatknp
left a comment
There was a problem hiding this comment.
Picking this up to continue the work.
Before going further, want to lock the scope of focusableWhenDisabled so we are aligned.
Putting the intended behavior and styling matrix here, so the discussion has one reference.
The idea: a disabled + focusableWhenDisabled element is reachable but inert. It can take focus and receive hover/pointer events, so assistive tech finds it and Tooltip can explain why it is off. But it must still look and act not-actionable.
Behavior
| Capability | enabled | disabled (native) | disabled + focusableWhenDisabled |
|---|---|---|---|
| In tab order / focusable | ✅ | ❌ | ✅ |
Programmatic .focus() |
✅ | ❌ | ✅ |
| Receives hover/pointer events | ✅ | ❌ pointer-events:none |
✅ pointer-events:auto |
Tooltip on hover |
✅ | ❌ (needs <span> wrapper) |
✅ |
Tooltip on keyboard focus |
✅ | ❌ | ✅ |
onClick fires |
✅ | ❌ | ❌ blocked |
| Keyboard activate (Enter/Space) | ✅ | ❌ | ❌ blocked |
| Event bubbles to ancestor | ✅ | ❌ | ❌ stopPropagation |
onMouseLeave / onBlur fire |
✅ | ❌ | ✅ (so Tooltip can close) |
| Announced to AT as disabled | — | via disabled attr |
via aria-disabled="true" |
Applies to links (href/to) |
— | — | ❌ links stay non-focusable |
Styling (disabled + focusableWhenDisabled)
| Visual treatment | Show? | Why |
|---|---|---|
Disabled/dimmed base (action.disabled) |
✅ | it is disabled |
Cursor default |
✅ | not actionable, keep production behavior |
| Hover background / elevation | ❌ | inert, would falsely signal clickable |
| Active / pressed style | ❌ | not actionable |
| Ripple on press/tap | ❌ | ripple means "did something" |
| Focus-visible ring | out of scope this PR | rely on existing focus-visible style, verify it stays visible (WCAG 2.4.7), else separate follow-up |
Scope calls
- Focus ring: drop it from this PR. The focus-ring CSS variables and
outlineare a separate concern. Keep this PR to behavior only. - Cursor: keep the production behavior (
default). No change. - Hover: prevent Material UI built-in hover styles when disabled. The
:hover:not(.Mui-disabled)approach is right.
| props: { focusableWhenDisabled: true }, | ||
| style: { | ||
| [`&.${buttonClasses.disabled}.${buttonClasses.focusVisible}`]: { | ||
| outline: '2px solid var(--Button-focusRingColor)', |
There was a problem hiding this comment.
Is there a reason to add the focus ring here? --Button-focusRingColor plus outline looks like a separate focus-ring effort, not part of the disabled-focusable behavior.
Please drop it from this PR and keep Button scoped to behavior.
Two follow-ons once it is gone:
- likely can drop the
focusableWhenDisabledownerStateandshouldForwardPropplumbing in the styled root too. - please confirm a disabled-focusable button still shows a visible focus indicator from the existing focus-visible style. If not, that is a separate accessibility follow-up, not a reason to add a ring system here.
| props: { focusableWhenDisabled: true }, | ||
| style: { | ||
| [`&.${iconButtonClasses.disabled}.${buttonBaseClasses.focusVisible}`]: { | ||
| outline: '2px solid var(--IconButton-focusRingColor)', |
There was a problem hiding this comment.
Same as Button — please drop --IconButton-focusRingColor and the outline from this PR. Separate concern, keep IconButton scoped to behavior.
| // Backward compatibility: `preventDefault()` inside `onClose` used to stop later | ||
| // Snackbars from handling the same Escape event. Preserve that documented behavior | ||
| // without letting unrelated, pre-existing `defaultPrevented` values suppress Snackbar. | ||
| if (!defaultPreventedBeforeClose && nativeEvent.defaultPrevented) { |
There was a problem hiding this comment.
I think this misses the already-prevented case. When Escape reaches this listener already defaultPrevented (a focused child prevented it), defaultPreventedBeforeClose is already true, so the first Snackbar onClose calling preventDefault() cannot set defaultMuiPrevented.
From what I traced, with multiple open Snackbars they all close instead of one-at-a-time.
Please add a regression test for the pre-prevented + multi-snackbar case.
I was trying to do it lightly here (this was before you started the theme-level focus ring config) because just opening up the Originally I added the focus ring only in a few demos, but it doesn't seem like a good DX for this feature to require users to add their own styles in order to be fully functional
The disabled style "grays out" the whole button, so it's impossible for both these styles to coexist without some explicit design change/a new visual affordance:
Maybe we should try to do the library/theme-level focus ring first, WDYT? @siriwatknp @silviuaavram |
e18f541 to
7854857
Compare


Closes #32917
Preview: https://deploy-preview-48613--material-ui.netlify.app/material-ui/react-button/#loading-2
In the default styles, the disabled styles suppress the focus styles completely; an alt focus style (added a focus ring for this) is needed in order for "focusable when disabled" to be visually identifiable