diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index a9a1c8e6dc28..e37ed8330b56 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -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); @@ -929,8 +924,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.`); }); @@ -952,7 +947,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); @@ -971,8 +966,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.`); }); @@ -994,8 +989,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.`); }); diff --git a/src/lib/core/overlay/_overlay.scss b/src/lib/core/overlay/_overlay.scss index 91d82ee81a12..ce6a99b5411a 100644 --- a/src/lib/core/overlay/_overlay.scss +++ b/src/lib/core/overlay/_overlay.scss @@ -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. diff --git a/src/lib/core/overlay/position/connected-position-strategy.spec.ts b/src/lib/core/overlay/position/connected-position-strategy.spec.ts index fb0dcb66bb73..a6a8473f7880 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.spec.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.spec.ts @@ -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) { } } diff --git a/src/lib/core/overlay/position/connected-position-strategy.ts b/src/lib/core/overlay/position/connected-position-strategy.ts index 2eda42461e93..1e9087b5a4da 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.ts @@ -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. */ diff --git a/src/lib/menu/menu.spec.ts b/src/lib/menu/menu.spec.ts index 38f7af4c9f17..4f5c79ed2b79 100644 --- a/src/lib/menu/menu.spec.ts +++ b/src/lib/menu/menu.spec.ts @@ -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}; - } -} diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index e5c1921762cd..000ebc4c2511 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -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.`); })); });