fix(connected-overlay): better handling of dynamic content (#4250)

* fix(connected-overlay): better handling of dynamic content

* Refactors the `ConnectedPositionStrategy` to be able to use `right` and `bottom` to position the panel. This will allow the element to keep its position automatically, even if the element's size changes.
* Removes the uses of the `FakeViewportRuler` in some of the unit tests since it doesn't work very well when the element is positioned relatively to the right edge of the screen.
* Rounds down the values when testing positioning. This is necessary, because some browsers (particularly Chrome on Windows) can have some subpixel deviations.

Fixes #4155.

* refactor: move new logic into _setElementPosition method
This commit is contained in:
Kristiyan Kostadinov
2017-05-04 23:47:45 +02:00
committed by Andrew Seguin
parent f8a2860408
commit 525ce1e0fe
6 changed files with 307 additions and 233 deletions
+8 -13
View File
@@ -18,8 +18,6 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {Subscription} from 'rxjs/Subscription';
import {ENTER, DOWN_ARROW, SPACE, UP_ARROW, HOME, END} from '../core/keyboard/keycodes';
import {MdOption} from '../core/option/option';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
import {FakeViewportRuler} from '../core/overlay/position/fake-viewport-ruler';
import {MdAutocomplete} from './autocomplete';
import {MdInputContainer} from '../input/input-container';
import {Observable} from 'rxjs/Observable';
@@ -65,10 +63,7 @@ describe('MdAutocomplete', () => {
return {getContainerElement: () => overlayContainerElement};
}},
{provide: Dir, useFactory: () => {
return {value: dir};
}},
{provide: ViewportRuler, useClass: FakeViewportRuler},
{provide: Dir, useFactory: () => ({value: dir})},
{provide: ScrollDispatcher, useFactory: () => {
return {scrolled: (delay: number, callback: () => any) => {
return scrolledSubject.asObservable().subscribe(callback);
@@ -933,8 +928,8 @@ describe('MdAutocomplete', () => {
const panelTop = panel.getBoundingClientRect().top;
// Panel is offset by 6px in styles so that the underline has room to display.
expect((inputBottom + 6).toFixed(1))
.toEqual(panelTop.toFixed(1), `Expected panel top to match input bottom by default.`);
expect(Math.floor(inputBottom + 6))
.toEqual(Math.floor(panelTop), `Expected panel top to match input bottom by default.`);
expect(fixture.componentInstance.trigger.autocomplete.positionY)
.toEqual('below', `Expected autocomplete positionY to default to below.`);
});
@@ -956,7 +951,7 @@ describe('MdAutocomplete', () => {
const panel = overlayContainerElement.querySelector('.mat-autocomplete-panel');
const panelTop = panel.getBoundingClientRect().top;
expect((inputBottom + 6).toFixed(1)).toEqual(panelTop.toFixed(1),
expect(Math.floor(inputBottom + 6)).toEqual(Math.floor(panelTop),
'Expected panel top to match input bottom after scrolling.');
document.body.removeChild(spacer);
@@ -975,8 +970,8 @@ describe('MdAutocomplete', () => {
const panelBottom = panel.getBoundingClientRect().bottom;
// Panel is offset by 24px in styles so that the label has room to display.
expect((inputTop - 24).toFixed(1))
.toEqual(panelBottom.toFixed(1), `Expected panel to fall back to above position.`);
expect(Math.floor(inputTop - 24))
.toEqual(Math.floor(panelBottom), `Expected panel to fall back to above position.`);
expect(fixture.componentInstance.trigger.autocomplete.positionY)
.toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
});
@@ -998,8 +993,8 @@ describe('MdAutocomplete', () => {
const panelBottom = panel.getBoundingClientRect().bottom;
// Panel is offset by 24px in styles so that the label has room to display.
expect((inputTop - 24).toFixed(1))
.toEqual(panelBottom.toFixed(1), `Expected panel to stay aligned after filtering.`);
expect(Math.floor(inputTop - 24))
.toEqual(Math.floor(panelBottom), `Expected panel to stay aligned after filtering.`);
expect(fixture.componentInstance.trigger.autocomplete.positionY)
.toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
});
+4 -2
View File
@@ -9,8 +9,10 @@
// The container should be the size of the viewport.
top: 0;
left: 0;
height: 100%;
width: 100%;
// Note: we prefer viewport units, because they aren't being offset by the global scrollbar.
height: 100vh;
width: 100vw;
}
// The overlay-container is an invisible element which contains all individual overlays.
@@ -41,7 +41,6 @@ describe('ConnectedPositionStrategy', () => {
let overlayContainerElement: HTMLElement;
let strategy: ConnectedPositionStrategy;
let fakeElementRef: ElementRef;
let fakeViewportRuler: FakeViewportRuler;
let positionBuilder: OverlayPositionBuilder;
let originRect: ClientRect;
@@ -49,11 +48,9 @@ describe('ConnectedPositionStrategy', () => {
let originCenterY: number;
beforeEach(() => {
fakeViewportRuler = new FakeViewportRuler();
// The origin and overlay elements need to be in the document body in order to have geometry.
originElement = createPositionedBlockElement();
overlayContainerElement = createFixedElement();
overlayContainerElement = createOverlayContainer();
overlayElement = createPositionedBlockElement();
document.body.appendChild(originElement);
document.body.appendChild(overlayContainerElement);
@@ -148,8 +145,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.bottom);
expect(overlayRect.left).toBe(originRect.left);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left));
});
it('should reposition the overlay if it would go off the left of the screen', () => {
@@ -172,18 +169,14 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2));
expect(overlayRect.left).toBe(originRect.right);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originCenterY - (OVERLAY_HEIGHT / 2)));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.right));
});
it('should reposition the overlay if it would go off the bottom of the screen', () => {
// 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);
positionBuilder = new OverlayPositionBuilder(viewportRuler);
originElement.style.top = '475px';
originElement.style.bottom = '25px';
originElement.style.left = '200px';
originRect = originElement.getBoundingClientRect();
@@ -198,19 +191,15 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.bottom).toBe(originRect.top);
expect(overlayRect.right).toBe(originRect.right);
expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top));
expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right));
});
it('should reposition the overlay if it would go off the right of the screen', () => {
// 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);
positionBuilder = new OverlayPositionBuilder(viewportRuler);
originElement.style.top = '200px';
originElement.style.left = '475px';
originElement.style.right = '25px';
originRect = originElement.getBoundingClientRect();
strategy = positionBuilder.connectedTo(
@@ -224,19 +213,16 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.bottom);
expect(overlayRect.right).toBe(originRect.left);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom));
expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.left));
});
it('should recalculate and set the last position with recalculateLastPosition()', () => {
// 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);
positionBuilder = new OverlayPositionBuilder(viewportRuler);
// Push the trigger down so the overlay doesn't have room to open on the bottom.
originElement.style.top = '475px';
originElement.style.bottom = '25px';
originRect = originElement.getBoundingClientRect();
strategy = positionBuilder.connectedTo(
@@ -257,16 +243,12 @@ describe('ConnectedPositionStrategy', () => {
strategy.recalculateLastPosition();
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.bottom).toBe(originRect.top,
expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top),
'Expected overlay to be re-aligned to the trigger in the previous position.');
});
it('should default to the initial position, if no positions fit in the viewport', () => {
// 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);
positionBuilder = new OverlayPositionBuilder(viewportRuler);
// Make the origin element taller than the viewport.
originElement.style.height = '1000px';
@@ -283,7 +265,7 @@ describe('ConnectedPositionStrategy', () => {
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.bottom).toBe(originRect.top,
expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top),
'Expected overlay to be re-aligned to the trigger in the initial position.');
});
@@ -300,8 +282,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.bottom);
expect(overlayRect.right).toBe(originRect.right);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom));
expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right));
});
it('should position a panel with the x offset provided', () => {
@@ -315,8 +297,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.top);
expect(overlayRect.left).toBe(originRect.left + 10);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left + 10));
});
it('should position a panel with the y offset provided', () => {
@@ -330,20 +312,16 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.top + 50);
expect(overlayRect.left).toBe(originRect.left);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top + 50));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left));
});
});
it('should emit onPositionChange event when position changes', () => {
// force the overlay to open in a fallback position
fakeViewportRuler.fakeRect = {
top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500
};
positionBuilder = new OverlayPositionBuilder(fakeViewportRuler);
positionBuilder = new OverlayPositionBuilder(viewportRuler);
originElement.style.top = '200px';
originElement.style.left = '475px';
originElement.style.right = '25px';
strategy = positionBuilder.connectedTo(
fakeElementRef,
@@ -363,14 +341,10 @@ describe('ConnectedPositionStrategy', () => {
`Expected strategy to emit an instance of ConnectedOverlayPositionChange.`);
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);
positionBuilder = new OverlayPositionBuilder(viewportRuler);
originElement.style.top = '200px';
originElement.style.left = '475px';
originElement.style.right = '25px';
originRect = originElement.getBoundingClientRect();
strategy = positionBuilder.connectedTo(
@@ -388,8 +362,8 @@ describe('ConnectedPositionStrategy', () => {
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.top);
expect(overlayRect.left).toBe(originRect.left);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left));
});
it('should position a panel properly when rtl', () => {
@@ -430,8 +404,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.bottom);
expect(overlayRect.left).toBe(originRect.left);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left));
});
it('should position to the right, center aligned vertically', () => {
@@ -443,8 +417,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2));
expect(overlayRect.left).toBe(originRect.right);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originCenterY - (OVERLAY_HEIGHT / 2)));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.right));
});
it('should position to the left, below', () => {
@@ -456,8 +430,9 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.bottom);
expect(overlayRect.right).toBe(originRect.left);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom));
expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.left));
});
it('should position above, right aligned', () => {
@@ -469,8 +444,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.bottom).toBe(originRect.top);
expect(overlayRect.right).toBe(originRect.right);
expect(Math.floor(overlayRect.bottom)).toBe(Math.floor(originRect.top));
expect(Math.floor(overlayRect.right)).toBe(Math.floor(originRect.right));
});
it('should position below, centered', () => {
@@ -482,8 +457,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.bottom);
expect(overlayRect.left).toBe(originCenterX - (OVERLAY_WIDTH / 2));
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originCenterX - (OVERLAY_WIDTH / 2)));
});
it('should center the overlay on the origin', () => {
@@ -495,8 +470,8 @@ describe('ConnectedPositionStrategy', () => {
strategy.apply(overlayElement);
let overlayRect = overlayElement.getBoundingClientRect();
expect(overlayRect.top).toBe(originRect.top);
expect(overlayRect.left).toBe(originRect.left);
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.top));
expect(Math.floor(overlayRect.left)).toBe(Math.floor(originRect.left));
});
}
});
@@ -513,7 +488,7 @@ describe('ConnectedPositionStrategy', () => {
beforeEach(() => {
// Set up the overlay
overlayContainerElement = createFixedElement();
overlayContainerElement = createOverlayContainer();
overlayElement = createPositionedBlockElement();
document.body.appendChild(overlayContainerElement);
overlayContainerElement.appendChild(overlayElement);
@@ -603,14 +578,114 @@ describe('ConnectedPositionStrategy', () => {
});
});
});
describe('positioning properties', () => {
let originElement: HTMLElement;
let overlayElement: HTMLElement;
let overlayContainerElement: HTMLElement;
let strategy: ConnectedPositionStrategy;
let fakeElementRef: ElementRef;
let positionBuilder: OverlayPositionBuilder;
beforeEach(() => {
// The origin and overlay elements need to be in the document body in order to have geometry.
originElement = createPositionedBlockElement();
overlayContainerElement = createOverlayContainer();
overlayElement = createPositionedBlockElement();
document.body.appendChild(originElement);
document.body.appendChild(overlayContainerElement);
overlayContainerElement.appendChild(overlayElement);
fakeElementRef = new FakeElementRef(originElement);
positionBuilder = new OverlayPositionBuilder(viewportRuler);
});
describe('in ltr', () => {
it('should use `left` when positioning an element at the start', () => {
strategy = positionBuilder.connectedTo(
fakeElementRef,
{originX: 'start', originY: 'top'},
{overlayX: 'start', overlayY: 'top'});
strategy.apply(overlayElement);
expect(overlayElement.style.left).toBeTruthy();
expect(overlayElement.style.right).toBeFalsy();
});
it('should use `right` when positioning an element at the end', () => {
strategy = positionBuilder.connectedTo(
fakeElementRef,
{originX: 'end', originY: 'top'},
{overlayX: 'end', overlayY: 'top'});
strategy.apply(overlayElement);
expect(overlayElement.style.right).toBeTruthy();
expect(overlayElement.style.left).toBeFalsy();
});
});
describe('in rtl', () => {
it('should use `right` when positioning an element at the start', () => {
strategy = positionBuilder.connectedTo(
fakeElementRef,
{originX: 'start', originY: 'top'},
{overlayX: 'start', overlayY: 'top'}
)
.withDirection('rtl');
strategy.apply(overlayElement);
expect(overlayElement.style.right).toBeTruthy();
expect(overlayElement.style.left).toBeFalsy();
});
it('should use `left` when positioning an element at the end', () => {
strategy = positionBuilder.connectedTo(
fakeElementRef,
{originX: 'end', originY: 'top'},
{overlayX: 'end', overlayY: 'top'}
).withDirection('rtl');
strategy.apply(overlayElement);
expect(overlayElement.style.left).toBeTruthy();
expect(overlayElement.style.right).toBeFalsy();
});
});
describe('vertical', () => {
it('should use `top` when positioning at element along the top', () => {
strategy = positionBuilder.connectedTo(
fakeElementRef,
{originX: 'start', originY: 'top'},
{overlayX: 'start', overlayY: 'top'}
);
strategy.apply(overlayElement);
expect(overlayElement.style.top).toBeTruthy();
expect(overlayElement.style.bottom).toBeFalsy();
});
it('should use `bottom` when positioning at element along the bottom', () => {
strategy = positionBuilder.connectedTo(
fakeElementRef,
{originX: 'start', originY: 'bottom'},
{overlayX: 'start', overlayY: 'bottom'}
);
strategy.apply(overlayElement);
expect(overlayElement.style.bottom).toBeTruthy();
expect(overlayElement.style.top).toBeFalsy();
});
});
});
});
/** Creates an absolutely positioned, display: block element with a default size. */
function createPositionedBlockElement() {
let element = createBlockElement();
element.style.position = 'absolute';
element.style.top = '0';
element.style.left = '0';
return element;
}
@@ -624,15 +699,10 @@ function createBlockElement() {
return element;
}
/** Creates an position: fixed element that spans the screen size. */
function createFixedElement() {
/** Creates the wrapper for all of the overlays. */
function createOverlayContainer() {
let element = document.createElement('div');
element.style.position = 'fixed';
element.style.top = '0';
element.style.left = '0';
element.style.width = `100%`;
element.style.height = `100%`;
element.style.zIndex = '100';
element.classList.add('cdk-overlay-container');
return element;
}
@@ -648,23 +718,7 @@ function createOverflowContainerElement() {
}
/** Fake implementation of ViewportRuler that just returns the previously given ClientRect. */
class FakeViewportRuler implements ViewportRuler {
fakeRect: ClientRect = {left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014};
fakeScrollPos: {top: number, left: number} = {top: 0, left: 0};
getViewportRect() {
return this.fakeRect;
}
getViewportScrollPosition(documentRect?: ClientRect): {top: number; left: number} {
return this.fakeScrollPos;
}
}
/** Fake implementation of ElementRef that is just a simple container for nativeElement. */
class FakeElementRef implements ElementRef {
constructor(public nativeElement: HTMLElement) {
}
constructor(public nativeElement: HTMLElement) { }
}
@@ -108,6 +108,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
// Fallback point if none of the fallbacks fit into the viewport.
let fallbackPoint: OverlayPoint = null;
let fallbackPosition: ConnectionPositionPair = null;
// We want to place the overlay in the first of the preferred positions such that the
// overlay fits on-screen.
@@ -119,7 +120,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
// If the overlay in the calculated position fits on-screen, put it there and we're done.
if (overlayPoint.fitsInViewport) {
this._setElementPosition(element, overlayPoint);
this._setElementPosition(element, overlayRect, overlayPoint, pos);
// Save the last connected position in case the position needs to be re-calculated.
this._lastConnectedPosition = pos;
@@ -132,12 +133,13 @@ export class ConnectedPositionStrategy implements PositionStrategy {
return Promise.resolve(null);
} else if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) {
fallbackPoint = overlayPoint;
fallbackPosition = pos;
}
}
// If none of the preferred positions were in the viewport, take the one
// with the largest visible area.
this._setElementPosition(element, fallbackPoint);
this._setElementPosition(element, overlayRect, fallbackPoint, fallbackPosition);
return Promise.resolve(null);
}
@@ -155,7 +157,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
let originPoint = this._getOriginConnectionPoint(originRect, lastPosition);
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, lastPosition);
this._setElementPosition(this._pane, overlayPoint);
this._setElementPosition(this._pane, overlayRect, overlayPoint, lastPosition);
}
/**
@@ -346,14 +348,48 @@ export class ConnectedPositionStrategy implements PositionStrategy {
});
}
/**
* Physically positions the overlay element to the given coordinate.
* @param element
* @param overlayPoint
*/
private _setElementPosition(element: HTMLElement, overlayPoint: Point) {
element.style.left = overlayPoint.x + 'px';
element.style.top = overlayPoint.y + 'px';
/** Physically positions the overlay element to the given coordinate. */
private _setElementPosition(
element: HTMLElement,
overlayRect: ClientRect,
overlayPoint: Point,
pos: ConnectionPositionPair) {
const viewport = this._viewportRuler.getViewportRect();
// We want to set either `top` or `bottom` based on whether the overlay wants to appear above
// or below the origin and the direction in which the element will expand.
let verticalStyleProperty = pos.overlayY === 'bottom' ? 'bottom' : 'top';
// When using `bottom`, we adjust the y position such that it is the distance
// from the bottom of the viewport rather than the top.
let y = verticalStyleProperty === 'top' ?
overlayPoint.y :
viewport.height - (overlayPoint.y + overlayRect.height);
// We want to set either `left` or `right` based on whether the overlay wants to appear "before"
// or "after" the origin, which determines the direction in which the element will expand.
// For the horizontal axis, the meaning of "before" and "after" change based on whether the
// page is in RTL or LTR.
let horizontalStyleProperty: string;
if (this._dir === 'rtl') {
horizontalStyleProperty = pos.overlayX === 'end' ? 'left' : 'right';
} else {
horizontalStyleProperty = pos.overlayX === 'end' ? 'right' : 'left';
}
// When we're setting `right`, we adjust the x position such that it is the distance
// from the right edge of the viewport rather than the left edge.
let x = horizontalStyleProperty === 'left' ?
overlayPoint.x :
viewport.width - (overlayPoint.x + overlayRect.width);
// Reset any existing styles. This is necessary in case the preferred position has
// changed since the last `apply`.
['top', 'bottom', 'left', 'right'].forEach(p => element.style[p] = null);
element.style[verticalStyleProperty] = `${y}px`;
element.style[horizontalStyleProperty] = `${x}px`;
}
/** Returns the bounding positions of the provided element with respect to the viewport. */
+35 -49
View File
@@ -34,9 +34,7 @@ describe('MdMenu', () => {
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
overlayContainerElement.style.position = 'fixed';
overlayContainerElement.style.top = '0';
overlayContainerElement.style.left = '0';
overlayContainerElement.classList.add('cdk-overlay-container');
document.body.appendChild(overlayContainerElement);
// remove body padding to keep consistent cross-browser
@@ -46,8 +44,7 @@ describe('MdMenu', () => {
}},
{provide: Dir, useFactory: () => {
return {value: dir};
}},
{provide: ViewportRuler, useClass: FakeViewportRuler}
}}
]
});
@@ -118,7 +115,7 @@ describe('MdMenu', () => {
const trigger = fixture.componentInstance.triggerEl.nativeElement;
// Push trigger to the bottom edge of viewport,so it has space to open "above"
trigger.style.position = 'relative';
trigger.style.position = 'fixed';
trigger.style.top = '600px';
// Push trigger to the right, so it has space to open "before"
@@ -176,8 +173,9 @@ describe('MdMenu', () => {
// Push trigger to the right side of viewport, so it doesn't have space to open
// in its default "after" position on the right side.
trigger.style.position = 'relative';
trigger.style.left = '950px';
trigger.style.position = 'fixed';
trigger.style.right = '-50px';
trigger.style.top = '200px';
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
@@ -188,13 +186,13 @@ describe('MdMenu', () => {
// In "before" position, the right sides of the overlay and the origin are aligned.
// To find the overlay left, subtract the menu width from the origin's right side.
const expectedLeft = triggerRect.right - overlayRect.width;
expect(Math.round(overlayRect.left))
.toBe(Math.round(expectedLeft),
expect(Math.floor(overlayRect.left))
.toBe(Math.floor(expectedLeft),
`Expected menu to open in "before" position if "after" position wouldn't fit.`);
// The y-position of the overlay should be unaffected, as it can already fit vertically
expect(Math.round(overlayRect.top))
.toBe(Math.round(triggerRect.top),
expect(Math.floor(overlayRect.top))
.toBe(Math.floor(triggerRect.top),
`Expected menu top position to be unchanged if it can fit in the viewport.`);
});
@@ -205,8 +203,8 @@ describe('MdMenu', () => {
// Push trigger to the bottom part of viewport, so it doesn't have space to open
// in its default "below" position below the trigger.
trigger.style.position = 'relative';
trigger.style.top = '600px';
trigger.style.position = 'fixed';
trigger.style.bottom = '65px';
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
@@ -217,13 +215,13 @@ describe('MdMenu', () => {
// In "above" position, the bottom edges of the overlay and the origin are aligned.
// To find the overlay top, subtract the menu height from the origin's bottom edge.
const expectedTop = triggerRect.bottom - overlayRect.height;
expect(Math.round(overlayRect.top))
.toBe(Math.round(expectedTop),
expect(Math.floor(overlayRect.top))
.toBe(Math.floor(expectedTop),
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
// The xPosition of the overlay should be unaffected, as it can already fit horizontally
expect(Math.round(overlayRect.left))
.toBe(Math.round(triggerRect.left),
// The x-position of the overlay should be unaffected, as it can already fit horizontally
expect(Math.floor(overlayRect.left))
.toBe(Math.floor(triggerRect.left),
`Expected menu x position to be unchanged if it can fit in the viewport.`);
});
@@ -234,9 +232,9 @@ describe('MdMenu', () => {
// push trigger to the bottom, right part of viewport, so it doesn't have space to open
// in its default "after below" position.
trigger.style.position = 'relative';
trigger.style.left = '950px';
trigger.style.top = '600px';
trigger.style.position = 'fixed';
trigger.style.right = '-50px';
trigger.style.bottom = '65px';
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
@@ -247,12 +245,12 @@ describe('MdMenu', () => {
const expectedLeft = triggerRect.right - overlayRect.width;
const expectedTop = triggerRect.bottom - overlayRect.height;
expect(Math.round(overlayRect.left))
.toBe(Math.round(expectedLeft),
expect(Math.floor(overlayRect.left))
.toBe(Math.floor(expectedLeft),
`Expected menu to open in "before" position if "after" position wouldn't fit.`);
expect(Math.round(overlayRect.top))
.toBe(Math.round(expectedTop),
expect(Math.floor(overlayRect.top))
.toBe(Math.floor(expectedTop),
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
});
@@ -269,14 +267,14 @@ describe('MdMenu', () => {
// As designated "before" position won't fit on screen, the menu should fall back
// to "after" mode, where the left sides of the overlay and trigger are aligned.
expect(Math.round(overlayRect.left))
.toBe(Math.round(triggerRect.left),
expect(Math.floor(overlayRect.left))
.toBe(Math.floor(triggerRect.left),
`Expected menu to open in "after" position if "before" position wouldn't fit.`);
// As designated "above" position won't fit on screen, the menu should fall back
// to "below" mode, where the top edges of the overlay and trigger are aligned.
expect(Math.round(overlayRect.top))
.toBe(Math.round(triggerRect.top),
expect(Math.floor(overlayRect.top))
.toBe(Math.floor(triggerRect.top),
`Expected menu to open in "below" position if "above" position wouldn't fit.`);
});
@@ -343,8 +341,8 @@ describe('MdMenu', () => {
subject.openMenu();
// Since the menu is overlaying the trigger, the overlay top should be the trigger top.
expect(Math.round(subject.overlayRect.top))
.toBe(Math.round(subject.triggerRect.top),
expect(Math.floor(subject.overlayRect.top))
.toBe(Math.floor(subject.triggerRect.top),
`Expected menu to open in default "below" position.`);
});
});
@@ -358,20 +356,20 @@ describe('MdMenu', () => {
subject.openMenu();
// Since the menu is below the trigger, the overlay top should be the trigger bottom.
expect(Math.round(subject.overlayRect.top))
.toBe(Math.round(subject.triggerRect.bottom),
expect(Math.floor(subject.overlayRect.top))
.toBe(Math.floor(subject.triggerRect.bottom),
`Expected menu to open directly below the trigger.`);
});
it('supports above position fall back', () => {
// Push trigger to the bottom part of viewport, so it doesn't have space to open
// in its default "below" position below the trigger.
subject.updateTriggerStyle({position: 'relative', top: '650px'});
subject.updateTriggerStyle({position: 'fixed', bottom: '0'});
subject.openMenu();
// Since the menu is above the trigger, the overlay bottom should be the trigger top.
expect(Math.round(subject.overlayRect.bottom))
.toBe(Math.round(subject.triggerRect.top),
expect(Math.floor(subject.overlayRect.bottom))
.toBe(Math.floor(subject.triggerRect.top),
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
});
@@ -530,15 +528,3 @@ class CustomMenuPanel implements MdMenuPanel {
class CustomMenu {
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
}
class FakeViewportRuler {
getViewportRect() {
return {
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
};
}
getViewportScrollPosition() {
return {top: 0, left: 0};
}
}
+62 -61
View File
@@ -1,4 +1,4 @@
import {TestBed, async, ComponentFixture, fakeAsync, tick} from '@angular/core/testing';
import {TestBed, async, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {
Component,
@@ -28,23 +28,11 @@ import {TAB} from '../core/keyboard/keycodes';
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
class FakeViewportRuler {
getViewportRect() {
return {
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
};
}
getViewportScrollPosition() {
return {top: 0, left: 0};
}
}
describe('MdSelect', () => {
let overlayContainerElement: HTMLElement;
let dir: {value: 'ltr'|'rtl'};
let scrolledSubject = new Subject();
let fakeViewportRuler = new FakeViewportRuler();
let viewportRuler: ViewportRuler;
beforeEach(async(() => {
TestBed.configureTestingModule({
@@ -83,10 +71,7 @@ describe('MdSelect', () => {
return {getContainerElement: () => overlayContainerElement};
}},
{provide: ViewportRuler, useValue: fakeViewportRuler},
{provide: Dir, useFactory: () => {
return dir = { value: 'ltr' };
}},
{provide: Dir, useFactory: () => dir = { value: 'ltr' }},
{provide: ScrollDispatcher, useFactory: () => {
return {scrolled: (delay: number, callback: () => any) => {
return scrolledSubject.asObservable().subscribe(callback);
@@ -98,6 +83,10 @@ describe('MdSelect', () => {
TestBed.compileComponents();
}));
beforeEach(inject([ViewportRuler], (_ruler: ViewportRuler) => {
viewportRuler = _ruler;
}));
afterEach(() => {
document.body.removeChild(overlayContainerElement);
});
@@ -755,15 +744,17 @@ describe('MdSelect', () => {
// The option text should align with the trigger text. Because each option is 18px
// larger in height than the trigger, the option needs to be adjusted up 9 pixels.
expect(optionTop.toFixed(2))
.toEqual((triggerTop - 9).toFixed(2), `Expected trigger to align with option ${index}.`);
expect(Math.floor(optionTop))
.toEqual(Math.floor(triggerTop - 9), `Expected trigger to align with option ${index}.`);
// For the animation to start at the option's center, its origin must be the distance
// from the top of the overlay to the option top + half the option height (48/2 = 24).
const expectedOrigin = optionTop - overlayTop + 24;
expect(fixture.componentInstance.select._transformOrigin)
.toContain(`${expectedOrigin}px`,
`Expected panel animation to originate in the center of option ${index}.`);
const expectedOrigin = Math.floor(optionTop - overlayTop + 24);
const rawYOrigin = fixture.componentInstance.select._transformOrigin.split(' ')[1].trim();
const origin = Math.floor(parseInt(rawYOrigin));
expect(origin).toBe(expectedOrigin,
`Expected panel animation to originate in the center of option ${index}.`);
}
describe('ample space to open', () => {
@@ -771,9 +762,9 @@ describe('MdSelect', () => {
beforeEach(() => {
// these styles are necessary because we are first testing the overlay's position
// if there is room for it to open to its full extent in either direction.
select.style.marginTop = '300px';
select.style.marginLeft = '20px';
select.style.marginRight = '20px';
select.style.position = 'fixed';
select.style.top = '300px';
select.style.left = '20px';
});
@@ -849,14 +840,14 @@ describe('MdSelect', () => {
describe('limited space to open vertically', () => {
beforeEach(() => {
select.style.marginLeft = '20px';
select.style.marginRight = '20px';
select.style.position = 'fixed';
select.style.left = '20px';
});
it('should adjust position of centered option if there is little space above', () => {
// Push the select to a position with not quite enough space on the top to open
// with the option completely centered (needs 113px at least: 256/2 - 48/2 + 9)
select.style.marginTop = '85px';
select.style.top = '85px';
// Select an option in the middle of the list
fixture.componentInstance.control.setValue('chips-4');
@@ -879,7 +870,7 @@ describe('MdSelect', () => {
it('should adjust position of centered option if there is little space below', () => {
// Push the select to a position with not quite enough space on the bottom to open
// with the option completely centered (needs 113px at least: 256/2 - 48/2 + 9)
select.style.marginTop = '600px';
select.style.bottom = '56px';
// Select an option in the middle of the list
fixture.componentInstance.control.setValue('chips-4');
@@ -891,10 +882,10 @@ describe('MdSelect', () => {
const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
// Scroll should adjust by the difference between the bottom space available
// (686px - 600px margin - 30px trigger height = 56px - 8px padding = 48px)
// (56px from the bottom of the screen - 8px padding = 48px)
// and the height of the panel below the option (113px).
// 113px - 48px = 75px difference. Original scrollTop 88px - 75px = 23px
expect(scrollContainer.scrollTop)
expect(Math.ceil(scrollContainer.scrollTop))
.toEqual(23, `Expected panel to adjust scroll position to fit in viewport.`);
checkTriggerAlignedWithOption(4);
@@ -902,7 +893,7 @@ describe('MdSelect', () => {
it('should fall back to "above" positioning if scroll adjustment will not help', () => {
// Push the select to a position with not enough space on the bottom to open
select.style.marginTop = '600px';
select.style.bottom = '56px';
fixture.detectChanges();
// Select an option that cannot be scrolled any farther upward
@@ -920,8 +911,8 @@ describe('MdSelect', () => {
// Expect no scroll to be attempted
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to be scrolled.`);
expect(overlayBottom.toFixed(2))
.toEqual(triggerBottom.toFixed(2),
expect(Math.floor(overlayBottom))
.toEqual(Math.floor(triggerBottom),
`Expected trigger bottom to align with overlay bottom.`);
expect(fixture.componentInstance.select._transformOrigin)
@@ -930,7 +921,7 @@ describe('MdSelect', () => {
it('should fall back to "below" positioning if scroll adjustment will not help', () => {
// Push the select to a position with not enough space on the top to open
select.style.marginTop = '85px';
select.style.top = '85px';
// Select an option that cannot be scrolled any farther downward
fixture.componentInstance.control.setValue('sushi-7');
@@ -947,8 +938,8 @@ describe('MdSelect', () => {
// Expect scroll to remain at the max scroll position
expect(scrollContainer.scrollTop).toEqual(128, `Expected panel to be at max scroll.`);
expect(overlayTop.toFixed(2))
.toEqual(triggerTop.toFixed(2), `Expected trigger top to align with overlay top.`);
expect(Math.floor(overlayTop))
.toEqual(Math.floor(triggerTop), `Expected trigger top to align with overlay top.`);
expect(fixture.componentInstance.select._transformOrigin)
.toContain(`top`, `Expected panel animation to originate at the top.`);
@@ -994,7 +985,7 @@ describe('MdSelect', () => {
tick(400);
fixture.detectChanges();
const viewportRect = fakeViewportRuler.getViewportRect().right;
const viewportRect = viewportRuler.getViewportRect().right;
const panelRight = document.querySelector('.mat-select-panel')
.getBoundingClientRect().right;
@@ -1009,7 +1000,7 @@ describe('MdSelect', () => {
tick(400);
fixture.detectChanges();
const viewportRect = fakeViewportRuler.getViewportRect().right;
const viewportRect = viewportRuler.getViewportRect().right;
const panelRight = document.querySelector('.mat-select-panel')
.getBoundingClientRect().right;
@@ -1042,6 +1033,7 @@ describe('MdSelect', () => {
});
describe('when scrolled', () => {
const startingWindowHeight = window.innerHeight;
// Need to set the scrollTop two different ways to support
// both Chrome and Firefox.
@@ -1053,17 +1045,14 @@ describe('MdSelect', () => {
beforeEach(() => {
// Make the div above the select very tall, so the page will scroll
fixture.componentInstance.heightAbove = 2000;
fixture.detectChanges();
setScrollTop(0);
// Give the select enough horizontal space to open
select.style.marginLeft = '20px';
select.style.marginRight = '20px';
});
afterEach(() => {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
});
it('should align the first option properly when scrolled', () => {
// Give the select enough space to open
fixture.componentInstance.heightBelow = 400;
@@ -1113,12 +1102,23 @@ describe('MdSelect', () => {
it('should fall back to "above" positioning properly when scrolled', () => {
// Give the select insufficient space to open below the trigger
fixture.componentInstance.heightAbove = 0;
fixture.componentInstance.heightBelow = 100;
trigger.style.marginTop = '2000px';
fixture.detectChanges();
// Scroll the select into view
setScrollTop(1400);
// In the iOS simulator (BrowserStack & SauceLabs), adding the content to the
// body causes karma's iframe for the test to stretch to fit that content once we attempt to
// scroll the page. Setting width / height / maxWidth / maxHeight on the iframe does not
// successfully constrain its size. As such, skip assertions in environments where the
// window size has changed since the start of the test.
if (window.innerHeight > startingWindowHeight) {
return;
}
trigger.click();
fixture.detectChanges();
@@ -1126,8 +1126,8 @@ describe('MdSelect', () => {
const triggerBottom = trigger.getBoundingClientRect().bottom;
const overlayBottom = overlayPane.getBoundingClientRect().bottom;
expect(overlayBottom.toFixed(2))
.toEqual(triggerBottom.toFixed(2),
expect(Math.floor(overlayBottom))
.toEqual(Math.floor(triggerBottom),
`Expected trigger bottom to align with overlay bottom.`);
});
@@ -1150,16 +1150,17 @@ describe('MdSelect', () => {
const triggerTop = trigger.getBoundingClientRect().top;
const overlayTop = overlayPane.getBoundingClientRect().top;
expect(overlayTop.toFixed(2))
.toEqual(triggerTop.toFixed(2), `Expected trigger top to align with overlay top.`);
expect(Math.floor(overlayTop))
.toEqual(Math.floor(triggerTop), `Expected trigger top to align with overlay top.`);
});
});
describe('x-axis positioning', () => {
beforeEach(() => {
select.style.marginLeft = '30px';
select.style.marginRight = '30px';
select.style.position = 'fixed';
select.style.left = '30px';
});
it('should align the trigger and the selected option on the x-axis in ltr', fakeAsync(() => {
@@ -1173,7 +1174,7 @@ describe('MdSelect', () => {
// Each option is 32px wider than the trigger, so it must be adjusted 16px
// to ensure the text overlaps correctly.
expect(firstOptionLeft.toFixed(2)).toEqual((triggerLeft - 16).toFixed(2),
expect(Math.floor(firstOptionLeft)).toEqual(Math.floor(triggerLeft - 16),
`Expected trigger to align with the selected option on the x-axis in LTR.`);
}));
@@ -1191,8 +1192,8 @@ describe('MdSelect', () => {
// Each option is 32px wider than the trigger, so it must be adjusted 16px
// to ensure the text overlaps correctly.
expect(firstOptionRight.toFixed(2))
.toEqual((triggerRight + 16).toFixed(2),
expect(Math.floor(firstOptionRight))
.toEqual(Math.floor(triggerRight + 16),
`Expected trigger to align with the selected option on the x-axis in RTL.`);
}));
});
@@ -1206,8 +1207,8 @@ describe('MdSelect', () => {
trigger = multiFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
select = multiFixture.debugElement.query(By.css('md-select')).nativeElement;
select.style.marginLeft = '60px';
select.style.marginRight = '60px';
select.style.position = 'fixed';
select.style.left = '60px';
});
it('should adjust for the checkbox in ltr', async(() => {
@@ -1220,8 +1221,8 @@ describe('MdSelect', () => {
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().left;
// 48px accounts for the checkbox size, margin and the panel's padding.
expect(firstOptionLeft.toFixed(2))
.toEqual((triggerLeft - 48).toFixed(2),
expect(Math.floor(firstOptionLeft))
.toEqual(Math.floor(triggerLeft - 48),
`Expected trigger label to align along x-axis, accounting for the checkbox.`);
});
}));
@@ -1237,8 +1238,8 @@ describe('MdSelect', () => {
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
// 48px accounts for the checkbox size, margin and the panel's padding.
expect(firstOptionRight.toFixed(2))
.toEqual((triggerRight + 48).toFixed(2),
expect(Math.floor(firstOptionRight))
.toEqual(Math.floor(triggerRight + 48),
`Expected trigger label to align along x-axis, accounting for the checkbox.`);
}));
});