Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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);
Copy link
Contributor

@lixiaoyan lixiaoyan Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might not work either. Chrome will return an incorrect document.documentElement.clientWidth value, which includes the scrollbar width, if the document has scrollbar-gutter: stable; but no scrollbar is present.

See also w3c/csswg-drafts#8099 and https://interop-2022-viewport.netlify.app/scrollbar-gutter/short-stable/


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);
});
});
});