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 1bf84a26a1f4..a600598b03e6 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.spec.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.spec.ts @@ -259,6 +259,64 @@ describe('ConnectedPositionStrategy', () => { }); + describe('transform origin', () => { + let animatedDiv: HTMLElement; + + beforeEach(() => { + // append a child to the overlay to act as the animation root + animatedDiv = document.createElement('div'); + animatedDiv.classList.add('md-animated-div'); + overlayElement.appendChild(animatedDiv); + }); + + it('should apply the default transform origin to the animation root', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'top'}, + {overlayX: 'start', overlayY: 'top'}) + .withTransformOrigin('.md-animated-div', 'start top'); + + strategy.apply(overlayElement); + expect(animatedDiv.style.transformOrigin).toEqual('left top 0px'); + }); + + it('should apply the correct transform origin in RTL', () => { + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'start', originY: 'top'}, + {overlayX: 'start', overlayY: 'top'}) + .withTransformOrigin('.md-animated-div', 'start top') + .withDirection('rtl'); + + strategy.apply(overlayElement); + expect(animatedDiv.style.transformOrigin).toEqual('right top 0px'); + }); + + it('should apply the correct transform origin given a fallback position', () => { + fakeViewportRuler.fakeRect = { + top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500 + }; + positionBuilder = new OverlayPositionBuilder(fakeViewportRuler); + + originElement.style.top = '200px'; + originElement.style.left = '475px'; + + strategy = positionBuilder.connectedTo( + fakeElementRef, + {originX: 'end', originY: 'center'}, + {overlayX: 'start', overlayY: 'center'}) + .withTransformOrigin('.md-animated-div', 'start top') + .withFallbackPosition( + {originX: 'start', originY: 'bottom'}, + {overlayX: 'end', overlayY: 'top'}, + 'end top'); + + strategy.apply(overlayElement); + expect(animatedDiv.style.transformOrigin).toEqual('right top 0px'); + }); + + }); + /** * Run all tests for connecting the overlay to the origin such that first preferred diff --git a/src/lib/core/overlay/position/connected-position-strategy.ts b/src/lib/core/overlay/position/connected-position-strategy.ts index 7e3da99318f6..b3a2869eecd8 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.ts @@ -5,7 +5,9 @@ import {applyCssTransform} from '../../style/apply-transform'; import { ConnectionPositionPair, OriginConnectionPosition, - OverlayConnectionPosition + OverlayConnectionPosition, + PreferredPosition, + TransformOrigin } from './connected-position'; @@ -25,13 +27,16 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** The offset in pixels for the overlay connection point on the y-axis */ private _offsetY: number = 0; + /** The selector for the overlay descendant that controls the animation. */ + private _animationRootSelector: string; + /** Whether the we're dealing with an RTL context */ get _isRtl() { return this._dir === 'rtl'; } /** Ordered list of preferred positions, from most to least desirable. */ - _preferredPositions: ConnectionPositionPair[] = []; + _preferredPositions: PreferredPosition[] = []; /** The origin element against which the overlay will be positioned. */ private _origin: HTMLElement; @@ -70,13 +75,16 @@ export class ConnectedPositionStrategy implements PositionStrategy { 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); + let originPoint = this._getOriginConnectionPoint(originRect, pos.position); + let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, pos.position); 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); + if (pos.transformOrigin) { + this._setElementTransformOrigin(element, pos.transformOrigin); + } return Promise.resolve(null); } } @@ -89,8 +97,11 @@ export class ConnectedPositionStrategy implements PositionStrategy { withFallbackPosition( originPos: OriginConnectionPosition, - overlayPos: OverlayConnectionPosition): this { - this._preferredPositions.push(new ConnectionPositionPair(originPos, overlayPos)); + overlayPos: OverlayConnectionPosition, transformOrigin?: TransformOrigin): this { + this._preferredPositions.push({ + position: new ConnectionPositionPair(originPos, overlayPos), + transformOrigin: transformOrigin + }); return this; } @@ -112,6 +123,13 @@ export class ConnectedPositionStrategy implements PositionStrategy { return this; } + /** Sets a custom transform origin on the element that matches the given selector. */ + withTransformOrigin(animationRootSelector: string, transformOrigin: TransformOrigin): this { + this._animationRootSelector = animationRootSelector; + this._preferredPositions[0].transformOrigin = transformOrigin; + return this; + } + /** * Gets the horizontal (x) "start" dimension based on whether the overlay is in an RTL context. * @param rect @@ -224,10 +242,16 @@ export class ConnectedPositionStrategy implements PositionStrategy { // because it will need to be used for animations. applyCssTransform(element, `translateX(${x}px) translateY(${y}px)`); } + + private _setElementTransformOrigin(element: HTMLElement, + transformOrigin: TransformOrigin): void { + const animationRoot = element.querySelector(this._animationRootSelector) as HTMLElement; + const [start, end] = this._isRtl ? ['right', 'left'] : ['left', 'right']; + animationRoot.style.transformOrigin = (transformOrigin as string).replace('start', start) + .replace('end', end); + } } /** A simple (x, y) coordinate. */ type Point = {x: number, y: number}; - - diff --git a/src/lib/core/overlay/position/connected-position.ts b/src/lib/core/overlay/position/connected-position.ts index 92fab48398cc..53701c3952b7 100644 --- a/src/lib/core/overlay/position/connected-position.ts +++ b/src/lib/core/overlay/position/connected-position.ts @@ -31,3 +31,16 @@ export class ConnectionPositionPair { this.overlayY = overlay.overlayY; } } + +/** The connection position and transform origin. */ +export type PreferredPosition = { + position: ConnectionPositionPair, + transformOrigin: TransformOrigin +}; + +/** + * Supported transform origin property values. Values with 'start' or 'end' will be + * converted to 'left' or 'right' depending on the text direction. + */ +export type TransformOrigin = 'start' | 'end' | 'top' | 'bottom' | + 'start top' | 'end top' | 'start bottom' | 'end bottom';