feat(overlay): add connected overlay directive (#496)

This commit is contained in:
Jeremy Elbourn
2016-05-25 18:37:05 -07:00
parent c923f56c6e
commit 3b527e8721
12 changed files with 263 additions and 39 deletions
@@ -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<ConnectedOverlayDirectiveTest>;
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 =
<ConnectedPositionStrategy> overlayDirective.overlayRef.getState().positionStrategy;
expect(strategy) .toEqual(jasmine.any(ConnectedPositionStrategy));
let positions = strategy.positions;
expect(positions.length).toBeGreaterThan(0);
});
});
@Component({
template: `
<button overlay-origin #trigger="overlayOrigin">Toggle menu</button>
<template connected-overlay [origin]="trigger">
<p>Menu content</p>
</template>`,
directives: [ConnectedOverlayDirective, OverlayOrigin],
})
class ConnectedOverlayDirectiveTest {
@ViewChild(ConnectedOverlayDirective) connectedOverlayDirective: ConnectedOverlayDirective;
}
+106
View File
@@ -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<any>,
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];
+5
View File
@@ -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) {
+2
View File
@@ -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;
+6 -5
View File
@@ -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';
@@ -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;
@@ -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;
@@ -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);
}
}
+2
View File
@@ -100,3 +100,5 @@ export class PortalHostDirective extends BasePortalHost {
});
}
}
export const PORTAL_DIRECTIVES = [TemplatePortalDirective, PortalHostDirective];
+5
View File
@@ -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';
+11
View File
@@ -10,6 +10,17 @@
Pasta 3
</button>
<button overlay-origin #trigger="overlayOrigin" (click)="isMenuOpen = !isMenuOpen">
Open menu
</button>
<template connected-overlay [origin]="trigger">
<div style="background-color: mediumpurple" *ngIf="isMenuOpen">
This is the menu panel.
</div>
</template>
<!-- Template to load into an overlay. -->
<template portal>
<p class="demo-fusilli"> Fusilli </p>
+15 -13
View File
@@ -1,23 +1,24 @@
import {
Component,
ElementRef,
ViewChildren,
QueryList,
ViewEncapsulation,
Directive,
ViewChild,
ViewContainerRef
} from '@angular/core';
import {Overlay, OverlayState, OVERLAY_PROVIDERS} from '@angular2-material/core/overlay/overlay';
import {ComponentPortal, Portal} from '@angular2-material/core/portal/portal';
import {TemplatePortalDirective} from '@angular2-material/core/portal/portal-directives';
@Directive({selector: '[overlay-origin]'})
class OverlayOrigin {
constructor(public elementRef: ElementRef) { }
}
import {
Overlay,
OverlayState,
OverlayOrigin,
OVERLAY_PROVIDERS,
OVERLAY_DIRECTIVES
} from '@angular2-material/core/overlay/overlay';
import {
ComponentPortal,
Portal,
PORTAL_DIRECTIVES,
TemplatePortalDirective
} from '@angular2-material/core/portal/portal';
@Component({
@@ -25,12 +26,13 @@ class OverlayOrigin {
selector: 'overlay-demo',
templateUrl: 'overlay-demo.html',
styleUrls: ['overlay-demo.css'],
directives: [TemplatePortalDirective, OverlayOrigin],
directives: [PORTAL_DIRECTIVES, OVERLAY_DIRECTIVES],
providers: [OVERLAY_PROVIDERS],
encapsulation: ViewEncapsulation.None,
})
export class OverlayDemo {
nextPosition: number = 0;
isMenuOpen: boolean = false;
@ViewChildren(TemplatePortalDirective) templatePortals: QueryList<Portal<any>>;
@ViewChild(OverlayOrigin) private _overlayOrigin: OverlayOrigin;