feat(overlay): add connected position strategy (#335)

This commit is contained in:
Jeremy Elbourn
2016-05-12 16:33:19 -07:00
parent dfe683bc3f
commit 7f3b1bd43b
18 changed files with 911 additions and 63 deletions
+7
View File
@@ -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"
+15 -2
View File
@@ -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<any>): Promise<any> {
return this._portalHost.attach(portal);
return this._portalHost.attach(portal).then(() => {
this._updatePosition();
});
}
detach(): Promise<any> {
@@ -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.
}
+1
View File
@@ -16,4 +16,5 @@
.md-overlay-pane {
position: absolute;
pointer-events: auto;
box-sizing: border-box;
}
+4
View File
@@ -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;
+19 -39
View File
@@ -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<OverlayRef> {
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<HTMLElement> {
private _createPaneElement(): Promise<HTMLElement> {
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,
];
@@ -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) { }
}
@@ -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<void> {
// 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;
}
@@ -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();
}
@@ -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);
}
}
@@ -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);
});
});
}
@@ -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};
}
}
+13
View File
@@ -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;
}
+4
View File
@@ -6,6 +6,10 @@
Pasta 2
</button>
<button overlay-origin (click)="openSpaghettiPanel()">
Pasta 3
</button>
<!-- Template to load into an overlay. -->
<template portal>
<p class="demo-fusilli"> Fusilli </p>
+9
View File
@@ -9,3 +9,12 @@
border: 1px solid black;
background-color: lightgoldenrodyellow;
}
.demo-spagetti {
margin: 0;
padding: 10px;
border: 1px solid black;
color: white;
background-color: rebeccapurple;
opacity: 0.5;
}
+50 -9
View File
@@ -1,26 +1,39 @@
import {
Component, ViewChildren, QueryList, ViewEncapsulation,
Component,
ElementRef,
ViewChildren,
QueryList,
ViewEncapsulation,
Directive,
ViewChild,
ViewContainerRef
} from '@angular/core';
import {
Overlay,
OverlayState} from '../../core/overlay/overlay';
import {Overlay, OverlayState, OVERLAY_PROVIDERS} from '../../core/overlay/overlay';
import {ComponentPortal, Portal} from '../../core/portal/portal';
import {TemplatePortalDirective} from '../../core/portal/portal-directives';
import {RelativePosition} from '../../core/overlay/position/connected-position-strategy';
@Directive({selector: '[overlay-origin]'})
class OverlayOrigin {
constructor(public elementRef: ElementRef) { }
}
@Component({
selector: 'overlay-demo',
templateUrl: 'demo-app/overlay/overlay-demo.html',
styleUrls: ['demo-app/overlay/overlay-demo.css'],
directives: [TemplatePortalDirective],
providers: [Overlay],
directives: [TemplatePortalDirective, OverlayOrigin],
providers: [OVERLAY_PROVIDERS],
encapsulation: ViewEncapsulation.None,
})
export class OverlayDemo {
nextPosition: number = 0;
@ViewChildren(TemplatePortalDirective) templatePortals: QueryList<Portal<any>>;
@ViewChild(OverlayOrigin) private _overlayOrigin: OverlayOrigin;
constructor(public overlay: Overlay, public viewContainerRef: ViewContainerRef) { }
@@ -35,7 +48,7 @@ export class OverlayDemo {
this.nextPosition += 30;
this.overlay.create(config).then(ref => {
ref.attach(new ComponentPortal(PastaPanel, this.viewContainerRef));
ref.attach(new ComponentPortal(RotiniPanel, this.viewContainerRef));
});
}
@@ -53,13 +66,41 @@ export class OverlayDemo {
ref.attach(this.templatePortals.first);
});
}
openSpaghettiPanel() {
// TODO(jelbourn): make a nice API for this.
// TODO(jelbourn): separate overlay demo for connected positioning.
let strategy = this.overlay.position().connectedTo(this._overlayOrigin.elementRef);
var pos = new RelativePosition();
pos.originX = 'start';
pos.originY = 'end';
pos.overlayX = 'start';
pos.overlayY = 'start';
strategy.addPreferredPosition(pos);
let config = new OverlayState();
config.positionStrategy = strategy;
this.overlay.create(config).then(ref => {
ref.attach(new ComponentPortal(SpagettiPanel, this.viewContainerRef));
});
}
}
/** Simple component to load into an overlay */
@Component({
selector: 'pasta-panel',
selector: 'rotini-panel',
template: '<p class="demo-rotini">Rotini {{value}}</p>'
})
class PastaPanel {
class RotiniPanel {
value: number = 9000;
}
/** Simple component to load into an overlay */
@Component({
selector: 'spagetti-panel',
template: '<div class="demo-spagetti">Spagetti {{value}}</div>'
})
class SpagettiPanel {
value: string = 'Omega';
}
+2 -1
View File
@@ -12,7 +12,8 @@
"rootDir": ".",
"sourceMap": true,
"target": "es5",
"inlineSources": true
"inlineSources": true,
"stripInternal": true
},
"files": [
"main.ts",
+17 -7
View File
@@ -43,18 +43,25 @@ const configuration: { [name: string]: ConfigurationInfo } = {
'Safari7': { unitTest: {target: null, required: false}, e2e: {target: null, required: true}},
'Safari8': { unitTest: {target: 'BS', required: false}, e2e: {target: null, required: true}},
'Safari9': { unitTest: {target: 'BS', required: false}, e2e: {target: null, required: true}},
'iOS7': { unitTest: {target: 'BS', required: true}, e2e: {target: null, required: true}},
'iOS8': { unitTest: {target: 'BS', required: false}, e2e: {target: null, required: true}},
'iOS7': { unitTest: {target: 'BS', required: false}, e2e: {target: null, required: true}},
'iOS8': { unitTest: {target: 'BS', required: true}, e2e: {target: null, required: true}},
// TODO(mlaval): iOS9 deactivated as not reliable, reactivate after
// https://github.com/angular/angular/issues/5408
'iOS9': { unitTest: {target: null, required: false}, e2e: {target: null, required: true}},
'iOS9': { unitTest: {target: 'BS', required: false}, e2e: {target: null, required: true}},
'WindowsPhone': { unitTest: {target: 'BS', required: false}, e2e: {target: null, required: true}}
};
export const customLaunchers: { [name: string]: BrowserLauncherInfo } = {
'ChromeNoSandbox': {
base: 'Chrome',
flags: ['--no-sandbox'] },
flags: ['--no-sandbox']
},
// Chrome set to 1024x768 resolution for *local testing only*.
// On CI, both SauceLabs and Browserstack already default all browser window sizes to 1024x768.
'Chrome_1024x768': {
base : 'Chrome',
flags: ['--window-size=1024,768']
},
'SL_CHROME': {
base: 'SauceLabs',
browserName: 'chrome',
@@ -210,19 +217,22 @@ export const customLaunchers: { [name: string]: BrowserLauncherInfo } = {
base: 'BrowserStack',
device: 'iPhone 5S',
os: 'ios',
os_version: '7.0'
os_version: '7.0',
resolution: '1024x768'
},
'BS_IOS8': {
base: 'BrowserStack',
device: 'iPhone 6',
os: 'ios',
os_version: '8.3'
os_version: '8.3',
resolution: '1024x768'
},
'BS_IOS9': {
base: 'BrowserStack',
device: 'iPhone 6S',
os: 'ios',
os_version: '9.0'
os_version: '9.0',
resolution: '1024x768'
},
'BS_IE9': {
base: 'BrowserStack',
+2 -2
View File
@@ -27,7 +27,7 @@ export function config(config) {
{pattern: 'dist/vendor/zone.js/dist/async-test.js', included: true, watched: false},
{pattern: 'dist/vendor/zone.js/dist/fake-async-test.js', included: true, watched: false},
{pattern: 'test/karma-test-shim.js', included: true, watched: true},
{pattern: 'test/karma-test-shim.js', included: true, watched: false},
// paths loaded via module imports
{pattern: 'dist/**/*.js', included: false, watched: true},
@@ -81,7 +81,7 @@ export function config(config) {
browserDisconnectTimeout: 20000,
browserNoActivityTimeout: 240000,
captureTimeout: 120000,
browsers: ['Chrome'],
browsers: ['Chrome_1024x768'],
singleRun: false
});