From 3b527e87213db3884653814bed5f07759a8ac131 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Wed, 25 May 2016 18:37:05 -0700 Subject: [PATCH] feat(overlay): add connected overlay directive (#496) --- src/core/overlay/overlay-directives.spec.ts | 83 ++++++++++++++ src/core/overlay/overlay-directives.ts | 106 ++++++++++++++++++ src/core/overlay/overlay-ref.ts | 5 + src/core/overlay/overlay.scss | 2 + src/core/overlay/overlay.ts | 11 +- .../position/connected-position-strategy.ts | 28 +++-- .../overlay/position/connected-position.ts | 13 +-- .../position/overlay-position-builder.ts | 8 +- src/core/portal/portal-directives.ts | 2 + src/core/portal/portal.ts | 5 + src/demo-app/overlay/overlay-demo.html | 11 ++ src/demo-app/overlay/overlay-demo.ts | 28 ++--- 12 files changed, 263 insertions(+), 39 deletions(-) create mode 100644 src/core/overlay/overlay-directives.spec.ts create mode 100644 src/core/overlay/overlay-directives.ts diff --git a/src/core/overlay/overlay-directives.spec.ts b/src/core/overlay/overlay-directives.spec.ts new file mode 100644 index 000000000000..24d541cf3225 --- /dev/null +++ b/src/core/overlay/overlay-directives.spec.ts @@ -0,0 +1,83 @@ +import { + it, + describe, + expect, + beforeEach, + inject, + async, + fakeAsync, + flushMicrotasks, + beforeEachProviders +} from '@angular/core/testing'; +import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; +import {Component, provide, ViewChild} from '@angular/core'; +import {ConnectedOverlayDirective, OverlayOrigin} from './overlay-directives'; +import {OVERLAY_CONTAINER_TOKEN, Overlay} from './overlay'; +import {ViewportRuler} from './position/viewport-ruler'; +import {OverlayPositionBuilder} from './position/overlay-position-builder'; +import {ConnectedPositionStrategy} from './position/connected-position-strategy'; + + +describe('Overlay directives', () => { + let builder: TestComponentBuilder; + let overlayContainerElement: HTMLElement; + let fixture: ComponentFixture; + + beforeEachProviders(() => [ + Overlay, + OverlayPositionBuilder, + ViewportRuler, + provide(OVERLAY_CONTAINER_TOKEN, {useFactory: () => { + overlayContainerElement = document.createElement('div'); + return overlayContainerElement; + }}) + ]); + + beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { + builder = tcb; + })); + + beforeEach(async(() => { + builder.createAsync(ConnectedOverlayDirectiveTest).then(f => { + fixture = f; + fixture.detectChanges(); + }); + })); + + it(`should create an overlay and attach the directive's template`, () => { + expect(overlayContainerElement.textContent).toContain('Menu content'); + }); + + it('should destroy the overlay when the directive is destroyed', fakeAsync(() => { + fixture.destroy(); + flushMicrotasks(); + + expect(overlayContainerElement.textContent.trim()).toBe(''); + })); + + it('should use a connected position strategy with a default set of positions', () => { + let testComponent: ConnectedOverlayDirectiveTest = + fixture.debugElement.componentInstance; + let overlayDirective = testComponent.connectedOverlayDirective; + + let strategy = + overlayDirective.overlayRef.getState().positionStrategy; + expect(strategy) .toEqual(jasmine.any(ConnectedPositionStrategy)); + + let positions = strategy.positions; + expect(positions.length).toBeGreaterThan(0); + }); +}); + + +@Component({ + template: ` + + `, + directives: [ConnectedOverlayDirective, OverlayOrigin], +}) +class ConnectedOverlayDirectiveTest { + @ViewChild(ConnectedOverlayDirective) connectedOverlayDirective: ConnectedOverlayDirective; +} diff --git a/src/core/overlay/overlay-directives.ts b/src/core/overlay/overlay-directives.ts new file mode 100644 index 000000000000..00e0d8d423b8 --- /dev/null +++ b/src/core/overlay/overlay-directives.ts @@ -0,0 +1,106 @@ +import { + Directive, + TemplateRef, + ViewContainerRef, + OnInit, + Input, + OnDestroy, + ElementRef +} from '@angular/core'; +import {Overlay} from './overlay'; +import {OverlayRef} from './overlay-ref'; +import {TemplatePortal} from '../portal/portal'; +import {OverlayState} from './overlay-state'; +import {ConnectionPositionPair} from './position/connected-position'; + +/** Default set of positions for the overlay. Follows the behavior of a dropdown. */ +let defaultPositionList = [ + new ConnectionPositionPair( + {originX: 'start', originY: 'bottom'}, + {overlayX: 'start', overlayY: 'top'}), + new ConnectionPositionPair( + {originX: 'start', originY: 'top'}, + {overlayX: 'start', overlayY: 'bottom'}), +]; + + +/** + * Directive to facilitate declarative creation of an Overlay using a ConnectedPositionStrategy. + */ +@Directive({ + selector: '[connected-overlay]' +}) +export class ConnectedOverlayDirective implements OnInit, OnDestroy { + private _overlayRef: OverlayRef; + private _templatePortal: TemplatePortal; + + @Input() origin: OverlayOrigin; + @Input() positions: ConnectionPositionPair[]; + + // TODO(jelbourn): inputs for size, scroll behavior, animation, etc. + + constructor( + private _overlay: Overlay, + templateRef: TemplateRef, + viewContainerRef: ViewContainerRef) { + this._templatePortal = new TemplatePortal(templateRef, viewContainerRef); + } + + get overlayRef() { + return this._overlayRef; + } + + /** @internal */ + ngOnInit() { + this._createOverlay(); + } + + /** @internal */ + ngOnDestroy() { + this._destroyOverlay(); + } + + /** Creates an overlay and attaches this directive's template to it. */ + private _createOverlay() { + if (!this.positions || !this.positions.length) { + this.positions = defaultPositionList; + } + + let overlayConfig = new OverlayState(); + overlayConfig.positionStrategy = + this._overlay.position().connectedTo( + this.origin.elementRef, + {originX: this.positions[0].overlayX, originY: this.positions[0].originY}, + {overlayX: this.positions[0].overlayX, overlayY: this.positions[0].overlayY}); + + this._overlay.create(overlayConfig).then(ref => { + this._overlayRef = ref; + this._overlayRef.attach(this._templatePortal); + }); + } + + /** Destroys the overlay created by this directive. */ + private _destroyOverlay() { + this._overlayRef.dispose(); + } +} + + +/** + * Directive applied to an element to make it usable as an origin for an Overlay using a + * ConnectedPositionStrategy. + */ +@Directive({ + selector: '[overlay-origin]', + exportAs: 'overlayOrigin', +}) +export class OverlayOrigin { + constructor(private _elementRef: ElementRef) { } + + get elementRef() { + return this._elementRef; + } +} + + +export const OVERLAY_DIRECTIVES = [ConnectedOverlayDirective, OverlayOrigin]; diff --git a/src/core/overlay/overlay-ref.ts b/src/core/overlay/overlay-ref.ts index 2dd11c741123..8eb91664e706 100644 --- a/src/core/overlay/overlay-ref.ts +++ b/src/core/overlay/overlay-ref.ts @@ -29,6 +29,11 @@ export class OverlayRef implements PortalHost { return this._portalHost.hasAttached(); } + /** Gets the current state config of the overlay. */ + getState() { + return this._state; + } + /** Updates the position of the overlay based on the position strategy. */ private _updatePosition() { if (this._state.positionStrategy) { diff --git a/src/core/overlay/overlay.scss b/src/core/overlay/overlay.scss index b8560242a1c3..1db02bae0b36 100644 --- a/src/core/overlay/overlay.scss +++ b/src/core/overlay/overlay.scss @@ -1,3 +1,5 @@ +// TODO(jelbourn): change from the `md` prefix to something else for everything in the toolkit. + /** The overlay-container is an invisible element which contains all individual overlays. */ .md-overlay-container { position: absolute; diff --git a/src/core/overlay/overlay.ts b/src/core/overlay/overlay.ts index eecedf993f78..380bf2ef267c 100644 --- a/src/core/overlay/overlay.ts +++ b/src/core/overlay/overlay.ts @@ -12,11 +12,6 @@ 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. -export {OverlayState} from './overlay-state'; -export {OverlayRef} from './overlay-ref'; -export {createOverlayContainer} from './overlay-container'; - /** Token used to inject the DOM element that serves as the overlay container. */ export const OVERLAY_CONTAINER_TOKEN = new OpaqueToken('overlayContainer'); @@ -103,3 +98,9 @@ export const OVERLAY_PROVIDERS = [ OverlayPositionBuilder, Overlay, ]; + +// Re-export overlay-related modules so they can be imported directly from here. +export {OverlayState} from './overlay-state'; +export {OverlayRef} from './overlay-ref'; +export {createOverlayContainer} from './overlay-container'; +export {OVERLAY_DIRECTIVES, ConnectedOverlayDirective, OverlayOrigin} from './overlay-directives'; diff --git a/src/core/overlay/position/connected-position-strategy.ts b/src/core/overlay/position/connected-position-strategy.ts index a727b590d9dd..8f6733d9f86e 100644 --- a/src/core/overlay/position/connected-position-strategy.ts +++ b/src/core/overlay/position/connected-position-strategy.ts @@ -2,8 +2,11 @@ import {PositionStrategy} from './position-strategy'; import {ElementRef} from '@angular/core'; import {ViewportRuler} from './viewport-ruler'; import {applyCssTransform} from '@angular2-material/core/style/apply-transform'; -import {ConnectionPair, OriginPos, OverlayPos} from './connected-position'; - +import { + ConnectionPositionPair, + OriginConnectionPosition, + OverlayConnectionPosition +} from './connected-position'; /** @@ -19,7 +22,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { _isRtl: boolean = false; /** Ordered list of preferred positions, from most to least desirable. */ - _preferredPositions: ConnectionPair[] = []; + _preferredPositions: ConnectionPositionPair[] = []; /** The origin element against which the overlay will be positioned. */ private _origin: HTMLElement; @@ -27,13 +30,16 @@ export class ConnectedPositionStrategy implements PositionStrategy { constructor( private _connectedTo: ElementRef, - private _originPos: OriginPos, - private _overlayPos: OverlayPos, + private _originPos: OriginConnectionPosition, + private _overlayPos: OverlayConnectionPosition, private _viewportRuler: ViewportRuler) { this._origin = this._connectedTo.nativeElement; this.withFallbackPosition(_originPos, _overlayPos); } + get positions() { + return this._preferredPositions; + } /** * Updates the position of the overlay element, using whichever preferred position relative @@ -74,12 +80,14 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** Adds a preferred position to the end of the ordered preferred position list. */ - addPreferredPosition(pos: ConnectionPair): void { + addPreferredPosition(pos: ConnectionPositionPair): void { this._preferredPositions.push(pos); } - withFallbackPosition(originPos: OriginPos, overlayPos: OverlayPos): this { - this._preferredPositions.push(new ConnectionPair(originPos, overlayPos)); + withFallbackPosition( + originPos: OriginConnectionPosition, + overlayPos: OverlayConnectionPosition): this { + this._preferredPositions.push(new ConnectionPositionPair(originPos, overlayPos)); return this; } @@ -106,7 +114,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @param originRect * @param pos */ - private _getOriginConnectionPoint(originRect: ClientRect, pos: ConnectionPair): Point { + private _getOriginConnectionPoint(originRect: ClientRect, pos: ConnectionPositionPair): Point { const originStartX = this._getStartX(originRect); const originEndX = this._getEndX(originRect); @@ -138,7 +146,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { private _getOverlayPoint( originPoint: Point, overlayRect: ClientRect, - pos: ConnectionPair): Point { + pos: ConnectionPositionPair): Point { // Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position // relative to the origin point. let overlayStartX: number; diff --git a/src/core/overlay/position/connected-position.ts b/src/core/overlay/position/connected-position.ts index 16300a41feac..92fab48398cc 100644 --- a/src/core/overlay/position/connected-position.ts +++ b/src/core/overlay/position/connected-position.ts @@ -6,28 +6,25 @@ export type VerticalConnectionPos = 'top' | 'center' | 'bottom'; /** A connection point on the origin element. */ -export interface OriginPos { +export interface OriginConnectionPosition { originX: HorizontalConnectionPos; originY: VerticalConnectionPos; } /** A connection point on the overlay element. */ -export interface OverlayPos { +export interface OverlayConnectionPosition { overlayX: HorizontalConnectionPos; overlayY: VerticalConnectionPos; } -/** - * The points of the origin element and the overlay element to connect. - * @internal - */ -export class ConnectionPair { +/** The points of the origin element and the overlay element to connect. */ +export class ConnectionPositionPair { originX: HorizontalConnectionPos; originY: VerticalConnectionPos; overlayX: HorizontalConnectionPos; overlayY: VerticalConnectionPos; - constructor(origin: OriginPos, overlay: OverlayPos) { + constructor(origin: OriginConnectionPosition, overlay: OverlayConnectionPosition) { this.originX = origin.originX; this.originY = origin.originY; this.overlayX = overlay.overlayX; diff --git a/src/core/overlay/position/overlay-position-builder.ts b/src/core/overlay/position/overlay-position-builder.ts index 212a1d9d849e..585677852538 100644 --- a/src/core/overlay/position/overlay-position-builder.ts +++ b/src/core/overlay/position/overlay-position-builder.ts @@ -2,7 +2,7 @@ import {ViewportRuler} from './viewport-ruler'; import {ConnectedPositionStrategy} from './connected-position-strategy'; import {ElementRef, Injectable} from '@angular/core'; import {GlobalPositionStrategy} from './global-position-strategy'; -import {OverlayPos, OriginPos} from './connected-position'; +import {OverlayConnectionPosition, OriginConnectionPosition} from './connected-position'; @@ -17,8 +17,10 @@ export class OverlayPositionBuilder { } /** Creates a relative position strategy. */ - connectedTo(elementRef: ElementRef, originPos: OriginPos, overlayPos: OverlayPos) { + connectedTo( + elementRef: ElementRef, + originPos: OriginConnectionPosition, + overlayPos: OverlayConnectionPosition) { return new ConnectedPositionStrategy(elementRef, originPos, overlayPos, this._viewportRuler); } } - diff --git a/src/core/portal/portal-directives.ts b/src/core/portal/portal-directives.ts index 8d8b1ef13a2e..fbc5abc472d6 100644 --- a/src/core/portal/portal-directives.ts +++ b/src/core/portal/portal-directives.ts @@ -100,3 +100,5 @@ export class PortalHostDirective extends BasePortalHost { }); } } + +export const PORTAL_DIRECTIVES = [TemplatePortalDirective, PortalHostDirective]; diff --git a/src/core/portal/portal.ts b/src/core/portal/portal.ts index 1676cc9a4d29..9a8306315713 100644 --- a/src/core/portal/portal.ts +++ b/src/core/portal/portal.ts @@ -9,6 +9,7 @@ import { } from './portal-errors'; + /** * A `Portal` is something that you want to render somewhere else. * It can be attach to / detached from a `PortalHost`. @@ -202,3 +203,7 @@ export abstract class BasePortalHost implements PortalHost { this._disposeFn = fn; } } + + +export {PORTAL_DIRECTIVES, TemplatePortalDirective, PortalHostDirective} from './portal-directives'; +export {DomPortalHost} from './dom-portal-host'; diff --git a/src/demo-app/overlay/overlay-demo.html b/src/demo-app/overlay/overlay-demo.html index a1b87cd8a27a..d43c333296a0 100644 --- a/src/demo-app/overlay/overlay-demo.html +++ b/src/demo-app/overlay/overlay-demo.html @@ -10,6 +10,17 @@ Pasta 3 + + + + +