Skip to content

Commit

Permalink
feat(connected-position): apply the fallback position that shows the …
Browse files Browse the repository at this point in the history
…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 angular#2049.
  • Loading branch information
crisbeto committed Dec 7, 2016
1 parent 7b90d62 commit ebb5629
Show file tree
Hide file tree
Showing 2 changed files with 85 additions and 35 deletions.
30 changes: 30 additions & 0 deletions src/lib/core/overlay/position/connected-position-strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
90 changes: 55 additions & 35 deletions src/lib/core/overlay/position/connected-position-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,28 +76,33 @@ 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.
for (let pos of this._preferredPositions) {
// 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);
}

Expand Down Expand Up @@ -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;
Expand All @@ -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 >= viewportRect.left &&
overlayPoint.x + overlayRect.width <= viewportRect.right &&
overlayPoint.y >= viewportRect.top &&
overlayPoint.y + overlayRect.height <= viewportRect.bottom;
}
// 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.
Expand All @@ -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;
}

0 comments on commit ebb5629

Please sign in to comment.