-
Notifications
You must be signed in to change notification settings - Fork 2.9k
fix(react-positioning): don't treat static overflow:hidden ancestors as a hide-middleware clipping boundary #36605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Paul Mardling (PaulGMardling)
wants to merge
12
commits into
microsoft:master
Choose a base branch
from
PaulGMardling:fix/36604-tooltip-escaped-hide-boundary
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 11 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
9e4ee6d
fix(react-positioning): don't treat static overflow:hidden ancestors …
PaulGMardling 05f0206
test(react-positioning): pin hide() wrapper's boundary mapping and do…
PaulGMardling db252ac
fix(react-tooltip): split TooltipOverflowHidden.stories.tsx into sing…
PaulGMardling 649edda
Merge branch 'master' into fix/36604-tooltip-escaped-hide-boundary
PaulGMardling c4671f6
Merge branch 'master' into fix/36604-tooltip-escaped-hide-boundary
PaulGMardling 8485cfb
test(react-tooltip): clarify overflow regression suite name
PaulGMardling 006c67e
refactor(react-positioning): inline hide boundary mapping
PaulGMardling 1b22a42
fix(react-tooltip): scope hide boundary behavior to tooltips
PaulGMardling a8f6855
test(react-tooltip): cover nested static overflow:hidden inside a scr…
PaulGMardling c8cefc9
test(react-positioning): add Jest coverage for scrollParent boundary …
PaulGMardling b74bd10
test(react-tooltip): add Jest/JSDOM regression test for #36604 static…
PaulGMardling 98fa096
test(react-tooltip): drop fragile heavy-mock hide/escaped integration…
PaulGMardling File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@fluentui-react-positioning-95a68719-9f3f-4da2-8ac8-e2c22ecd0b4d.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
7 changes: 7 additions & 0 deletions
7
change/@fluentui-react-tooltip-36604-tooltip-hide-boundary.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
87 changes: 87 additions & 0 deletions
87
packages/react-components/react-positioning/library/src/utils/getBoundary.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 126 additions & 0 deletions
126
...ct-components/react-tooltip/library/src/components/Tooltip/TooltipOverflowHidden.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import * as React from 'react'; | ||
| import { act, fireEvent, render } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { Tooltip } from './Tooltip'; | ||
| import { resetIdsForTests } from '@fluentui/react-utilities'; | ||
|
|
||
| // Regression coverage for https://github.com/microsoft/fluentui/issues/36604. | ||
| // | ||
| // JSDOM doesn't run a real layout engine, so every element reports a zero-size | ||
| // `getBoundingClientRect` by default. Floating UI's `hide` middleware treats a | ||
| // zero-size reference/floating rect as fully clipped, which would make this test | ||
| // "pass" for the wrong reason (tooltip always hidden, regardless of the fix). | ||
| // To exercise the actual regression, we give the DOM nodes involved | ||
| // browser-realistic, non-zero rects that reproduce the reported layout: | ||
| // a trigger sitting in a tightly-fitted `overflow: hidden` wrapper, itself | ||
| // nested inside a real scrollable ancestor. | ||
| function mockRect(rect: Partial<DOMRect>): DOMRect { | ||
| return { | ||
| x: rect.left ?? 0, | ||
| y: rect.top ?? 0, | ||
| top: 0, | ||
| left: 0, | ||
| right: 0, | ||
| bottom: 0, | ||
| width: (rect.right ?? 0) - (rect.left ?? 0), | ||
| height: (rect.bottom ?? 0) - (rect.top ?? 0), | ||
| toJSON: () => ({}), | ||
| ...rect, | ||
| } as DOMRect; | ||
| } | ||
|
|
||
| describe('Tooltip overflow:hidden regression (#36604)', () => { | ||
| let spies: jest.SpyInstance[]; | ||
|
|
||
| afterEach(() => { | ||
| spies.forEach(spy => spy.mockRestore()); | ||
| resetIdsForTests(); | ||
| }); | ||
|
|
||
| it('still shows the tooltip when its trigger sits in a tightly-fitted, static overflow:hidden container nested inside a real scroll parent', async () => { | ||
| const result = render( | ||
| <div | ||
| data-testid="scroll-ancestor" | ||
| style={{ height: '600px', width: '800px', overflow: 'auto', position: 'relative' }} | ||
| > | ||
| <div data-testid="static-hidden" style={{ overflow: 'hidden', display: 'flex' }}> | ||
| <Tooltip content="I should still appear" relationship="label" showDelay={0} hideDelay={0}> | ||
| <button data-testid="trigger">Hover me</button> | ||
| </Tooltip> | ||
| </div> | ||
| </div>, | ||
| ); | ||
|
|
||
| const scrollAncestor = result.getByTestId('scroll-ancestor'); | ||
| const staticHidden = result.getByTestId('static-hidden'); | ||
| const trigger = result.getByTestId('trigger'); | ||
|
|
||
| // A tight box around the trigger - the static `overflow: hidden` wrapper hugs it exactly, | ||
| // matching the "tightly-fitted toolbar/card" repro from the issue. | ||
| const triggerRect = { top: 300, left: 300, right: 380, bottom: 332 }; | ||
| const scrollAncestorRect = { top: 0, left: 0, right: 800, bottom: 600 }; | ||
| const viewportRect = { top: 0, left: 0, right: 1024, bottom: 768 }; | ||
| const tooltipContentRect = { top: 0, left: 0, right: 100, bottom: 32 }; | ||
|
|
||
| function rectFor(element: Element) { | ||
| if (element === scrollAncestor) { | ||
| return scrollAncestorRect; | ||
| } | ||
| if (element === staticHidden || element === trigger) { | ||
| return triggerRect; | ||
| } | ||
| if (element === document.documentElement || element === document.body) { | ||
| return viewportRect; | ||
| } | ||
| // The tooltip bubble itself (and anything else, e.g. the arrow): give it a modest, | ||
| // non-zero size so Floating UI's placement math has something realistic to work with. | ||
| return tooltipContentRect; | ||
| } | ||
|
|
||
| // JSDOM has no real layout engine: `getBoundingClientRect` always returns a zero rect, and | ||
| // `offsetWidth`/`offsetHeight` (which Floating UI's dimension measurement actually reads via | ||
| // `getCssDimensions`) are always 0 too. Both need mocking in lockstep, or Floating UI falls | ||
| // back to treating every element as zero-size regardless of the rects above. | ||
| spies = [ | ||
|
PaulGMardling marked this conversation as resolved.
Outdated
|
||
| jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { | ||
| return mockRect(rectFor(this)); | ||
| }), | ||
| jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { | ||
| const rect = rectFor(this); | ||
| return rect.right - rect.left; | ||
| }), | ||
| jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { | ||
| const rect = rectFor(this); | ||
| return rect.bottom - rect.top; | ||
| }), | ||
| // Floating UI's viewport/clipping-ancestor rects (`getViewportRect`, `getInnerBoundingClientRect`) read | ||
| // `clientWidth`/`clientHeight`, not `getBoundingClientRect` - these need mocking too, or the resolved | ||
| // boundary (however correctly it resolves) collapses to a zero-size rect regardless. | ||
| jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) { | ||
| const rect = rectFor(this); | ||
| return rect.right - rect.left; | ||
| }), | ||
| jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) { | ||
| const rect = rectFor(this); | ||
| return rect.bottom - rect.top; | ||
| }), | ||
| ]; | ||
|
|
||
| await userEvent.hover(trigger); | ||
|
|
||
| // Let Floating UI's async `computePosition` (and our positioning-end event) settle. | ||
| await act(async () => { | ||
| await new Promise(resolve => setTimeout(resolve, 50)); | ||
| }); | ||
|
|
||
| const tooltip = result.baseElement.querySelector('[role="tooltip"]') as HTMLElement; | ||
| expect(tooltip).not.toBeNull(); | ||
| expect(tooltip.textContent).toBe('I should still appear'); | ||
| expect(getComputedStyle(tooltip).visibility).not.toBe('hidden'); | ||
|
|
||
| await act(async () => { | ||
| fireEvent.pointerLeave(trigger); | ||
| await new Promise(resolve => setTimeout(resolve, 50)); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.