Skip to content

Commit 38a7696

Browse files
fix: Popover positioning and various other bugs (#8816)
* fix: popover positioning * fix the position * add pending state to action buttons * support a default label slot in s2 picker * make it so cell focus ring is always on top of the content * actually disable interactions on buttons in pending state, including through context * fix lint * fix more lint * fix ActionButton avatar visibility during pending * fix lint for real * fix lint for real__FINAL * SelectBoxGroup shouldn't lose focus ring for mouse like checkbox/radio don't * fix lint... * fix lint and remove extra console log * fix tests by making them more realistic * leave focus events on pending button alone * Add delays * longer delay? and click body first? * click body before on mobile as well * cleanup extraneous timeouts * scuffed fix, figure out if we should be flipping other instances of containerOffsetWithBoundary * make story more useful * more half fixes for positioning * fix import * fix overlay position!!!!! * fix html as the container * fix scrolled case --------- Co-authored-by: Daniel Lu <[email protected]>
1 parent 89e9e5b commit 38a7696

File tree

14 files changed

+304
-127
lines changed

14 files changed

+304
-127
lines changed

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

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ function getContainerDimensions(containerNode: Element): Dimensions {
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+
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[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,55 @@ function getMaxHeight(
283291
margins: Position,
284292
padding: number,
285293
overlayHeight: number,
286-
heightGrowthDirection: HeightGrowthDirection
294+
heightGrowthDirection: HeightGrowthDirection,
295+
containerDimensions: Dimensions
287296
) {
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);
297+
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : containerDimensions[TOTAL_SIZE.height]);
298+
// For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top
299+
// with respect to the container.
300+
let overlayTop = position.top != null ? position.top : (containerHeight - (position.bottom ?? 0) - overlayHeight);
292301
let maxHeight = heightGrowthDirection !== 'top' ?
293302
// We want the distance between the top of the overlay to the bottom of the boundary
294303
Math.max(0,
295-
(boundaryDimensions.height + boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the bottom of the boundary
304+
(boundaryDimensions.height + boundaryDimensions.top) // this is the bottom of the boundary
296305
- overlayTop // this is the top of the overlay
297306
- ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding
298307
)
299308
// We want the distance between the bottom of the overlay to the top of the boundary
300309
: Math.max(0,
301310
(overlayTop + overlayHeight) // this is the bottom of the overlay
302-
- (boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the top of the boundary
311+
- boundaryDimensions.top // this is the top of the boundary
303312
- ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding
304313
);
305314
return Math.min(boundaryDimensions.height - (padding * 2), maxHeight);
306315
}
307316

308317
function getAvailableSpace(
309-
boundaryDimensions: Dimensions,
318+
boundaryDimensions: Dimensions, // boundary
310319
containerOffsetWithBoundary: Offset,
311-
childOffset: Offset,
312-
margins: Position,
313-
padding: number,
320+
childOffset: Offset, // trigger
321+
margins: Position, // overlay
322+
padding: number, // overlay <-> boundary
314323
placementInfo: ParsedPlacement
315324
) {
316325
let {placement, axis, size} = placementInfo;
317326
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);
327+
return Math.max(0,
328+
childOffset[axis] // trigger start
329+
- boundaryDimensions[axis] // boundary start
330+
- (margins[axis] ?? 0) // margins usually for arrows or other decorations
331+
- margins[FLIPPED_DIRECTION[axis]]
332+
- padding); // padding between overlay and boundary
319333
}
320334

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);
335+
return Math.max(0,
336+
boundaryDimensions[size]
337+
+ boundaryDimensions[axis]
338+
- childOffset[axis]
339+
- childOffset[size]
340+
- (margins[axis] ?? 0)
341+
- margins[FLIPPED_DIRECTION[axis]]
342+
- padding);
322343
}
323344

324345
export function calculatePositionInternal(
@@ -341,7 +362,7 @@ export function calculatePositionInternal(
341362
): PositionResult {
342363
let placementInfo = parsePlacement(placementInput);
343364
let {size, crossAxis, crossSize, placement, crossPlacement} = placementInfo;
344-
let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
365+
let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
345366
let normalizedOffset = offset;
346367
let space = getAvailableSpace(
347368
boundaryDimensions,
@@ -355,7 +376,8 @@ export function calculatePositionInternal(
355376
// Check if the scroll size of the overlay is greater than the available space to determine if we need to flip
356377
if (flip && scrollSize[size] > space) {
357378
let flippedPlacementInfo = parsePlacement(`${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement);
358-
let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
379+
let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
380+
359381
let flippedSpace = getAvailableSpace(
360382
boundaryDimensions,
361383
containerOffsetWithBoundary,
@@ -400,7 +422,8 @@ export function calculatePositionInternal(
400422
margins,
401423
padding,
402424
overlaySize.height,
403-
heightGrowthDirection
425+
heightGrowthDirection,
426+
containerDimensions
404427
);
405428

406429
if (userSetMaxHeight && userSetMaxHeight < maxHeight) {
@@ -409,7 +432,7 @@ export function calculatePositionInternal(
409432

410433
overlaySize.height = Math.min(overlaySize.height, maxHeight);
411434

412-
position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset);
435+
position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions);
413436
delta = getDelta(crossAxis, position[crossAxis]!, overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary);
414437
position[crossAxis]! += delta;
415438

@@ -507,7 +530,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
507530
// If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the
508531
// 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
509532
// 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);
533+
let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(boundaryElement, container, false);
511534
if (container.tagName === 'HTML' && boundaryElement.tagName === 'BODY') {
512535
containerDimensions.scroll.top = 0;
513536
containerDimensions.scroll.left = 0;
@@ -536,7 +559,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
536559
export function getRect(node: Element, ignoreScale: boolean) {
537560
let {top, left, width, height} = node.getBoundingClientRect();
538561

539-
// Use offsetWidth and offsetHeight if this is an HTML element, so that
562+
// Use offsetWidth and offsetHeight if this is an HTML element, so that
540563
// the size is not affected by scale transforms.
541564
if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) {
542565
width = node.offsetWidth;

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

packages/@react-aria/overlays/test/useOverlayPosition.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ describe('useOverlayPosition', function () {
8989
position: absolute;
9090
z-index: 100000;
9191
left: 12px;
92-
bottom: 518px;
92+
bottom: 350px;
9393
max-height: 238px;
9494
`);
9595

@@ -281,7 +281,7 @@ describe('useOverlayPosition with positioned container', () => {
281281
z-index: 100000;
282282
left: 12px;
283283
top: 200px;
284-
max-height: 406px;
284+
max-height: 556px;
285285
`);
286286

287287
expect(overlay).toHaveTextContent('placement: bottom');
@@ -303,7 +303,7 @@ describe('useOverlayPosition with positioned container', () => {
303303
z-index: 100000;
304304
left: 12px;
305305
bottom: 300px;
306-
max-height: 238px;
306+
max-height: 88px;
307307
`);
308308

309309
expect(overlay).toHaveTextContent('placement: top');

packages/@react-spectrum/menu/chromatic/Submenu.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ Default.play = async ({canvasElement}) => {
143143
let body = canvasElement.ownerDocument.body;
144144
let menu = await within(body).getByRole('menu');
145145
let menuItems = within(menu).getAllByRole('menuitem');
146+
147+
// clean up any previous click state
148+
await userEvent.click(document.body);
146149
await userEvent.hover(menuItems[0]);
147150
let submenuTrigger = await within(body).findByText('Baseline');
148151
await userEvent.hover(submenuTrigger);
@@ -166,6 +169,9 @@ Mobile.play = async ({canvasElement}) => {
166169
let body = canvasElement.ownerDocument.body;
167170
let menu = await within(body).getByRole('menu');
168171
let menuItems = within(menu).getAllByRole('menuitem');
172+
173+
// clean up any previous click state
174+
await userEvent.click(document.body);
169175
await userEvent.click(menuItems[0]);
170176
await within(body).findByText('Baseline');
171177
};

0 commit comments

Comments
 (0)