diff --git a/docs/data/material/components/tooltips/DisabledTooltips.js b/docs/data/material/components/tooltips/DisabledTooltips.js index a00764ef2a437e..d4d6edf6c6fae4 100644 --- a/docs/data/material/components/tooltips/DisabledTooltips.js +++ b/docs/data/material/components/tooltips/DisabledTooltips.js @@ -4,9 +4,9 @@ import Tooltip from '@mui/material/Tooltip'; export default function DisabledTooltips() { return ( - - - + ); } diff --git a/docs/data/material/components/tooltips/DisabledTooltips.tsx b/docs/data/material/components/tooltips/DisabledTooltips.tsx index a00764ef2a437e..d4d6edf6c6fae4 100644 --- a/docs/data/material/components/tooltips/DisabledTooltips.tsx +++ b/docs/data/material/components/tooltips/DisabledTooltips.tsx @@ -4,9 +4,9 @@ import Tooltip from '@mui/material/Tooltip'; export default function DisabledTooltips() { return ( - - - + ); } diff --git a/docs/data/material/components/tooltips/DisabledTooltips.tsx.preview b/docs/data/material/components/tooltips/DisabledTooltips.tsx.preview index f83193be661f3d..9253da1e600e21 100644 --- a/docs/data/material/components/tooltips/DisabledTooltips.tsx.preview +++ b/docs/data/material/components/tooltips/DisabledTooltips.tsx.preview @@ -1,5 +1,5 @@ - - - + \ No newline at end of file diff --git a/docs/data/material/components/tooltips/tooltips.md b/docs/data/material/components/tooltips/tooltips.md index 42dc76cfee6928..f07d3a1611b1ee 100644 --- a/docs/data/material/components/tooltips/tooltips.md +++ b/docs/data/material/components/tooltips/tooltips.md @@ -147,28 +147,14 @@ You can disable this behavior (thus failing the success criterion which is requi ## Disabled elements -By default disabled elements like ` - - -``` - ## Transitions Use `slots.transition` and `slotProps.transition` to use a different transition. diff --git a/packages/mui-material/src/Tooltip/Tooltip.js b/packages/mui-material/src/Tooltip/Tooltip.js index f0ffb0c969f66a..d181976b02b54e 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.js +++ b/packages/mui-material/src/Tooltip/Tooltip.js @@ -264,6 +264,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { const [childNode, setChildNode] = React.useState(); const [arrowRef, setArrowRef] = React.useState(null); const ignoreNonTouchEvents = React.useRef(false); + const openedByDisabledTriggerRef = React.useRef(false); const disableInteractive = disableInteractiveProp || followCursor; @@ -280,34 +281,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { }); let open = openState; - - if (process.env.NODE_ENV !== 'production') { - // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/rules-of-hooks -- process.env never changes - const { current: isControlled } = React.useRef(openProp !== undefined); - - // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/rules-of-hooks -- process.env never changes - React.useEffect(() => { - if ( - childNode && - childNode.disabled && - !isControlled && - title !== '' && - childNode.tagName.toLowerCase() === 'button' - ) { - console.warn( - [ - 'MUI: You are providing a disabled `button` child to the Tooltip component.', - 'A disabled element does not fire events.', - "Tooltip needs to listen to the child element's events to display the title.", - '', - 'Add a simple wrapper element, such as a `span`.', - ].join('\n'), - ); - } - }, [title, childNode, isControlled]); - } + const { current: isControlled } = React.useRef(openProp !== undefined); const id = useId(idProp); @@ -341,6 +315,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { * @param {React.SyntheticEvent | Event} event */ (event) => { + openedByDisabledTriggerRef.current = false; hystersisTimer.start(800 + leaveDelay, () => { hystersisOpen = false; }); @@ -357,9 +332,6 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { ); const handleMouseOver = (event) => { - if (childNode?.disabled) { - return; - } if (ignoreNonTouchEvents.current && event.type !== 'touchstart') { return; } @@ -382,6 +354,31 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { } }; + const handleTriggerMouseOver = (event) => { + if (childNode?.disabled && !isControlled) { + // A disabled trigger can open the tooltip if it receives pointer events. + // However, if the trigger became disabled while the tooltip was already open, + // stray mouseover events must not cancel the pending close. + if (open && !openedByDisabledTriggerRef.current) { + return; + } + + openedByDisabledTriggerRef.current = true; + } else { + openedByDisabledTriggerRef.current = false; + } + + handleMouseOver(event); + }; + + const handleInteractiveWrapperMouseOver = (event) => { + if (childNode?.disabled && !isControlled && !openedByDisabledTriggerRef.current) { + return; + } + + handleMouseOver(event); + }; + const handleMouseLeave = (event) => { enterTimer.clear(); leaveTimer.start(leaveDelay, () => { @@ -418,6 +415,8 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { setChildNode(event.currentTarget); } + openedByDisabledTriggerRef.current = false; + if (isFocusVisible(event.target)) { // Workaround for https://github.com/facebook/react/issues/9142. // React does not fire blur when a focused element becomes disabled. @@ -455,7 +454,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { touchTimer.start(enterTouchDelay, () => { document.body.style.WebkitUserSelect = prevUserSelect.current; - handleMouseOver(event); + handleTriggerMouseOver(event); }); }; @@ -559,11 +558,14 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { } if (!disableHoverListener) { - childrenProps.onMouseOver = composeEventHandler(handleMouseOver, childrenProps.onMouseOver); + childrenProps.onMouseOver = composeEventHandler( + handleTriggerMouseOver, + childrenProps.onMouseOver, + ); childrenProps.onMouseLeave = composeEventHandler(handleMouseLeave, childrenProps.onMouseLeave); if (!disableInteractive) { - interactiveWrapperListeners.onMouseOver = handleMouseOver; + interactiveWrapperListeners.onMouseOver = handleInteractiveWrapperMouseOver; interactiveWrapperListeners.onMouseLeave = handleMouseLeave; } } diff --git a/packages/mui-material/src/Tooltip/Tooltip.test.js b/packages/mui-material/src/Tooltip/Tooltip.test.js index 3106a6578c43fb..e958cd403a7e4a 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.test.js +++ b/packages/mui-material/src/Tooltip/Tooltip.test.js @@ -15,6 +15,7 @@ import { } from '@mui/internal-test-utils'; import { camelCase } from 'es-toolkit/string'; import Tooltip, { tooltipClasses as classes } from '@mui/material/Tooltip'; +import Button from '@mui/material/Button'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import { testReset } from './Tooltip'; import describeConformance from '../../test/describeConformance'; @@ -387,6 +388,77 @@ describe('', () => { expect(screen.queryByRole('tooltip')).to.equal(null); }); + it('opens when a disabled native button receives mouse events', async () => { + clock.restore(); + + const { user } = render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + + it('opens when a disabled Button receives mouse events', async () => { + clock.restore(); + + const { user } = render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + + it('keeps a disabled-trigger tooltip open when the tooltip is hovered', async () => { + clock.restore(); + + const { user } = render( + + + , + ); + + const button = screen.getByRole('button'); + + await user.hover(button); + expect(screen.getByRole('tooltip')).toBeVisible(); + + await user.unhover(button); + await act(async () => { + await new Promise((resolve) => { + // Wait a bit but not long enough for the tooltip to disappear + setTimeout(resolve, 250); + }); + }); + await user.hover(screen.getByRole('tooltip')); + await act(async () => { + await new Promise((resolve) => { + // Wait out the close timeout until the tooltip should have disappeared + setTimeout(resolve, 300); + }); + }); + + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + it('opens on the next task when reduced motion is always', () => { const handleEntered = spy(); const theme = createTheme({ @@ -794,44 +866,6 @@ describe('', () => { }); }); - describe('disabled button warning', () => { - it('should not raise a warning if title is empty', () => { - expect(() => { - render( - - - , - ); - }).not.toErrorDev(); - }); - - it('should raise a warning when we are uncontrolled and can not listen to events', () => { - expect(() => { - render( - - - , - ); - }).toWarnDev('MUI: You are providing a disabled `button` child to the Tooltip component'); - }); - - it('should not raise a warning when we are controlled', () => { - expect(() => { - render( - - - , - ); - }).not.toErrorDev(); - }); - }); - describe('prop: disableInteractive', () => { it('when false should keep the overlay open if the popper element is hovered', () => { render( @@ -1161,9 +1195,9 @@ describe('', () => { expect(handleClose.callCount).to.equal(1); }); - it('stays closed when a stray mouseover lands while the disabled child is closing', async () => { + it('stays closed when a stray mouseover lands while the disabled trigger is closing', async () => { // Deterministic regression test for the flaky "stuck open" tooltip: - // when the focused child becomes disabled the close is scheduled via the React + // when the focused trigger becomes disabled the close is scheduled via the React // #9142 native-blur workaround, but a layout-shift `mouseover` on the interactive // popper used to cancel that pending close and reopen the tooltip. A disabled // anchor must never (re)open. `leaveDelay` opens a deterministic window in which to @@ -1195,11 +1229,11 @@ describe('', () => { expect(screen.getByRole('tooltip')).toBeVisible(); }); - // Disabling the focused child schedules the close (leaveDelay window still pending). + // Disabling the focused trigger schedules the close (leaveDelay window still pending). await user.keyboard('{Enter}'); // A stray `mouseover` reaches the interactive popper before the close fires. - fireEvent.mouseOver(screen.getByRole('tooltip')); + await user.hover(screen.getByRole('tooltip')); // The disabled anchor must still close (and not reopen). await waitFor(() => {