Skip to content

Commit d169afa

Browse files
chore: fix overlay positioning (#8848)
* chore: fix overlay positioning This reverts commit 155970a. * incorporate viewport, bounding box, and container descendent of boundary * fix flip for when overlay should based on height not on scrolling --------- Co-authored-by: Reid Barber <[email protected]>
1 parent 05346a6 commit d169afa

File tree

3 files changed

+169
-64
lines changed

3 files changed

+169
-64
lines changed

packages/@react-aria/overlays/src/calculatePosition.ts

Lines changed: 96 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,18 @@ const TOTAL_SIZE = {
103103

104104
const PARSED_PLACEMENT_CACHE = {};
105105

106-
let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null;
106+
let getVisualViewport = () => typeof document !== 'undefined' ? window.visualViewport : null;
107107

108-
function getContainerDimensions(containerNode: Element): Dimensions {
108+
function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null): Dimensions {
109109
let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0;
110110
let scroll: Position = {};
111111
let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1;
112112

113-
if (containerNode.tagName === 'BODY') {
113+
// In the case where the container is `html` or `body` and the container doesn't have something like `position: relative`,
114+
// then position absolute will be positioned relative to the viewport, also known as the `initial containing block`.
115+
// That's why we use the visual viewport instead.
116+
117+
if (containerNode.tagName === 'BODY' || containerNode.tagName === 'HTML') {
114118
let documentElement = document.documentElement;
115119
totalWidth = documentElement.clientWidth;
116120
totalHeight = documentElement.clientHeight;
@@ -179,10 +183,13 @@ function getDelta(
179183
let boundarySize = boundaryDimensions[AXIS_SIZE[axis]];
180184
// Calculate the edges of the boundary (accomodating for the boundary padding) and the edges of the overlay.
181185
// Note that these values are with respect to the visual viewport (aka 0,0 is the top left of the viewport)
182-
let boundaryStartEdge = boundaryDimensions.scroll[AXIS[axis]] + padding;
183-
let boundaryEndEdge = boundarySize + boundaryDimensions.scroll[AXIS[axis]] - padding;
184-
let startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];
185-
let endEdgeOffset = offset - containerScroll + size + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];
186+
187+
let boundaryStartEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + padding;
188+
let boundaryEndEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + boundarySize - padding;
189+
// transformed value of the left edge of the overlay
190+
let startEdgeOffset = offset - containerScroll + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];
191+
// transformed value of the right edge of the overlay
192+
let endEdgeOffset = offset - containerScroll + size + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]];
186193

187194
// If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's
188195
// edges with the closest boundary edge.
@@ -234,7 +241,8 @@ function computePosition(
234241
containerOffsetWithBoundary: Offset,
235242
isContainerPositioned: boolean,
236243
arrowSize: number,
237-
arrowBoundaryOffset: number
244+
arrowBoundaryOffset: number,
245+
containerDimensions: Dimensions
238246
) {
239247
let {placement, crossPlacement, axis, crossAxis, size, crossSize} = placementInfo;
240248
let position: Position = {};
@@ -255,9 +263,9 @@ function computePosition(
255263

256264
position[crossAxis]! += crossOffset;
257265

258-
// overlay top overlapping arrow with button bottom
266+
// overlay top or left overlapping arrow with button bottom or right
259267
const minPosition = childOffset[crossAxis] - overlaySize[crossSize] + arrowSize + arrowBoundaryOffset;
260-
// overlay bottom overlapping arrow with button top
268+
// overlay bottom or right overlapping arrow with button top or left
261269
const maxPosition = childOffset[crossAxis] + childOffset[crossSize] - arrowSize - arrowBoundaryOffset;
262270
position[crossAxis] = clamp(position[crossAxis]!, minPosition, maxPosition);
263271

@@ -266,8 +274,8 @@ function computePosition(
266274
// If the container is positioned (non-static), then we use the container's actual
267275
// height, as `bottom` will be relative to this height. But if the container is static,
268276
// then it can only be the `document.body`, and `bottom` will be relative to _its_
269-
// container, which should be as large as boundaryDimensions.
270-
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[TOTAL_SIZE[size]]);
277+
// container.
278+
let containerHeight = (isContainerPositioned ? containerDimensions[size] : containerDimensions[TOTAL_SIZE[size]]);
271279
position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset);
272280
} else {
273281
position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset);
@@ -283,42 +291,72 @@ function getMaxHeight(
283291
margins: Position,
284292
padding: number,
285293
overlayHeight: number,
286-
heightGrowthDirection: HeightGrowthDirection
294+
heightGrowthDirection: HeightGrowthDirection,
295+
containerDimensions: Dimensions,
296+
isContainerDescendentOfBoundary: boolean,
297+
visualViewport: VisualViewport | null
287298
) {
288-
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : boundaryDimensions[TOTAL_SIZE.height]);
289-
// 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
290-
// used in computePosition.
291-
let overlayTop = position.top != null ? containerOffsetWithBoundary.top + position.top : containerOffsetWithBoundary.top + (containerHeight - (position.bottom ?? 0) - overlayHeight);
299+
// For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top
300+
// with respect to the container.
301+
let overlayTop = (position.top != null ? position.top : (containerDimensions[TOTAL_SIZE.height] - (position.bottom ?? 0) - overlayHeight)) - (containerDimensions.scroll.top ?? 0);
302+
// calculate the dimentions of the "boundingRect" which is most restrictive top/bottom of the boundaryRect and the visual view port
303+
let boundaryToContainerTransformOffset = isContainerDescendentOfBoundary ? containerOffsetWithBoundary.top : 0;
304+
let boundingRect = {
305+
// This should be boundary top in container coord system vs viewport top in container coord system
306+
// For the viewport top, there are several cases
307+
// 1. pinchzoom case where we want the viewports offset top as top here
308+
// 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
309+
top: Math.max(boundaryDimensions.top + boundaryToContainerTransformOffset, (visualViewport?.offsetTop ?? boundaryDimensions.top) + boundaryToContainerTransformOffset),
310+
bottom: Math.min((boundaryDimensions.top + boundaryDimensions.height + boundaryToContainerTransformOffset), (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? 0))
311+
};
312+
292313
let maxHeight = heightGrowthDirection !== 'top' ?
293314
// We want the distance between the top of the overlay to the bottom of the boundary
294315
Math.max(0,
295-
(boundaryDimensions.height + boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the bottom of the boundary
316+
boundingRect.bottom // this is the bottom of the boundary
296317
- overlayTop // this is the top of the overlay
297318
- ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding
298319
)
299320
// We want the distance between the bottom of the overlay to the top of the boundary
300321
: Math.max(0,
301322
(overlayTop + overlayHeight) // this is the bottom of the overlay
302-
- (boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the top of the boundary
323+
- boundingRect.top // this is the top of the boundary
303324
- ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding
304325
);
305-
return Math.min(boundaryDimensions.height - (padding * 2), maxHeight);
326+
return maxHeight;
306327
}
307328

308329
function getAvailableSpace(
309-
boundaryDimensions: Dimensions,
330+
boundaryDimensions: Dimensions, // boundary
310331
containerOffsetWithBoundary: Offset,
311-
childOffset: Offset,
312-
margins: Position,
313-
padding: number,
314-
placementInfo: ParsedPlacement
332+
childOffset: Offset, // trigger, position based of container's non-viewport 0,0
333+
margins: Position, // overlay
334+
padding: number, // overlay <-> boundary
335+
placementInfo: ParsedPlacement,
336+
containerDimensions: Dimensions,
337+
isContainerDescendentOfBoundary: boolean
315338
) {
316339
let {placement, axis, size} = placementInfo;
317340
if (placement === axis) {
318-
return Math.max(0, childOffset[axis] - boundaryDimensions[axis] - (boundaryDimensions.scroll[axis] ?? 0) + containerOffsetWithBoundary[axis] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding);
341+
return Math.max(0,
342+
childOffset[axis] // trigger start
343+
- (containerDimensions.scroll[axis] ?? 0) // transform trigger position to be with respect to viewport 0,0
344+
- (boundaryDimensions[axis] + (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0)) // boundary start
345+
- (margins[axis] ?? 0) // margins usually for arrows or other decorations
346+
- margins[FLIPPED_DIRECTION[axis]]
347+
- padding); // padding between overlay and boundary
319348
}
320349

321-
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);
350+
return Math.max(0,
351+
(boundaryDimensions[size]
352+
+ boundaryDimensions[axis]
353+
+ (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0))
354+
- childOffset[axis]
355+
- childOffset[size]
356+
+ (containerDimensions.scroll[axis] ?? 0)
357+
- (margins[axis] ?? 0)
358+
- margins[FLIPPED_DIRECTION[axis]]
359+
- padding);
322360
}
323361

324362
export function calculatePositionInternal(
@@ -337,32 +375,39 @@ export function calculatePositionInternal(
337375
isContainerPositioned: boolean,
338376
userSetMaxHeight: number | undefined,
339377
arrowSize: number,
340-
arrowBoundaryOffset: number
378+
arrowBoundaryOffset: number,
379+
isContainerDescendentOfBoundary: boolean,
380+
visualViewport: VisualViewport | null
341381
): PositionResult {
342382
let placementInfo = parsePlacement(placementInput);
343383
let {size, crossAxis, crossSize, placement, crossPlacement} = placementInfo;
344-
let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
384+
let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
345385
let normalizedOffset = offset;
346386
let space = getAvailableSpace(
347387
boundaryDimensions,
348388
containerOffsetWithBoundary,
349389
childOffset,
350390
margins,
351391
padding + offset,
352-
placementInfo
392+
placementInfo,
393+
containerDimensions,
394+
isContainerDescendentOfBoundary
353395
);
354396

355397
// Check if the scroll size of the overlay is greater than the available space to determine if we need to flip
356-
if (flip && scrollSize[size] > space) {
398+
if (flip && overlaySize[size] > space) {
357399
let flippedPlacementInfo = parsePlacement(`${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement);
358-
let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
400+
let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
401+
359402
let flippedSpace = getAvailableSpace(
360403
boundaryDimensions,
361404
containerOffsetWithBoundary,
362405
childOffset,
363406
margins,
364407
padding + offset,
365-
flippedPlacementInfo
408+
flippedPlacementInfo,
409+
containerDimensions,
410+
isContainerDescendentOfBoundary
366411
);
367412

368413
// If the available space for the flipped position is greater than the original available space, flip.
@@ -400,7 +445,10 @@ export function calculatePositionInternal(
400445
margins,
401446
padding,
402447
overlaySize.height,
403-
heightGrowthDirection
448+
heightGrowthDirection,
449+
containerDimensions,
450+
isContainerDescendentOfBoundary,
451+
visualViewport
404452
);
405453

406454
if (userSetMaxHeight && userSetMaxHeight < maxHeight) {
@@ -409,7 +457,7 @@ export function calculatePositionInternal(
409457

410458
overlaySize.height = Math.min(overlaySize.height, maxHeight);
411459

412-
position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
460+
position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
413461
delta = getDelta(crossAxis, position[crossAxis]!, overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary);
414462
position[crossAxis]! += delta;
415463

@@ -484,6 +532,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
484532
arrowBoundaryOffset = 0
485533
} = opts;
486534

535+
let visualViewport = getVisualViewport();
487536
let container = overlayNode instanceof HTMLElement ? getContainingBlock(overlayNode) : document.documentElement;
488537
let isViewportContainer = container === document.documentElement;
489538
const containerPositionStyle = window.getComputedStyle(container).position;
@@ -502,17 +551,19 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
502551
overlaySize.height += (margins.top ?? 0) + (margins.bottom ?? 0);
503552

504553
let scrollSize = getScroll(scrollNode);
505-
let boundaryDimensions = getContainerDimensions(boundaryElement);
506-
let containerDimensions = getContainerDimensions(container);
554+
555+
// Note that due to logic inside getContainerDimensions, for cases where the boundary element is the body, we will return
556+
// 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)
557+
// 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)
558+
// 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)
559+
let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport);
560+
let containerDimensions = getContainerDimensions(container, visualViewport);
507561
// If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the
508562
// 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
509563
// by the container scroll since they are essentially the same containing element and thus in the same coordinate system
510-
let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(container, boundaryElement, false);
511-
if (container.tagName === 'HTML' && boundaryElement.tagName === 'BODY') {
512-
containerDimensions.scroll.top = 0;
513-
containerDimensions.scroll.left = 0;
514-
}
564+
let containerOffsetWithBoundary: Offset = getPosition(boundaryElement, container, false);
515565

566+
let isContainerDescendentOfBoundary = boundaryElement.contains(container);
516567
return calculatePositionInternal(
517568
placement,
518569
childOffset,
@@ -529,7 +580,9 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
529580
isContainerPositioned,
530581
maxHeight,
531582
arrowSize,
532-
arrowBoundaryOffset
583+
arrowBoundaryOffset,
584+
isContainerDescendentOfBoundary,
585+
visualViewport
533586
);
534587
}
535588

packages/@react-aria/overlays/test/calculatePosition.test.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,14 @@ describe('calculatePosition', function () {
109109
// The tests are all based on top/left positioning. Convert to bottom/right positioning if needed.
110110
let pos: {right?: number, top?: number, left?: number, bottom?: number} = {};
111111
if ((placementAxis === 'left' && !flip) || (placementAxis === 'right' && flip)) {
112-
pos.right = boundaryDimensions.width - (expected[0] + overlaySize.width);
112+
pos.right = containerDimensions.width - (expected[0] + overlaySize.width);
113113
pos.top = expected[1];
114114
} else if ((placementAxis === 'right' && !flip) || (placementAxis === 'left' && flip)) {
115115
pos.left = expected[0];
116116
pos.top = expected[1];
117117
} else if (placementAxis === 'top') {
118118
pos.left = expected[0];
119-
pos.bottom = boundaryDimensions.height - providerOffset - (expected[1] + overlaySize.height);
119+
pos.bottom = containerDimensions.height - (expected[1] + overlaySize.height);
120120
} else if (placementAxis === 'bottom') {
121121
pos.left = expected[0];
122122
pos.top = expected[1];
@@ -138,13 +138,16 @@ describe('calculatePosition', function () {
138138
};
139139

140140
const container = createElementWithDimensions('div', containerDimensions);
141+
Object.assign(container.style, {
142+
position: 'relative'
143+
});
141144
const target = createElementWithDimensions('div', targetDimension);
142145
const overlay = createElementWithDimensions('div', overlaySize, margins);
143146

144147
const parentElement = document.createElement('div');
145148
parentElement.appendChild(container);
146149
parentElement.appendChild(target);
147-
parentElement.appendChild(overlay);
150+
container.appendChild(overlay);
148151

149152
document.documentElement.appendChild(parentElement);
150153

@@ -330,6 +333,22 @@ describe('calculatePosition', function () {
330333

331334
testCases.forEach(function (testCase) {
332335
const {placement} = testCase;
336+
beforeEach(() => {
337+
window.visualViewport = {
338+
offsetTop: 0,
339+
height: 600,
340+
offsetLeft: 0,
341+
scale: 1,
342+
width: 0,
343+
addEventListener: () => {},
344+
removeEventListener: () => {},
345+
dispatchEvent: () => true,
346+
onresize: () => {},
347+
onscroll: () => {},
348+
pageLeft: 0,
349+
pageTop: 0
350+
} as VisualViewport;
351+
});
333352

334353
describe(`placement = ${placement}`, function () {
335354
describe('no viewport offset', function () {

0 commit comments

Comments
 (0)