From c8f87a427a999b9217250b2e4a4dfcced76cc2d4 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Thu, 24 Mar 2016 12:43:54 -0700 Subject: [PATCH] feat(overlay): add global position strategy --- src/core/overlay/overlay-state.ts | 13 +- src/core/overlay/overlay.scss | 1 + src/core/overlay/overlay.spec.ts | 30 +++++ src/core/overlay/overlay.ts | 44 +++++-- .../position/global-position-strategy.spec.ts | 118 ++++++++++++++++++ .../position/global-position-strategy.ts | 112 +++++++++++++++++ .../overlay/position/position-strategy.ts | 6 + .../position/relative-position-strategy.ts | 11 ++ src/demo-app/overlay/overlay-demo.html | 2 +- src/demo-app/overlay/overlay-demo.scss | 11 ++ src/demo-app/overlay/overlay-demo.ts | 37 ++++-- 11 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 src/core/overlay/position/global-position-strategy.spec.ts create mode 100644 src/core/overlay/position/global-position-strategy.ts create mode 100644 src/core/overlay/position/position-strategy.ts create mode 100644 src/core/overlay/position/relative-position-strategy.ts diff --git a/src/core/overlay/overlay-state.ts b/src/core/overlay/overlay-state.ts index 712c8e9ffcbb..573dd034638a 100644 --- a/src/core/overlay/overlay-state.ts +++ b/src/core/overlay/overlay-state.ts @@ -1,8 +1,17 @@ +import {PositionStrategy} from './position/position-strategy'; + + /** * OverlayState is a bag of values for either the initial configuration or current state of an * overlay. */ export class OverlayState { - // Not yet implemented. - // TODO(jelbourn): add overlay state / configuration. + /** Strategy with which to position the overlay. */ + positionStrategy: PositionStrategy; + + // TODO(jelbourn): configuration still to add + // - overlay size + // - focus trap + // - disable pointer events + // - z-index } diff --git a/src/core/overlay/overlay.scss b/src/core/overlay/overlay.scss index 3675267fba72..5f98c17c24a4 100644 --- a/src/core/overlay/overlay.scss +++ b/src/core/overlay/overlay.scss @@ -15,4 +15,5 @@ /** A single overlay pane. */ .md-overlay-pane { position: absolute; + pointer-events: auto; } diff --git a/src/core/overlay/overlay.spec.ts b/src/core/overlay/overlay.spec.ts index a4c000101ee1..9a2510d8388f 100644 --- a/src/core/overlay/overlay.spec.ts +++ b/src/core/overlay/overlay.spec.ts @@ -23,6 +23,8 @@ import {TemplatePortal, ComponentPortal} from '../portal/portal'; import {Overlay, OVERLAY_CONTAINER_TOKEN} from './overlay'; import {DOM} from '../platform/dom/dom_adapter'; import {OverlayRef} from './overlay-ref'; +import {OverlayState} from './overlay-state'; +import {PositionStrategy} from './position/position-strategy'; export function main() { @@ -121,6 +123,26 @@ export function main() { expect(overlayContainerElement.childNodes.length).toBe(0); expect(overlayContainerElement.textContent).toBe(''); })); + + describe('applyState', () => { + let state: OverlayState; + + beforeEach(() => { + state = new OverlayState(); + }); + + it('should apply the positioning strategy', fakeAsyncTest(() => { + state.positionStrategy = new FakePositionStrategy(); + + overlay.create(state).then(ref => { + ref.attach(componentPortal); + }); + + flushMicrotasks(); + + expect(DOM.querySelectorAll(overlayContainerElement, '.fake-positioned').length).toBe(1); + })); + }); }); } @@ -144,6 +166,14 @@ class TestComponentWithTemplatePortals { constructor(public elementRef: ElementRef) { } } +class FakePositionStrategy implements PositionStrategy { + apply(element: Element): Promise { + DOM.addClass(element, 'fake-positioned'); + return Promise.resolve(); + } + +} + function fakeAsyncTest(fn: () => void) { return inject([], fakeAsync(fn)); } diff --git a/src/core/overlay/overlay.ts b/src/core/overlay/overlay.ts index 8f51fb125241..87f097ca4ddc 100644 --- a/src/core/overlay/overlay.ts +++ b/src/core/overlay/overlay.ts @@ -1,14 +1,18 @@ import { - DynamicComponentLoader, - AppViewManager, - OpaqueToken, - Inject, - Injectable} from 'angular2/core'; + DynamicComponentLoader, + AppViewManager, + OpaqueToken, + Inject, + Injectable, ElementRef +} from 'angular2/core'; import {CONST_EXPR} from 'angular2/src/facade/lang'; import {OverlayState} from './overlay-state'; import {DomPortalHost} from '../portal/dom-portal-host'; import {OverlayRef} from './overlay-ref'; import {DOM} from '../platform/dom/dom_adapter'; +import {GlobalPositionStrategy} from './position/global-position-strategy'; +import {RelativePositionStrategy} from './position/relative-position-strategy'; + // Re-export overlay-related modules so they can be imported directly from here. export {OverlayState} from './overlay-state'; @@ -50,6 +54,14 @@ export class Overlay { return this._createPaneElement(state).then(pane => this._createOverlayRef(pane)); } + /** + * Returns a position builder that can be used, via fluent API, + * to construct and configure a position strategy. + */ + position() { + return POSITION_BUILDER; + } + /** * Creates the DOM element for an overlay. * @param state State to apply to the created element. @@ -72,8 +84,9 @@ export class Overlay { * @param state The state to apply. */ applyState(pane: Element, state: OverlayState) { - // Not yet implemented. - // TODO(jelbourn): apply state to the pane element. + if (state.positionStrategy != null) { + state.positionStrategy.apply(pane); + } } /** @@ -97,3 +110,20 @@ export class Overlay { return new OverlayRef(this._createPortalHost(pane)); } } + + +/** 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(); diff --git a/src/core/overlay/position/global-position-strategy.spec.ts b/src/core/overlay/position/global-position-strategy.spec.ts new file mode 100644 index 000000000000..18cde84347e1 --- /dev/null +++ b/src/core/overlay/position/global-position-strategy.spec.ts @@ -0,0 +1,118 @@ +import { + inject, + fakeAsync, + flushMicrotasks, +} from 'angular2/testing'; +import { + it, + describe, + expect, + beforeEach, +} from '../../../core/facade/testing'; +import {BrowserDomAdapter} from '../../platform/browser/browser_adapter'; +import {DOM} from '../../platform/dom/dom_adapter'; +import {GlobalPositionStrategy} from './global-position-strategy'; + + +export function main() { + describe('GlobalPositonStrategy', () => { + BrowserDomAdapter.makeCurrent(); + let element: HTMLElement; + let strategy: GlobalPositionStrategy; + + beforeEach(() => { + element = DOM.createElement('div'); + strategy = new GlobalPositionStrategy(); + }); + + it('should set explicit (top, left) position to the element', fakeAsyncTest(() => { + strategy.top('10px').left('40%').apply(element); + + flushMicrotasks(); + + expect(element.style.top).toBe('10px'); + expect(element.style.left).toBe('40%'); + expect(element.style.bottom).toBe(''); + expect(element.style.right).toBe(''); + })); + + it('should set explicit (bottom, right) position to the element', fakeAsyncTest(() => { + strategy.bottom('70px').right('15em').apply(element); + + flushMicrotasks(); + + expect(element.style.top).toBe(''); + expect(element.style.left).toBe(''); + expect(element.style.bottom).toBe('70px'); + expect(element.style.right).toBe('15em'); + })); + + it('should overwrite previously applied positioning', fakeAsyncTest(() => { + strategy.centerHorizontally().centerVertically().apply(element); + flushMicrotasks(); + + strategy.top('10px').left('40%').apply(element); + flushMicrotasks(); + + expect(element.style.top).toBe('10px'); + expect(element.style.left).toBe('40%'); + expect(element.style.bottom).toBe(''); + expect(element.style.right).toBe(''); + expect(element.style.transform).not.toContain('translate'); + + strategy.bottom('70px').right('15em').apply(element); + + flushMicrotasks(); + + expect(element.style.top).toBe(''); + expect(element.style.left).toBe(''); + expect(element.style.bottom).toBe('70px'); + expect(element.style.right).toBe('15em'); + expect(element.style.transform).not.toContain('translate'); + })); + + it('should center the element', fakeAsyncTest(() => { + strategy.centerHorizontally().centerVertically().apply(element); + + flushMicrotasks(); + + expect(element.style.top).toBe('50%'); + expect(element.style.left).toBe('50%'); + expect(element.style.transform).toContain('translateX(-50%)'); + expect(element.style.transform).toContain('translateY(-50%)'); + })); + + it('should center the element with an offset', fakeAsyncTest(() => { + strategy.centerHorizontally('10px').centerVertically('15px').apply(element); + + flushMicrotasks(); + + expect(element.style.top).toBe('50%'); + expect(element.style.left).toBe('50%'); + expect(element.style.transform).toContain('translateX(-50%)'); + expect(element.style.transform).toContain('translateX(10px)'); + expect(element.style.transform).toContain('translateY(-50%)'); + expect(element.style.transform).toContain('translateY(15px)'); + })); + + it('should default the element to position: absolute', fakeAsyncTest(() => { + strategy.apply(element); + + flushMicrotasks(); + + expect(element.style.position).toBe('absolute'); + })); + + it('should make the element position: fixed', fakeAsyncTest(() => { + strategy.fixed().apply(element); + + flushMicrotasks(); + + expect(element.style.position).toBe('fixed'); + })); + }); +} + +function fakeAsyncTest(fn: () => void) { + return inject([], fakeAsync(fn)); +} diff --git a/src/core/overlay/position/global-position-strategy.ts b/src/core/overlay/position/global-position-strategy.ts new file mode 100644 index 000000000000..82fc0941a2ef --- /dev/null +++ b/src/core/overlay/position/global-position-strategy.ts @@ -0,0 +1,112 @@ +import {PositionStrategy} from './position-strategy'; +import {DOM} from '../../platform/dom/dom_adapter'; + + +/** + * A strategy for positioning overlays. Using this strategy, an overlay is given an + * explicit position relative to the browser's viewport. + */ +export class GlobalPositionStrategy implements PositionStrategy { + private _cssPosition: string = 'absolute'; + private _top: string = ''; + private _bottom: string = ''; + private _left: string = ''; + private _right: string = ''; + + /** Array of individual applications of translateX(). Currently only for centering. */ + private _translateX: string[] = []; + + /** Array of individual applications of translateY(). Currently only for centering. */ + private _translateY: string[] = []; + + /** Sets the element to usee CSS position: fixed */ + fixed() { + this._cssPosition = 'fixed'; + return this; + } + + /** Sets the element to usee CSS position: absolute. This is the default. */ + absolute() { + this._cssPosition = 'absolute'; + return this; + } + + /** Sets the top position of the overlay. Clears any previously set vertical position. */ + top(value: string) { + this._bottom = ''; + this._translateY = []; + this._top = value; + return this; + } + + /** Sets the left position of the overlay. Clears any previously set horizontal position. */ + left(value: string) { + this._right = ''; + this._translateX = []; + this._left = value; + return this; + } + + /** Sets the bottom position of the overlay. Clears any previously set vertical position. */ + bottom(value: string) { + this._top = ''; + this._translateY = []; + this._bottom = value; + return this; + } + + /** Sets the right position of the overlay. Clears any previously set horizontal position. */ + right(value: string) { + this._left = ''; + this._translateX = []; + this._right = value; + return this; + } + + /** + * Centers the overlay horizontally with an optional offset. + * Clears any previously set horizontal position. + */ + centerHorizontally(offset = '0px') { + this._left = '50%'; + this._right = ''; + this._translateX = ['-50%', offset]; + return this; + } + + /** + * Centers the overlay vertically with an optional offset. + * Clears any previously set vertical position. + */ + centerVertically(offset = '0px') { + this._top = '50%'; + this._bottom = ''; + this._translateY = ['-50%', offset]; + return this; + } + + /** Apply the position to the element. */ + apply(element: Element): Promise { + DOM.setStyle(element, 'position', this._cssPosition); + DOM.setStyle(element, 'top', this._top); + DOM.setStyle(element, 'left', this._left); + DOM.setStyle(element, 'bottom', this._bottom); + DOM.setStyle(element, 'right', this._right); + + // TODO(jelbourn): we don't want to always overwrite the transform property here, + // because it will need to be used for animations. + 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. + DOM.setStyle(element, 'transform', `${tranlateX} ${translateY}`.trim()); + + return Promise.resolve(); + } + + /** Reduce a list of translate values to a string that can be used in the transform property */ + private _reduceTranslateValues(translateFn: string, values: string[]) { + return values.map(t => `${translateFn}(${t})`).join(' '); + } +} diff --git a/src/core/overlay/position/position-strategy.ts b/src/core/overlay/position/position-strategy.ts new file mode 100644 index 000000000000..297f526921fe --- /dev/null +++ b/src/core/overlay/position/position-strategy.ts @@ -0,0 +1,6 @@ +/** Strategy for setting the position on an overlay. */ +export interface PositionStrategy { + + /** Updates the position of the overlay element. */ + apply(element: Element): Promise; +} diff --git a/src/core/overlay/position/relative-position-strategy.ts b/src/core/overlay/position/relative-position-strategy.ts new file mode 100644 index 000000000000..337027a18cd8 --- /dev/null +++ b/src/core/overlay/position/relative-position-strategy.ts @@ -0,0 +1,11 @@ +import {PositionStrategy} from './position-strategy'; +import {ElementRef} from 'angular2/core'; + +export class RelativePositionStrategy implements PositionStrategy { + constructor(private _relativeTo: ElementRef) { } + + apply(element: Element): Promise { + // Not yet implemented. + return null; + } +} diff --git a/src/demo-app/overlay/overlay-demo.html b/src/demo-app/overlay/overlay-demo.html index 33c1c01499d2..551d7e60206a 100644 --- a/src/demo-app/overlay/overlay-demo.html +++ b/src/demo-app/overlay/overlay-demo.html @@ -8,5 +8,5 @@ diff --git a/src/demo-app/overlay/overlay-demo.scss b/src/demo-app/overlay/overlay-demo.scss index e69de29bb2d1..bf3582e8a2b2 100644 --- a/src/demo-app/overlay/overlay-demo.scss +++ b/src/demo-app/overlay/overlay-demo.scss @@ -0,0 +1,11 @@ +.demo-rotini { + padding: 10px; + border: 1px solid black; + background-color: skyblue; +} + +.demo-fusilli { + padding: 10px; + border: 1px solid black; + background-color: lightgoldenrodyellow; +} diff --git a/src/demo-app/overlay/overlay-demo.ts b/src/demo-app/overlay/overlay-demo.ts index b28f89c42964..6044961e748d 100644 --- a/src/demo-app/overlay/overlay-demo.ts +++ b/src/demo-app/overlay/overlay-demo.ts @@ -1,5 +1,7 @@ -import {Component, ElementRef, ViewChildren, QueryList} from 'angular2/core'; -import {Overlay} from '../../core/overlay/overlay'; +import {Component, ElementRef, ViewChildren, QueryList, ViewEncapsulation} from 'angular2/core'; +import { + Overlay, + OverlayState} from '../../core/overlay/overlay'; import {ComponentPortal, Portal} from '../../core/portal/portal'; import {TemplatePortalDirective} from '../../core/portal/portal-directives'; @@ -9,23 +11,42 @@ import {TemplatePortalDirective} from '../../core/portal/portal-directives'; templateUrl: 'demo-app/overlay/overlay-demo.html', styleUrls: ['demo-app/overlay/overlay-demo.css'], directives: [TemplatePortalDirective], - providers: [ - Overlay, - ] + providers: [Overlay], + encapsulation: ViewEncapsulation.None, }) export class OverlayDemo { + nextPosition: number = 0; + @ViewChildren(TemplatePortalDirective) templatePortals: QueryList>; constructor(public overlay: Overlay, public elementRef: ElementRef) { } openRotiniPanel() { - this.overlay.create().then(ref => { + let config = new OverlayState(); + + config.positionStrategy = this.overlay.position() + .global() + .left(`${this.nextPosition}px`) + .top(`${this.nextPosition}px`); + + this.nextPosition += 30; + + this.overlay.create(config).then(ref => { ref.attach(new ComponentPortal(PastaPanel, this.elementRef)); }); } openFusilliPanel() { - this.overlay.create().then(ref => { + let config = new OverlayState(); + + config.positionStrategy = this.overlay.position() + .global() + .centerHorizontally() + .top(`${this.nextPosition}px`); + + this.nextPosition += 30; + + this.overlay.create(config).then(ref => { ref.attach(this.templatePortals.first); }); } @@ -34,7 +55,7 @@ export class OverlayDemo { /** Simple component to load into an overlay */ @Component({ selector: 'pasta-panel', - template: '

Rotini {{value}}

' + template: '

Rotini {{value}}

' }) class PastaPanel { value: number = 9000;