Skip to content

Commit 0c8cfa7

Browse files
authored
fix(cdk/overlay): sub-pixel deviations throwing off positioning in some cases (#21427)
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 54d6b3e commit 0c8cfa7

File tree

2 files changed

+71
-4
lines changed

2 files changed

+71
-4
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: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,9 +525,12 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
525525
}
526526

527527
/** Gets how well an overlay at the given point will fit within the viewport. */
528-
private _getOverlayFit(point: Point, overlay: ClientRect, viewport: ClientRect,
528+
private _getOverlayFit(point: Point, rawOverlayRect: ClientRect, viewport: ClientRect,
529529
position: ConnectedPosition): OverlayFit {
530530

531+
// Round the overlay rect when comparing against the
532+
// viewport, because the viewport is always rounded.
533+
const overlay = getRoundedBoundingClientRect(rawOverlayRect);
531534
let {x, y} = point;
532535
let offsetX = this._getOffset(position, 'x');
533536
let offsetY = this._getOffset(position, 'y');
@@ -595,7 +598,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
595598
* originPoint.
596599
*/
597600
private _pushOverlayOnScreen(start: Point,
598-
overlay: ClientRect,
601+
rawOverlayRect: ClientRect,
599602
scrollPosition: ViewportScrollPosition): Point {
600603
// If the position is locked and we've pushed the overlay already, reuse the previous push
601604
// amount, rather than pushing it again. If we were to continue pushing, the element would
@@ -607,6 +610,9 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
607610
};
608611
}
609612

613+
// Round the overlay rect when comparing against the
614+
// viewport, because the viewport is always rounded.
615+
const overlay = getRoundedBoundingClientRect(rawOverlayRect);
610616
const viewport = this._viewportRect;
611617

612618
// Determine how much the overlay goes outside the viewport on each
@@ -1219,3 +1225,20 @@ function getPixelValue(input: number|string|null|undefined): number|null {
12191225

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

0 commit comments

Comments
 (0)