Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/data/material/components/tooltips/DisabledTooltips.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import Tooltip from '@mui/material/Tooltip';
export default function DisabledTooltips() {
return (
<Tooltip describeChild title="You don't have permission to do this">
<span>
<Button disabled>A Disabled Button</Button>
</span>
<Button disabled style={{ pointerEvents: 'auto' }}>
A Disabled Button
</Button>
</Tooltip>
);
}
6 changes: 3 additions & 3 deletions docs/data/material/components/tooltips/DisabledTooltips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import Tooltip from '@mui/material/Tooltip';
export default function DisabledTooltips() {
return (
<Tooltip describeChild title="You don't have permission to do this">
<span>
<Button disabled>A Disabled Button</Button>
</span>
<Button disabled style={{ pointerEvents: 'auto' }}>
A Disabled Button
</Button>
</Tooltip>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<Tooltip describeChild title="You don't have permission to do this">
<span>
<Button disabled>A Disabled Button</Button>
</span>
<Button disabled style={{ pointerEvents: 'auto' }}>
A Disabled Button
</Button>
</Tooltip>
18 changes: 2 additions & 16 deletions docs/data/material/components/tooltips/tooltips.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,28 +147,14 @@

## Disabled elements

By default disabled elements like `<button>` do not trigger user interactions so a `Tooltip` will not activate on normal events like hover. To accommodate disabled elements, add a simple wrapper element, such as a `span`.

:::warning
In order to work with Safari, you need at least one display block or flex item below the tooltip wrapper.
:::
When wrapping a Material UI component that inherits from `ButtonBase`, you should add the CSS property _pointer-events: auto;_ to your element when disabled:

{{"demo": "DisabledTooltips.js"}}

:::warning
If you're not wrapping a Material UI component that inherits from `ButtonBase`, for instance, a native `<button>` element, you should also add the CSS property _pointer-events: none;_ to your element when disabled:
By default, disabled elements like `<button>` are not keyboard focusable, so a `Tooltip` will only work for mouse users.

Check warning on line 155 in docs/data/material/components/tooltips/tooltips.md

View workflow job for this annotation

GitHub Actions / test-dev (ubuntu-latest)

[vale] reported by reviewdog 🐶 [Google.Will] Avoid using 'will'. Raw Output: {"message": "[Google.Will] Avoid using 'will'.", "location": {"path": "docs/data/material/components/tooltips/tooltips.md", "range": {"start": {"line": 155, "column": 90}}}, "severity": "WARNING"}
:::

```jsx
<Tooltip describeChild title="You don't have permission to do this">
<span>
<button disabled={disabled} style={disabled ? { pointerEvents: 'none' } : {}}>
A disabled button
</button>
</span>
</Tooltip>
```

## Transitions

Use `slots.transition` and `slotProps.transition` to use a different transition.
Expand Down
70 changes: 36 additions & 34 deletions packages/mui-material/src/Tooltip/Tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);

Expand Down Expand Up @@ -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;
});
Expand All @@ -357,9 +332,6 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) {
);

const handleMouseOver = (event) => {
if (childNode?.disabled) {
return;
}
Comment on lines -360 to -362

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needed to be removed as well in addition to the warning

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I also found this when testing #48623 (comment)

if (ignoreNonTouchEvents.current && event.type !== 'touchstart') {
return;
}
Expand All @@ -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.
Comment on lines +360 to +361

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the openedByDisabledTriggerRef in order to not affect the existing "tooltip trigger becomes disabled while tooltip is open" case

This comment was marked as resolved.

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, () => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
});
};

Expand Down Expand Up @@ -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;
}
}
Expand Down
118 changes: 76 additions & 42 deletions packages/mui-material/src/Tooltip/Tooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -387,6 +388,77 @@ describe('<Tooltip />', () => {
expect(screen.queryByRole('tooltip')).to.equal(null);
});

it('opens when a disabled native button receives mouse events', async () => {
clock.restore();

const { user } = render(
<Tooltip title="Hello World" enterDelay={0} slotProps={{ transition: { timeout: 0 } }}>
<button disabled type="button">
Hello World
</button>
</Tooltip>,
);

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(
<Tooltip title="Hello World" enterDelay={0} slotProps={{ transition: { timeout: 0 } }}>
<Button disabled style={{ pointerEvents: 'auto' }}>
Hello World
</Button>
</Tooltip>,
);

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(
<Tooltip
title="Hello World"
enterDelay={0}
leaveDelay={500}
slotProps={{ transition: { timeout: 0 } }}
>
<button disabled type="button">
Hello World
</button>
</Tooltip>,
);

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);
});
});
Comment on lines +444 to +457

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't the flow be: unhover button, wait for some time not enough for the tooltip to disappear, hover the tooltip, then check?

@mayank99 mayank99 Jun 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mj12albert Since you wrote this test, would you be able to help with this?

Edit: Addressed in 1ef6079.


expect(screen.getByRole('tooltip')).toBeVisible();
});

it('opens on the next task when reduced motion is always', () => {
const handleEntered = spy();
const theme = createTheme({
Expand Down Expand Up @@ -794,44 +866,6 @@ describe('<Tooltip />', () => {
});
});

describe('disabled button warning', () => {
it('should not raise a warning if title is empty', () => {
expect(() => {
render(
<Tooltip title="">
<button type="submit" disabled>
Hello World
</button>
</Tooltip>,
);
}).not.toErrorDev();
});

it('should raise a warning when we are uncontrolled and can not listen to events', () => {
expect(() => {
render(
<Tooltip title="Hello World">
<button type="submit" disabled>
Hello World
</button>
</Tooltip>,
);
}).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(
<Tooltip title="Hello World" open>
<button type="submit" disabled>
Hello World
</button>
</Tooltip>,
);
}).not.toErrorDev();
});
});

describe('prop: disableInteractive', () => {
it('when false should keep the overlay open if the popper element is hovered', () => {
render(
Expand Down Expand Up @@ -1161,9 +1195,9 @@ describe('<Tooltip />', () => {
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
Expand Down Expand Up @@ -1195,11 +1229,11 @@ describe('<Tooltip />', () => {
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(() => {
Expand Down
Loading