diff --git a/packages/react-aria/src/overlays/calculatePosition.ts b/packages/react-aria/src/overlays/calculatePosition.ts index 5c82a9f0f52..35b7fc7a329 100644 --- a/packages/react-aria/src/overlays/calculatePosition.ts +++ b/packages/react-aria/src/overlays/calculatePosition.ts @@ -461,8 +461,10 @@ export function calculatePositionInternal( isContainerDescendentOfBoundary ); - // Check if the scroll size of the overlay is greater than the available space to determine if we need to flip - if (flip && overlaySize[size] > space) { + // On the height axis, use the user-set maxHeight instead of a possibly stale clamped measurement. + let sizeForFlipCheck = + size === 'height' ? (userSetMaxHeight ?? overlaySize[size]) : overlaySize[size]; + if (flip && sizeForFlipCheck > space) { let flippedPlacementInfo = parsePlacement( `${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement ); diff --git a/packages/react-aria/test/overlays/useOverlayPosition.test.tsx b/packages/react-aria/test/overlays/useOverlayPosition.test.tsx index cb517994598..c28dfe54de5 100644 --- a/packages/react-aria/test/overlays/useOverlayPosition.test.tsx +++ b/packages/react-aria/test/overlays/useOverlayPosition.test.tsx @@ -11,6 +11,7 @@ */ import {fireEvent, render} from '@react-spectrum/test-utils-internal'; +import {I18nProvider} from '../../src/i18n/I18nProvider'; import React, {useRef} from 'react'; import {useOverlayPosition} from '../../src/overlays/useOverlayPosition'; @@ -202,6 +203,68 @@ describe('useOverlayPosition', function () { `); }); + it('should not flip in RTL when a user maxHeight fits within the available space', function () { + // 'start' cross-placement exercises the RTL codepath. + let res = render( + + + + ); + let overlay = res.getByTestId('overlay'); + + expect(overlay).toHaveTextContent('placement: bottom'); + expect(overlay).toHaveStyle('max-height: 400px;'); + }); + + describe('flipping when the overlay outgrows its current placement', function () { + // Natural (unclamped) content height; tests mutate this to simulate content + // populating after the initial positioning pass. + let overlayNaturalHeight: number; + + beforeEach(() => { + overlayNaturalHeight = 40; + + // Simulates a scrollable overlay whose offsetHeight is clamped to its CSS max-height. + jest + .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') + .mockImplementation(function (this: HTMLElement) { + if (this.getAttribute?.('data-testid') === 'overlay') { + let styleMaxHeight = + this.style.maxHeight !== '' ? parseInt(this.style.maxHeight, 10) : Infinity; + return Math.min(overlayNaturalHeight, styleMaxHeight); + } + return parseInt(this.style.height, 10) || 0; + }); + }); + + it('flips to the top once content grows, when no maxHeight is set (self-heals)', function () { + let res = render(); + let overlay = res.getByTestId('overlay'); + + expect(overlay).toHaveTextContent('placement: bottom'); + + overlayNaturalHeight = 400; + fireEvent(window, new Event('resize')); + + expect(overlay).toHaveTextContent('placement: top'); + expect(parseInt(overlay.style.maxHeight, 10)).toBeGreaterThan(300); + }); + + it('flips to the top and grows toward the requested maxHeight, once a user maxHeight is set', function () { + let res = render(); + let overlay = res.getByTestId('overlay'); + + expect(overlay).toHaveTextContent('placement: top'); + expect(parseInt(overlay.style.maxHeight, 10)).toBe(300); + + overlayNaturalHeight = 400; + fireEvent(window, new Event('resize')); + + expect(overlay).toHaveTextContent('placement: top'); + expect(parseInt(overlay.style.maxHeight, 10)).toBe(300); + }); + }); + it('should close the overlay when the trigger scrolls', function () { let onClose = jest.fn(); let res = render(