diff --git a/docs/data/material/components/buttons/LoadingButtonsTransition.js b/docs/data/material/components/buttons/LoadingButtonsTransition.js
index 2278b2684fe7b5..7dfea2d67f3f31 100644
--- a/docs/data/material/components/buttons/LoadingButtonsTransition.js
+++ b/docs/data/material/components/buttons/LoadingButtonsTransition.js
@@ -33,6 +33,7 @@ export default function LoadingButtonsTransition() {
loading={loading}
variant="outlined"
disabled
+ focusableWhenDisabled
>
Disabled
@@ -42,6 +43,7 @@ export default function LoadingButtonsTransition() {
loading={loading}
loadingIndicator="Loading…"
variant="outlined"
+ focusableWhenDisabled
>
Fetch data
@@ -52,6 +54,7 @@ export default function LoadingButtonsTransition() {
loading={loading}
loadingPosition="end"
variant="contained"
+ focusableWhenDisabled
>
Send
@@ -63,12 +66,19 @@ export default function LoadingButtonsTransition() {
loadingPosition="start"
startIcon={}
variant="contained"
+ focusableWhenDisabled
>
Save
button': { m: 1 } }}>
-
@@ -95,6 +107,7 @@ export default function LoadingButtonsTransition() {
loadingPosition="start"
startIcon={}
variant="contained"
+ focusableWhenDisabled
>
Save
diff --git a/docs/data/material/components/buttons/LoadingButtonsTransition.tsx b/docs/data/material/components/buttons/LoadingButtonsTransition.tsx
index 2278b2684fe7b5..7dfea2d67f3f31 100644
--- a/docs/data/material/components/buttons/LoadingButtonsTransition.tsx
+++ b/docs/data/material/components/buttons/LoadingButtonsTransition.tsx
@@ -33,6 +33,7 @@ export default function LoadingButtonsTransition() {
loading={loading}
variant="outlined"
disabled
+ focusableWhenDisabled
>
Disabled
@@ -42,6 +43,7 @@ export default function LoadingButtonsTransition() {
loading={loading}
loadingIndicator="Loading…"
variant="outlined"
+ focusableWhenDisabled
>
Fetch data
@@ -52,6 +54,7 @@ export default function LoadingButtonsTransition() {
loading={loading}
loadingPosition="end"
variant="contained"
+ focusableWhenDisabled
>
Send
@@ -63,12 +66,19 @@ export default function LoadingButtonsTransition() {
loadingPosition="start"
startIcon={}
variant="contained"
+ focusableWhenDisabled
>
Save
button': { m: 1 } }}>
-
+
Disabled
Fetch data
@@ -85,6 +96,7 @@ export default function LoadingButtonsTransition() {
loading={loading}
loadingPosition="end"
variant="contained"
+ focusableWhenDisabled
>
Send
@@ -95,6 +107,7 @@ export default function LoadingButtonsTransition() {
loadingPosition="start"
startIcon={}
variant="contained"
+ focusableWhenDisabled
>
Save
diff --git a/docs/data/material/components/buttons/LoadingIconButton.js b/docs/data/material/components/buttons/LoadingIconButton.js
index 6778d7281d47d7..74a5193352e501 100644
--- a/docs/data/material/components/buttons/LoadingIconButton.js
+++ b/docs/data/material/components/buttons/LoadingIconButton.js
@@ -13,7 +13,11 @@ export default function LoadingIconButton() {
});
return (
- setLoading(true)} loading={loading}>
+ setLoading(true)}
+ loading={loading}
+ focusableWhenDisabled
+ >
diff --git a/docs/data/material/components/buttons/LoadingIconButton.tsx b/docs/data/material/components/buttons/LoadingIconButton.tsx
index 6778d7281d47d7..74a5193352e501 100644
--- a/docs/data/material/components/buttons/LoadingIconButton.tsx
+++ b/docs/data/material/components/buttons/LoadingIconButton.tsx
@@ -13,7 +13,11 @@ export default function LoadingIconButton() {
});
return (
- setLoading(true)} loading={loading}>
+ setLoading(true)}
+ loading={loading}
+ focusableWhenDisabled
+ >
diff --git a/docs/data/material/components/buttons/LoadingIconButton.tsx.preview b/docs/data/material/components/buttons/LoadingIconButton.tsx.preview
index 9c9a8b0cbf868a..fef81274c002c9 100644
--- a/docs/data/material/components/buttons/LoadingIconButton.tsx.preview
+++ b/docs/data/material/components/buttons/LoadingIconButton.tsx.preview
@@ -1,5 +1,9 @@
- setLoading(true)} loading={loading}>
+ setLoading(true)}
+ loading={loading}
+ focusableWhenDisabled
+ >
\ No newline at end of file
diff --git a/docs/data/material/components/buttons/buttons.md b/docs/data/material/components/buttons/buttons.md
index fd45b56f885231..ed1f6eb0d0646f 100644
--- a/docs/data/material/components/buttons/buttons.md
+++ b/docs/data/material/components/buttons/buttons.md
@@ -21,6 +21,10 @@ Buttons communicate actions that users can take. They are typically placed throu
{{"component": "@mui/internal-core-docs/ComponentLinkHeader"}}
+## Usage guidelines
+
+- **Keep disabled buttons discoverable**: Use `focusableWhenDisabled` to keep disabled `Button`s and `IconButton`s in the tab order. This makes unavailable actions discoverable to screen reader users while still preventing activation.
+
## Basic button
The `Button` comes with three variants: text (default), contained, and outlined.
@@ -115,6 +119,7 @@ Use `color` prop to apply theme color palette to component.
### Loading
Starting from v6.4.0, use `loading` prop to set icon buttons in a loading state and disable interactions.
+For icon buttons that enter a loading state after being clicked, set `focusableWhenDisabled` to retain focus and maintain tab order while disabled.
{{"demo": "LoadingIconButton.js"}}
@@ -133,6 +138,7 @@ To create a file upload button, turn the button into a label using `component="l
## Loading
Starting from v6.4.0, use the `loading` prop to set buttons in a loading state and disable interactions.
+For buttons that enter a loading state after being clicked, set `focusableWhenDisabled` to retain focus and maintain tab order while disabled.
{{"demo": "LoadingButtons.js"}}
@@ -191,23 +197,31 @@ Here is a [more detailed guide](/material-ui/integrations/routing/#button).
### Cursor not-allowed
-The ButtonBase component sets `pointer-events: none;` on disabled buttons, which prevents the appearance of a disabled cursor.
+The ButtonBase component sets `pointer-events: none;` on disabled buttons by default, which prevents the appearance of a disabled cursor.
If you wish to use `not-allowed`, you have two options:
-1. **CSS only**. You can remove the pointer-events style on the disabled state of the `` element:
+1. **CSS only**. You can remove the pointer-events style on the disabled state:
```css
-.MuiButtonBase-root:disabled {
+.MuiButtonBase-root.Mui-disabled {
cursor: not-allowed;
pointer-events: auto;
}
```
-However:
+For disabled `Button` and `IconButton` components that need to trigger a Tooltip, use the `focusableWhenDisabled` prop instead:
+
+```jsx
+
+
+ Disabled
+
+
+```
-- You should add `pointer-events: none;` back when you need to display [tooltips on disabled elements](/material-ui/react-tooltip/#disabled-elements).
-- The cursor won't change if you render something other than a button element, for instance, a link `` element.
+The prop keeps disabled buttons focusable and hoverable while preventing activation.
+Disabled links remain non-focusable and pointer-inert.
2. **DOM change**. You can wrap the button:
diff --git a/docs/data/material/components/snackbars/snackbars.md b/docs/data/material/components/snackbars/snackbars.md
index f6f26b2a7e4cb3..525dd3efd6d40e 100644
--- a/docs/data/material/components/snackbars/snackbars.md
+++ b/docs/data/material/components/snackbars/snackbars.md
@@ -122,7 +122,7 @@ Note that notistack prevents Snackbars from being [closed by pressing Escape. If there are multiple instances appearing at the same time and you want Escape to dismiss only the oldest one that's currently open, call `event.preventDefault` in the `onClose` prop.
+The user should be able to dismiss Snackbars by pressing Escape. If there are multiple instances appearing at the same time and you want Escape to dismiss only the oldest one that's currently open, call `event.preventDefault()` in the `onClose` prop.
```jsx
export default function MyComponent() {
@@ -133,9 +133,11 @@ export default function MyComponent() {
{
- // `reason === 'escapeKeyDown'` if `Escape` was pressed
setOpen(false);
- // call `event.preventDefault` to only close one Snackbar at a time.
+ if (reason === 'escapeKeyDown') {
+ // Only close one Snackbar at a time.
+ event.preventDefault();
+ }
}}
/>
setOpen(false)} />
diff --git a/docs/pages/material-ui/api/button.json b/docs/pages/material-ui/api/button.json
index d2d48a86a4be6a..7d5742f7e106ec 100644
--- a/docs/pages/material-ui/api/button.json
+++ b/docs/pages/material-ui/api/button.json
@@ -15,6 +15,7 @@
"disableFocusRipple": { "type": { "name": "bool" }, "default": "false" },
"disableRipple": { "type": { "name": "bool" }, "default": "false" },
"endIcon": { "type": { "name": "node" } },
+ "focusableWhenDisabled": { "type": { "name": "bool" }, "default": "false" },
"fullWidth": { "type": { "name": "bool" }, "default": "false" },
"href": { "type": { "name": "string" } },
"loading": { "type": { "name": "bool" }, "default": "null" },
diff --git a/docs/pages/material-ui/api/icon-button.json b/docs/pages/material-ui/api/icon-button.json
index 239a8076de7185..606c7f337858a4 100644
--- a/docs/pages/material-ui/api/icon-button.json
+++ b/docs/pages/material-ui/api/icon-button.json
@@ -19,6 +19,7 @@
},
"default": "false"
},
+ "focusableWhenDisabled": { "type": { "name": "bool" }, "default": "false" },
"loading": { "type": { "name": "bool" }, "default": "null" },
"loadingIndicator": {
"type": { "name": "node" },
diff --git a/docs/translations/api-docs/button/button.json b/docs/translations/api-docs/button/button.json
index d5f831042aa076..81502df92eec7b 100644
--- a/docs/translations/api-docs/button/button.json
+++ b/docs/translations/api-docs/button/button.json
@@ -18,6 +18,9 @@
"description": "If true, the ripple effect is disabled.
⚠️ Without a ripple there is no styling for :focus-visible by default. Be sure to highlight the element by applying separate styles with the .Mui-focusVisible class."
},
"endIcon": { "description": "Element placed after the children." },
+ "focusableWhenDisabled": {
+ "description": "If true, allows a disabled component to retain keyboard and programmatic focusability while preventing activation. Disabled links remain non-focusable."
+ },
"fullWidth": {
"description": "If true, the button will take up the full width of its container."
},
diff --git a/docs/translations/api-docs/icon-button/icon-button.json b/docs/translations/api-docs/icon-button/icon-button.json
index 23ff81bc47769a..b0a9e4607a0fcb 100644
--- a/docs/translations/api-docs/icon-button/icon-button.json
+++ b/docs/translations/api-docs/icon-button/icon-button.json
@@ -16,6 +16,9 @@
"edge": {
"description": "If given, uses a negative margin to counteract the padding on one side (this is often helpful for aligning the left or right side of the icon with content above or below, without ruining the border size and shape)."
},
+ "focusableWhenDisabled": {
+ "description": "If true, allows a disabled component to retain keyboard and programmatic focusability while preventing activation. Disabled links remain non-focusable."
+ },
"loading": {
"description": "If true, the loading indicator is visible and the button is disabled. If true | false, the loading wrapper is always rendered before the children to prevent Google Translation Crash."
},
diff --git a/packages/mui-material/src/Button/Button.d.ts b/packages/mui-material/src/Button/Button.d.ts
index 94c99fa01f26aa..9c03c4ce1aca9a 100644
--- a/packages/mui-material/src/Button/Button.d.ts
+++ b/packages/mui-material/src/Button/Button.d.ts
@@ -52,6 +52,12 @@ export interface ButtonOwnProps {
* Element placed after the children.
*/
endIcon?: React.ReactNode;
+ /**
+ * If `true`, allows a disabled component to retain keyboard and programmatic focusability while preventing activation.
+ * Disabled links remain non-focusable.
+ * @default false
+ */
+ focusableWhenDisabled?: boolean | undefined;
/**
* If `true`, the button will take up the full width of its container.
* @default false
diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js
index 13d4b1ecad8c32..8cec9c6ace453e 100644
--- a/packages/mui-material/src/Button/Button.js
+++ b/packages/mui-material/src/Button/Button.js
@@ -104,6 +104,7 @@ const ButtonRoot = styled(ButtonBase, {
padding: '6px 16px',
border: 0,
borderRadius: (theme.vars || theme).shape.borderRadius,
+ '--Button-focusRingColor': (theme.vars || theme).palette.primary.main,
...getTransitionStyles(theme, ['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
}),
@@ -114,6 +115,15 @@ const ButtonRoot = styled(ButtonBase, {
color: (theme.vars || theme).palette.action.disabled,
},
variants: [
+ {
+ props: { focusableWhenDisabled: true },
+ style: {
+ [`&.${buttonClasses.disabled}.${buttonClasses.focusVisible}`]: {
+ outline: '2px solid var(--Button-focusRingColor)',
+ outlineOffset: 2,
+ },
+ },
+ },
{
props: { variant: 'contained' },
style: {
@@ -174,8 +184,9 @@ const ButtonRoot = styled(ButtonBase, {
),
'--variant-containedColor': (theme.vars || theme).palette[color].contrastText,
'--variant-containedBg': (theme.vars || theme).palette[color].main,
+ '--Button-focusRingColor': (theme.vars || theme).palette[color].main,
'@media (hover: hover)': {
- '&:hover': {
+ [`&:hover:not(.${buttonClasses.disabled})`]: {
'--variant-containedBg': (theme.vars || theme).palette[color].dark,
'--variant-textBg': theme.alpha(
(theme.vars || theme).palette[color].main,
@@ -200,8 +211,9 @@ const ButtonRoot = styled(ButtonBase, {
'--variant-containedBg': theme.vars
? theme.vars.palette.Button.inheritContainedBg
: inheritContainedBackgroundColor,
+ '--Button-focusRingColor': (theme.vars || theme).palette.text.primary,
'@media (hover: hover)': {
- '&:hover': {
+ [`&:hover:not(.${buttonClasses.disabled})`]: {
'--variant-containedBg': theme.vars
? theme.vars.palette.Button.inheritContainedHoverBg
: inheritContainedHoverBackgroundColor,
@@ -509,6 +521,7 @@ const Button = React.forwardRef(function Button(inProps, ref) {
disableElevation = false,
disableFocusRipple = false,
endIcon: endIconProp,
+ focusableWhenDisabled = false,
focusVisibleClassName,
fullWidth = false,
id: idProp,
@@ -521,7 +534,6 @@ const Button = React.forwardRef(function Button(inProps, ref) {
variant = 'text',
...other
} = props;
-
const loadingId = useId(idProp);
const loadingIndicator = loadingIndicatorProp ?? (
@@ -597,6 +609,7 @@ const Button = React.forwardRef(function Button(inProps, ref) {
type={type}
id={loading ? loadingId : idProp}
{...other}
+ focusableWhenDisabled={focusableWhenDisabled === true}
classes={forwardedClasses}
>
{startIcon}
@@ -667,6 +680,12 @@ Button.propTypes /* remove-proptypes */ = {
* Element placed after the children.
*/
endIcon: PropTypes.node,
+ /**
+ * If `true`, allows a disabled component to retain keyboard and programmatic focusability while preventing activation.
+ * Disabled links remain non-focusable.
+ * @default false
+ */
+ focusableWhenDisabled: PropTypes.bool,
/**
* @ignore
*/
diff --git a/packages/mui-material/src/Button/Button.test.js b/packages/mui-material/src/Button/Button.test.js
index f2cea0b5cbb336..dec0d9d2281f58 100644
--- a/packages/mui-material/src/Button/Button.test.js
+++ b/packages/mui-material/src/Button/Button.test.js
@@ -1,5 +1,6 @@
import * as React from 'react';
import { expect } from 'chai';
+import { spy } from 'sinon';
import {
createRenderer,
screen,
@@ -11,6 +12,7 @@ import { ClassNames } from '@emotion/react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import Button, { buttonClasses as classes } from '@mui/material/Button';
import ButtonBase, { touchRippleClasses } from '@mui/material/ButtonBase';
+import Tooltip from '@mui/material/Tooltip';
import describeConformance from '../../test/describeConformance';
import * as ripple from '../../test/ripple';
@@ -137,16 +139,113 @@ describe('', () => {
errorSpy.mockRestore();
});
- it('does not forward focusableWhenDisabled to ButtonBase', () => {
+ it('allows disabled buttons to remain focusable without activation', async () => {
+ const onClick = spy();
+ const onParentClick = spy();
+
+ const { user } = render(
+
+
+ Hello World
+
+
,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).not.to.have.attribute('disabled');
+ expect(button).to.have.attribute('aria-disabled', 'true');
+ expect(button).to.have.property('tabIndex', 0);
+ expect(button).toHaveComputedStyle({ pointerEvents: 'auto' });
+
+ await user.tab();
+ expect(button).toHaveFocus();
+
+ await user.keyboard('{Enter}');
+ await user.keyboard(' ');
+ await user.click(button);
+
+ expect(onClick.callCount).to.equal(0);
+ expect(onParentClick.callCount).to.equal(0);
+ });
+
+ it.skipIf(isJsdom())('applies a customizable focus ring to disabled focusable buttons', () => {
+ const theme = createTheme({
+ components: {
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ '--Button-focusRingColor': 'rgb(1, 2, 3)',
+ },
+ },
+ },
+ },
+ });
+
render(
-
- Hello World
- ,
+
+
+ Hello World
+
+ ,
);
+ const button = screen.getByRole('button');
+
+ expect(button).toHaveComputedStyle({
+ outlineStyle: 'solid',
+ outlineWidth: '2px',
+ outlineOffset: '2px',
+ });
+ expect(getComputedStyle(button).getPropertyValue('--Button-focusRingColor')).to.equal(
+ 'rgb(1, 2, 3)',
+ );
+ });
+ it('allows Tooltip to open from hover and focus on disabled focusable buttons', async () => {
+ const { user } = render(
+
+
+ Hello World
+
+ ,
+ );
const button = screen.getByRole('button');
- expect(button).to.have.attribute('disabled');
- expect(button).not.to.have.attribute('aria-disabled');
+
+ await user.hover(button);
+ expect(await screen.findByRole('tooltip')).to.have.text('Disabled action');
+
+ await user.unhover(button);
+ await user.tab();
+ expect(button).toHaveFocus();
+ expect(await screen.findByRole('tooltip')).to.have.text('Disabled action');
+ });
+
+ it('keeps disabled links non-focusable and pointer-inert', () => {
+ const RouterLink = React.forwardRef(function RouterLink(props, ref) {
+ const { children, to, ...otherProps } = props;
+ return (
+
+ {children}
+
+ );
+ });
+
+ render(
+
+
+ Href
+
+
+ To
+
+ ,
+ );
+ const links = screen.getAllByRole('link');
+
+ links.forEach((link) => {
+ expect(link).to.have.attribute('aria-disabled', 'true');
+ expect(link).to.have.property('tabIndex', -1);
+ expect(link).toHaveComputedStyle({ pointerEvents: 'none' });
+ });
});
it('does not pass classes.root to ButtonBase classes', () => {
@@ -971,6 +1070,76 @@ describe('', () => {
expect(screen.getByRole('button')).to.have.property('disabled', true);
});
+ it('can retain focus while loading when focusableWhenDisabled', async () => {
+ const { rerender, user } = render(Submit);
+ const button = screen.getByRole('button');
+
+ await user.tab();
+ expect(button).toHaveFocus();
+
+ rerender(
+
+ Submit
+ ,
+ );
+
+ const loadingButton = screen.getByRole('button');
+ expect(loadingButton).toHaveFocus();
+ expect(loadingButton).to.have.property('disabled', false);
+ expect(loadingButton).to.have.attribute('aria-disabled', 'true');
+ });
+
+ [
+ {
+ label: 'disabled focusableWhenDisabled',
+ props: { disabled: true, focusableWhenDisabled: true },
+ nativeDisabled: false,
+ ariaDisabled: true,
+ tabIndex: 0,
+ },
+ {
+ label: 'loading',
+ props: { loading: true, focusableWhenDisabled: true },
+ nativeDisabled: false,
+ ariaDisabled: true,
+ tabIndex: 0,
+ },
+ {
+ label: 'disabled loading',
+ props: { disabled: true, loading: true, focusableWhenDisabled: true },
+ nativeDisabled: false,
+ ariaDisabled: true,
+ tabIndex: 0,
+ },
+ {
+ label: 'disabled loading={false}',
+ props: { disabled: true, loading: false, focusableWhenDisabled: true },
+ nativeDisabled: false,
+ ariaDisabled: true,
+ tabIndex: 0,
+ },
+ {
+ label: 'default loading={null}',
+ props: { disabled: true, loading: null, focusableWhenDisabled: true },
+ nativeDisabled: false,
+ ariaDisabled: true,
+ tabIndex: 0,
+ },
+ ].forEach(({ label, props, nativeDisabled, ariaDisabled, tabIndex }) => {
+ it(`resolves focusable disabled state for ${label}`, () => {
+ render(Submit);
+
+ const button = screen.getByRole('button');
+ expect(button).to.have.property('disabled', nativeDisabled);
+ expect(button).to.have.property('tabIndex', tabIndex);
+ if (ariaDisabled) {
+ expect(button).to.have.attribute('aria-disabled', 'true');
+ } else {
+ expect(button).not.to.have.attribute('aria-disabled');
+ }
+ });
+ });
+
it('renders a progressbar that is labelled by the button', () => {
render(Submit);
diff --git a/packages/mui-material/src/ButtonBase/ButtonBase.js b/packages/mui-material/src/ButtonBase/ButtonBase.js
index d92162eb51f276..c8d217eb328913 100644
--- a/packages/mui-material/src/ButtonBase/ButtonBase.js
+++ b/packages/mui-material/src/ButtonBase/ButtonBase.js
@@ -7,6 +7,7 @@ import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef';
import composeClasses from '@mui/utils/composeClasses';
import isFocusVisible from '@mui/utils/isFocusVisible';
import { styled } from '../zero-styled';
+import rootShouldForwardProp from '../styles/rootShouldForwardProp';
import { useDefaultProps } from '../DefaultPropsProvider';
import useForkRef from '../utils/useForkRef';
import useEventCallback from '../utils/useEventCallback';
@@ -32,10 +33,13 @@ const useUtilityClasses = (ownerState) => {
return composedClasses;
};
+const shouldForwardProp = (prop) => rootShouldForwardProp(prop) && prop !== 'focusableWhenDisabled';
+
export const ButtonBaseRoot = styled('button', {
name: 'MuiButtonBase',
slot: 'Root',
-})({
+ shouldForwardProp,
+})(({ ownerState }) => ({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
@@ -61,13 +65,13 @@ export const ButtonBaseRoot = styled('button', {
borderStyle: 'none', // Remove Firefox dotted outline.
},
[`&.${buttonBaseClasses.disabled}`]: {
- pointerEvents: 'none', // Disable link interactions
+ pointerEvents: ownerState?.focusableWhenDisabled ? 'auto' : 'none', // Disable link interactions
cursor: 'default',
},
'@media print': {
colorAdjust: 'exact',
},
-});
+}));
/**
* `ButtonBase` contains as few styles as possible.
@@ -88,8 +92,8 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) {
focusRipple = false,
focusVisibleClassName,
/* eslint-disable react/prop-types */
- // replaces internal handling in Chip, other components can opt-in individually to use this in the future
- focusableWhenDisabled,
+ // private prop for scoped components that intentionally opt into disabled focusability
+ focusableWhenDisabled: focusableWhenDisabledProp = false,
// escape hatch to suppress the focusVisible state and callback
// used by anchored
+ }
+ />
+
+ ,
+ );
+ const button = screen.getByRole('button', { name: 'Undo' });
+
+ act(() => {
+ button.focus();
+ });
+ expect(button).toHaveFocus();
+
+ const defaultNotPrevented = fireEvent.keyDown(button, { key: 'Escape' });
+
+ expect(defaultNotPrevented).to.equal(false);
+ expect(handleCloseA.callCount).to.equal(1);
+ expect(handleCloseA.args[0][0]).to.have.property('defaultMuiPrevented', true);
+ expect(handleCloseB.callCount).to.equal(0);
+ });
+
+ it('skips Escape close when defaultMuiPrevented is true', () => {
+ const handleClose = spy();
+ render();
+
+ const event = new window.KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ });
+ event.defaultMuiPrevented = true;
+ document.body.dispatchEvent(event);
+
+ expect(handleClose.callCount).to.equal(0);
+ });
});
describe('Consecutive messages', () => {
diff --git a/packages/mui-material/src/Snackbar/useSnackbar.ts b/packages/mui-material/src/Snackbar/useSnackbar.ts
index 3f3c92b0b9528b..ea9d67daebd87b 100644
--- a/packages/mui-material/src/Snackbar/useSnackbar.ts
+++ b/packages/mui-material/src/Snackbar/useSnackbar.ts
@@ -10,6 +10,10 @@ import {
} from './useSnackbar.types';
import { EventHandlers } from '../utils/types';
+type MuiPreventableKeyboardEvent = KeyboardEvent & {
+ defaultMuiPrevented?: boolean | undefined;
+};
+
function useSnackbar(parameters: UseSnackbarParameters = {}): UseSnackbarReturnValue {
const {
autoHideDuration = null,
@@ -29,11 +33,22 @@ function useSnackbar(parameters: UseSnackbarParameters = {}): UseSnackbarReturnV
/**
* @param {KeyboardEvent} nativeEvent
*/
- function handleKeyDown(nativeEvent: KeyboardEvent) {
- if (!nativeEvent.defaultPrevented) {
- if (nativeEvent.key === 'Escape') {
- // not calling `preventDefault` since we don't know if people may ignore this event e.g. a permanently open snackbar
- onClose?.(nativeEvent, 'escapeKeyDown');
+ function handleKeyDown(nativeEvent: MuiPreventableKeyboardEvent) {
+ if (nativeEvent.defaultMuiPrevented) {
+ return;
+ }
+
+ if (nativeEvent.key === 'Escape') {
+ const defaultPreventedBeforeClose = nativeEvent.defaultPrevented;
+
+ // not calling `preventDefault` since we don't know if people may ignore this event e.g. a permanently open snackbar
+ onClose?.(nativeEvent, 'escapeKeyDown');
+
+ // 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) {
+ nativeEvent.defaultMuiPrevented = true;
}
}
}
diff --git a/packages/mui-material/src/Snackbar/useSnackbar.types.ts b/packages/mui-material/src/Snackbar/useSnackbar.types.ts
index 91acc91b1e1fa2..633e0ff5666600 100644
--- a/packages/mui-material/src/Snackbar/useSnackbar.types.ts
+++ b/packages/mui-material/src/Snackbar/useSnackbar.types.ts
@@ -1,5 +1,9 @@
export type SnackbarCloseReason = 'timeout' | 'clickaway' | 'escapeKeyDown';
+export type SnackbarCloseEvent = (React.SyntheticEvent | Event) & {
+ defaultMuiPrevented?: boolean | undefined;
+};
+
export interface UseSnackbarParameters {
/**
* The number of milliseconds to wait before automatically calling the
@@ -24,9 +28,7 @@ export interface UseSnackbarParameters {
* @param {React.SyntheticEvent | Event} event The event source of the callback.
* @param {string} reason Can be: `"timeout"` (`autoHideDuration` expired), `"clickaway"`, or `"escapeKeyDown"`.
*/
- onClose?:
- | ((event: React.SyntheticEvent | Event | null, reason: SnackbarCloseReason) => void)
- | undefined;
+ onClose?: ((event: SnackbarCloseEvent | null, reason: SnackbarCloseReason) => void) | undefined;
/**
* If `true`, the component is shown.
*/
diff --git a/packages/mui-material/test/typescript/focusableWhenDisabled.spec.tsx b/packages/mui-material/test/typescript/focusableWhenDisabled.spec.tsx
new file mode 100644
index 00000000000000..a4e1e8a866c3da
--- /dev/null
+++ b/packages/mui-material/test/typescript/focusableWhenDisabled.spec.tsx
@@ -0,0 +1,73 @@
+import * as React from 'react';
+import { createTheme } from '@mui/material/styles';
+import Button from '@mui/material/Button';
+import ButtonBase from '@mui/material/ButtonBase';
+import IconButton from '@mui/material/IconButton';
+import MenuItem from '@mui/material/MenuItem';
+import StepButton from '@mui/material/StepButton';
+import Tab from '@mui/material/Tab';
+import ToggleButton from '@mui/material/ToggleButton';
+
+;
+;
+
+// @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+;
+// @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+;
+// @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+;
+// @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+;
+// @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+;
+
+createTheme({
+ components: {
+ MuiButton: {
+ defaultProps: {
+ focusableWhenDisabled: true,
+ },
+ },
+ MuiIconButton: {
+ defaultProps: {
+ focusableWhenDisabled: true,
+ },
+ },
+ },
+});
+
+createTheme({
+ components: {
+ MuiButtonBase: {
+ defaultProps: {
+ // @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+ focusableWhenDisabled: true,
+ },
+ },
+ MuiMenuItem: {
+ defaultProps: {
+ // @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+ focusableWhenDisabled: true,
+ },
+ },
+ MuiStepButton: {
+ defaultProps: {
+ // @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+ focusableWhenDisabled: true,
+ },
+ },
+ MuiToggleButton: {
+ defaultProps: {
+ // @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+ focusableWhenDisabled: true,
+ },
+ },
+ MuiTab: {
+ defaultProps: {
+ // @ts-expect-error focusableWhenDisabled is only exposed by Button and IconButton
+ focusableWhenDisabled: true,
+ },
+ },
+ },
+});