diff --git a/firebase.json b/firebase.json index e4e2520dd7d7..48d40be158b7 100644 --- a/firebase.json +++ b/firebase.json @@ -9,6 +9,13 @@ "deploy", "typings" ], + "headers": [{ + "source": "*", + "headers": [{ + "key": "Cache-Control", + "value": "no-cache" + }] + }], "rewrites": [{ "source": "/**/!(*.@(js|ts|html|css|json|svg|png|jpg|jpeg))", "destination": "/index.html" diff --git a/src/core/overlay/overlay-ref.ts b/src/core/overlay/overlay-ref.ts index ce9fb46b1476..2dd11c741123 100644 --- a/src/core/overlay/overlay-ref.ts +++ b/src/core/overlay/overlay-ref.ts @@ -1,14 +1,20 @@ import {PortalHost, Portal} from '../portal/portal'; +import {OverlayState} from './overlay-state'; /** * Reference to an overlay that has been created with the Overlay service. * Used to manipulate or dispose of said overlay. */ export class OverlayRef implements PortalHost { - constructor(private _portalHost: PortalHost) { } + constructor( + private _portalHost: PortalHost, + private _pane: HTMLElement, + private _state: OverlayState) { } attach(portal: Portal): Promise { - return this._portalHost.attach(portal); + return this._portalHost.attach(portal).then(() => { + this._updatePosition(); + }); } detach(): Promise { @@ -23,5 +29,12 @@ export class OverlayRef implements PortalHost { return this._portalHost.hasAttached(); } + /** Updates the position of the overlay based on the position strategy. */ + private _updatePosition() { + if (this._state.positionStrategy) { + this._state.positionStrategy.apply(this._pane); + } + } + // TODO(jelbourn): add additional methods for manipulating the overlay. } diff --git a/src/core/overlay/overlay.scss b/src/core/overlay/overlay.scss index 5f98c17c24a4..b8560242a1c3 100644 --- a/src/core/overlay/overlay.scss +++ b/src/core/overlay/overlay.scss @@ -16,4 +16,5 @@ .md-overlay-pane { position: absolute; pointer-events: auto; + box-sizing: border-box; } diff --git a/src/core/overlay/overlay.spec.ts b/src/core/overlay/overlay.spec.ts index 48450b21d223..6fd22e4952cd 100644 --- a/src/core/overlay/overlay.spec.ts +++ b/src/core/overlay/overlay.spec.ts @@ -20,6 +20,8 @@ import {Overlay, OVERLAY_CONTAINER_TOKEN} from './overlay'; import {OverlayRef} from './overlay-ref'; import {OverlayState} from './overlay-state'; import {PositionStrategy} from './position/position-strategy'; +import {OverlayPositionBuilder} from './position/overlay-position-builder'; +import {ViewportRuler} from './position/viewport-ruler'; export function main() { @@ -32,6 +34,8 @@ export function main() { beforeEachProviders(() => [ Overlay, + OverlayPositionBuilder, + ViewportRuler, provide(OVERLAY_CONTAINER_TOKEN, {useFactory: () => { overlayContainerElement = document.createElement('div'); return overlayContainerElement; diff --git a/src/core/overlay/overlay.ts b/src/core/overlay/overlay.ts index 8bcec53073fa..aeccd6b71ebc 100644 --- a/src/core/overlay/overlay.ts +++ b/src/core/overlay/overlay.ts @@ -3,13 +3,13 @@ import { OpaqueToken, Inject, Injectable, - ElementRef } from '@angular/core'; import {OverlayState} from './overlay-state'; import {DomPortalHost} from '../portal/dom-portal-host'; import {OverlayRef} from './overlay-ref'; -import {GlobalPositionStrategy} from './position/global-position-strategy'; -import {RelativePositionStrategy} from './position/relative-position-strategy'; + +import {OverlayPositionBuilder} from './position/overlay-position-builder'; +import {ViewportRuler} from './position/viewport-ruler'; // Re-export overlay-related modules so they can be imported directly from here. @@ -39,7 +39,8 @@ let defaultState = new OverlayState(); export class Overlay { constructor( @Inject(OVERLAY_CONTAINER_TOKEN) private _overlayContainerElement: HTMLElement, - private _dynamicComponentLoader: DynamicComponentLoader) { + private _dynamicComponentLoader: DynamicComponentLoader, + private _positionBuilder: OverlayPositionBuilder) { } /** @@ -48,7 +49,7 @@ export class Overlay { * @returns A reference to the created overlay. */ create(state: OverlayState = defaultState): Promise { - return this._createPaneElement(state).then(pane => this._createOverlayRef(pane)); + return this._createPaneElement().then(pane => this._createOverlayRef(pane, state)); } /** @@ -56,36 +57,23 @@ export class Overlay { * to construct and configure a position strategy. */ position() { - return POSITION_BUILDER; + return this._positionBuilder; } /** - * Creates the DOM element for an overlay. - * @param state State to apply to the created element. + * Creates the DOM element for an overlay and appends it to the overlay container. * @returns Promise resolving to the created element. */ - private _createPaneElement(state: OverlayState): Promise { + private _createPaneElement(): Promise { var pane = document.createElement('div'); - pane.id = `md-overlay-${nextUniqueId++}`; + pane.id = `md-overlay-${nextUniqueId++}`; pane.classList.add('md-overlay-pane'); - this.applyState(pane, state); this._overlayContainerElement.appendChild(pane); return Promise.resolve(pane); } - /** - * Applies a given state to the given pane element. - * @param pane The pane to modify. - * @param state The state to apply. - */ - applyState(pane: HTMLElement, state: OverlayState) { - if (state.positionStrategy != null) { - state.positionStrategy.apply(pane); - } - } - /** * Create a DomPortalHost into which the overlay content can be loaded. * @param pane The DOM element to turn into a portal host. @@ -100,26 +88,18 @@ export class Overlay { /** * Creates an OverlayRef for an overlay in the given DOM element. * @param pane DOM element for the overlay + * @param state * @returns {OverlayRef} */ - private _createOverlayRef(pane: HTMLElement): OverlayRef { - return new OverlayRef(this._createPortalHost(pane)); + private _createOverlayRef(pane: HTMLElement, state: OverlayState): OverlayRef { + return new OverlayRef(this._createPortalHost(pane), pane, state); } } -/** Builder for overlay position strategy. */ -export class OverlayPositionBuilder { - /** Creates a global position strategy. */ - global() { - return new GlobalPositionStrategy(); - } - - /** Creates a relative position strategy. */ - relativeTo(elementRef: ElementRef) { - return new RelativePositionStrategy(elementRef); - } -} - -// We only ever need one position builder. -let POSITION_BUILDER: OverlayPositionBuilder = new OverlayPositionBuilder(); +/** Providers for Overlay and its related injectables. */ +export const OVERLAY_PROVIDERS = [ + ViewportRuler, + OverlayPositionBuilder, + Overlay, +]; diff --git a/src/core/overlay/position/connected-position-strategy.spec.ts b/src/core/overlay/position/connected-position-strategy.spec.ts new file mode 100644 index 000000000000..70917d655f21 --- /dev/null +++ b/src/core/overlay/position/connected-position-strategy.spec.ts @@ -0,0 +1,384 @@ +import {it, describe, expect, beforeEach} from '@angular/core/testing'; +import {ElementRef} from '@angular/core'; +import {ConnectedPositionStrategy, RelativePosition} from './connected-position-strategy'; +import {ViewportRuler} from './viewport-ruler'; + + +// Default width and height of the overlay and origin panels throughout these tests. +const DEFAULT_HEIGHT = 30; +const DEFAULT_WIDTH = 60; + +export function main() { + + // For all tests, we assume the browser window is 1024x786 (outerWidth x outerHeight). + // The karma config has been set to this for local tests, and it is the default size + // for tests on CI (both SauceLabs and Browserstack). + + describe('ConnectedPositionStrategy', () => { + const ORIGIN_HEIGHT = DEFAULT_HEIGHT; + const ORIGIN_WIDTH = DEFAULT_WIDTH; + const OVERLAY_HEIGHT = DEFAULT_HEIGHT; + const OVERLAY_WIDTH = DEFAULT_WIDTH; + + let originElement: HTMLElement; + let overlayElement: HTMLElement; + let strategy: ConnectedPositionStrategy; + let fakeElementRef: ElementRef; + let fakeViewportRuler: FakeViewportRuler; + + let originRect: ClientRect; + let originCenterX: number; + 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(); + overlayElement = createPositionedBlockElement(); + document.body.appendChild(originElement); + document.body.appendChild(overlayElement); + + fakeElementRef = new FakeElementRef(originElement); + + strategy = new ConnectedPositionStrategy(fakeElementRef, new ViewportRuler()); + }); + + afterEach(() => { + document.body.removeChild(originElement); + document.body.removeChild(overlayElement); + + // Reset the origin geometry after each test so we don't accidently keep state between tests. + originRect = null; + originCenterX = null; + originCenterY = null; + }); + + describe('when not near viewport edge, not scrolled', () => { + // Place the original element close to the center of the window. + // (1024 / 2, 768 / 2). It's not exact, since outerWidth/Height includes browser + // chrome, but it doesn't really matter for these tests. + const ORIGIN_LEFT = 500; + const ORIGIN_TOP = 350; + + beforeEach(() => { + originElement.style.left = `${ORIGIN_LEFT}px`; + originElement.style.top = `${ORIGIN_TOP}px`; + + originRect = originElement.getBoundingClientRect(); + originCenterX = originRect.left + (ORIGIN_WIDTH / 2); + originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + }); + + // Preconditions are set, now just run the full set of simple position tests. + runSimplePositionTests(); + }); + + describe('when scrolled', () => { + // Place the original element decently far outside the unscrolled document (1024x768). + const ORIGIN_LEFT = 2500; + const ORIGIN_TOP = 2500; + + // Create a very large element that will make the page scrollable. + let veryLargeElement: HTMLElement = document.createElement('div'); + veryLargeElement.style.width = '4000px'; + veryLargeElement.style.height = '4000px'; + + beforeEach(() => { + // Scroll the page such that the origin element is roughly in the + // center of the visible viewport (2500 - 1024/2, 2500 - 768/2). + document.body.appendChild(veryLargeElement); + document.body.scrollTop = 2100; + document.body.scrollLeft = 2100; + + originElement.style.top = `${ORIGIN_TOP}px`; + originElement.style.left = `${ORIGIN_LEFT}px`; + + originRect = originElement.getBoundingClientRect(); + originCenterX = originRect.left + (ORIGIN_WIDTH / 2); + originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + }); + + afterEach(() => { + document.body.removeChild(veryLargeElement); + document.body.scrollTop = 0; + document.body.scrollLeft = 0; + }); + + // Preconditions are set, now just run the full set of simple position tests. + runSimplePositionTests(); + }); + + describe('when near viewport edge', () => { + it('should reposition the overlay if it would go off the top of the screen', () => { + // We can use the real ViewportRuler in this test since we know that zero is + // always the top of the viewport. + + originElement.style.top = '5px'; + originElement.style.left = '200px'; + originRect = originElement.getBoundingClientRect(); + + // Above, right aligned. + let offscreenPos = new RelativePosition(); + offscreenPos.originX = 'end'; + offscreenPos.originY = 'start'; + offscreenPos.overlayX = 'end'; + offscreenPos.overlayY = 'end'; + + // Below, left aligned. + let fallbackPosition = new RelativePosition(); + fallbackPosition.originX = 'start'; + fallbackPosition.originY = 'end'; + fallbackPosition.overlayX = 'start'; + fallbackPosition.overlayY = 'start'; + + strategy.addPreferredPosition(offscreenPos); + strategy.addPreferredPosition(fallbackPosition); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.left).toBe(originRect.left); + }); + + it('should reposition the overlay if it would go off the left of the screen', () => { + // We can use the real ViewportRuler in this test since we know that zero is + // always the left edge of the viewport. + + originElement.style.top = '200px'; + originElement.style.left = '5px'; + originRect = originElement.getBoundingClientRect(); + originCenterY = originRect.top + (ORIGIN_HEIGHT / 2); + + // To the left, below. + let offscreenPos = new RelativePosition(); + offscreenPos.originX = 'start'; + offscreenPos.originY = 'end'; + offscreenPos.overlayX = 'end'; + offscreenPos.overlayY = 'start'; + + // To the right, centered. + let fallbackPos = new RelativePosition(); + fallbackPos.originX = 'end'; + fallbackPos.originY = 'center'; + fallbackPos.overlayX = 'start'; + fallbackPos.overlayY = 'center'; + + strategy.addPreferredPosition(offscreenPos); + strategy.addPreferredPosition(fallbackPos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2)); + expect(overlayRect.left).toBe(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 + }; + strategy = new ConnectedPositionStrategy(fakeElementRef, fakeViewportRuler); + + originElement.style.top = '475px'; + originElement.style.left = '200px'; + originRect = originElement.getBoundingClientRect(); + + // Below, left aligned. + let offscreenPos = new RelativePosition(); + offscreenPos.originX = 'start'; + offscreenPos.originY = 'end'; + offscreenPos.overlayX = 'start'; + offscreenPos.overlayY = 'start'; + + // Above, right aligned. + let fallbackPos = new RelativePosition(); + fallbackPos.originX = 'end'; + fallbackPos.originY = 'start'; + fallbackPos.overlayX = 'end'; + fallbackPos.overlayY = 'end'; + + strategy.addPreferredPosition(offscreenPos); + strategy.addPreferredPosition(fallbackPos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.bottom).toBe(originRect.top); + expect(overlayRect.right).toBe(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 + }; + strategy = new ConnectedPositionStrategy(fakeElementRef, fakeViewportRuler); + + originElement.style.top = '200px'; + originElement.style.left = '475px'; + originRect = originElement.getBoundingClientRect(); + + // To the right, centered. + let offscreenPos = new RelativePosition(); + offscreenPos.originX = 'end'; + offscreenPos.originY = 'center'; + offscreenPos.overlayX = 'start'; + offscreenPos.overlayY = 'center'; + + // To the left, below. + let fallbackPos = new RelativePosition(); + fallbackPos.originX = 'start'; + fallbackPos.originY = 'end'; + fallbackPos.overlayX = 'end'; + fallbackPos.overlayY = 'start'; + + strategy.addPreferredPosition(offscreenPos); + strategy.addPreferredPosition(fallbackPos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.right).toBe(originRect.left); + }); + }); + + + /** + * Run all tests for connecting the overlay to the origin such that first preferred + * position does not go off-screen. We do this because there are several cases where we + * want to run the exact same tests with different preconditions (e.g., not scroll, scrolled, + * different element sized, etc.). + */ + function runSimplePositionTests() { + it('should position a panel below, left-aligned', () => { + var pos = new RelativePosition(); + pos.originX = 'start'; + pos.originY = 'end'; + pos.overlayX = 'start'; + pos.overlayY = 'start'; + strategy.addPreferredPosition(pos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.left).toBe(originRect.left); + }); + + it('should position to the right, center aligned vertically', () => { + var pos = new RelativePosition(); + pos.originX = 'end'; + pos.originY = 'center'; + pos.overlayX = 'start'; + pos.overlayY = 'center'; + strategy.addPreferredPosition(pos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originCenterY - (OVERLAY_HEIGHT / 2)); + expect(overlayRect.left).toBe(originRect.right); + }); + + it('should position to the left, below', () => { + var pos = new RelativePosition(); + pos.originX = 'start'; + pos.originY = 'end'; + pos.overlayX = 'end'; + pos.overlayY = 'start'; + strategy.addPreferredPosition(pos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.right).toBe(originRect.left); + }); + + it('should position above, right aligned', () => { + var pos = new RelativePosition(); + pos.originX = 'end'; + pos.originY = 'start'; + pos.overlayX = 'end'; + pos.overlayY = 'end'; + strategy.addPreferredPosition(pos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.bottom).toBe(originRect.top); + expect(overlayRect.right).toBe(originRect.right); + }); + + it('should position below, centered', () => { + var pos = new RelativePosition(); + pos.originX = 'center'; + pos.originY = 'end'; + pos.overlayX = 'center'; + pos.overlayY = 'start'; + strategy.addPreferredPosition(pos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.bottom); + expect(overlayRect.left).toBe(originCenterX - (OVERLAY_WIDTH / 2)); + }); + + it('should center the overlay on the origin', () => { + var pos = new RelativePosition(); + pos.originX = 'center'; + pos.originY = 'center'; + pos.overlayX = 'center'; + pos.overlayY = 'center'; + strategy.addPreferredPosition(pos); + + strategy.apply(overlayElement); + + let overlayRect = overlayElement.getBoundingClientRect(); + expect(overlayRect.top).toBe(originRect.top); + expect(overlayRect.left).toBe(originRect.left); + }); + } + + }); +} + + +/** Creates an absolutely positioned, display: block element with a default size. */ +function createPositionedBlockElement() { + let element = document.createElement('div'); + element.style.position = 'absolute'; + element.style.top = '0'; + element.style.left = '0'; + element.style.width = `${DEFAULT_WIDTH}px`; + element.style.height = `${DEFAULT_HEIGHT}px`; + element.style.backgroundColor = 'rebeccapurple'; + element.style.zIndex = '100'; + return element; +} + + +/** 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) { } +} diff --git a/src/core/overlay/position/connected-position-strategy.ts b/src/core/overlay/position/connected-position-strategy.ts new file mode 100644 index 000000000000..f1103f8a8644 --- /dev/null +++ b/src/core/overlay/position/connected-position-strategy.ts @@ -0,0 +1,204 @@ +import {PositionStrategy} from './position-strategy'; +import {ElementRef} from '@angular/core'; +import {ViewportRuler} from './viewport-ruler'; +import {applyCssTransform} from '../../style/apply-transform'; + + + +/** + * A strategy for positioning overlays. Using this strategy, an overlay is given an + * implict position relative some origin element. The relative position is defined in terms of + * a point on the origin element that is connected to a point on the overlay element. For example, + * a basic dropdown is connecting the bottom-left corner of the origin to the top-left corner + * of the overlay. + */ +export class ConnectedPositionStrategy implements PositionStrategy { + // TODO(jelbourn): set RTL to the actual value from the app. + /** Whether the we're dealing with an RTL context */ + _isRtl: boolean = false; + + /** Ordered list of preferred positions, from most to least desirable. */ + _preferredPositions: RelativePosition[] = []; + + /** The origin element against which the overlay will be positioned. */ + private _origin: HTMLElement; + + + constructor(private _connectedTo: ElementRef, private _viewportRuler: ViewportRuler) { + this._origin = this._connectedTo.nativeElement; + } + + + /** + * Updates the position of the overlay element, using whichever preferred position relative + * to the origin fits on-screen. + */ + apply(element: HTMLElement): Promise { + // We need the bounding rects for the origin and the overlay to determine how to position + // the overlay relative to the origin. + const originRect = this._origin.getBoundingClientRect(); + const overlayRect = element.getBoundingClientRect(); + + // We use the viewport rect to determine whether a position would go off-screen. + const viewportRect = this._viewportRuler.getViewportRect(); + let firstOverlayPoint: Point = 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; + + // If the overlay in the calculated position fits on-screen, put it there and we're done. + if (this._willOverlayFitWithinViewport(overlayPoint, overlayRect, viewportRect)) { + this._setElementPosition(element, overlayPoint); + return Promise.resolve(); + } + } + + // 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); + return Promise.resolve(); + } + + + /** Adds a preferred position to the end of the ordered preferred position list. */ + addPreferredPosition(pos: RelativePosition): void { + this._preferredPositions.push(pos); + } + + + /** + * Gets the horizontal (x) "start" dimension based on whether the overlay is in an RTL context. + * @param rect + */ + _getStartX(rect: ClientRect): number { + return this._isRtl ? rect.right : rect.left; + } + + /** + * Gets the horizontal (x) "end" dimension based on whether the overlay is in an RTL context. + * @param rect + */ + _getEndX(rect: ClientRect): number { + return this._isRtl ? rect.left : rect.right; + } + + + /** + * Gets the (x, y) coordinate of a connection point on the origin based on a relative position. + * @param originRect + * @param pos + */ + private _getOriginConnectionPoint(originRect: ClientRect, pos: RelativePosition): Point { + const originStartX = this._getStartX(originRect); + const originEndX = this._getEndX(originRect); + + let x: number; + if (pos.originX == 'center') { + x = originStartX + (originRect.width / 2); + } else { + x = pos.originX == 'start' ? originStartX : originEndX; + } + + let y: number; + if (pos.originY == 'center') { + y = originRect.top + (originRect.height / 2); + } else { + y = pos.originY == 'start' ? originRect.top : originRect.bottom; + } + + return {x, y}; + } + + + /** + * 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 + */ + private _getOverlayPoint( + originPoint: Point, + overlayRect: ClientRect, + pos: RelativePosition): Point { + // Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position + // relative to the origin point. + let overlayStartX: number; + if (pos.overlayX == 'center') { + overlayStartX = -overlayRect.width / 2; + } else { + overlayStartX = pos.overlayX == 'start' ? 0 : -overlayRect.width; + } + + let overlayStartY: number; + if (pos.overlayY == 'center') { + overlayStartY = -overlayRect.height / 2; + } else { + overlayStartY = pos.overlayY == 'start' ? 0 : -overlayRect.height; + } + + return { + x: originPoint.x + overlayStartX, + y: originPoint.y + overlayStartY + }; + } + + + /** + * 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 { + + // TODO(jelbourn): probably also want some space between overlay edge and viewport edge. + return overlayPoint.x >= viewportRect.left && + overlayPoint.x + overlayRect.width <= viewportRect.right && + overlayPoint.y >= viewportRect.top && + overlayPoint.y + overlayRect.height <= viewportRect.bottom; + } + + + /** + * Physically positions the overlay element to the given coordinate. + * @param element + * @param overlayPoint + */ + private _setElementPosition(element: HTMLElement, overlayPoint: Point) { + let scrollPos = this._viewportRuler.getViewportScrollPosition(); + + let x = overlayPoint.x + scrollPos.left; + let y = overlayPoint.y + scrollPos.top; + + // TODO(jelbourn): we don't want to always overwrite the transform property here, + // because it will need to be used for animations. + applyCssTransform(element, `translateX(${x}px) translateY(${y}px)`); + } +} + + +/** A simple (x, y) coordinate. */ +type Point = {x: number, y: number}; + + +/** One dimension of a connection point on the perimeter of the origin or overlay element. */ +export type ConnectionPoint = 'start' | 'center' | 'end'; + + +/** The points of the origin element and the overlay element to connect. */ +export class RelativePosition { + originX: ConnectionPoint; + originY: ConnectionPoint; + overlayX: ConnectionPoint; + overlayY: ConnectionPoint; +} diff --git a/src/core/overlay/position/global-position-strategy.ts b/src/core/overlay/position/global-position-strategy.ts index 8b6fce5f3037..0e2379f4bacc 100644 --- a/src/core/overlay/position/global-position-strategy.ts +++ b/src/core/overlay/position/global-position-strategy.ts @@ -1,4 +1,5 @@ import {PositionStrategy} from './position-strategy'; +import {applyCssTransform} from '../../style/apply-transform'; /** @@ -97,9 +98,7 @@ export class GlobalPositionStrategy implements PositionStrategy { let tranlateX = this._reduceTranslateValues('translateX', this._translateX); let translateY = this._reduceTranslateValues('translateY', this._translateY); - // It's important to trim the result, because the browser will ignore the set operation - // if the string contains only whitespace. - element.style.transform = `${tranlateX} ${translateY}`.trim(); + applyCssTransform(element, `${tranlateX} ${translateY}`); return Promise.resolve(); } diff --git a/src/core/overlay/position/overlay-position-builder.ts b/src/core/overlay/position/overlay-position-builder.ts new file mode 100644 index 000000000000..502d9d15e0b5 --- /dev/null +++ b/src/core/overlay/position/overlay-position-builder.ts @@ -0,0 +1,23 @@ +import {ViewportRuler} from './viewport-ruler'; +import {ConnectedPositionStrategy} from './connected-position-strategy'; +import {ElementRef, Injectable} from '@angular/core'; +import {GlobalPositionStrategy} from './global-position-strategy'; + + + +/** Builder for overlay position strategy. */ +@Injectable() +export class OverlayPositionBuilder { + constructor(private _viewportRuler: ViewportRuler) { } + + /** Creates a global position strategy. */ + global() { + return new GlobalPositionStrategy(); + } + + /** Creates a relative position strategy. */ + connectedTo(elementRef: ElementRef) { + return new ConnectedPositionStrategy(elementRef, this._viewportRuler); + } +} + diff --git a/src/core/overlay/position/viewport-ruler.spec.ts b/src/core/overlay/position/viewport-ruler.spec.ts new file mode 100644 index 000000000000..b66d266d212c --- /dev/null +++ b/src/core/overlay/position/viewport-ruler.spec.ts @@ -0,0 +1,93 @@ +import {it, describe, expect, beforeEach} from '@angular/core/testing'; +import {ViewportRuler} from './viewport-ruler'; + + +export function main() { + + // For all tests, we assume the browser window is 1024x786 (outerWidth x outerHeight). + // The karma config has been set to this for local tests, and it is the default size + // for tests on CI (both SauceLabs and Browserstack). + + // While we know the *outer* window width/height, the innerWidth and innerHeight depend on the + // the size of the individual browser's chrome, so we have to use window.innerWidth and + // window.innerHeight in the unit test instead of hard-coded values. + + describe('ViewportRuler', () => { + let ruler: ViewportRuler; + + let startingWindowWidth = window.innerWidth; + let startingWindowHeight = window.innerHeight; + + // Create a very large element that will make the page scrollable. + let veryLargeElement: HTMLElement = document.createElement('div'); + veryLargeElement.style.width = '6000px'; + veryLargeElement.style.height = '6000px'; + + beforeEach(() => { + ruler = new ViewportRuler(); + scrollTo(0, 0); + }); + + it('should get the viewport bounds when the page is not scrolled', () => { + let bounds = ruler.getViewportRect(); + expect(bounds.top).toBe(0); + expect(bounds.left).toBe(0); + expect(bounds.bottom).toBe(window.innerHeight); + expect(bounds.right).toBe(window.innerWidth); + }); + + it('should get the viewport bounds when the page is scrolled', () => { + document.body.appendChild(veryLargeElement); + scrollTo(1500, 2000); + + let bounds = ruler.getViewportRect(); + + // 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.innerWidth > startingWindowWidth || window.innerHeight > startingWindowHeight) { + return; + } + + expect(bounds.top).toBe(2000); + expect(bounds.left).toBe(1500); + expect(bounds.bottom).toBe(2000 + window.innerHeight); + expect(bounds.right).toBe(1500 + window.innerWidth); + + document.body.removeChild(veryLargeElement); + }); + + it('should get the bounds based on client coordinates when the page is pinch-zoomed', () => { + // There is no API to make the browser pinch-zoom, so there's no real way to automate + // tests for this behavior. Leaving this test here as documentation for the behavior. + }); + + it('should get the scroll position when the page is not scrolled', () => { + var scrollPos = ruler.getViewportScrollPosition(); + expect(scrollPos.top).toBe(0); + expect(scrollPos.left).toBe(0); + }); + + it('should get the scroll position when the page is scrolled', () => { + document.body.appendChild(veryLargeElement); + scrollTo(1500, 2000); + + // 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.innerWidth > startingWindowWidth || window.innerHeight > startingWindowHeight) { + return; + } + + var scrollPos = ruler.getViewportScrollPosition(); + expect(scrollPos.top).toBe(2000); + expect(scrollPos.left).toBe(1500); + + document.body.removeChild(veryLargeElement); + }); + }); +} diff --git a/src/core/overlay/position/viewport-ruler.ts b/src/core/overlay/position/viewport-ruler.ts new file mode 100644 index 000000000000..db98643d5bf9 --- /dev/null +++ b/src/core/overlay/position/viewport-ruler.ts @@ -0,0 +1,62 @@ +import {Injectable} from '@angular/core'; + + + +/** + * Simple utility for getting the bounds of the browser viewport. + * @internal + */ +@Injectable() +export class ViewportRuler { + // TODO(jelbourn): cache the document's bounding rect and only update it when the window + // is resized (debounced). + + + /** Gets a ClientRect for the viewport's bounds. */ + getViewportRect(): ClientRect { + // Use the document element's bounding rect rather than the window scroll properties + // (e.g. pageYOffset, scrollY) due to in issue in Chrome and IE where window scroll + // properties and client coordinates (boundingClientRect, clientX/Y, etc.) are in different + // conceptual viewports. Under most circumstances these viewports are equivalent, but they + // can disagree when the page is pinch-zoomed (on devices that support touch). + // See https://bugs.chromium.org/p/chromium/issues/detail?id=489206#c4 + // We use the documentElement instead of the body because, by default (without a css reset) + // browsers typically give the document body an 8px margin, which is not included in + // getBoundingClientRect(). + const documentRect = document.documentElement.getBoundingClientRect(); + const scrollPosition = this.getViewportScrollPosition(documentRect); + const height = window.innerHeight; + const width = window.innerWidth; + + return { + top: scrollPosition.top, + left: scrollPosition.left, + bottom: scrollPosition.top + height, + right: scrollPosition.left + width, + height, + width, + }; + } + + + /** + * Gets the (top, left) scroll position of the viewport. + * @param documentRect + */ + getViewportScrollPosition(documentRect = document.documentElement.getBoundingClientRect()) { + // The top-left-corner of the viewport is determined by the scroll position of the document + // body, normally just (scrollLeft, scrollTop). However, Chrome and Firefox disagree about + // whether `document.body` or `document.documentElement` is the scrolled element, so reading + // `scrollTop` and `scrollLeft` is inconsistent. However, using the bounding rect of + // `document.documentElement` works consistently, where the `top` and `left` values will + // equal negative the scroll position. + const top = documentRect.top < 0 && document.body.scrollTop == 0 ? + -documentRect.top : + document.body.scrollTop; + const left = documentRect.left < 0 && document.body.scrollLeft == 0 ? + -documentRect.left : + document.body.scrollLeft; + + return {top, left}; + } +} diff --git a/src/core/style/apply-transform.ts b/src/core/style/apply-transform.ts new file mode 100644 index 000000000000..7a856e819588 --- /dev/null +++ b/src/core/style/apply-transform.ts @@ -0,0 +1,13 @@ +/** + * Applies a CSS transform to an element, including browser-prefixed properties. + * @param element + * @param transformValue + */ +export function applyCssTransform(element: HTMLElement, transformValue: string) { + // It's important to trim the result, because the browser will ignore the set operation + // if the string contains only whitespace. + let value = transformValue.trim(); + + element.style.transform = value; + element.style.webkitTransform = value; +} diff --git a/src/demo-app/overlay/overlay-demo.html b/src/demo-app/overlay/overlay-demo.html index 551d7e60206a..a1b87cd8a27a 100644 --- a/src/demo-app/overlay/overlay-demo.html +++ b/src/demo-app/overlay/overlay-demo.html @@ -6,6 +6,10 @@ Pasta 2 + +