From ddb89e9b24a6698b72ec68eb73d5e735be2ba6f8 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Wed, 7 Dec 2016 22:48:17 +0100 Subject: [PATCH 1/2] feat(connected-position): apply the fallback position that shows the largest area of the element Switches the `connected-position` to pick the fallback position with the largest visible area, if all of the fallbacks didn't fit into the viewport. Fixes #2049. --- .../connected-position-strategy.spec.ts | 30 +++++++ .../position/connected-position-strategy.ts | 90 +++++++++++-------- 2 files changed, 85 insertions(+), 35 deletions(-) diff --git a/src/lib/core/overlay/position/connected-position-strategy.spec.ts b/src/lib/core/overlay/position/connected-position-strategy.spec.ts index 1884ca964270..e233c2d5cd01 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.spec.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.spec.ts @@ -211,6 +211,36 @@ describe('ConnectedPositionStrategy', () => { expect(overlayRect.right).toBe(originRect.left); }); + it('should pick the fallback position that shows the largest area of the element', () => { + // Use the fake viewport ruler because we don't know *exactly* how big the viewport is. + fakeViewportRuler.fakeRect = { + top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 + }; + positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); + + originElement.style.top = '200px'; + originElement.style.left = '475px'; + originRect = originElement.getBoundingClientRect(); + + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'end', originY: 'center'}, + {overlayX: 'start', overlayY: 'center'}) + .withFallbackPosition( + {originX: 'end', originY: 'top'}, + {overlayX: 'start', overlayY: 'bottom'}) + .withFallbackPosition( + {originX: 'end', originY: 'top'}, + {overlayX: 'end', overlayY: 'top'}); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + + expect(overlayRect.top).toBe(originRect.top); + expect(overlayRect.left).toBe(originRect.left); + }); + it('should position a panel properly when rtl', () => { // must make the overlay longer than the origin to properly test attachment overlayElement.style.width = `500px`; diff --git a/src/lib/core/overlay/position/connected-position-strategy.ts b/src/lib/core/overlay/position/connected-position-strategy.ts index b111f1279932..91f7b9a88b5f 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.ts @@ -76,7 +76,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { // We use the viewport rect to determine whether a position would go off-screen. const viewportRect = this._viewportRuler.getViewportRect(); - let firstOverlayPoint: Point = null; + let attemptedPositions: OverlayPoint[] = []; // We want to place the overlay in the first of the preferred positions such that the // overlay fits on-screen. @@ -84,20 +84,25 @@ export class ConnectedPositionStrategy implements PositionStrategy { // Get the (x, y) point of connection on the origin, and then use that to get the // (top, left) coordinate for the overlay at `pos`. let originPoint = this._getOriginConnectionPoint(originRect, pos); - let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, pos); - firstOverlayPoint = firstOverlayPoint || overlayPoint; + let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, pos); // If the overlay in the calculated position fits on-screen, put it there and we're done. - if (this._willOverlayFitWithinViewport(overlayPoint, overlayRect, viewportRect)) { + if (overlayPoint.fitsInViewport) { this._setElementPosition(element, overlayPoint); this._onPositionChange.next(new ConnectedOverlayPositionChange(pos)); return Promise.resolve(null); + } else { + attemptedPositions.push(overlayPoint); } } - // TODO(jelbourn): fallback behavior for when none of the preferred positions fit on-screen. - // For now, just stick it in the first position and let it go off-screen. - this._setElementPosition(element, firstOverlayPoint); + // If none of the preferred positions were in the viewport, rank them based on the + // visible area that the element would have at that position and take the one with + // largest visible area. + this._setElementPosition(element, attemptedPositions.sort((a, b) => { + return a.visibleArea - b.visibleArea; + }).pop()); + return Promise.resolve(null); } @@ -172,15 +177,14 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** * Gets the (x, y) coordinate of the top-left corner of the overlay given a given position and - * origin point to which the overlay should be connected. - * @param originPoint - * @param overlayRect - * @param pos + * origin point to which the overlay should be connected, as well as how much of the element + * would be inside the viewport at that position. */ private _getOverlayPoint( originPoint: Point, overlayRect: ClientRect, - pos: ConnectionPositionPair): Point { + viewportRect: ClientRect, + pos: ConnectionPositionPair): OverlayPoint { // Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position // relative to the origin point. let overlayStartX: number; @@ -199,31 +203,26 @@ export class ConnectedPositionStrategy implements PositionStrategy { overlayStartY = pos.overlayY == 'top' ? 0 : -overlayRect.height; } - return { - x: originPoint.x + overlayStartX + this._offsetX, - y: originPoint.y + overlayStartY + this._offsetY - }; - } + // The (x, y) coordinates of the overlay. + let x = originPoint.x + overlayStartX + this._offsetX; + let y = originPoint.y + overlayStartY + this._offsetY; + // How much the overlay would overflow at this position, on each side. + let leftOverflow = viewportRect.left - x; + let rightOverflow = (x + overlayRect.width) - viewportRect.right; + let topOverflow = viewportRect.top - y; + let bottomOverflow = (y + overlayRect.height) - viewportRect.bottom; - /** - * Gets whether the overlay positioned at the given point will fit on-screen. - * @param overlayPoint The top-left coordinate of the overlay. - * @param overlayRect Bounding rect of the overlay, used to get its size. - * @param viewportRect The bounding viewport. - */ - private _willOverlayFitWithinViewport( - overlayPoint: Point, - overlayRect: ClientRect, - viewportRect: ClientRect): boolean { + // Visible parts of the element on each axis. + let visibleWidth = this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow); + let visibleHeight = this._subtractOverflows(overlayRect.height, topOverflow, bottomOverflow); - // TODO(jelbourn): probably also want some space between overlay edge and viewport edge. - return overlayPoint.x >= 0 && - overlayPoint.x + overlayRect.width <= viewportRect.width && - overlayPoint.y >= 0 && - overlayPoint.y + overlayRect.height <= viewportRect.height; - } + // The area of the element that's within the viewport. + let visibleArea = visibleWidth * visibleHeight; + let fitsInViewport = (overlayRect.width * overlayRect.height) === visibleArea; + return {x, y, fitsInViewport, visibleArea}; + } /** * Physically positions the overlay element to the given coordinate. @@ -234,8 +233,29 @@ export class ConnectedPositionStrategy implements PositionStrategy { element.style.left = overlayPoint.x + 'px'; element.style.top = overlayPoint.y + 'px'; } -} + /** + * Subtracts the amount that an element is overflowing on an axis from it's length. + */ + private _subtractOverflows(length: number, ...overflows: number[]): number { + return overflows.reduce((currentValue: number, currentOverflow: number) => { + return currentValue - Math.max(currentOverflow, 0); + }, length); + } +} /** A simple (x, y) coordinate. */ -type Point = {x: number, y: number}; +interface Point { + x: number; + y: number; +}; + +/** + * Expands the simple (x, y) coordinate by adding info about whether the + * element would fit inside the viewport at that position, as well as + * how much of the element would be visible. + */ +interface OverlayPoint extends Point { + visibleArea?: number; + fitsInViewport?: boolean; +} From 2f5a2ae917df103fb0e76f43d93db2f5eb089f3b Mon Sep 17 00:00:00 2001 From: crisbeto Date: Wed, 7 Dec 2016 23:12:23 +0100 Subject: [PATCH 2/2] Sort the fallbacks within the same loop. --- .../position/connected-position-strategy.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/lib/core/overlay/position/connected-position-strategy.ts b/src/lib/core/overlay/position/connected-position-strategy.ts index 91f7b9a88b5f..828f80d6f57e 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.ts @@ -76,7 +76,9 @@ export class ConnectedPositionStrategy implements PositionStrategy { // We use the viewport rect to determine whether a position would go off-screen. const viewportRect = this._viewportRuler.getViewportRect(); - let attemptedPositions: OverlayPoint[] = []; + + // Fallback point if none of the fallbacks fit into the viewport. + let fallbackPoint: OverlayPoint = null; // We want to place the overlay in the first of the preferred positions such that the // overlay fits on-screen. @@ -91,17 +93,14 @@ export class ConnectedPositionStrategy implements PositionStrategy { this._setElementPosition(element, overlayPoint); this._onPositionChange.next(new ConnectedOverlayPositionChange(pos)); return Promise.resolve(null); - } else { - attemptedPositions.push(overlayPoint); + } else if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) { + fallbackPoint = overlayPoint; } } - // If none of the preferred positions were in the viewport, rank them based on the - // visible area that the element would have at that position and take the one with - // largest visible area. - this._setElementPosition(element, attemptedPositions.sort((a, b) => { - return a.visibleArea - b.visibleArea; - }).pop()); + // If none of the preferred positions were in the viewport, take the one + // with the largest visible area. + this._setElementPosition(element, fallbackPoint); return Promise.resolve(null); }