Skip to content

Commit 3b527e8

Browse files
committed
feat(overlay): add connected overlay directive (#496)
1 parent c923f56 commit 3b527e8

12 files changed

+263
-39
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {
2+
it,
3+
describe,
4+
expect,
5+
beforeEach,
6+
inject,
7+
async,
8+
fakeAsync,
9+
flushMicrotasks,
10+
beforeEachProviders
11+
} from '@angular/core/testing';
12+
import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing';
13+
import {Component, provide, ViewChild} from '@angular/core';
14+
import {ConnectedOverlayDirective, OverlayOrigin} from './overlay-directives';
15+
import {OVERLAY_CONTAINER_TOKEN, Overlay} from './overlay';
16+
import {ViewportRuler} from './position/viewport-ruler';
17+
import {OverlayPositionBuilder} from './position/overlay-position-builder';
18+
import {ConnectedPositionStrategy} from './position/connected-position-strategy';
19+
20+
21+
describe('Overlay directives', () => {
22+
let builder: TestComponentBuilder;
23+
let overlayContainerElement: HTMLElement;
24+
let fixture: ComponentFixture<ConnectedOverlayDirectiveTest>;
25+
26+
beforeEachProviders(() => [
27+
Overlay,
28+
OverlayPositionBuilder,
29+
ViewportRuler,
30+
provide(OVERLAY_CONTAINER_TOKEN, {useFactory: () => {
31+
overlayContainerElement = document.createElement('div');
32+
return overlayContainerElement;
33+
}})
34+
]);
35+
36+
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
37+
builder = tcb;
38+
}));
39+
40+
beforeEach(async(() => {
41+
builder.createAsync(ConnectedOverlayDirectiveTest).then(f => {
42+
fixture = f;
43+
fixture.detectChanges();
44+
});
45+
}));
46+
47+
it(`should create an overlay and attach the directive's template`, () => {
48+
expect(overlayContainerElement.textContent).toContain('Menu content');
49+
});
50+
51+
it('should destroy the overlay when the directive is destroyed', fakeAsync(() => {
52+
fixture.destroy();
53+
flushMicrotasks();
54+
55+
expect(overlayContainerElement.textContent.trim()).toBe('');
56+
}));
57+
58+
it('should use a connected position strategy with a default set of positions', () => {
59+
let testComponent: ConnectedOverlayDirectiveTest =
60+
fixture.debugElement.componentInstance;
61+
let overlayDirective = testComponent.connectedOverlayDirective;
62+
63+
let strategy =
64+
<ConnectedPositionStrategy> overlayDirective.overlayRef.getState().positionStrategy;
65+
expect(strategy) .toEqual(jasmine.any(ConnectedPositionStrategy));
66+
67+
let positions = strategy.positions;
68+
expect(positions.length).toBeGreaterThan(0);
69+
});
70+
});
71+
72+
73+
@Component({
74+
template: `
75+
<button overlay-origin #trigger="overlayOrigin">Toggle menu</button>
76+
<template connected-overlay [origin]="trigger">
77+
<p>Menu content</p>
78+
</template>`,
79+
directives: [ConnectedOverlayDirective, OverlayOrigin],
80+
})
81+
class ConnectedOverlayDirectiveTest {
82+
@ViewChild(ConnectedOverlayDirective) connectedOverlayDirective: ConnectedOverlayDirective;
83+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {
2+
Directive,
3+
TemplateRef,
4+
ViewContainerRef,
5+
OnInit,
6+
Input,
7+
OnDestroy,
8+
ElementRef
9+
} from '@angular/core';
10+
import {Overlay} from './overlay';
11+
import {OverlayRef} from './overlay-ref';
12+
import {TemplatePortal} from '../portal/portal';
13+
import {OverlayState} from './overlay-state';
14+
import {ConnectionPositionPair} from './position/connected-position';
15+
16+
/** Default set of positions for the overlay. Follows the behavior of a dropdown. */
17+
let defaultPositionList = [
18+
new ConnectionPositionPair(
19+
{originX: 'start', originY: 'bottom'},
20+
{overlayX: 'start', overlayY: 'top'}),
21+
new ConnectionPositionPair(
22+
{originX: 'start', originY: 'top'},
23+
{overlayX: 'start', overlayY: 'bottom'}),
24+
];
25+
26+
27+
/**
28+
* Directive to facilitate declarative creation of an Overlay using a ConnectedPositionStrategy.
29+
*/
30+
@Directive({
31+
selector: '[connected-overlay]'
32+
})
33+
export class ConnectedOverlayDirective implements OnInit, OnDestroy {
34+
private _overlayRef: OverlayRef;
35+
private _templatePortal: TemplatePortal;
36+
37+
@Input() origin: OverlayOrigin;
38+
@Input() positions: ConnectionPositionPair[];
39+
40+
// TODO(jelbourn): inputs for size, scroll behavior, animation, etc.
41+
42+
constructor(
43+
private _overlay: Overlay,
44+
templateRef: TemplateRef<any>,
45+
viewContainerRef: ViewContainerRef) {
46+
this._templatePortal = new TemplatePortal(templateRef, viewContainerRef);
47+
}
48+
49+
get overlayRef() {
50+
return this._overlayRef;
51+
}
52+
53+
/** @internal */
54+
ngOnInit() {
55+
this._createOverlay();
56+
}
57+
58+
/** @internal */
59+
ngOnDestroy() {
60+
this._destroyOverlay();
61+
}
62+
63+
/** Creates an overlay and attaches this directive's template to it. */
64+
private _createOverlay() {
65+
if (!this.positions || !this.positions.length) {
66+
this.positions = defaultPositionList;
67+
}
68+
69+
let overlayConfig = new OverlayState();
70+
overlayConfig.positionStrategy =
71+
this._overlay.position().connectedTo(
72+
this.origin.elementRef,
73+
{originX: this.positions[0].overlayX, originY: this.positions[0].originY},
74+
{overlayX: this.positions[0].overlayX, overlayY: this.positions[0].overlayY});
75+
76+
this._overlay.create(overlayConfig).then(ref => {
77+
this._overlayRef = ref;
78+
this._overlayRef.attach(this._templatePortal);
79+
});
80+
}
81+
82+
/** Destroys the overlay created by this directive. */
83+
private _destroyOverlay() {
84+
this._overlayRef.dispose();
85+
}
86+
}
87+
88+
89+
/**
90+
* Directive applied to an element to make it usable as an origin for an Overlay using a
91+
* ConnectedPositionStrategy.
92+
*/
93+
@Directive({
94+
selector: '[overlay-origin]',
95+
exportAs: 'overlayOrigin',
96+
})
97+
export class OverlayOrigin {
98+
constructor(private _elementRef: ElementRef) { }
99+
100+
get elementRef() {
101+
return this._elementRef;
102+
}
103+
}
104+
105+
106+
export const OVERLAY_DIRECTIVES = [ConnectedOverlayDirective, OverlayOrigin];

src/core/overlay/overlay-ref.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export class OverlayRef implements PortalHost {
2929
return this._portalHost.hasAttached();
3030
}
3131

32+
/** Gets the current state config of the overlay. */
33+
getState() {
34+
return this._state;
35+
}
36+
3237
/** Updates the position of the overlay based on the position strategy. */
3338
private _updatePosition() {
3439
if (this._state.positionStrategy) {

src/core/overlay/overlay.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// TODO(jelbourn): change from the `md` prefix to something else for everything in the toolkit.
2+
13
/** The overlay-container is an invisible element which contains all individual overlays. */
24
.md-overlay-container {
35
position: absolute;

src/core/overlay/overlay.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ import {OverlayPositionBuilder} from './position/overlay-position-builder';
1212
import {ViewportRuler} from './position/viewport-ruler';
1313

1414

15-
// Re-export overlay-related modules so they can be imported directly from here.
16-
export {OverlayState} from './overlay-state';
17-
export {OverlayRef} from './overlay-ref';
18-
export {createOverlayContainer} from './overlay-container';
19-
2015
/** Token used to inject the DOM element that serves as the overlay container. */
2116
export const OVERLAY_CONTAINER_TOKEN = new OpaqueToken('overlayContainer');
2217

@@ -103,3 +98,9 @@ export const OVERLAY_PROVIDERS = [
10398
OverlayPositionBuilder,
10499
Overlay,
105100
];
101+
102+
// Re-export overlay-related modules so they can be imported directly from here.
103+
export {OverlayState} from './overlay-state';
104+
export {OverlayRef} from './overlay-ref';
105+
export {createOverlayContainer} from './overlay-container';
106+
export {OVERLAY_DIRECTIVES, ConnectedOverlayDirective, OverlayOrigin} from './overlay-directives';

src/core/overlay/position/connected-position-strategy.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import {PositionStrategy} from './position-strategy';
22
import {ElementRef} from '@angular/core';
33
import {ViewportRuler} from './viewport-ruler';
44
import {applyCssTransform} from '@angular2-material/core/style/apply-transform';
5-
import {ConnectionPair, OriginPos, OverlayPos} from './connected-position';
6-
5+
import {
6+
ConnectionPositionPair,
7+
OriginConnectionPosition,
8+
OverlayConnectionPosition
9+
} from './connected-position';
710

811

912
/**
@@ -19,21 +22,24 @@ export class ConnectedPositionStrategy implements PositionStrategy {
1922
_isRtl: boolean = false;
2023

2124
/** Ordered list of preferred positions, from most to least desirable. */
22-
_preferredPositions: ConnectionPair[] = [];
25+
_preferredPositions: ConnectionPositionPair[] = [];
2326

2427
/** The origin element against which the overlay will be positioned. */
2528
private _origin: HTMLElement;
2629

2730

2831
constructor(
2932
private _connectedTo: ElementRef,
30-
private _originPos: OriginPos,
31-
private _overlayPos: OverlayPos,
33+
private _originPos: OriginConnectionPosition,
34+
private _overlayPos: OverlayConnectionPosition,
3235
private _viewportRuler: ViewportRuler) {
3336
this._origin = this._connectedTo.nativeElement;
3437
this.withFallbackPosition(_originPos, _overlayPos);
3538
}
3639

40+
get positions() {
41+
return this._preferredPositions;
42+
}
3743

3844
/**
3945
* Updates the position of the overlay element, using whichever preferred position relative
@@ -74,12 +80,14 @@ export class ConnectedPositionStrategy implements PositionStrategy {
7480

7581

7682
/** Adds a preferred position to the end of the ordered preferred position list. */
77-
addPreferredPosition(pos: ConnectionPair): void {
83+
addPreferredPosition(pos: ConnectionPositionPair): void {
7884
this._preferredPositions.push(pos);
7985
}
8086

81-
withFallbackPosition(originPos: OriginPos, overlayPos: OverlayPos): this {
82-
this._preferredPositions.push(new ConnectionPair(originPos, overlayPos));
87+
withFallbackPosition(
88+
originPos: OriginConnectionPosition,
89+
overlayPos: OverlayConnectionPosition): this {
90+
this._preferredPositions.push(new ConnectionPositionPair(originPos, overlayPos));
8391
return this;
8492
}
8593

@@ -106,7 +114,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
106114
* @param originRect
107115
* @param pos
108116
*/
109-
private _getOriginConnectionPoint(originRect: ClientRect, pos: ConnectionPair): Point {
117+
private _getOriginConnectionPoint(originRect: ClientRect, pos: ConnectionPositionPair): Point {
110118
const originStartX = this._getStartX(originRect);
111119
const originEndX = this._getEndX(originRect);
112120

@@ -138,7 +146,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
138146
private _getOverlayPoint(
139147
originPoint: Point,
140148
overlayRect: ClientRect,
141-
pos: ConnectionPair): Point {
149+
pos: ConnectionPositionPair): Point {
142150
// Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position
143151
// relative to the origin point.
144152
let overlayStartX: number;

src/core/overlay/position/connected-position.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,25 @@ export type VerticalConnectionPos = 'top' | 'center' | 'bottom';
66

77

88
/** A connection point on the origin element. */
9-
export interface OriginPos {
9+
export interface OriginConnectionPosition {
1010
originX: HorizontalConnectionPos;
1111
originY: VerticalConnectionPos;
1212
}
1313

1414
/** A connection point on the overlay element. */
15-
export interface OverlayPos {
15+
export interface OverlayConnectionPosition {
1616
overlayX: HorizontalConnectionPos;
1717
overlayY: VerticalConnectionPos;
1818
}
1919

20-
/**
21-
* The points of the origin element and the overlay element to connect.
22-
* @internal
23-
*/
24-
export class ConnectionPair {
20+
/** The points of the origin element and the overlay element to connect. */
21+
export class ConnectionPositionPair {
2522
originX: HorizontalConnectionPos;
2623
originY: VerticalConnectionPos;
2724
overlayX: HorizontalConnectionPos;
2825
overlayY: VerticalConnectionPos;
2926

30-
constructor(origin: OriginPos, overlay: OverlayPos) {
27+
constructor(origin: OriginConnectionPosition, overlay: OverlayConnectionPosition) {
3128
this.originX = origin.originX;
3229
this.originY = origin.originY;
3330
this.overlayX = overlay.overlayX;

src/core/overlay/position/overlay-position-builder.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {ViewportRuler} from './viewport-ruler';
22
import {ConnectedPositionStrategy} from './connected-position-strategy';
33
import {ElementRef, Injectable} from '@angular/core';
44
import {GlobalPositionStrategy} from './global-position-strategy';
5-
import {OverlayPos, OriginPos} from './connected-position';
5+
import {OverlayConnectionPosition, OriginConnectionPosition} from './connected-position';
66

77

88

@@ -17,8 +17,10 @@ export class OverlayPositionBuilder {
1717
}
1818

1919
/** Creates a relative position strategy. */
20-
connectedTo(elementRef: ElementRef, originPos: OriginPos, overlayPos: OverlayPos) {
20+
connectedTo(
21+
elementRef: ElementRef,
22+
originPos: OriginConnectionPosition,
23+
overlayPos: OverlayConnectionPosition) {
2124
return new ConnectedPositionStrategy(elementRef, originPos, overlayPos, this._viewportRuler);
2225
}
2326
}
24-

src/core/portal/portal-directives.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,5 @@ export class PortalHostDirective extends BasePortalHost {
100100
});
101101
}
102102
}
103+
104+
export const PORTAL_DIRECTIVES = [TemplatePortalDirective, PortalHostDirective];

src/core/portal/portal.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from './portal-errors';
1010

1111

12+
1213
/**
1314
* A `Portal` is something that you want to render somewhere else.
1415
* It can be attach to / detached from a `PortalHost`.
@@ -202,3 +203,7 @@ export abstract class BasePortalHost implements PortalHost {
202203
this._disposeFn = fn;
203204
}
204205
}
206+
207+
208+
export {PORTAL_DIRECTIVES, TemplatePortalDirective, PortalHostDirective} from './portal-directives';
209+
export {DomPortalHost} from './dom-portal-host';

0 commit comments

Comments
 (0)