Skip to content

Commit

Permalink
feat(overlay): add connected overlay directive
Browse files Browse the repository at this point in the history
  • Loading branch information
jelbourn committed May 26, 2016
1 parent c923f56 commit 2cf7f68
Show file tree
Hide file tree
Showing 12 changed files with 263 additions and 39 deletions.
83 changes: 83 additions & 0 deletions src/core/overlay/overlay-directives.spec.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 106 additions & 0 deletions src/core/overlay/overlay-directives.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions src/core/overlay/overlay-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/core/overlay/overlay.scss
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
11 changes: 6 additions & 5 deletions src/core/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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';
28 changes: 18 additions & 10 deletions src/core/overlay/position/connected-position-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


/**
Expand All @@ -19,21 +22,24 @@ 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;


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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);

Expand Down Expand Up @@ -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;
Expand Down
13 changes: 5 additions & 8 deletions src/core/overlay/position/connected-position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions src/core/overlay/position/overlay-position-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';



Expand All @@ -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 changes: 2 additions & 0 deletions src/core/portal/portal-directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,5 @@ export class PortalHostDirective extends BasePortalHost {
});
}
}

export const PORTAL_DIRECTIVES = [TemplatePortalDirective, PortalHostDirective];
5 changes: 5 additions & 0 deletions src/core/portal/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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';
Loading

0 comments on commit 2cf7f68

Please sign in to comment.