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..778a4a0ed9665b 100644
--- a/packages/mui-material/src/Tooltip/Tooltip.test.js
+++ b/packages/mui-material/src/Tooltip/Tooltip.test.js
@@ -387,6 +387,61 @@ 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('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 +849,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 +1178,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 +1212,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(() => {