diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 160de612c72..e5df4569701 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -103,14 +103,18 @@ const TOTAL_SIZE = { const PARSED_PLACEMENT_CACHE = {}; -let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null; +let getVisualViewport = () => typeof document !== 'undefined' ? window.visualViewport : null; -function getContainerDimensions(containerNode: Element): Dimensions { +function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null): Dimensions { let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0; let scroll: Position = {}; let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1; - if (containerNode.tagName === 'BODY') { + // In the case where the container is `html` or `body` and the container doesn't have something like `position: relative`, + // then position absolute will be positioned relative to the viewport, also known as the `initial containing block`. + // That's why we use the visual viewport instead. + + if (containerNode.tagName === 'BODY' || containerNode.tagName === 'HTML') { let documentElement = document.documentElement; totalWidth = documentElement.clientWidth; totalHeight = documentElement.clientHeight; @@ -179,10 +183,13 @@ function getDelta( let boundarySize = boundaryDimensions[AXIS_SIZE[axis]]; // Calculate the edges of the boundary (accomodating for the boundary padding) and the edges of the overlay. // Note that these values are with respect to the visual viewport (aka 0,0 is the top left of the viewport) - let boundaryStartEdge = boundaryDimensions.scroll[AXIS[axis]] + padding; - let boundaryEndEdge = boundarySize + boundaryDimensions.scroll[AXIS[axis]] - padding; - let startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; - let endEdgeOffset = offset - containerScroll + size + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; + + let boundaryStartEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + padding; + let boundaryEndEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + boundarySize - padding; + // transformed value of the left edge of the overlay + let startEdgeOffset = offset - containerScroll + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; + // transformed value of the right edge of the overlay + let endEdgeOffset = offset - containerScroll + size + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; // If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's // edges with the closest boundary edge. @@ -234,7 +241,8 @@ function computePosition( containerOffsetWithBoundary: Offset, isContainerPositioned: boolean, arrowSize: number, - arrowBoundaryOffset: number + arrowBoundaryOffset: number, + containerDimensions: Dimensions ) { let {placement, crossPlacement, axis, crossAxis, size, crossSize} = placementInfo; let position: Position = {}; @@ -255,9 +263,9 @@ function computePosition( position[crossAxis]! += crossOffset; - // overlay top overlapping arrow with button bottom + // overlay top or left overlapping arrow with button bottom or right const minPosition = childOffset[crossAxis] - overlaySize[crossSize] + arrowSize + arrowBoundaryOffset; - // overlay bottom overlapping arrow with button top + // overlay bottom or right overlapping arrow with button top or left const maxPosition = childOffset[crossAxis] + childOffset[crossSize] - arrowSize - arrowBoundaryOffset; position[crossAxis] = clamp(position[crossAxis]!, minPosition, maxPosition); @@ -266,8 +274,8 @@ function computePosition( // If the container is positioned (non-static), then we use the container's actual // height, as `bottom` will be relative to this height. But if the container is static, // then it can only be the `document.body`, and `bottom` will be relative to _its_ - // container, which should be as large as boundaryDimensions. - const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[TOTAL_SIZE[size]]); + // container. + let containerHeight = (isContainerPositioned ? containerDimensions[size] : containerDimensions[TOTAL_SIZE[size]]); position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset); } else { position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset); @@ -283,42 +291,72 @@ function getMaxHeight( margins: Position, padding: number, overlayHeight: number, - heightGrowthDirection: HeightGrowthDirection + heightGrowthDirection: HeightGrowthDirection, + containerDimensions: Dimensions, + isContainerDescendentOfBoundary: boolean, + visualViewport: VisualViewport | null ) { - const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : boundaryDimensions[TOTAL_SIZE.height]); - // For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top with respect to the boundary. Reverse calculate this with the same method - // used in computePosition. - let overlayTop = position.top != null ? containerOffsetWithBoundary.top + position.top : containerOffsetWithBoundary.top + (containerHeight - (position.bottom ?? 0) - overlayHeight); + // For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top + // with respect to the container. + let overlayTop = (position.top != null ? position.top : (containerDimensions[TOTAL_SIZE.height] - (position.bottom ?? 0) - overlayHeight)) - (containerDimensions.scroll.top ?? 0); + // calculate the dimentions of the "boundingRect" which is most restrictive top/bottom of the boundaryRect and the visual view port + let boundaryToContainerTransformOffset = isContainerDescendentOfBoundary ? containerOffsetWithBoundary.top : 0; + 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 + // 1. pinchzoom case where we want the viewports offset top as top here + // 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), + bottom: Math.min((boundaryDimensions.top + boundaryDimensions.height + boundaryToContainerTransformOffset), (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? 0)) + }; + let maxHeight = heightGrowthDirection !== 'top' ? // We want the distance between the top of the overlay to the bottom of the boundary Math.max(0, - (boundaryDimensions.height + boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the bottom of the boundary + boundingRect.bottom // this is the bottom of the boundary - overlayTop // this is the top of the overlay - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding ) // We want the distance between the bottom of the overlay to the top of the boundary : Math.max(0, (overlayTop + overlayHeight) // this is the bottom of the overlay - - (boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the top of the boundary + - boundingRect.top // this is the top of the boundary - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding ); - return Math.min(boundaryDimensions.height - (padding * 2), maxHeight); + return maxHeight; } function getAvailableSpace( - boundaryDimensions: Dimensions, + boundaryDimensions: Dimensions, // boundary containerOffsetWithBoundary: Offset, - childOffset: Offset, - margins: Position, - padding: number, - placementInfo: ParsedPlacement + childOffset: Offset, // trigger, position based of container's non-viewport 0,0 + margins: Position, // overlay + padding: number, // overlay <-> boundary + placementInfo: ParsedPlacement, + containerDimensions: Dimensions, + isContainerDescendentOfBoundary: boolean ) { let {placement, axis, size} = placementInfo; if (placement === axis) { - return Math.max(0, childOffset[axis] - boundaryDimensions[axis] - (boundaryDimensions.scroll[axis] ?? 0) + containerOffsetWithBoundary[axis] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding); + return Math.max(0, + childOffset[axis] // trigger start + - (containerDimensions.scroll[axis] ?? 0) // transform trigger position to be with respect to viewport 0,0 + - (boundaryDimensions[axis] + (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0)) // boundary start + - (margins[axis] ?? 0) // margins usually for arrows or other decorations + - margins[FLIPPED_DIRECTION[axis]] + - padding); // padding between overlay and boundary } - return Math.max(0, boundaryDimensions[size] + boundaryDimensions[axis] + boundaryDimensions.scroll[axis] - containerOffsetWithBoundary[axis] - childOffset[axis] - childOffset[size] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding); + return Math.max(0, + (boundaryDimensions[size] + + boundaryDimensions[axis] + + (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0)) + - childOffset[axis] + - childOffset[size] + + (containerDimensions.scroll[axis] ?? 0) + - (margins[axis] ?? 0) + - margins[FLIPPED_DIRECTION[axis]] + - padding); } export function calculatePositionInternal( @@ -337,11 +375,13 @@ export function calculatePositionInternal( isContainerPositioned: boolean, userSetMaxHeight: number | undefined, arrowSize: number, - arrowBoundaryOffset: number + arrowBoundaryOffset: number, + isContainerDescendentOfBoundary: boolean, + visualViewport: VisualViewport | null ): PositionResult { let placementInfo = parsePlacement(placementInput); let {size, crossAxis, crossSize, placement, crossPlacement} = placementInfo; - let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); + let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions); let normalizedOffset = offset; let space = getAvailableSpace( boundaryDimensions, @@ -349,20 +389,25 @@ export function calculatePositionInternal( childOffset, margins, padding + offset, - placementInfo + placementInfo, + containerDimensions, + isContainerDescendentOfBoundary ); // Check if the scroll size of the overlay is greater than the available space to determine if we need to flip - if (flip && scrollSize[size] > space) { + if (flip && overlaySize[size] > space) { let flippedPlacementInfo = parsePlacement(`${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement); - let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); + let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions); + let flippedSpace = getAvailableSpace( boundaryDimensions, containerOffsetWithBoundary, childOffset, margins, padding + offset, - flippedPlacementInfo + flippedPlacementInfo, + containerDimensions, + isContainerDescendentOfBoundary ); // If the available space for the flipped position is greater than the original available space, flip. @@ -400,7 +445,10 @@ export function calculatePositionInternal( margins, padding, overlaySize.height, - heightGrowthDirection + heightGrowthDirection, + containerDimensions, + isContainerDescendentOfBoundary, + visualViewport ); if (userSetMaxHeight && userSetMaxHeight < maxHeight) { @@ -409,7 +457,7 @@ export function calculatePositionInternal( overlaySize.height = Math.min(overlaySize.height, maxHeight); - position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); + position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions); delta = getDelta(crossAxis, position[crossAxis]!, overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary); position[crossAxis]! += delta; @@ -484,6 +532,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult { arrowBoundaryOffset = 0 } = opts; + let visualViewport = getVisualViewport(); let container = overlayNode instanceof HTMLElement ? getContainingBlock(overlayNode) : document.documentElement; let isViewportContainer = container === document.documentElement; const containerPositionStyle = window.getComputedStyle(container).position; @@ -502,17 +551,19 @@ export function calculatePosition(opts: PositionOpts): PositionResult { overlaySize.height += (margins.top ?? 0) + (margins.bottom ?? 0); let scrollSize = getScroll(scrollNode); - let boundaryDimensions = getContainerDimensions(boundaryElement); - let containerDimensions = getContainerDimensions(container); + + // Note that due to logic inside getContainerDimensions, for cases where the boundary element is the body, we will return + // a height/width that matches the visual viewport size rather than the body's height/width (aka for zoom it will be zoom adjusted size) + // and a top/left that is adjusted as well (will return the top/left of the zoomed in viewport, or 0,0 for a non-zoomed body) + // Otherwise this returns the height/width of a arbitrary boundary element, and its top/left with respect to the viewport (NOTE THIS MEANS IT DOESNT INCLUDE SCROLL) + let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport); + let containerDimensions = getContainerDimensions(container, visualViewport); // If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the // body element's scroll. Set the container's scroll values to 0 since the overlay's edge position value in getDelta don't then need to be further offset // by the container scroll since they are essentially the same containing element and thus in the same coordinate system - let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(container, boundaryElement, false); - if (container.tagName === 'HTML' && boundaryElement.tagName === 'BODY') { - containerDimensions.scroll.top = 0; - containerDimensions.scroll.left = 0; - } + let containerOffsetWithBoundary: Offset = getPosition(boundaryElement, container, false); + let isContainerDescendentOfBoundary = boundaryElement.contains(container); return calculatePositionInternal( placement, childOffset, @@ -529,14 +580,16 @@ export function calculatePosition(opts: PositionOpts): PositionResult { isContainerPositioned, maxHeight, arrowSize, - arrowBoundaryOffset + arrowBoundaryOffset, + isContainerDescendentOfBoundary, + visualViewport ); } export function getRect(node: Element, ignoreScale: boolean) { let {top, left, width, height} = node.getBoundingClientRect(); - // Use offsetWidth and offsetHeight if this is an HTML element, so that + // Use offsetWidth and offsetHeight if this is an HTML element, so that // the size is not affected by scale transforms. if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) { width = node.offsetWidth; diff --git a/packages/@react-aria/overlays/test/calculatePosition.test.ts b/packages/@react-aria/overlays/test/calculatePosition.test.ts index 96907043448..3f9b4db7d86 100644 --- a/packages/@react-aria/overlays/test/calculatePosition.test.ts +++ b/packages/@react-aria/overlays/test/calculatePosition.test.ts @@ -109,14 +109,14 @@ describe('calculatePosition', function () { // The tests are all based on top/left positioning. Convert to bottom/right positioning if needed. let pos: {right?: number, top?: number, left?: number, bottom?: number} = {}; if ((placementAxis === 'left' && !flip) || (placementAxis === 'right' && flip)) { - pos.right = boundaryDimensions.width - (expected[0] + overlaySize.width); + pos.right = containerDimensions.width - (expected[0] + overlaySize.width); pos.top = expected[1]; } else if ((placementAxis === 'right' && !flip) || (placementAxis === 'left' && flip)) { pos.left = expected[0]; pos.top = expected[1]; } else if (placementAxis === 'top') { pos.left = expected[0]; - pos.bottom = boundaryDimensions.height - providerOffset - (expected[1] + overlaySize.height); + pos.bottom = containerDimensions.height - (expected[1] + overlaySize.height); } else if (placementAxis === 'bottom') { pos.left = expected[0]; pos.top = expected[1]; @@ -138,13 +138,16 @@ describe('calculatePosition', function () { }; const container = createElementWithDimensions('div', containerDimensions); + Object.assign(container.style, { + position: 'relative' + }); const target = createElementWithDimensions('div', targetDimension); const overlay = createElementWithDimensions('div', overlaySize, margins); const parentElement = document.createElement('div'); parentElement.appendChild(container); parentElement.appendChild(target); - parentElement.appendChild(overlay); + container.appendChild(overlay); document.documentElement.appendChild(parentElement); @@ -330,6 +333,22 @@ describe('calculatePosition', function () { testCases.forEach(function (testCase) { const {placement} = testCase; + beforeEach(() => { + window.visualViewport = { + offsetTop: 0, + height: 600, + offsetLeft: 0, + scale: 1, + width: 0, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onresize: () => {}, + onscroll: () => {}, + pageLeft: 0, + pageTop: 0 + } as VisualViewport; + }); describe(`placement = ${placement}`, function () { describe('no viewport offset', function () { diff --git a/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx b/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx index c81c34a94c1..0c2a8dfdf4b 100644 --- a/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx +++ b/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx @@ -14,7 +14,8 @@ import {fireEvent, render} from '@react-spectrum/test-utils-internal'; import React, {useRef} from 'react'; import {useOverlayPosition} from '../'; -function Example({triggerTop = 250, ...props}) { + +function Example({triggerTop = 250, containerStyle = {width: 600, height: 600} as React.CSSProperties, ...props}) { let targetRef = useRef(null); let containerRef = useRef(null); let overlayRef = useRef(null); @@ -23,7 +24,7 @@ function Example({triggerTop = 250, ...props}) { return (
Trigger
-
+
placement: {placement} @@ -36,6 +37,13 @@ function Example({triggerTop = 250, ...props}) { let original = window.HTMLElement.prototype.getBoundingClientRect; HTMLElement.prototype.getBoundingClientRect = function () { let rect = original.apply(this); + if (this.tagName === 'BODY') { + return { + ...rect, + height: this.clientHeight, + width: this.clientWidth + }; + } return { ...rect, left: parseInt(this.style.left, 10) || 0, @@ -49,6 +57,21 @@ HTMLElement.prototype.getBoundingClientRect = function () { describe('useOverlayPosition', function () { beforeEach(() => { + window.visualViewport = { + offsetTop: 0, + height: 768, + offsetLeft: 0, + scale: 1, + width: 500, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onresize: () => {}, + onscroll: () => {}, + pageLeft: 0, + pageTop: 0 + } as VisualViewport; + document.body.style.margin = '0'; // jsdom defaults to having a margin of 8px, we should fix this down the line Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 768}); Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 500}); @@ -89,7 +112,7 @@ describe('useOverlayPosition', function () { position: absolute; z-index: 100000; left: 12px; - bottom: 518px; + bottom: 350px; max-height: 238px; `); @@ -112,6 +135,7 @@ describe('useOverlayPosition', function () { expect(overlay).toHaveTextContent('placement: bottom'); Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 1000}); + Object.defineProperty(window.visualViewport, 'height', {configurable: true, value: 1000}); fireEvent(window, new Event('resize')); expect(overlay).toHaveStyle(` @@ -226,6 +250,21 @@ describe('useOverlayPosition with positioned container', () => { let realGetBoundingClientRect = window.HTMLElement.prototype.getBoundingClientRect; let realGetComputedStyle = window.getComputedStyle; beforeEach(() => { + window.visualViewport = { + offsetTop: 0, + height: 768, + offsetLeft: 0, + scale: 1, + width: 500, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onresize: () => {}, + onscroll: () => {}, + pageLeft: 0, + pageTop: 0 + } as VisualViewport; + document.body.style.margin = '0'; Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 768}); Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 500}); stubs.push( @@ -238,19 +277,7 @@ describe('useOverlayPosition with positioned container', () => { } }), jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.attributes.getNamedItem('data-testid')?.value === 'container') { - // Say, overlay is positioned somewhere - let real = realGetBoundingClientRect.apply(this); - return { - ...real, - top: 150, - left: 0, - width: 400, - height: 400 - }; - } else { - return realGetBoundingClientRect.apply(this); - } + return realGetBoundingClientRect.apply(this); }), jest.spyOn(window, 'getComputedStyle').mockImplementation(element => { if (element.attributes.getNamedItem('data-testid')?.value === 'container') { @@ -260,6 +287,12 @@ describe('useOverlayPosition with positioned container', () => { } else { return realGetComputedStyle(element); } + }), + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + return parseInt(this.style.width, 10) || 0; + }), + jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return parseInt(this.style.height, 10) || 0; }) ); }); @@ -270,7 +303,7 @@ describe('useOverlayPosition with positioned container', () => { }); it('should position the overlay relative to the trigger', function () { - let res = render(); + let res = render(); let overlay = res.getByTestId('overlay'); let arrow = res.getByTestId('arrow'); @@ -291,7 +324,7 @@ describe('useOverlayPosition with positioned container', () => { }); it('should position the overlay relative to the trigger at top', function () { - let res = render(); + let res = render(); let overlay = res.getByTestId('overlay'); let arrow = res.getByTestId('arrow');