From 1c67f2af5022c6922bd0f601ba0641cb93bb6fe8 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Fri, 10 Jul 2026 16:22:34 +0200 Subject: [PATCH 1/5] fix(react-tooltip): hide tooltip when trigger scrolls out of overflow container --- ...-b42de849-5054-47de-a5e9-ef7450aabbb1.json | 7 + ...-b1816358-7b53-4ebc-8579-8308e77a0468.json | 7 + .../library/src/createPositionManager.test.ts | 107 +++++++++++++++- .../react-tooltip/library/cypress.config.ts | 3 + .../src/components/Tooltip/Tooltip.cy.tsx | 121 ++++++++++++++++++ .../Tooltip/useTooltipStyles.styles.ts | 13 ++ .../react-tooltip/library/tsconfig.cy.json | 9 ++ .../react-tooltip/library/tsconfig.json | 3 + .../Tooltip/TooltipOverflowHidden.stories.tsx | 49 +++++++ .../stories/src/Tooltip/index.stories.tsx | 1 + 10 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json create mode 100644 change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json create mode 100644 packages/react-components/react-tooltip/library/cypress.config.ts create mode 100644 packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx create mode 100644 packages/react-components/react-tooltip/library/tsconfig.cy.json create mode 100644 packages/react-components/react-tooltip/stories/src/Tooltip/TooltipOverflowHidden.stories.tsx diff --git a/change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json b/change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json new file mode 100644 index 0000000000000..e984d68106d82 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "remove custom-babe-loader", + "packageName": "@fluentui/react-storybook-addon-export-to-sandbox", + "email": "paulmardling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json b/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json new file mode 100644 index 0000000000000..860ab4f3388df --- /dev/null +++ b/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "remove custom-babel-loader", + "packageName": "@fluentui/react-tooltip", + "email": "paulmardling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-positioning/library/src/createPositionManager.test.ts b/packages/react-components/react-positioning/library/src/createPositionManager.test.ts index 943c592c0d8a1..e21e73dbb5d68 100644 --- a/packages/react-components/react-positioning/library/src/createPositionManager.test.ts +++ b/packages/react-components/react-positioning/library/src/createPositionManager.test.ts @@ -1,7 +1,7 @@ import { computePosition } from '@floating-ui/dom'; import type { Placement } from '@floating-ui/dom'; import { createPositionManager } from './createPositionManager'; -import { POSITIONING_END_EVENT } from './constants'; +import { DATA_POSITIONING_ESCAPED, DATA_POSITIONING_HIDDEN, POSITIONING_END_EVENT } from './constants'; import type { OnPositioningEndEvent } from './types'; jest.mock('@floating-ui/dom', () => ({ @@ -171,4 +171,109 @@ describe('createPositionManager', () => { expect(listener).not.toHaveBeenCalled(); }); + + describe('hide middleware data attributes', () => { + it('sets data-popper-reference-hidden when referenceHidden is true', async () => { + computePositionMock.mockResolvedValue({ + x: 0, + y: 0, + placement: 'bottom', + strategy: 'absolute', + middlewareData: { + ...mockMiddlewareData, + hide: { escaped: false, referenceHidden: true }, + }, + }); + + const { container, target } = createTestElements(); + createPositionManager({ + container, + target, + arrow: null, + strategy: 'absolute', + middleware: [], + placement: 'bottom', + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(true); + expect(container.hasAttribute(DATA_POSITIONING_ESCAPED)).toBe(false); + }); + + it('removes data-popper-reference-hidden when referenceHidden becomes false', async () => { + const { container, target } = createTestElements(); + + // First position update: reference is hidden + computePositionMock.mockResolvedValueOnce({ + x: 0, + y: 0, + placement: 'bottom', + strategy: 'absolute', + middlewareData: { + ...mockMiddlewareData, + hide: { escaped: false, referenceHidden: true }, + }, + }); + + const manager = createPositionManager({ + container, + target, + arrow: null, + strategy: 'absolute', + middleware: [], + placement: 'bottom', + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(true); + + // Second position update: reference is back in view + computePositionMock.mockResolvedValueOnce({ + x: 0, + y: 0, + placement: 'bottom', + strategy: 'absolute', + middlewareData: { + ...mockMiddlewareData, + hide: { escaped: false, referenceHidden: false }, + }, + }); + + manager.updatePosition(); + await flushMicrotasks(); + expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(false); + }); + + it('sets data-popper-escaped when escaped is true', async () => { + computePositionMock.mockResolvedValue({ + x: 0, + y: 0, + placement: 'bottom', + strategy: 'absolute', + middlewareData: { + ...mockMiddlewareData, + hide: { escaped: true, referenceHidden: false }, + }, + }); + + const { container, target } = createTestElements(); + createPositionManager({ + container, + target, + arrow: null, + strategy: 'absolute', + middleware: [], + placement: 'bottom', + disableUpdateOnResize: true, + }); + + await flushMicrotasks(); + + expect(container.hasAttribute(DATA_POSITIONING_ESCAPED)).toBe(true); + expect(container.hasAttribute(DATA_POSITIONING_HIDDEN)).toBe(false); + }); + }); }); diff --git a/packages/react-components/react-tooltip/library/cypress.config.ts b/packages/react-components/react-tooltip/library/cypress.config.ts new file mode 100644 index 0000000000000..ca52cf041bbf2 --- /dev/null +++ b/packages/react-components/react-tooltip/library/cypress.config.ts @@ -0,0 +1,3 @@ +import { baseConfig } from '@fluentui/scripts-cypress'; + +export default baseConfig; diff --git a/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx b/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx new file mode 100644 index 0000000000000..4e50bab283aa4 --- /dev/null +++ b/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx @@ -0,0 +1,121 @@ +import 'cypress-real-events'; +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { FluentProvider } from '@fluentui/react-provider'; +import { teamsLightTheme } from '@fluentui/react-theme'; +import { Button } from '@fluentui/react-button'; +import { Tooltip } from './index'; + +const mount = (element: React.ReactElement) => { + mountBase({element}); +}; + +// The data attribute the positioning middleware writes when the reference is hidden +const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden'; +// The data attribute the positioning middleware writes when the floating element has escaped clipping +const DATA_POSITIONING_ESCAPED = 'data-popper-escaped'; + +describe('Tooltip', () => { + describe('overflow behavior (regression: #32882)', () => { + /** + * Renders a tooltip trigger inside an overflow:hidden container tall enough + * to scroll the trigger out of view, then verifies the tooltip content element + * gets `data-popper-reference-hidden` set once scrolled, and loses it once + * scrolled back. + * + * This is the E2E complement to the unit tests in createPositionManager.test.ts + * and the CSS fix in useTooltipStyles.styles.ts. + */ + it('sets data-popper-reference-hidden on the tooltip when the trigger scrolls out of view', () => { + mount( +
+ {/* tall content so the container becomes scrollable */} +
+ + + +
+
, + ); + + // Hover the trigger to open the tooltip + cy.get('#trigger').realHover(); + + // Wait for the tooltip to become visible (it renders into a portal) + cy.get('[role="tooltip"]').should('be.visible'); + + // Scroll the container until the trigger is out of view + cy.get('#scroll-container').scrollTo(0, 300); + + // The positioning middleware should now set the referenceHidden attribute + cy.get('[role="tooltip"]').should('have.attr', DATA_POSITIONING_HIDDEN); + }); + + it('removes data-popper-reference-hidden when the trigger scrolls back into view', () => { + mount( +
+
+ + + +
+
, + ); + + cy.get('#trigger').realHover(); + cy.get('[role="tooltip"]').should('be.visible'); + + // Scroll away — attribute should appear + cy.get('#scroll-container').scrollTo(0, 300); + cy.get('[role="tooltip"]').should('have.attr', DATA_POSITIONING_HIDDEN); + + // Scroll back — attribute should be removed + cy.get('#scroll-container').scrollTo(0, 0); + cy.get('[role="tooltip"]').should('not.have.attr', DATA_POSITIONING_HIDDEN); + }); + + it('applies visibility:hidden CSS when data-popper-reference-hidden is present', () => { + mount( +
+
+ + + +
+
, + ); + + cy.get('#trigger').realHover(); + cy.get('[role="tooltip"]').should('be.visible'); + + cy.get('#scroll-container').scrollTo(0, 300); + + // The CSS rule `&[data-popper-reference-hidden] { visibility: hidden }` should apply + cy.get('[role="tooltip"]').should('have.css', 'visibility', 'hidden'); + }); + }); +}); diff --git a/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipStyles.styles.ts b/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipStyles.styles.ts index 110c95ca667cb..25dba0e954657 100644 --- a/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipStyles.styles.ts +++ b/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipStyles.styles.ts @@ -38,6 +38,19 @@ const useStyles = makeStyles({ visible: { display: 'block', + + // Hide when the trigger has scrolled out of view (referenceHidden) or when + // the tooltip itself has overflowed its clipping boundary (escaped). + // The positioning middleware writes these data attributes automatically. + // See https://github.com/microsoft/fluentui/issues/32882 + '&[data-popper-reference-hidden]': { + visibility: 'hidden', + pointerEvents: 'none', + }, + '&[data-popper-escaped]': { + visibility: 'hidden', + pointerEvents: 'none', + }, }, inverted: { diff --git a/packages/react-components/react-tooltip/library/tsconfig.cy.json b/packages/react-components/react-tooltip/library/tsconfig.cy.json new file mode 100644 index 0000000000000..90e0e0c7bd6ec --- /dev/null +++ b/packages/react-components/react-tooltip/library/tsconfig.cy.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "isolatedModules": false, + "types": ["node", "cypress", "cypress-real-events"], + "typeRoots": ["../../../../node_modules", "../../../../node_modules/@types"] + }, + "include": ["**/*.cy.ts", "**/*.cy.tsx"] +} diff --git a/packages/react-components/react-tooltip/library/tsconfig.json b/packages/react-components/react-tooltip/library/tsconfig.json index 32bdbdf1ac26f..7a15fb89d7f23 100644 --- a/packages/react-components/react-tooltip/library/tsconfig.json +++ b/packages/react-components/react-tooltip/library/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.cy.json" } ] } diff --git a/packages/react-components/react-tooltip/stories/src/Tooltip/TooltipOverflowHidden.stories.tsx b/packages/react-components/react-tooltip/stories/src/Tooltip/TooltipOverflowHidden.stories.tsx new file mode 100644 index 0000000000000..80937a96e36a6 --- /dev/null +++ b/packages/react-components/react-tooltip/stories/src/Tooltip/TooltipOverflowHidden.stories.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Button, Tooltip } from '@fluentui/react-components'; + +/** + * A tooltip whose trigger is inside an `overflow: hidden` container. + * When the trigger is scrolled out of view, the tooltip should become hidden + * (via `data-popper-reference-hidden` set by the positioning middleware). + * + * Regression story for https://github.com/microsoft/fluentui/issues/32882 + */ +export const OverflowHidden = (): JSXElement => { + return ( +
+

+ Scroll the box below. The tooltip should disappear when the button scrolls out of view, not follow it outside + the container boundary. +

+
+
+ + + +
+
+
+ ); +}; + +OverflowHidden.parameters = { + docs: { + description: { + story: `When a tooltip trigger is inside an \`overflow: hidden\` container and the trigger scrolls out +of view, the tooltip must be hidden rather than appearing outside the overflow boundary. + +The positioning middleware sets \`data-popper-reference-hidden\` on the tooltip element when the trigger +is clipped by its scroll container. The Tooltip styles respond to this attribute with \`visibility: hidden\`.`, + }, + }, +}; diff --git a/packages/react-components/react-tooltip/stories/src/Tooltip/index.stories.tsx b/packages/react-components/react-tooltip/stories/src/Tooltip/index.stories.tsx index a97707568a678..a055ba3993f54 100644 --- a/packages/react-components/react-tooltip/stories/src/Tooltip/index.stories.tsx +++ b/packages/react-components/react-tooltip/stories/src/Tooltip/index.stories.tsx @@ -15,6 +15,7 @@ export { Controlled } from './TooltipControlled.stories'; export { Positioning } from './TooltipPositioning.stories'; export { Target } from './TooltipTarget.stories'; export { Icon } from './TooltipIcon.stories'; +export { OverflowHidden } from './TooltipOverflowHidden.stories'; export default { title: 'Components/Tooltip', From 9d0c2fc62579e6262ad8c338d67296d4f0a65925 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Fri, 10 Jul 2026 16:22:34 +0200 Subject: [PATCH 2/5] fix(react-tooltip): hide tooltip when trigger scrolls out of overflow container --- ...rt-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json | 7 ------- .../library/src/components/Tooltip/Tooltip.cy.tsx | 2 -- 2 files changed, 9 deletions(-) delete mode 100644 change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json diff --git a/change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json b/change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json deleted file mode 100644 index e984d68106d82..0000000000000 --- a/change/@fluentui-react-storybook-addon-export-to-sandbox-b42de849-5054-47de-a5e9-ef7450aabbb1.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "patch", - "comment": "remove custom-babe-loader", - "packageName": "@fluentui/react-storybook-addon-export-to-sandbox", - "email": "paulmardling@microsoft.com", - "dependentChangeType": "patch" -} diff --git a/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx b/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx index 4e50bab283aa4..e9df3dd4a800d 100644 --- a/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx +++ b/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx @@ -12,8 +12,6 @@ const mount = (element: React.ReactElement) => { // The data attribute the positioning middleware writes when the reference is hidden const DATA_POSITIONING_HIDDEN = 'data-popper-reference-hidden'; -// The data attribute the positioning middleware writes when the floating element has escaped clipping -const DATA_POSITIONING_ESCAPED = 'data-popper-escaped'; describe('Tooltip', () => { describe('overflow behavior (regression: #32882)', () => { From 1f1b19ec92cf611ddc9a88bda3bc19b707c80902 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 13 Jul 2026 15:10:04 +0200 Subject: [PATCH 3/5] fix(react-tooltio): remove test comments --- .../library/src/components/Tooltip/Tooltip.cy.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx b/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx index e9df3dd4a800d..0250c954ef7e1 100644 --- a/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx +++ b/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx @@ -35,7 +35,6 @@ describe('Tooltip', () => { position: 'relative', }} > - {/* tall content so the container becomes scrollable */}
@@ -44,16 +43,9 @@ describe('Tooltip', () => {
, ); - // Hover the trigger to open the tooltip cy.get('#trigger').realHover(); - - // Wait for the tooltip to become visible (it renders into a portal) cy.get('[role="tooltip"]').should('be.visible'); - - // Scroll the container until the trigger is out of view cy.get('#scroll-container').scrollTo(0, 300); - - // The positioning middleware should now set the referenceHidden attribute cy.get('[role="tooltip"]').should('have.attr', DATA_POSITIONING_HIDDEN); }); @@ -79,11 +71,9 @@ describe('Tooltip', () => { cy.get('#trigger').realHover(); cy.get('[role="tooltip"]').should('be.visible'); - // Scroll away — attribute should appear cy.get('#scroll-container').scrollTo(0, 300); cy.get('[role="tooltip"]').should('have.attr', DATA_POSITIONING_HIDDEN); - // Scroll back — attribute should be removed cy.get('#scroll-container').scrollTo(0, 0); cy.get('[role="tooltip"]').should('not.have.attr', DATA_POSITIONING_HIDDEN); }); @@ -111,8 +101,6 @@ describe('Tooltip', () => { cy.get('[role="tooltip"]').should('be.visible'); cy.get('#scroll-container').scrollTo(0, 300); - - // The CSS rule `&[data-popper-reference-hidden] { visibility: hidden }` should apply cy.get('[role="tooltip"]').should('have.css', 'visibility', 'hidden'); }); }); From a5f7cd031b5c048c14b81802696b3978e326deaa Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Fri, 17 Jul 2026 10:33:47 +0200 Subject: [PATCH 4/5] chore(vrt): stabilize storywright timing under CI load --- apps/vr-tests-react-components/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vr-tests-react-components/package.json b/apps/vr-tests-react-components/package.json index 947b718394ca5..522acf30ebc55 100644 --- a/apps/vr-tests-react-components/package.json +++ b/apps/vr-tests-react-components/package.json @@ -11,7 +11,7 @@ "start": "storybook dev", "test": "just-scripts test", "type-check": "tsc -p . --noEmit --baseUrl .", - "test-vr": "storywright --browsers chromium --url dist/storybook --destpath dist/screenshots --waitTimeScreenshot 500 --concurrency 4 --headless true --bailOnStoriesError --stepsApi parameters" + "test-vr": "storywright --browsers chromium --url dist/storybook --destpath dist/screenshots --waitTimeScreenshot 1000 --concurrency 2 --headless true --bailOnStoriesError --stepsApi parameters" }, "dependencies": { "@fluentui/react-accordion": "*", From f38b0bad60f7e87dca6231af616adfc5482ece52 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Fri, 17 Jul 2026 12:11:02 +0200 Subject: [PATCH 5/5] chore: update tooltip change description --- ...ntui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json b/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json index 860ab4f3388df..de21aa6b51dc5 100644 --- a/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json +++ b/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "remove custom-babel-loader", + "comment": "tooltip appears outside of overflow", "packageName": "@fluentui/react-tooltip", "email": "paulmardling@microsoft.com", "dependentChangeType": "patch"