Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(connected-position): apply the fallback position that shows the largest area of the element #2102

Merged
merged 2 commits into from
Dec 20, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
89 changes: 54 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,32 @@ 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;

// 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.
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 if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) {
fallbackPoint = 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, take the one
// with the largest visible area.
this._setElementPosition(element, fallbackPoint);

return Promise.resolve(null);
}

Expand Down Expand Up @@ -172,15 +176,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 +202,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.
Expand All @@ -234,8 +232,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;
}