Skip to content

Commit 4f5b9c5

Browse files
crisbetojelbourn
authored andcommitted
feat(connected-position): apply the fallback position that shows the largest area of the element (#2102)
* 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. * Sort the fallbacks within the same loop.
1 parent 9b68e68 commit 4f5b9c5

File tree

2 files changed

+84
-35
lines changed

2 files changed

+84
-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: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,32 @@ 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+
80+
// Fallback point if none of the fallbacks fit into the viewport.
81+
let fallbackPoint: OverlayPoint = null;
8082

8183
// We want to place the overlay in the first of the preferred positions such that the
8284
// overlay fits on-screen.
8385
for (let pos of this._preferredPositions) {
8486
// Get the (x, y) point of connection on the origin, and then use that to get the
8587
// (top, left) coordinate for the overlay at `pos`.
8688
let originPoint = this._getOriginConnectionPoint(originRect, pos);
87-
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, pos);
88-
firstOverlayPoint = firstOverlayPoint || overlayPoint;
89+
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, pos);
8990

9091
// If the overlay in the calculated position fits on-screen, put it there and we're done.
91-
if (this._willOverlayFitWithinViewport(overlayPoint, overlayRect, viewportRect)) {
92+
if (overlayPoint.fitsInViewport) {
9293
this._setElementPosition(element, overlayPoint);
9394
this._onPositionChange.next(new ConnectedOverlayPositionChange(pos));
9495
return Promise.resolve(null);
96+
} else if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) {
97+
fallbackPoint = overlayPoint;
9598
}
9699
}
97100

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);
101+
// If none of the preferred positions were in the viewport, take the one
102+
// with the largest visible area.
103+
this._setElementPosition(element, fallbackPoint);
104+
101105
return Promise.resolve(null);
102106
}
103107

@@ -172,15 +176,14 @@ export class ConnectedPositionStrategy implements PositionStrategy {
172176

173177
/**
174178
* 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
179+
* origin point to which the overlay should be connected, as well as how much of the element
180+
* would be inside the viewport at that position.
179181
*/
180182
private _getOverlayPoint(
181183
originPoint: Point,
182184
overlayRect: ClientRect,
183-
pos: ConnectionPositionPair): Point {
185+
viewportRect: ClientRect,
186+
pos: ConnectionPositionPair): OverlayPoint {
184187
// Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position
185188
// relative to the origin point.
186189
let overlayStartX: number;
@@ -199,31 +202,26 @@ export class ConnectedPositionStrategy implements PositionStrategy {
199202
overlayStartY = pos.overlayY == 'top' ? 0 : -overlayRect.height;
200203
}
201204

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

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

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 {
215+
// Visible parts of the element on each axis.
216+
let visibleWidth = this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow);
217+
let visibleHeight = this._subtractOverflows(overlayRect.height, topOverflow, bottomOverflow);
219218

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

223+
return {x, y, fitsInViewport, visibleArea};
224+
}
227225

228226
/**
229227
* Physically positions the overlay element to the given coordinate.
@@ -234,8 +232,29 @@ export class ConnectedPositionStrategy implements PositionStrategy {
234232
element.style.left = overlayPoint.x + 'px';
235233
element.style.top = overlayPoint.y + 'px';
236234
}
237-
}
238235

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

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

0 commit comments

Comments
 (0)