diff --git a/apps/vr-tests-react-components/package.json b/apps/vr-tests-react-components/package.json index 947b718394ca52..522acf30ebc554 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": "*", 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 00000000000000..de21aa6b51dc5a --- /dev/null +++ b/change/@fluentui-react-tooltip-b1816358-7b53-4ebc-8579-8308e77a0468.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "tooltip appears outside of overflow", + "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 943c592c0d8a1f..e21e73dbb5d68c 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 00000000000000..ca52cf041bbf2c --- /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 00000000000000..0250c954ef7e1f --- /dev/null +++ b/packages/react-components/react-tooltip/library/src/components/Tooltip/Tooltip.cy.tsx @@ -0,0 +1,107 @@ +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'; + +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( +
+
+ + + +
+
, + ); + + cy.get('#trigger').realHover(); + cy.get('[role="tooltip"]').should('be.visible'); + cy.get('#scroll-container').scrollTo(0, 300); + 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'); + + cy.get('#scroll-container').scrollTo(0, 300); + cy.get('[role="tooltip"]').should('have.attr', DATA_POSITIONING_HIDDEN); + + 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); + 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 110c95ca667cb1..25dba0e9546579 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 00000000000000..90e0e0c7bd6ec7 --- /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 32bdbdf1ac26f0..7a15fb89d7f233 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 00000000000000..80937a96e36a61 --- /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 a97707568a678e..a055ba3993f543 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',