Skip to content

Commit 476595b

Browse files
LFDanLuktaborssnowystinger
authored
Make submenu properly size itself to fill available room and improve overlay positioning when pinch zoomed (#5660)
* progress on adding 2d flipping * calculate max height of overlays using the overlay bottom instead of the trigger top in the submenu case, the offset of the menu means we need to calculate the max height with respect to the overlay bottom not the trigger itself. Use overlay size instead of position so that we dont run into complexities with if we are receiving "bottom" or "top" as overlay coords * fix some tests * fix more tests needed to refine the getDelta logic so that the popover position would be properly adjusted when top/bottom were outside the boundary and maxHeight wouldnt be adjusted to prevent that * todo: question about what the actual behavior should be * bounding the max height by the height of the boundary * getting rid of flip and fixed edge flip code mimic MacOS in how the submenus will simply be pushed up/down if they would exceed the boundary * update left bottom postion tests maxheight for leftbottom is calculated to the top of the boundary * fixing remaining test * some cleanup of old logic * missed some last cleanup * Fix overlay positioning when the boundary has been scrolled * fix max height calc and Safari overlay positioning when already pinch zoomed in * accidentally deleted too much * forgot a set of parens ugh * fix examples where triggers are placed at the bottom of the page and are scrolled into view * this works, but why... * fix logic * update copy --------- Co-authored-by: Kyle Taborski <ktabors@yahoo.com> Co-authored-by: Robert Snow <rsnow@adobe.com>
1 parent e4eae9d commit 476595b

File tree

3 files changed

+108
-46
lines changed

3 files changed

+108
-46
lines changed

.storybook/custom-addons/scrolling/index.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import {addons, makeDecorator} from '@storybook/addons';
22
import clsx from 'clsx';
33
import {getQueryParams} from '@storybook/client-api';
44
import React, {useEffect, useState} from 'react';
5-
import {useViewportSize} from '@react-aria/utils';
65

76
function ScrollingDecorator(props) {
87
let {children} = props;
98
let [isScrolling, setScrolling] = useState(getQueryParams()?.scrolling === 'true' || false);
10-
let {height: minHeight} = useViewportSize();
119

1210
useEffect(() => {
1311
let channel = addons.getChannel();
@@ -31,7 +29,7 @@ function ScrollingDecorator(props) {
3129
);
3230
} else {
3331
return (
34-
<StoryWrapper style={{...styles, minHeight: minHeight}}>
32+
<StoryWrapper style={{...styles, minHeight: '100svh'}}>
3533
{children}
3634
</StoryWrapper>
3735
);

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

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {Axis, Placement, PlacementAxis, SizeAxis} from '@react-types/overlays';
14-
import {clamp} from '@react-aria/utils';
14+
import {clamp, isWebKit} from '@react-aria/utils';
1515

1616
interface Position {
1717
top?: number,
@@ -61,6 +61,8 @@ interface PositionOpts {
6161
arrowBoundaryOffset?: number
6262
}
6363

64+
type HeightGrowthDirection = 'top' | 'bottom';
65+
6466
export interface PositionResult {
6567
position?: Position,
6668
arrowOffsetLeft?: number,
@@ -106,16 +108,24 @@ let visualViewport = typeof document !== 'undefined' && window.visualViewport;
106108
function getContainerDimensions(containerNode: Element): Dimensions {
107109
let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0;
108110
let scroll: Position = {};
111+
let isPinchZoomedIn = visualViewport?.scale > 1;
109112

110113
if (containerNode.tagName === 'BODY') {
111114
let documentElement = document.documentElement;
112115
totalWidth = documentElement.clientWidth;
113116
totalHeight = documentElement.clientHeight;
114117
width = visualViewport?.width ?? totalWidth;
115118
height = visualViewport?.height ?? totalHeight;
116-
117119
scroll.top = documentElement.scrollTop || containerNode.scrollTop;
118120
scroll.left = documentElement.scrollLeft || containerNode.scrollLeft;
121+
122+
// The goal of the below is to get a top/left value that represents the top/left of the visual viewport with
123+
// respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate
124+
// coordinates/values with respect to the visual viewport or with respect to the layout viewport.
125+
if (visualViewport) {
126+
top = visualViewport.offsetTop;
127+
left = visualViewport.offsetLeft;
128+
}
119129
} else {
120130
({width, height, top, left} = getOffset(containerNode));
121131
scroll.top = containerNode.scrollTop;
@@ -124,6 +134,17 @@ function getContainerDimensions(containerNode: Element): Dimensions {
124134
totalHeight = height;
125135
}
126136

137+
if (isWebKit() && (containerNode.tagName === 'BODY' || containerNode.tagName === 'HTML') && isPinchZoomedIn) {
138+
// Safari will report a non-zero scrollTop/Left for the non-scrolling body/HTML element when pinch zoomed in unlike other browsers.
139+
// Set to zero for parity calculations so we get consistent positioning of overlays across all browsers.
140+
// Also switch to visualViewport.pageTop/pageLeft so that we still accomodate for scroll positioning for body/HTML elements that are actually scrollable
141+
// before pinch zoom happens
142+
scroll.top = 0;
143+
scroll.left = 0;
144+
top = visualViewport.pageTop;
145+
left = visualViewport.pageLeft;
146+
}
147+
127148
return {width, height, totalWidth, totalHeight, scroll, top, left};
128149
}
129150

@@ -136,6 +157,7 @@ function getScroll(node: Element): Offset {
136157
};
137158
}
138159

160+
// Determines the amount of space required when moving the overlay to ensure it remains in the boundary
139161
function getDelta(
140162
axis: Axis,
141163
offset: number,
@@ -149,17 +171,25 @@ function getDelta(
149171
// is portaled somewhere other than the body and has an ancestor with
150172
// position: relative/absolute, it will be different.
151173
containerDimensions: Dimensions,
152-
padding: number
174+
padding: number,
175+
containerOffsetWithBoundary: Offset
153176
) {
154177
let containerScroll = containerDimensions.scroll[axis];
155-
let boundaryHeight = boundaryDimensions[AXIS_SIZE[axis]];
156-
let startEdgeOffset = offset - padding - containerScroll;
157-
let endEdgeOffset = offset + padding - containerScroll + size;
158-
159-
if (startEdgeOffset < 0) {
160-
return -startEdgeOffset;
161-
} else if (endEdgeOffset > boundaryHeight) {
162-
return Math.max(boundaryHeight - endEdgeOffset, -startEdgeOffset);
178+
// The height/width of the boundary. Matches the axis along which we are adjusting the overlay position
179+
let boundarySize = boundaryDimensions[AXIS_SIZE[axis]];
180+
// Calculate the edges of the boundary (accomodating for the boundary padding) and the edges of the overlay.
181+
// 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+
// If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's
188+
// edges with the closest boundary edge.
189+
if (startEdgeOffset < boundaryStartEdge) {
190+
return boundaryStartEdge - startEdgeOffset;
191+
} else if (endEdgeOffset > boundaryEndEdge) {
192+
return Math.max(boundaryEndEdge - endEdgeOffset, boundaryStartEdge - startEdgeOffset);
163193
} else {
164194
return 0;
165195
}
@@ -222,7 +252,7 @@ function computePosition(
222252
}/* else {
223253
the overlay top should match the button top
224254
} */
225-
// add the crossOffset from props
255+
226256
position[crossAxis] += crossOffset;
227257

228258
// overlay top overlapping arrow with button bottom
@@ -242,31 +272,37 @@ function computePosition(
242272
} else {
243273
position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset);
244274
}
245-
246275
return position;
247276
}
248277

249278
function getMaxHeight(
250279
position: Position,
251280
boundaryDimensions: Dimensions,
252281
containerOffsetWithBoundary: Offset,
253-
childOffset: Offset,
282+
isContainerPositioned: boolean,
254283
margins: Position,
255-
padding: number
284+
padding: number,
285+
overlayHeight: number,
286+
heightGrowthDirection: HeightGrowthDirection
256287
) {
257-
return position.top != null
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 - overlayHeight);
292+
let maxHeight = heightGrowthDirection !== 'top' ?
258293
// We want the distance between the top of the overlay to the bottom of the boundary
259-
? Math.max(0,
294+
Math.max(0,
260295
(boundaryDimensions.height + boundaryDimensions.top + boundaryDimensions.scroll.top) // this is the bottom of the boundary
261-
- (containerOffsetWithBoundary.top + position.top) // this is the top of the overlay
296+
- overlayTop // this is the top of the overlay
262297
- (margins.top + margins.bottom + padding) // save additional space for margin and padding
263298
)
264-
// We want the distance between the top of the trigger to the top of the boundary
299+
// We want the distance between the bottom of the overlay to the top of the boundary
265300
: Math.max(0,
266-
(childOffset.top + containerOffsetWithBoundary.top) // this is the top of the trigger
301+
(overlayTop + overlayHeight) // this is the bottom of the overlay
267302
- (boundaryDimensions.top + boundaryDimensions.scroll.top) // this is the top of the boundary
268303
- (margins.top + margins.bottom + padding) // save additional space for margin and padding
269304
);
305+
return Math.min(boundaryDimensions.height - (padding * 2), maxHeight);
270306
}
271307

272308
function getAvailableSpace(
@@ -337,16 +373,34 @@ export function calculatePositionInternal(
337373
}
338374
}
339375

340-
let delta = getDelta(crossAxis, position[crossAxis], overlaySize[crossSize], boundaryDimensions, containerDimensions, padding);
376+
// Determine the direction the height of the overlay can grow so that we can choose how to calculate the max height
377+
let heightGrowthDirection: HeightGrowthDirection = 'bottom';
378+
if (placementInfo.axis === 'top') {
379+
if (placementInfo.placement === 'top') {
380+
heightGrowthDirection = 'top';
381+
} else if (placementInfo.placement === 'bottom') {
382+
heightGrowthDirection = 'bottom';
383+
}
384+
} else if (placementInfo.crossAxis === 'top') {
385+
if (placementInfo.crossPlacement === 'top') {
386+
heightGrowthDirection = 'bottom';
387+
} else if (placementInfo.crossPlacement === 'bottom') {
388+
heightGrowthDirection = 'top';
389+
}
390+
}
391+
392+
let delta = getDelta(crossAxis, position[crossAxis], overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary);
341393
position[crossAxis] += delta;
342394

343395
let maxHeight = getMaxHeight(
344396
position,
345397
boundaryDimensions,
346398
containerOffsetWithBoundary,
347-
childOffset,
399+
isContainerPositioned,
348400
margins,
349-
padding
401+
padding,
402+
overlaySize.height,
403+
heightGrowthDirection
350404
);
351405

352406
if (userSetMaxHeight && userSetMaxHeight < maxHeight) {
@@ -356,7 +410,7 @@ export function calculatePositionInternal(
356410
overlaySize.height = Math.min(overlaySize.height, maxHeight);
357411

358412
position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
359-
delta = getDelta(crossAxis, position[crossAxis], overlaySize[crossSize], boundaryDimensions, containerDimensions, padding);
413+
delta = getDelta(crossAxis, position[crossAxis], overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary);
360414
position[crossAxis] += delta;
361415

362416
let arrowPosition: Position = {};
@@ -425,7 +479,14 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
425479
let scrollSize = getScroll(scrollNode);
426480
let boundaryDimensions = getContainerDimensions(boundaryElement);
427481
let containerDimensions = getContainerDimensions(container);
482+
// If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the
483+
// 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
484+
// by the container scroll since they are essentially the same containing element and thus in the same coordinate system
428485
let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container) : getPosition(container, boundaryElement);
486+
if (container.tagName === 'HTML' && boundaryElement.tagName === 'BODY') {
487+
containerDimensions.scroll.top = 0;
488+
containerDimensions.scroll.left = 0;
489+
}
429490

430491
return calculatePositionInternal(
431492
placement,

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

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ const PROVIDER_OFFSET = 50;
9999

100100
describe('calculatePosition', function () {
101101
function checkPositionCommon(title, expected, placement, targetDimension, boundaryDimensions, offset, crossOffset, flip, providerOffset = 0, arrowSize = 8, arrowBoundaryOffset = 0) {
102-
const placementAxis = placement.split(' ')[0];
102+
let placementArray = placement.split(' ');
103+
const placementAxis = placementArray[0];
104+
const placementCrossAxis = placementArray[1];
103105

104106
// The tests are all based on top/left positioning. Convert to bottom/right positioning if needed.
105107
let pos: {right?: number, top?: number, left?: number, bottom?: number} = {};
@@ -121,7 +123,8 @@ describe('calculatePosition', function () {
121123
position: pos,
122124
arrowOffsetLeft: expected[2],
123125
arrowOffsetTop: expected[3],
124-
maxHeight: expected[4] - (placementAxis !== 'top' ? providerOffset : 0),
126+
// Note that a crossAxis of 'bottom' indicates that the overlay grows towards the top since the bottom of the overlay aligns with the bottom of the trigger
127+
maxHeight: expected[4] - (placementAxis !== 'top' && placementCrossAxis !== 'bottom' ? providerOffset : 0),
125128
placement: flip ? FLIPPED_DIRECTION[placementAxis] : placementAxis
126129
};
127130

@@ -216,13 +219,13 @@ describe('calculatePosition', function () {
216219
},
217220
{
218221
placement: 'left bottom',
219-
noOffset: [50, 150, undefined, 196, 400],
220-
offsetBefore: [-200, 50, undefined, 50, 500],
221-
offsetAfter: [300, 350, undefined, 196, 200],
222-
crossAxisOffsetPositive: [50, 160, undefined, 196, 390],
223-
crossAxisOffsetNegative: [50, 140, undefined, 196, 410],
224-
mainAxisOffset: [40, 150, undefined, 196, 400],
225-
arrowBoundaryOffset: [50, 322, undefined, 176, 228]
222+
noOffset: [50, 150, undefined, 196, 300],
223+
offsetBefore: [-200, 50, undefined, 50, 200],
224+
offsetAfter: [300, 350, undefined, 196, 500],
225+
crossAxisOffsetPositive: [50, 160, undefined, 196, 310],
226+
crossAxisOffsetNegative: [50, 140, undefined, 196, 290],
227+
mainAxisOffset: [40, 150, undefined, 196, 300],
228+
arrowBoundaryOffset: [50, 322, undefined, 176, 472]
226229
},
227230
{
228231
placement: 'top',
@@ -231,7 +234,7 @@ describe('calculatePosition', function () {
231234
offsetAfter: [350, 300, 196, undefined, 450],
232235
crossAxisOffsetPositive: [210, 50, 196, undefined, 200],
233236
crossAxisOffsetNegative: [190, 50, 196, undefined, 200],
234-
mainAxisOffset: [200, 40, 196, undefined, 200],
237+
mainAxisOffset: [200, 40, 196, undefined, 190],
235238
arrowBoundaryOffset: [322, 50, 176, undefined, 200]
236239
},
237240
{
@@ -241,7 +244,7 @@ describe('calculatePosition', function () {
241244
offsetAfter: [350, 300, 196, undefined, 450],
242245
crossAxisOffsetPositive: [260, 50, 196, undefined, 200],
243246
crossAxisOffsetNegative: [240, 50, 196, undefined, 200],
244-
mainAxisOffset: [250, 40, 196, undefined, 200],
247+
mainAxisOffset: [250, 40, 196, undefined, 190],
245248
arrowBoundaryOffset: [322, 50, 176, undefined, 200]
246249
},
247250
{
@@ -251,7 +254,7 @@ describe('calculatePosition', function () {
251254
offsetAfter: [350, 300, 196, undefined, 450],
252255
crossAxisOffsetPositive: [160, 50, 196, undefined, 200],
253256
crossAxisOffsetNegative: [140, 50, 196, undefined, 200],
254-
mainAxisOffset: [150, 40, 196, undefined, 200],
257+
mainAxisOffset: [150, 40, 196, undefined, 190],
255258
arrowBoundaryOffset: [322, 50, 176, undefined, 200]
256259
},
257260
{
@@ -306,13 +309,13 @@ describe('calculatePosition', function () {
306309
},
307310
{
308311
placement: 'right bottom',
309-
noOffset: [350, 150, undefined, 196, 400],
310-
offsetBefore: [100, 50, undefined, 50, 500],
311-
offsetAfter: [600, 350, undefined, 196, 200],
312-
crossAxisOffsetPositive: [350, 160, undefined, 196, 390],
313-
crossAxisOffsetNegative: [350, 140, undefined, 196, 410],
314-
mainAxisOffset: [360, 150, undefined, 196, 400],
315-
arrowBoundaryOffset: [350, 322, undefined, 176, 228]
312+
noOffset: [350, 150, undefined, 196, 300],
313+
offsetBefore: [100, 50, undefined, 50, 200],
314+
offsetAfter: [600, 350, undefined, 196, 500],
315+
crossAxisOffsetPositive: [350, 160, undefined, 196, 310],
316+
crossAxisOffsetNegative: [350, 140, undefined, 196, 290],
317+
mainAxisOffset: [360, 150, undefined, 196, 300],
318+
arrowBoundaryOffset: [350, 322, undefined, 176, 472]
316319
}
317320
];
318321

0 commit comments

Comments
 (0)