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 } }}> - @@ -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/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 } }}> - @@ -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 ` + +``` -- 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(' + , + ); + + 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( - , + + + , ); + 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( + + + , + ); 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( + + + + , + ); + 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('); + const button = screen.getByRole('button'); + + await user.tab(); + expect(button).toHaveFocus(); + + rerender( + , + ); + + 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(); + + 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(); 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 s to to suppress focus visible styling when opened with a pointer suppressFocusVisible = false, @@ -109,6 +113,8 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { onMouseDown, onMouseLeave, onMouseUp, + onPointerDown, + onPointerUp, onTouchEnd, onTouchMove, onTouchStart, @@ -121,6 +127,7 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { const isLink = Boolean(other.href || other.to); const hasFormAction = Boolean(other.formAction); + const focusableWhenDisabled = disabled === true && focusableWhenDisabledProp === true && !isLink; let ComponentProp = component; if (ComponentProp === 'button' && isLink) { @@ -136,11 +143,15 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { const handleRippleRef = useForkRef(ripple.ref, touchRippleRef); const [focusVisible, setFocusVisible] = React.useState(false); - if ((disabled || suppressFocusVisible) && focusVisible) { + if (((disabled && !focusableWhenDisabled) || suppressFocusVisible) && focusVisible) { setFocusVisible(false); } const handleBeforeKeyDown = useEventCallback((event) => { + if (disabled) { + return; + } + // Check if key is already down to avoid repeats being counted as multiple activations if (focusRipple && !event.repeat && focusVisible && event.key === ' ') { ripple.stop(event, () => { @@ -150,6 +161,10 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { }); const handleBeforeKeyUp = useEventCallback((event) => { + if (disabled) { + return; + } + // calling preventDefault in keyUp on a + } + />, + ); + const button = screen.getByRole('button', { name: 'Undo' }); + + act(() => { + button.focus(); + }); + expect(button).toHaveFocus(); + + const defaultNotPrevented = fireEvent.keyDown(button, { key: 'Escape' }); + + expect(defaultNotPrevented).to.equal(true); + expect(handleClose.callCount).to.equal(1); + expect(handleClose.args[0][0]).to.have.property('defaultPrevented', false); + expect(handleClose.args[0][1]).to.deep.equal('escapeKeyDown'); + }); + + it('can limit which Snackbars are closed when pressing Escape from a disabled focusable action', () => { + const handleCloseA = spy((event) => event.preventDefault()); + const handleCloseB = spy(); + render( + + + Undo + + } + /> + + , + ); + 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'; + +