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(