From e20421f9bdb7fdebb3aaef5427432dd2c4ac4d5c Mon Sep 17 00:00:00 2001 From: kokoskiwi <45340729+kokoskiwi@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:17:55 +0100 Subject: [PATCH] fix: keep popovers visible above the iOS keyboard --- .../src/overlays/calculatePosition.ts | 10 +++- .../test/overlays/calculatePosition.test.ts | 51 ++++++++++++++++++- .../test/overlays/useOverlayPosition.test.tsx | 20 ++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/react-aria/src/overlays/calculatePosition.ts b/packages/react-aria/src/overlays/calculatePosition.ts index 3e1957be2d8..9e6322378c0 100644 --- a/packages/react-aria/src/overlays/calculatePosition.ts +++ b/packages/react-aria/src/overlays/calculatePosition.ts @@ -343,6 +343,12 @@ function getMaxHeight( let boundaryToContainerTransformOffset = isContainerDescendentOfBoundary ? containerOffsetWithBoundary.top : 0; + // Use the same visual viewport origin as getContainerDimensions. WebKit can + // report an offsetTop that disagrees with pageTop while the keyboard is open. + // Reading from body also handles documents that scroll on body rather than html. + let visualViewportTop = visualViewport + ? getContainerDimensions(document.body, visualViewport).top + : null; let boundingRect = { // This should be boundary top in container coord system vs viewport top in container coord system // For the viewport top, there are several cases @@ -350,11 +356,11 @@ function getMaxHeight( // 2. case where container is offset from the boundary and is contained by the boundary. In this case the top we want here is NOT 0, we want to take boundary's top even though is is a negative number OR the visual viewport, whichever is more restrictive top: Math.max( boundaryDimensions.top + boundaryToContainerTransformOffset, - (visualViewport?.offsetTop ?? boundaryDimensions.top) + boundaryToContainerTransformOffset + (visualViewportTop ?? boundaryDimensions.top) + boundaryToContainerTransformOffset ), bottom: Math.min( boundaryDimensions.top + boundaryDimensions.height + boundaryToContainerTransformOffset, - (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? 0) + (visualViewportTop ?? 0) + (visualViewport?.height ?? 0) ) }; let maxHeight = diff --git a/packages/react-aria/test/overlays/calculatePosition.test.ts b/packages/react-aria/test/overlays/calculatePosition.test.ts index 89cd6ba1cab..f3d6f49c381 100644 --- a/packages/react-aria/test/overlays/calculatePosition.test.ts +++ b/packages/react-aria/test/overlays/calculatePosition.test.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {calculatePosition} from '../../src/overlays/calculatePosition'; +import {calculatePosition, calculatePositionInternal} from '../../src/overlays/calculatePosition'; const FLIPPED_DIRECTION = { left: 'right' @@ -101,6 +101,55 @@ const overlaySize = { const PROVIDER_OFFSET = 50; describe('calculatePosition', function () { + it('keeps an upward popover visible with the iOS keyboard open', () => { + let previousScrollTop = document.documentElement.scrollTop; + let previousBodyScrollTop = document.body.scrollTop; + document.documentElement.scrollTop = 466; + + try { + let dimensions = { + width: 390, + height: 404, + totalWidth: 390, + totalHeight: 714, + top: 0, + left: 0, + scroll: {top: 466, left: 0} + }; + let position = (offsetTop: number) => + calculatePositionInternal( + 'top', + {top: 680, left: 100, width: 100, height: 40}, + {top: 0, left: 0, width: 200, height: 200}, + {top: 0, left: 0, width: 200, height: 200}, + {top: 0, bottom: 0, left: 0, right: 0}, + 8, + false, + dimensions, + dimensions, + {top: 0, left: 0, width: 0, height: 0}, + 8, + 0, + false, + undefined, + 0, + 0, + true, + {pageTop: 466, offsetTop, height: 404, scale: 1} as VisualViewport + ); + + expect(position(0).maxHeight).toBe(198); + expect(position(310).maxHeight).toBe(198); + + document.documentElement.scrollTop = 0; + document.body.scrollTop = 466; + expect(position(310).maxHeight).toBe(198); + } finally { + document.documentElement.scrollTop = previousScrollTop; + document.body.scrollTop = previousBodyScrollTop; + } + }); + function checkPositionCommon( title, expected, diff --git a/packages/react-aria/test/overlays/useOverlayPosition.test.tsx b/packages/react-aria/test/overlays/useOverlayPosition.test.tsx index cb517994598..fc1679617f3 100644 --- a/packages/react-aria/test/overlays/useOverlayPosition.test.tsx +++ b/packages/react-aria/test/overlays/useOverlayPosition.test.tsx @@ -139,6 +139,26 @@ describe('useOverlayPosition', function () { `); }); + it('keeps a top popover open above the iOS keyboard when offsetTop is inconsistent', () => { + let clientHeight = jest + .spyOn(document.documentElement, 'clientHeight', 'get') + .mockReturnValue(714); + let previousScrollTop = document.documentElement.scrollTop; + document.documentElement.scrollTop = 466; + Object.assign(window.visualViewport!, {pageTop: 466, offsetTop: 310, height: 404}); + + try { + let res = render(); + let overlay = res.getByTestId('overlay'); + + expect(overlay).toHaveTextContent('placement: top'); + expect(parseFloat(overlay.style.maxHeight)).toBeGreaterThan(100); + } finally { + document.documentElement.scrollTop = previousScrollTop; + clientHeight.mockRestore(); + } + }); + it('should update the position on window resize', function () { let res = render(); let overlay = res.getByTestId('overlay');