diff --git a/change/@fluentui-react-positioning-95a68719-9f3f-4da2-8ac8-e2c22ecd0b4d.json b/change/@fluentui-react-positioning-95a68719-9f3f-4da2-8ac8-e2c22ecd0b4d.json new file mode 100644 index 0000000000000..9f528ed502cb0 --- /dev/null +++ b/change/@fluentui-react-positioning-95a68719-9f3f-4da2-8ac8-e2c22ecd0b4d.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add a hideBoundary positioning option for customizing escaped and reference-hidden detection", + "packageName": "@fluentui/react-positioning", + "email": "paulmardling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-tooltip-36604-tooltip-hide-boundary.json b/change/@fluentui-react-tooltip-36604-tooltip-hide-boundary.json new file mode 100644 index 0000000000000..9a51606378b7e --- /dev/null +++ b/change/@fluentui-react-tooltip-36604-tooltip-hide-boundary.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: allow tooltips inside non-scrolling overflow:hidden containers to remain visible", + "packageName": "@fluentui/react-tooltip", + "email": "paulmardling@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-positioning/library/etc/react-positioning.api.md b/packages/react-components/react-positioning/library/etc/react-positioning.api.md index 9a2e65951c1e1..0801edca8bc52 100644 --- a/packages/react-components/react-positioning/library/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/library/etc/react-positioning.api.md @@ -96,7 +96,7 @@ export type PositioningImperativeRef = { }; // @public -export interface PositioningProps extends Pick { +export interface PositioningProps extends Pick { positioningRef?: React_2.Ref; target?: TargetElement | null; } diff --git a/packages/react-components/react-positioning/library/src/types.test.ts b/packages/react-components/react-positioning/library/src/types.test.ts index 1d74fd5116c47..fb534a3dacaae 100644 --- a/packages/react-components/react-positioning/library/src/types.test.ts +++ b/packages/react-components/react-positioning/library/src/types.test.ts @@ -10,6 +10,7 @@ describe('PositioningProps', () => { autoSize: 'always', coverTarget: true, flipBoundary: null, + hideBoundary: 'scrollParent', offset: 0, overflowBoundary: null, overflowBoundaryPadding: 0, diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 762d3153be86f..d79cfbc366671 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -155,6 +155,9 @@ export interface PositioningOptions { /** The element which will define the boundaries of the positioned element for the overflow behavior. */ overflowBoundary?: PositioningBoundary | null; + /** The element which will define the boundaries for detecting whether the positioned element is hidden. */ + hideBoundary?: PositioningBoundary | null; + /** * Applies a padding to the overflow bounadry, so that overflow is detected earlier before the * positioned surface hits the overflow boundary. @@ -277,6 +280,7 @@ export interface PositioningProps | 'coverTarget' | 'fallbackPositions' | 'flipBoundary' + | 'hideBoundary' | 'offset' | 'overflowBoundary' | 'overflowBoundaryPadding' diff --git a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts index ef8719797caca..699d155dce6bc 100644 --- a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts +++ b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts @@ -1,7 +1,7 @@ 'use client'; import { devtools } from '@floating-ui/devtools'; -import { hide as hideMiddleware, arrow as arrowMiddleware } from '@floating-ui/dom'; +import { arrow as arrowMiddleware, hide as hideMiddleware } from '@floating-ui/dom'; import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import * as React from 'react'; @@ -17,7 +17,7 @@ import { matchTargetSize as matchTargetSizeMiddleware, } from './middleware'; import type { PositioningConfigurationFn, PositioningConfigurationFnOptions, PositioningOptions } from './types'; -import { toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; +import { getBoundary, toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; import { devtoolsCallback } from './utils/devtools'; import { usePositioningConfiguration } from './PositioningConfigurationContext'; @@ -38,6 +38,7 @@ function usePositioningConfigFn( coverTarget, disableUpdateOnResize, flipBoundary, + hideBoundary, offset, overflowBoundary, pinned, @@ -65,6 +66,7 @@ function usePositioningConfigFn( strategy, coverTarget, flipBoundary, + hideBoundary, overflowBoundary, useTransform, overflowBoundaryPadding, @@ -87,6 +89,7 @@ function usePositioningConfigFn( strategy, coverTarget, flipBoundary, + hideBoundary, overflowBoundary, useTransform, overflowBoundaryPadding, @@ -136,6 +139,7 @@ export function usePositioningOptions(options: PositioningOptions): ( offset, coverTarget, flipBoundary, + hideBoundary, overflowBoundary, useTransform, overflowBoundaryPadding, @@ -150,6 +154,8 @@ export function usePositioningOptions(options: PositioningOptions): ( unstable_disableTether, } = optionsAfterEnhancement; const normalizedAutoSize = normalizeAutoSize(autoSize); + const normalizedHideBoundary = getBoundary(container, hideBoundary ?? undefined); + const hideBoundaryOptions = normalizedHideBoundary ? { boundary: normalizedHideBoundary } : {}; const middleware = [ normalizedAutoSize && resetMaxSizeMiddleware(normalizedAutoSize), @@ -170,8 +176,8 @@ export function usePositioningOptions(options: PositioningOptions): ( maxSizeMiddleware(normalizedAutoSize, { container, overflowBoundary, overflowBoundaryPadding, isRtl }), intersectingMiddleware(), arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), - hideMiddleware({ strategy: 'referenceHidden' }), - hideMiddleware({ strategy: 'escaped' }), + hideMiddleware({ strategy: 'referenceHidden', ...hideBoundaryOptions }), + hideMiddleware({ strategy: 'escaped', ...hideBoundaryOptions }), process.env.NODE_ENV !== 'production' && targetDocument && devtools(targetDocument, devtoolsCallback(optionsAfterEnhancement)), diff --git a/packages/react-components/react-positioning/library/src/utils/getBoundary.test.ts b/packages/react-components/react-positioning/library/src/utils/getBoundary.test.ts new file mode 100644 index 0000000000000..39827c05262b7 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/utils/getBoundary.test.ts @@ -0,0 +1,87 @@ +import { getBoundary } from './getBoundary'; + +describe('getBoundary', () => { + it('returns undefined when boundary is undefined', () => { + const element = document.createElement('div'); + + expect(getBoundary(element, undefined)).toBeUndefined(); + }); + + it("returns the document element for 'window' boundary", () => { + const element = document.createElement('div'); + document.body.appendChild(element); + + expect(getBoundary(element, 'window')).toBe(document.documentElement); + }); + + it("returns 'clippingAncestors' for 'clippingParents' boundary", () => { + const element = document.createElement('div'); + + expect(getBoundary(element, 'clippingParents')).toBe('clippingAncestors'); + }); + + it('returns the boundary itself when it is already a floating-ui boundary', () => { + const element = document.createElement('div'); + const customBoundary = document.createElement('div'); + + expect(getBoundary(element, customBoundary)).toBe(customBoundary); + }); + + // Regression coverage for https://github.com/microsoft/fluentui/issues/36604 + // + // Before the fix, the hide middleware always used 'clippingAncestors' as its boundary, which meant any static + // (non-scrolling) `overflow: hidden` ancestor was treated the same as a real scroll container, causing + // `referenceHidden` to report true even though nothing was actually scrolled out of view. `getBoundary` with + // `'scrollParent'` is what the fix now uses instead, and it must only stop at ancestors that can actually scroll. + describe("'scrollParent' boundary", () => { + it('skips a static overflow:hidden ancestor that cannot scroll, falling back to the document element', () => { + const staticHiddenContainer = document.createElement('div'); + const trigger = document.createElement('button'); + + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ + overflow: 'hidden', + overflowX: '', + overflowY: '', + } as CSSStyleDeclaration); + + staticHiddenContainer.appendChild(trigger); + document.body.appendChild(staticHiddenContainer); + + expect(getBoundary(trigger, 'scrollParent')).toBe(document.documentElement); + }); + + it('resolves to the nearest real scroll parent, ignoring an intermediate static overflow:hidden container', () => { + const scrollableAncestor = document.createElement('div'); + const staticHiddenContainer = document.createElement('div'); + const trigger = document.createElement('button'); + + staticHiddenContainer.appendChild(trigger); + scrollableAncestor.appendChild(staticHiddenContainer); + document.body.appendChild(scrollableAncestor); + + jest.spyOn(window, 'getComputedStyle').mockImplementation( + (node: Element) => + ({ + overflow: node === scrollableAncestor ? 'scroll' : 'hidden', + overflowX: '', + overflowY: '', + } as CSSStyleDeclaration), + ); + + expect(getBoundary(trigger, 'scrollParent')).toBe(scrollableAncestor); + }); + + it('returns the document element when the resolved scroll parent is BODY', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ + overflow: 'visible', + overflowX: '', + overflowY: '', + } as CSSStyleDeclaration); + + expect(getBoundary(trigger, 'scrollParent')).toBe(document.documentElement); + }); + }); +}); 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 56edf98b81f39..983ddd4ca6562 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 @@ -44,4 +44,59 @@ describe('Tooltip', () => { }); }); }); + + // Verifies the fix for regression reported in https://github.com/microsoft/fluentui/issues/36604 + describe('static non-scrolling overflow:hidden container', () => { + it('still shows the tooltip when its trigger sits in a tightly-fitted overflow:hidden container', () => { + mount( +
+ + + +
, + ); + + cy.get('#trigger').realHover(); + + cy.get('[role="tooltip"]').should('be.visible').and('have.text', 'I should still appear'); + }); + }); + + // Verifies a static overflow:hidden wrapper nested inside a real scroll parent doesn't interfere with either + // the #36604 fix or the #32882 scroll-hide behavior. + describe('static overflow:hidden nested inside a scrollable ancestor', () => { + it('shows the tooltip while in view, then hides it once its trigger scrolls out of view', () => { + mount( +
+
+
+ + + +
+
+
, + ); + + cy.get('#trigger').realHover(); + + cy.get('[role="tooltip"]') + .should('be.visible') + .then($tooltip => { + cy.get('#scroll-container').scrollTo(0, 300); + cy.wrap($tooltip).should('not.be.visible'); + + cy.get('#scroll-container').scrollTo(0, 0); + cy.wrap($tooltip).should('be.visible'); + }); + }); + }); }); diff --git a/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipBase.tsx b/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipBase.tsx index 998bdc9bbc094..0b6c4680e3d1b 100644 --- a/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipBase.tsx +++ b/packages/react-components/react-tooltip/library/src/components/Tooltip/useTooltipBase.tsx @@ -95,6 +95,7 @@ export const useTooltipBase_unstable = (props: TooltipBaseProps): TooltipBaseSta position: 'above' as const, align: 'center' as const, offset: 4, + hideBoundary: 'scrollParent' as const, ...resolvedPositioning, onPositioningEnd, }; diff --git a/packages/react-components/react-tooltip/stories/src/Tooltip/TooltipStaticOverflowHidden.stories.tsx b/packages/react-components/react-tooltip/stories/src/Tooltip/TooltipStaticOverflowHidden.stories.tsx new file mode 100644 index 0000000000000..202d38235725f --- /dev/null +++ b/packages/react-components/react-tooltip/stories/src/Tooltip/TooltipStaticOverflowHidden.stories.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { Button, makeStyles, tokens, Tooltip } from '@fluentui/react-components'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + alignItems: 'flex-start', + }, + description: { + margin: 0, + fontSize: tokens.fontSizeBase300, + }, + staticContainer: { + display: 'flex', + overflow: 'hidden', + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + padding: tokens.spacingVerticalXS, + }, +}); + +export const StaticOverflowHidden = (): JSXElement => { + const styles = useStyles(); + + return ( +
+

+ The button below sits in a tightly-fitted, non-scrolling overflow: hidden container. The tooltip + should still appear on hover, since nothing is being scrolled out of view. +

+
+ + + +
+
+ ); +}; + +StaticOverflowHidden.parameters = { + docs: { + description: { + story: + 'A tooltip trigger placed inside a static, non-scrolling `overflow: hidden` container (e.g. a flex toolbar) should still show its tooltip, since it is not a scroll boundary being escaped.', + }, + }, +}; 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 a055ba3993f54..c3b8ae14ae115 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 @@ -16,6 +16,7 @@ export { Positioning } from './TooltipPositioning.stories'; export { Target } from './TooltipTarget.stories'; export { Icon } from './TooltipIcon.stories'; export { OverflowHidden } from './TooltipOverflowHidden.stories'; +export { StaticOverflowHidden } from './TooltipStaticOverflowHidden.stories'; export default { title: 'Components/Tooltip',