Skip to content

Commit 743c39f

Browse files
committed
fix(cdk/overlay): sub-pixel deviations throwing off positioning in some cases
If the browser is zoomed in beyond the default level, it may report numbers with sub-pixel deviations to `getBoundingClientRect` (e.g. 100.09 vs 100) which can throw off our logic when comparing against the viewport size which is always a whole number. These changes fix the issue by rounding down the numbers that we get from `getBoundingClientRect`. Fixes #21350.
1 parent 71b7b15 commit 743c39f

File tree

2 files changed

+73
-10
lines changed

2 files changed

+73
-10
lines changed

src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {ComponentPortal, PortalModule} from '@angular/cdk/portal';
22
import {CdkScrollable, ScrollingModule, ViewportRuler} from '@angular/cdk/scrolling';
3-
import {MockNgZone} from '@angular/cdk/testing/private';
3+
import {dispatchFakeEvent, MockNgZone} from '@angular/cdk/testing/private';
44
import {Component, ElementRef, NgModule, NgZone} from '@angular/core';
5-
import {inject, TestBed} from '@angular/core/testing';
5+
import {fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
66
import {Subscription} from 'rxjs';
77
import {map} from 'rxjs/operators';
88
import {
@@ -2194,7 +2194,51 @@ describe('FlexibleConnectedPositionStrategy', () => {
21942194
expect(Math.floor(overlayRect.width)).toBe(rightOffset);
21952195
});
21962196

2197+
it('should account for sub-pixel deviations in the size of the overlay', fakeAsync(() => {
2198+
originElement.style.top = '200px';
2199+
originElement.style.left = '200px';
2200+
2201+
positionStrategy
2202+
.withFlexibleDimensions()
2203+
.withPositions([{
2204+
originX: 'start',
2205+
originY: 'bottom',
2206+
overlayX: 'start',
2207+
overlayY: 'top'
2208+
}]);
2209+
2210+
attachOverlay({
2211+
positionStrategy,
2212+
height: '100%'
2213+
});
21972214

2215+
const originalGetBoundingClientRect = overlayRef.overlayElement.getBoundingClientRect;
2216+
2217+
// The browser may return a `ClientRect` with sub-pixel deviations if the screen is zoomed in.
2218+
// Since there's no way for us to zoom in the screen programmatically, we simulate the effect
2219+
// by patching `getBoundingClientRect` to return a slightly different value.
2220+
overlayRef.overlayElement.getBoundingClientRect = function() {
2221+
const clientRect = originalGetBoundingClientRect.apply(this);
2222+
const zoomOffset = 0.1;
2223+
2224+
return {
2225+
top: clientRect.top,
2226+
right: clientRect.right + zoomOffset,
2227+
bottom: clientRect.bottom + zoomOffset,
2228+
left: clientRect.left,
2229+
width: clientRect.width + zoomOffset,
2230+
height: clientRect.height + zoomOffset
2231+
} as any;
2232+
};
2233+
2234+
// Trigger a resize so that the overlay get repositioned from scratch
2235+
// and to have it use the patched `getBoundingClientRect`.
2236+
dispatchFakeEvent(window, 'resize');
2237+
tick(100); // The resize listener is usually debounced.
2238+
2239+
const overlayRect = originalGetBoundingClientRect.apply(overlayRef.overlayElement);
2240+
expect(Math.floor(overlayRect.top)).toBe(0);
2241+
}));
21982242

21992243
});
22002244

src/cdk/overlay/position/flexible-connected-position-strategy.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
206206
// We use the viewport rect to determine whether a position would go off-screen.
207207
this._viewportRect = this._getNarrowedViewportRect();
208208
this._originRect = this._getOriginRect();
209-
this._overlayRect = this._pane.getBoundingClientRect();
209+
this._overlayRect = getRoundedBoundingClientRect(this._pane);
210210

211211
const originRect = this._originRect;
212212
const overlayRect = this._overlayRect;
@@ -345,7 +345,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
345345
reapplyLastPosition(): void {
346346
if (!this._isDisposed && (!this._platform || this._platform.isBrowser)) {
347347
this._originRect = this._getOriginRect();
348-
this._overlayRect = this._pane.getBoundingClientRect();
348+
this._overlayRect = getRoundedBoundingClientRect(this._pane);
349349
this._viewportRect = this._getNarrowedViewportRect();
350350

351351
const lastPosition = this._lastPosition || this._preferredPositions[0];
@@ -931,8 +931,8 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
931931
overlayPoint = this._pushOverlayOnScreen(overlayPoint, this._overlayRect, scrollPosition);
932932
}
933933

934-
let virtualKeyboardOffset =
935-
this._overlayContainer.getContainerElement().getBoundingClientRect().top;
934+
const virtualKeyboardOffset =
935+
getRoundedBoundingClientRect(this._overlayContainer.getContainerElement()).top;
936936

937937
// Normally this would be zero, however when the overlay is attached to an input (e.g. in an
938938
// autocomplete), mobile browsers will shift everything in order to put the input in the middle
@@ -998,13 +998,13 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
998998
private _getScrollVisibility(): ScrollingVisibility {
999999
// Note: needs fresh rects since the position could've changed.
10001000
const originBounds = this._getOriginRect();
1001-
const overlayBounds = this._pane.getBoundingClientRect();
1001+
const overlayBounds = getRoundedBoundingClientRect(this._pane);
10021002

10031003
// TODO(jelbourn): instead of needing all of the client rects for these scrolling containers
10041004
// every time, we should be able to use the scrollTop of the containers if the size of those
10051005
// containers hasn't changed.
10061006
const scrollContainerBounds = this._scrollables.map(scrollable => {
1007-
return scrollable.getElementRef().nativeElement.getBoundingClientRect();
1007+
return getRoundedBoundingClientRect(scrollable.getElementRef().nativeElement);
10081008
});
10091009

10101010
return {
@@ -1109,12 +1109,12 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
11091109
const origin = this._origin;
11101110

11111111
if (origin instanceof ElementRef) {
1112-
return origin.nativeElement.getBoundingClientRect();
1112+
return getRoundedBoundingClientRect(origin.nativeElement);
11131113
}
11141114

11151115
// Check for Element so SVG elements are also supported.
11161116
if (origin instanceof Element) {
1117-
return origin.getBoundingClientRect();
1117+
return getRoundedBoundingClientRect(origin);
11181118
}
11191119

11201120
const width = origin.width || 0;
@@ -1219,3 +1219,22 @@ function getPixelValue(input: number|string|null|undefined): number|null {
12191219

12201220
return input || null;
12211221
}
1222+
1223+
/**
1224+
* Gets a version of an element's bounding `ClientRect` where all the values are rounded down to
1225+
* the nearest pixel. This allows us to account for the cases where there may be sub-pixel
1226+
* deviations in the `ClientRect` returned by the browser (e.g. when zoomed in with a percentage
1227+
* size, see #21350).
1228+
*/
1229+
function getRoundedBoundingClientRect(element: Element): ClientRect {
1230+
const clientRect = element.getBoundingClientRect();
1231+
1232+
return {
1233+
top: Math.round(clientRect.top),
1234+
right: Math.round(clientRect.right),
1235+
bottom: Math.round(clientRect.bottom),
1236+
left: Math.round(clientRect.left),
1237+
width: Math.round(clientRect.width),
1238+
height: Math.round(clientRect.height)
1239+
};
1240+
}

0 commit comments

Comments
 (0)