Skip to content

Commit bc96908

Browse files
authored
Mobile overlay positioning fixes (#4330)
1 parent 7db0dd3 commit bc96908

File tree

6 files changed

+77
-9
lines changed

6 files changed

+77
-9
lines changed

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

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ interface Position {
2323
interface Dimensions {
2424
width: number,
2525
height: number,
26+
totalWidth: number,
27+
totalHeight: number,
2628
top: number,
2729
left: number,
2830
scroll: Position
@@ -91,29 +93,38 @@ const AXIS_SIZE = {
9193
left: 'width'
9294
};
9395

96+
const TOTAL_SIZE = {
97+
width: 'totalWidth',
98+
height: 'totalHeight'
99+
};
100+
94101
const PARSED_PLACEMENT_CACHE = {};
95102

96103
// @ts-ignore
97104
let visualViewport = typeof window !== 'undefined' && window.visualViewport;
98105

99106
function getContainerDimensions(containerNode: Element): Dimensions {
100-
let width = 0, height = 0, top = 0, left = 0;
107+
let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0;
101108
let scroll: Position = {};
102109

103110
if (containerNode.tagName === 'BODY') {
104111
let documentElement = document.documentElement;
105-
width = visualViewport?.width ?? documentElement.clientWidth;
106-
height = visualViewport?.height ?? documentElement.clientHeight;
112+
totalWidth = documentElement.clientWidth;
113+
totalHeight = documentElement.clientHeight;
114+
width = visualViewport?.width ?? totalWidth;
115+
height = visualViewport?.height ?? totalHeight;
107116

108117
scroll.top = documentElement.scrollTop || containerNode.scrollTop;
109118
scroll.left = documentElement.scrollLeft || containerNode.scrollLeft;
110119
} else {
111120
({width, height, top, left} = getOffset(containerNode));
112121
scroll.top = containerNode.scrollTop;
113122
scroll.left = containerNode.scrollLeft;
123+
totalWidth = width;
124+
totalHeight = height;
114125
}
115126

116-
return {width, height, scroll, top, left};
127+
return {width, height, totalWidth, totalHeight, scroll, top, left};
117128
}
118129

119130
function getScroll(node: Element): Offset {
@@ -219,7 +230,7 @@ function computePosition(
219230
// height, as `bottom` will be relative to this height. But if the container is static,
220231
// then it can only be the `document.body`, and `bottom` will be relative to _its_
221232
// container, which should be as large as boundaryDimensions.
222-
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[size]);
233+
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[TOTAL_SIZE[size]]);
223234
position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset);
224235
} else {
225236
position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset);
@@ -386,13 +397,13 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
386397
arrowBoundaryOffset = 0
387398
} = opts;
388399

389-
let container = ((overlayNode instanceof HTMLElement && overlayNode.offsetParent) || document.body) as Element;
390-
let isBodyContainer = container.tagName === 'BODY';
400+
let container = overlayNode instanceof HTMLElement ? getContainingBlock(overlayNode) : document.documentElement;
401+
let isViewportContainer = container === document.documentElement;
391402
const containerPositionStyle = window.getComputedStyle(container).position;
392403
let isContainerPositioned = !!containerPositionStyle && containerPositionStyle !== 'static';
393-
let childOffset: Offset = isBodyContainer ? getOffset(targetNode) : getPosition(targetNode, container);
404+
let childOffset: Offset = isViewportContainer ? getOffset(targetNode) : getPosition(targetNode, container);
394405

395-
if (!isBodyContainer) {
406+
if (!isViewportContainer) {
396407
let {marginTop, marginLeft} = window.getComputedStyle(targetNode);
397408
childOffset.top += parseInt(marginTop, 10) || 0;
398409
childOffset.left += parseInt(marginLeft, 10) || 0;
@@ -457,3 +468,54 @@ function getPosition(node: Element, parent: Element): Offset {
457468
offset.left -= parseInt(style.marginLeft, 10) || 0;
458469
return offset;
459470
}
471+
472+
// Returns the containing block of an element, which is the element that
473+
// this element will be positioned relative to.
474+
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block
475+
function getContainingBlock(node: HTMLElement): Element {
476+
// The offsetParent of an element in most cases equals the containing block.
477+
// https://w3c.github.io/csswg-drafts/cssom-view/#dom-htmlelement-offsetparent
478+
let offsetParent = node.offsetParent;
479+
480+
// The offsetParent algorithm terminates at the document body,
481+
// even if the body is not a containing block. Double check that
482+
// and use the documentElement if so.
483+
if (
484+
offsetParent &&
485+
offsetParent === document.body &&
486+
window.getComputedStyle(offsetParent).position === 'static' &&
487+
!isContainingBlock(offsetParent)
488+
) {
489+
offsetParent = document.documentElement;
490+
}
491+
492+
// TODO(later): handle table elements?
493+
494+
// The offsetParent can be null if the element has position: fixed, or a few other cases.
495+
// We have to walk up the tree manually in this case because fixed positioned elements
496+
// are still positioned relative to their containing block, which is not always the viewport.
497+
if (offsetParent == null) {
498+
offsetParent = node.parentElement;
499+
while (offsetParent && !isContainingBlock(offsetParent)) {
500+
offsetParent = offsetParent.parentElement;
501+
}
502+
}
503+
504+
// Fall back to the viewport.
505+
return offsetParent || document.documentElement;
506+
}
507+
508+
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
509+
function isContainingBlock(node: Element): boolean {
510+
let style = window.getComputedStyle(node);
511+
return (
512+
style.transform !== 'none' ||
513+
/transform|perspective/.test(style.willChange) ||
514+
style.filter !== 'none' ||
515+
style.contain === 'paint' ||
516+
// @ts-ignore
517+
('backdropFilter' in style && style.backdropFilter !== 'none') ||
518+
// @ts-ignore
519+
('WebkitBackdropFilter' in style && style.WebkitBackdropFilter !== 'none')
520+
);
521+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,11 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
184184
};
185185

186186
visualViewport?.addEventListener('resize', onResize);
187+
visualViewport?.addEventListener('scroll', onResize);
187188

188189
return () => {
189190
visualViewport?.removeEventListener('resize', onResize);
191+
visualViewport?.removeEventListener('scroll', onResize);
190192
};
191193
}, [updatePosition]);
192194

packages/react-aria-components/docs/ComboBox.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ import {ComboBox, Label, Input, Button, Popover, ListBox, Item} from 'react-aria
122122
--text-color-disabled: var(--spectrum-alias-text-color-disabled);
123123

124124
max-height: inherit;
125+
box-sizing: border-box;
125126
overflow: auto;
126127
padding: 2px;
127128
outline: none;

packages/react-aria-components/docs/Menu.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {MenuTrigger, Button, Popover, Menu, Item} from 'react-aria-components';
9595
--text-color-disabled: var(--spectrum-alias-text-color-disabled);
9696

9797
max-height: inherit;
98+
box-sizing: border-box;
9899
overflow: auto;
99100
padding: 2px;
100101
margin: 0;

packages/react-aria-components/docs/Select.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ import {Select, SelectValue, Label, Button, Popover, ListBox, Item} from 'react-
156156
--text-color-disabled: var(--spectrum-alias-text-color-disabled);
157157

158158
max-height: inherit;
159+
box-sizing: border-box;
159160
overflow: auto;
160161
padding: 2px;
161162
outline: none;

packages/react-aria-components/docs/react-aria-components.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ import {Select, SelectValue, Label, Button, Popover, ListBox, Item} from 'react-
194194
--text-color-disabled: var(--spectrum-alias-text-color-disabled);
195195

196196
max-height: inherit;
197+
box-sizing: border-box;
197198
overflow: auto;
198199
padding: 2px;
199200
outline: none;

0 commit comments

Comments
 (0)