Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/@react-aria/overlays/src/calculatePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi
totalHeight = documentElement.clientHeight;
width = visualViewport?.width ?? totalWidth;
height = visualViewport?.height ?? totalHeight;

// If the visual viewport is larger than the client width, it means that the scrollbar gutter is taking up space
// that the visual viewport is not accounting for. In this case, we should cap the width at the client width.
width = Math.min(Math.round(width), totalWidth);

scroll.top = documentElement.scrollTop || containerNode.scrollTop;
scroll.left = documentElement.scrollLeft || containerNode.scrollLeft;

Expand Down
72 changes: 72 additions & 0 deletions packages/@react-aria/overlays/test/calculatePosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,76 @@ describe('calculatePosition', function () {
document.body.removeChild(target);
});
});

describe('visualViewport larger than clientWidth (scrollbar gutter issue)', () => {
let clientWidthSpy;

afterEach(() => {
if (clientWidthSpy) {
clientWidthSpy.mockRestore();
}
});

it('caps width at clientWidth', () => {
// Mock clientWidth to be smaller than visualViewport
clientWidthSpy = jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 985);

// Mock visualViewport
window.visualViewport = {
width: 1000,
height: 600,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
onresize: null,
onscroll: null
} as VisualViewport;

const target = document.createElement('div');
const overlayNode = document.createElement('div');
// Use body as boundary to trigger the specific code path
const container = document.body;

// Setup target position near the right edge
// Target at left=900, width=50. Center is 925.
jest.spyOn(target, 'getBoundingClientRect').mockImplementation(() => ({
top: 0, left: 900, width: 50, height: 50, right: 950, bottom: 50, x: 900, y: 0, toJSON: () => { }
}));

// Setup overlay size
// Width=200.
jest.spyOn(overlayNode, 'getBoundingClientRect').mockImplementation(() => ({
top: 0, left: 0, width: 200, height: 200, right: 200, bottom: 200, x: 0, y: 0, toJSON: () => { }
}));
jest.spyOn(overlayNode, 'offsetWidth', 'get').mockImplementation(() => 200);
jest.spyOn(overlayNode, 'offsetHeight', 'get').mockImplementation(() => 200);

let result = calculatePosition({
placement: 'bottom',
overlayNode,
targetNode: target,
scrollNode: overlayNode,
padding: 0,
shouldFlip: false,
boundaryElement: container,
offset: 0,
crossOffset: 0,
arrowSize: 0
});

// Expected calculation:
// Boundary width should be capped at 985 (clientWidth) instead of 1000 (visualViewport).
// Overlay width is 200.
// Max allowed left position = 985 - 200 = 785.
// Target center is 925. Centered overlay would be 925 - 100 = 825.
// 825 > 785, so it should be clamped to 785.

expect(result.position.left).toBe(785);
});
});
});