Skip to content

Commit 1c67f2a

Browse files
committed
fix(react-tooltip): hide tooltip when trigger scrolls out of overflow container
1 parent bc5035b commit 1c67f2a

10 files changed

Lines changed: 319 additions & 1 deletion
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "remove custom-babe-loader",
4+
"packageName": "@fluentui/react-storybook-addon-export-to-sandbox",
5+
"email": "paulmardling@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "remove custom-babel-loader",
4+
"packageName": "@fluentui/react-tooltip",
5+
"email": "paulmardling@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-positioning/library/src/createPositionManager.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { computePosition } from '@floating-ui/dom';
22
import type { Placement } from '@floating-ui/dom';
33
import { createPositionManager } from './createPositionManager';
4-
import { POSITIONING_END_EVENT } from './constants';
4+
import { DATA_POSITIONING_ESCAPED, DATA_POSITIONING_HIDDEN, POSITIONING_END_EVENT } from './constants';
55
import type { OnPositioningEndEvent } from './types';
66

77
jest.mock('@floating-ui/dom', () => ({
@@ -171,4 +171,109 @@ describe('createPositionManager', () => {
171171

172172
expect(listener).not.toHaveBeenCalled();
173173
});
174+
175+
describe('hide middleware data attributes', () => {
176+
it('sets data-popper-reference-hidden when referenceHidden is true', async () => {
177+
computePositionMock.mockResolvedValue({
178+
x: 0,
179+
y: 0,
180+
placement: 'bottom',
181+
strategy: 'absolute',
182+
middlewareData: {
183+
...mockMiddlewareData,
184+
hide: { escaped: false, referenceHidden: true },
185+
},
186+
});
187+
188+
const { container, target } = createTestElements();
189+
createPositionManager({
190+
container,
191+
target,
192+
arrow: null,
193+
strategy: 'absolute',
194+
middleware: [],
195+
placement: 'bottom',
196+
disableUpdateOnResize: true,
197+
});
198+
199+
await flushMicrotasks();
200+
201+
expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(true);
202+
expect(container.hasAttribute(DATA_POSITIONING_ESCAPED)).toBe(false);
203+
});
204+
205+
it('removes data-popper-reference-hidden when referenceHidden becomes false', async () => {
206+
const { container, target } = createTestElements();
207+
208+
// First position update: reference is hidden
209+
computePositionMock.mockResolvedValueOnce({
210+
x: 0,
211+
y: 0,
212+
placement: 'bottom',
213+
strategy: 'absolute',
214+
middlewareData: {
215+
...mockMiddlewareData,
216+
hide: { escaped: false, referenceHidden: true },
217+
},
218+
});
219+
220+
const manager = createPositionManager({
221+
container,
222+
target,
223+
arrow: null,
224+
strategy: 'absolute',
225+
middleware: [],
226+
placement: 'bottom',
227+
disableUpdateOnResize: true,
228+
});
229+
230+
await flushMicrotasks();
231+
expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(true);
232+
233+
// Second position update: reference is back in view
234+
computePositionMock.mockResolvedValueOnce({
235+
x: 0,
236+
y: 0,
237+
placement: 'bottom',
238+
strategy: 'absolute',
239+
middlewareData: {
240+
...mockMiddlewareData,
241+
hide: { escaped: false, referenceHidden: false },
242+
},
243+
});
244+
245+
manager.updatePosition();
246+
await flushMicrotasks();
247+
expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(false);
248+
});
249+
250+
it('sets data-popper-escaped when escaped is true', async () => {
251+
computePositionMock.mockResolvedValue({
252+
x: 0,
253+
y: 0,
254+
placement: 'bottom',
255+
strategy: 'absolute',
256+
middlewareData: {
257+
...mockMiddlewareData,
258+
hide: { escaped: true, referenceHidden: false },
259+
},
260+
});
261+
262+
const { container, target } = createTestElements();
263+
createPositionManager({
264+
container,
265+
target,
266+
arrow: null,
267+
strategy: 'absolute',
268+
middleware: [],
269+
placement: 'bottom',
270+
disableUpdateOnResize: true,
271+
});
272+
273+
await flushMicrotasks();
274+
275+
expect(container.hasAttribute(DATA_POSITIONING_ESCAPED)).toBe(true);
276+
expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(false);
277+
});
278+
});
174279
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { baseConfig } from '@fluentui/scripts-cypress';
2+
3+
export default baseConfig;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import 'cypress-real-events';
2+
import * as React from 'react';
3+
import { mount as mountBase } from '@fluentui/scripts-cypress';
4+
import { FluentProvider } from '@fluentui/react-provider';
5+
import { teamsLightTheme } from '@fluentui/react-theme';
6+
import { Button } from '@fluentui/react-button';
7+
import { Tooltip } from './index';
8+
9+
const mount = (element: React.ReactElement) => {
10+
mountBase(<FluentProvider theme={teamsLightTheme}>{element}</FluentProvider>);
11+
};
12+
13+
// The data attribute the positioning middleware writes when the reference is hidden
14+
const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden';
15+
// The data attribute the positioning middleware writes when the floating element has escaped clipping
16+
const DATA_POSITIONING_ESCAPED = 'data-popper-escaped';
17+
18+
describe('Tooltip', () => {
19+
describe('overflow behavior (regression: #32882)', () => {
20+
/**
21+
* Renders a tooltip trigger inside an overflow:hidden container tall enough
22+
* to scroll the trigger out of view, then verifies the tooltip content element
23+
* gets `data-popper-reference-hidden` set once scrolled, and loses it once
24+
* scrolled back.
25+
*
26+
* This is the E2E complement to the unit tests in createPositionManager.test.ts
27+
* and the CSS fix in useTooltipStyles.styles.ts.
28+
*/
29+
it('sets data-popper-reference-hidden on the tooltip when the trigger scrolls out of view', () => {
30+
mount(
31+
<div
32+
id="scroll-container"
33+
style={{
34+
height: '100px',
35+
width: '200px',
36+
overflow: 'hidden scroll',
37+
position: 'relative',
38+
}}
39+
>
40+
{/* tall content so the container becomes scrollable */}
41+
<div style={{ height: '400px', paddingTop: '8px' }}>
42+
<Tooltip content="Overflow tooltip" relationship="label" data-testid="tooltip-content">
43+
<Button id="trigger">Hover me</Button>
44+
</Tooltip>
45+
</div>
46+
</div>,
47+
);
48+
49+
// Hover the trigger to open the tooltip
50+
cy.get('#trigger').realHover();
51+
52+
// Wait for the tooltip to become visible (it renders into a portal)
53+
cy.get('[role="tooltip"]').should('be.visible');
54+
55+
// Scroll the container until the trigger is out of view
56+
cy.get('#scroll-container').scrollTo(0, 300);
57+
58+
// The positioning middleware should now set the referenceHidden attribute
59+
cy.get('[role="tooltip"]').should('have.attr', DATA_POSITIONING_HIDDEN);
60+
});
61+
62+
it('removes data-popper-reference-hidden when the trigger scrolls back into view', () => {
63+
mount(
64+
<div
65+
id="scroll-container"
66+
style={{
67+
height: '100px',
68+
width: '200px',
69+
overflow: 'hidden scroll',
70+
position: 'relative',
71+
}}
72+
>
73+
<div style={{ height: '400px', paddingTop: '8px' }}>
74+
<Tooltip content="Overflow tooltip" relationship="label">
75+
<Button id="trigger">Hover me</Button>
76+
</Tooltip>
77+
</div>
78+
</div>,
79+
);
80+
81+
cy.get('#trigger').realHover();
82+
cy.get('[role="tooltip"]').should('be.visible');
83+
84+
// Scroll away — attribute should appear
85+
cy.get('#scroll-container').scrollTo(0, 300);
86+
cy.get('[role="tooltip"]').should('have.attr', DATA_POSITIONING_HIDDEN);
87+
88+
// Scroll back — attribute should be removed
89+
cy.get('#scroll-container').scrollTo(0, 0);
90+
cy.get('[role="tooltip"]').should('not.have.attr', DATA_POSITIONING_HIDDEN);
91+
});
92+
93+
it('applies visibility:hidden CSS when data-popper-reference-hidden is present', () => {
94+
mount(
95+
<div
96+
id="scroll-container"
97+
style={{
98+
height: '100px',
99+
width: '200px',
100+
overflow: 'hidden scroll',
101+
position: 'relative',
102+
}}
103+
>
104+
<div style={{ height: '400px', paddingTop: '8px' }}>
105+
<Tooltip content="Overflow tooltip" relationship="label">
106+
<Button id="trigger">Hover me</Button>
107+
</Tooltip>
108+
</div>
109+
</div>,
110+
);
111+
112+
cy.get('#trigger').realHover();
113+
cy.get('[role="tooltip"]').should('be.visible');
114+
115+
cy.get('#scroll-container').scrollTo(0, 300);
116+
117+
// The CSS rule `&[data-popper-reference-hidden] { visibility: hidden }` should apply
118+
cy.get('[role="tooltip"]').should('have.css', 'visibility', 'hidden');
119+
});
120+
});
121+
});

packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipStyles.styles.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ const useStyles = makeStyles({
3838

3939
visible: {
4040
display: 'block',
41+
42+
// Hide when the trigger has scrolled out of view (referenceHidden) or when
43+
// the tooltip itself has overflowed its clipping boundary (escaped).
44+
// The positioning middleware writes these data attributes automatically.
45+
// See https://github.com/microsoft/fluentui/issues/32882
46+
'&[data-popper-reference-hidden]': {
47+
visibility: 'hidden',
48+
pointerEvents: 'none',
49+
},
50+
'&[data-popper-escaped]': {
51+
visibility: 'hidden',
52+
pointerEvents: 'none',
53+
},
4154
},
4255

4356
inverted: {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"isolatedModules": false,
5+
"types": ["node", "cypress", "cypress-real-events"],
6+
"typeRoots": ["../../../../node_modules", "../../../../node_modules/@types"]
7+
},
8+
"include": ["**/*.cy.ts", "**/*.cy.tsx"]
9+
}

packages/react-components/react-tooltip/library/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
},
1818
{
1919
"path": "./tsconfig.spec.json"
20+
},
21+
{
22+
"path": "./tsconfig.cy.json"
2023
}
2124
]
2225
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as React from 'react';
2+
import type { JSXElement } from '@fluentui/react-components';
3+
import { Button, Tooltip } from '@fluentui/react-components';
4+
5+
/**
6+
* A tooltip whose trigger is inside an `overflow: hidden` container.
7+
* When the trigger is scrolled out of view, the tooltip should become hidden
8+
* (via `data-popper-reference-hidden` set by the positioning middleware).
9+
*
10+
* Regression story for https://github.com/microsoft/fluentui/issues/32882
11+
*/
12+
export const OverflowHidden = (): JSXElement => {
13+
return (
14+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'flex-start' }}>
15+
<p style={{ margin: 0, fontSize: '14px' }}>
16+
Scroll the box below. The tooltip should disappear when the button scrolls out of view, not follow it outside
17+
the container boundary.
18+
</p>
19+
<div
20+
style={{
21+
height: '120px',
22+
width: '240px',
23+
overflow: 'hidden scroll',
24+
border: '1px solid #ccc',
25+
borderRadius: '4px',
26+
position: 'relative',
27+
}}
28+
>
29+
<div style={{ height: '300px', paddingTop: '8px', paddingLeft: '8px' }}>
30+
<Tooltip content="I should hide when scrolled out of view" relationship="label">
31+
<Button>Hover me, then scroll</Button>
32+
</Tooltip>
33+
</div>
34+
</div>
35+
</div>
36+
);
37+
};
38+
39+
OverflowHidden.parameters = {
40+
docs: {
41+
description: {
42+
story: `When a tooltip trigger is inside an \`overflow: hidden\` container and the trigger scrolls out
43+
of view, the tooltip must be hidden rather than appearing outside the overflow boundary.
44+
45+
The positioning middleware sets \`data-popper-reference-hidden\` on the tooltip element when the trigger
46+
is clipped by its scroll container. The Tooltip styles respond to this attribute with \`visibility: hidden\`.`,
47+
},
48+
},
49+
};

packages/react-components/react-tooltip/stories/src/Tooltip/index.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { Controlled } from './TooltipControlled.stories';
1515
export { Positioning } from './TooltipPositioning.stories';
1616
export { Target } from './TooltipTarget.stories';
1717
export { Icon } from './TooltipIcon.stories';
18+
export { OverflowHidden } from './TooltipOverflowHidden.stories';
1819

1920
export default {
2021
title: 'Components/Tooltip',

0 commit comments

Comments
 (0)