Skip to content

Commit ebb5629

Browse files
committed
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.
1 parent 7b90d62 commit ebb5629

File tree

2 files changed

+85
-35
lines changed

2 files changed

+85
-35
lines changed

src/lib/core/overlay/position/connected-position-strategy.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,36 @@ describe('ConnectedPositionStrategy', () => {
211211
expect(overlayRect.right).toBe(originRect.left);
212212
});
213213

214+
it('should pick the fallback position that shows the largest area of the element', () => {
215+
// Use the fake viewport ruler because we don't know *exactly* how big the viewport is.
216+
fakeViewportRuler.fakeRect = {
217+
top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500
218+
};
219+
positionBuilder = new OverlayPositionBuilder(fakeViewportRuler);
220+
221+
originElement.style.top = '200px';
222+
originElement.style.left = '475px';
223+
originRect = originElement.getBoundingClientRect();
224+
225+
strategy = positionBuilder.connectedTo(
226+
fakeElementRef,
227+
{originX: 'end', originY: 'center'},
228+
{overlayX: 'start', overlayY: 'center'})
229+
.withFallbackPosition(
230+
{originX: 'end', originY: 'top'},
231+
{overlayX: 'start', overlayY: 'bottom'})
232+
.withFallbackPosition(
233+
{originX: 'end', originY: 'top'},
234+
{overlayX: 'end', overlayY: 'top'});
235+
236+
strategy.apply(overlayElement);
237+
238+
let overlayRect = overlayElement.getBoundingClientRect();
239+
240+
expect(overlayRect.top).toBe(originRect.top);
241+
expect(overlayRect.left).toBe(originRect.left);
242+
});
243+
214244
it('should position a panel properly when rtl', () => {
215245
// must make the overlay longer than the origin to properly test attachment
216246
overlayElement.style.width = `500px`;

src/lib/core/overlay/position/connected-position-strategy.ts

Lines changed: 55 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,33 @@ export class ConnectedPositionStrategy implements PositionStrategy {
7676

7777
// We use the viewport rect to determine whether a position would go off-screen.
7878
const viewportRect = this._viewportRuler.getViewportRect();
79-
let firstOverlayPoint: Point = null;
79+
let attemptedPositions: OverlayPoint[] = [];
8080

8181
// We want to place the overlay in the first of the preferred positions such that the
8282
// overlay fits on-screen.
8383
for (let pos of this._preferredPositions) {
8484
// Get the (x, y) point of connection on the origin, and then use that to get the
8585
// (top, left) coordinate for the overlay at `pos`.
8686
let originPoint = this._getOriginConnectionPoint(originRect, pos);
87-
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, pos);
88-
firstOverlayPoint = firstOverlayPoint || overlayPoint;
87+
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, pos);
8988

9089
// If the overlay in the calculated position fits on-screen, put it there and we're done.
91-
if (this._willOverlayFitWithinViewport(overlayPoint, overlayRect, viewportRect)) {
90+
if (overlayPoint.fitsInViewport) {
9291
this._setElementPosition(element, overlayPoint);
9392
this._onPositionChange.next(new ConnectedOverlayPositionChange(pos));
9493
return Promise.resolve(null);
94+
} else {
95+
attemptedPositions.push(overlayPoint);
9596
}
9697
}
9798

98-
// TODO(jelbourn): fallback behavior for when none of the preferred positions fit on-screen.
99-
// For now, just stick it in the first position and let it go off-screen.
100-
this._setElementPosition(element, firstOverlayPoint);
99+
// If none of the preferred positions were in the viewport, rank them based on the
100+
// visible area that the element would have at that position and take the one with
101+
// largest visible area.
102+
this._setElementPosition(element, attemptedPositions.sort((a, b) => {
103+
return a.visibleArea - b.visibleArea;
104+
}).pop());
105+
101106
return Promise.resolve(null);
102107
}
103108

@@ -172,15 +177,14 @@ export class ConnectedPositionStrategy implements PositionStrategy {
172177

173178
/**
174179
* Gets the (x, y) coordinate of the top-left corner of the overlay given a given position and
175-
* origin point to which the overlay should be connected.
176-
* @param originPoint
177-
* @param overlayRect
178-
* @param pos
180+
* origin point to which the overlay should be connected, as well as how much of the element
181+
* would be inside the viewport at that position.
179182
*/
180183
private _getOverlayPoint(
181184
originPoint: Point,
182185
overlayRect: ClientRect,
183-
pos: ConnectionPositionPair): Point {
186+
viewportRect: ClientRect,
187+
pos: ConnectionPositionPair): OverlayPoint {
184188
// Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position
185189
// relative to the origin point.
186190
let overlayStartX: number;
@@ -199,31 +203,26 @@ export class ConnectedPositionStrategy implements PositionStrategy {
199203
overlayStartY = pos.overlayY == 'top' ? 0 : -overlayRect.height;
200204
}
201205

202-
return {
203-
x: originPoint.x + overlayStartX + this._offsetX,
204-
y: originPoint.y + overlayStartY + this._offsetY
205-
};
206-
}
206+
// The (x, y) coordinates of the overlay.
207+
let x = originPoint.x + overlayStartX + this._offsetX;
208+
let y = originPoint.y + overlayStartY + this._offsetY;
207209

210+
// How much the overlay would overflow at this position, on each side.
211+
let leftOverflow = viewportRect.left - x;
212+
let rightOverflow = (x + overlayRect.width) - viewportRect.right;
213+
let topOverflow = viewportRect.top - y;
214+
let bottomOverflow = (y + overlayRect.height) - viewportRect.bottom;
208215

209-
/**
210-
* Gets whether the overlay positioned at the given point will fit on-screen.
211-
* @param overlayPoint The top-left coordinate of the overlay.
212-
* @param overlayRect Bounding rect of the overlay, used to get its size.
213-
* @param viewportRect The bounding viewport.
214-
*/
215-
private _willOverlayFitWithinViewport(
216-
overlayPoint: Point,
217-
overlayRect: ClientRect,
218-
viewportRect: ClientRect): boolean {
216+
// Visible parts of the element on each axis.
217+
let visibleWidth = this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow);
218+
let visibleHeight = this._subtractOverflows(overlayRect.height, topOverflow, bottomOverflow);
219219

220-
// TODO(jelbourn): probably also want some space between overlay edge and viewport edge.
221-
return overlayPoint.x >= viewportRect.left &&
222-
overlayPoint.x + overlayRect.width <= viewportRect.right &&
223-
overlayPoint.y >= viewportRect.top &&
224-
overlayPoint.y + overlayRect.height <= viewportRect.bottom;
225-
}
220+
// The area of the element that's within the viewport.
221+
let visibleArea = visibleWidth * visibleHeight;
222+
let fitsInViewport = (overlayRect.width * overlayRect.height) === visibleArea;
226223

224+
return {x, y, fitsInViewport, visibleArea};
225+
}
227226

228227
/**
229228
* Physically positions the overlay element to the given coordinate.
@@ -234,8 +233,29 @@ export class ConnectedPositionStrategy implements PositionStrategy {
234233
element.style.left = overlayPoint.x + 'px';
235234
element.style.top = overlayPoint.y + 'px';
236235
}
237-
}
238236

237+
/**
238+
* Subtracts the amount that an element is overflowing on an axis from it's length.
239+
*/
240+
private _subtractOverflows(length: number, ...overflows: number[]): number {
241+
return overflows.reduce((currentValue: number, currentOverflow: number) => {
242+
return currentValue - Math.max(currentOverflow, 0);
243+
}, length);
244+
}
245+
}
239246

240247
/** A simple (x, y) coordinate. */
241-
type Point = {x: number, y: number};
248+
interface Point {
249+
x: number;
250+
y: number;
251+
};
252+
253+
/**
254+
* Expands the simple (x, y) coordinate by adding info about whether the
255+
* element would fit inside the viewport at that position, as well as
256+
* how much of the element would be visible.
257+
*/
258+
interface OverlayPoint extends Point {
259+
visibleArea?: number;
260+
fitsInViewport?: boolean;
261+
}

0 commit comments

Comments
 (0)