feat(portal): add initial portal foundation

This commit is contained in:
Jeremy Elbourn
2016-02-17 19:22:17 -08:00
parent 37e4ef8cba
commit 3070a719be
13 changed files with 749 additions and 18 deletions
+72
View File
@@ -0,0 +1,72 @@
# Portals
### Overview
A `Portal `is a piece of UI that can be dynamically rendered to an open slot on the page.
The "piece of UI" can be either a `Component` or a `TemplateRef`.
The "open slot" is a `PortalHost`.
Portals and PortalHosts are low-level building blocks that other concepts, such as overlays, can
be built upon.
##### `Portal<T>`
| Method | Description |
| --- | --- |
| `attach(PortalHost): Promise<T>` | Attaches the portal to a host. |
| `detach(): Promise<void>` | Detaches the portal from its host. |
| `isAttached: boolean` | Whether the portal is attached. |
##### `PortalHost`
| Method | Description |
| --- | --- |
| `attach(Portal): Promise<void>` | Attaches a portal to the host. |
| `detach(): Promise<void>` | Detaches the portal from the host. |
| `dispose(): Promise<void>` | Permanently dispose the host. |
| `hasAttached: boolean` | Whether a portal is attached to the host. |
### Using portals
##### `TemplatePortalDirective`
Used to get a portal from a `<template>`. `TemplatePortalDirectives` *is* a `Portal`.
Usage:
```html
<template portal>
<p>The content of this template is captured by the portal.</p>
</template>
<!-- OR -->
<!-- This result here is identical to the syntax above -->
<p *portal>
The content of this template is captured by the portal.
</p>
```
A component can use `@ViewChild` or `@ViewChildren` to get a reference to a
`TemplatePortalDiective`.
##### `ComponentPortal`
Used to create a portal from a component type.
Usage:
```ts
this.userSettingsPortal = new ComponentPortal(UserSettingsComponent);
```
##### `PortalHostDirective`
Used to add a portal host to a template. `PortalHostDirective` *is* a `PortalHost`.
Usage:
```html
<!-- Attaches the `userSettingsPortal` from the previous example. -->
<template [portalHost]="userSettingsPortal"></template>
```
+104
View File
@@ -0,0 +1,104 @@
import {Portal} from './portal';
import {TemplatePortal} from './portal';
import {ComponentRef} from 'angular2/core';
import {ComponentPortal} from './portal';
import {Directive} from 'angular2/core';
import {TemplateRef} from 'angular2/core';
import {BasePortalHost} from './portal';
import {DynamicComponentLoader} from 'angular2/core';
import {ElementRef} from 'angular2/core';
import {ViewContainerRef} from 'angular2/core';
/**
* Directive version of a `TemplatePortal`. Because the directive *is* a TemplatePortal,
* the directive instance itself can be attached to a host, enabling declarative use of portals.
*
* Usage:
* <template portal #greeting>
* <p> Hello {{name}} </p>
* </template>
*/
@Directive({
selector: '[portal]',
exportAs: 'portal',
})
export class TemplatePortalDirective extends TemplatePortal {
constructor(templateRef: TemplateRef) {
super(templateRef);
}
}
/**
* Directive version of a PortalHost. Because the directive *is* a PortalHost, portals can be
* directly attached to it, enabling declarative use.
*
* Usage:
* <template [portalHost]="greeting"></template>
*/
@Directive({
selector: '[portalHost]',
inputs: ['portal: portalHost']
})
export class PortalHostDirective extends BasePortalHost {
/** The attached portal. */
private portal_: Portal<any>;
constructor(
private dynamicComponentLoader_: DynamicComponentLoader,
private elementRef_: ElementRef,
private viewContainerRef_: ViewContainerRef) {
super();
}
get portal(): Portal<any> {
return this.portal_;
}
set portal(p: Portal<any>) {
this.replaceAttachedPortal_(p);
}
/** Attach the given ComponentPortal to this PortlHost using the DynamicComponentLoader. */
attachComponentPortal(portal: ComponentPortal): Promise<ComponentRef> {
portal.setAttachedHost(this);
// If the portal specifies an origin, use that as the logical location of the component
// in the application tree. Otherwise use the location of this PortalHost.
let elementRef = portal.origin != null ? portal.origin : this.elementRef_;
// Typecast is necessary for Dart transpilation.
return <Promise<ComponentRef>>
this.dynamicComponentLoader_.loadNextToLocation(portal.component, elementRef)
.then(ref => {
this.setDisposeFn(() => ref.dispose());
return ref;
});
}
/** Attach the given TemplatePortal to this PortlHost as an embedded View. */
attachTemplatePortal(portal: TemplatePortal): Promise<Map<string, any>> {
portal.setAttachedHost(this);
let viewRef = this.viewContainerRef_.createEmbeddedView(portal.templateRef);
portal.locals.forEach((v, k) => viewRef.setLocal(k, v));
this.setDisposeFn(() => this.viewContainerRef_.clear());
// TODO(jelbourn): return locals from view
// Typecast is necessary for Dart transpilation.
return <Promise<Map<string, any>>> Promise.resolve(new Map<string, any>());
}
/** Detatches the currently attached Portal (if there is one) and attaches the given Portal. */
private replaceAttachedPortal_(p: Portal<any>): void {
let maybeDetach = this.hasAttached() ? this.detach() : Promise.resolve(null);
maybeDetach.then(_ => {
if (p != null) {
this.attach(p);
this.portal_ = p;
}
});
}
}
+265
View File
@@ -0,0 +1,265 @@
import {
inject,
ComponentFixture,
TestComponentBuilder,
} from 'angular2/testing';
import {
it,
iit,
describe,
ddescribe,
expect,
beforeEach,
} from '../../core/facade/testing';
import {Component, ViewChildren, QueryList} from 'angular2/core';
import {TemplatePortalDirective} from './portal-directives';
import {Portal} from './portal';
import {ComponentPortal} from './portal';
import {PortalHostDirective} from './portal-directives';
import {fakeAsync} from 'angular2/testing';
import {flushMicrotasks} from 'angular2/testing';
export function main() {
describe('Portal directives', () => {
let builder: TestComponentBuilder;
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
builder = tcb;
}));
it('should load a component into the portal', fakeAsyncTest(() => {
let appFixture: ComponentFixture;
builder.createAsync(PortalTestApp).then(fixture => {
appFixture = fixture;
});
// Flush the async creation of the PortalTestApp.
flushMicrotasks();
// Set the selectedHost to be a ComponentPortal.
let testAppComponent = appFixture.debugElement.componentInstance;
testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg);
appFixture.detectChanges();
// Flush the attachment of the Portal.
flushMicrotasks();
// Expect that the content of the attached portal is present.
let hostContainer = appFixture.nativeElement.querySelector('.portal-container');
expect(hostContainer.textContent).toContain('Pizza');
}));
it('should load a <template> portal', fakeAsyncTest(() => {
let appFixture: ComponentFixture;
builder.createAsync(PortalTestApp).then(fixture => {
appFixture = fixture;
});
// Flush the async creation of the PortalTestApp.
flushMicrotasks();
let testAppComponent = appFixture.debugElement.componentInstance;
// Detect changes initially so that the component's ViewChildren are resolved.
appFixture.detectChanges();
// Set the selectedHost to be a TemplatePortal.
testAppComponent.selectedPortal = testAppComponent.cakePortal;
appFixture.detectChanges();
// Flush the attachment of the Portal.
flushMicrotasks();
// Expect that the content of the attached portal is present.
let hostContainer = appFixture.nativeElement.querySelector('.portal-container');
expect(hostContainer.textContent).toContain('Cake');
}));
it('should load a <template> portal with the `*` sugar', fakeAsyncTest(() => {
let appFixture: ComponentFixture;
builder.createAsync(PortalTestApp).then(fixture => {
appFixture = fixture;
});
// Flush the async creation of the PortalTestApp.
flushMicrotasks();
let testAppComponent = appFixture.debugElement.componentInstance;
// Detect changes initially so that the component's ViewChildren are resolved.
appFixture.detectChanges();
// Set the selectedHost to be a TemplatePortal (with the `*` syntax).
testAppComponent.selectedPortal = testAppComponent.piePortal;
appFixture.detectChanges();
// Flush the attachment of the Portal.
flushMicrotasks();
// Expect that the content of the attached portal is present.
let hostContainer = appFixture.nativeElement.querySelector('.portal-container');
expect(hostContainer.textContent).toContain('Pie');
}));
it('should load a <template> portal with a binding', fakeAsyncTest(() => {
let appFixture: ComponentFixture;
builder.createAsync(PortalTestApp).then(fixture => {
appFixture = fixture;
});
// Flush the async creation of the PortalTestApp.
flushMicrotasks();
let testAppComponent = appFixture.debugElement.componentInstance;
// Detect changes initially so that the component's ViewChildren are resolved.
appFixture.detectChanges();
// Set the selectedHost to be a TemplatePortal.
testAppComponent.selectedPortal = testAppComponent.portalWithBinding;
appFixture.detectChanges();
// Flush the attachment of the Portal.
flushMicrotasks();
// Now that the portal is attached, change detection has to happen again in order
// for the bindings to update.
appFixture.detectChanges();
// Expect that the content of the attached portal is present.
let hostContainer = appFixture.nativeElement.querySelector('.portal-container');
expect(hostContainer.textContent).toContain('Banana');
}));
it('should load a <template> portal with extra locals', fakeAsyncTest(() => {
let appFixture: ComponentFixture;
builder.createAsync(PortalTestApp).then(fixture => {
appFixture = fixture;
});
// Flush the async creation of the PortalTestApp.
flushMicrotasks();
let testAppComponent = appFixture.debugElement.componentInstance;
// Detect changes initially so that the component's ViewChildren are resolved.
appFixture.detectChanges();
let locals = new Map<string, string>();
locals.set('appetizer', 'Samosa');
let templatePortal = testAppComponent.portalWithLocals;
templatePortal.locals = locals;
// Set the selectedHost to be a TemplatePortal.
testAppComponent.selectedPortal = templatePortal;
appFixture.detectChanges();
// Flush the attachment of the Portal.
flushMicrotasks();
// Now that the portal is attached, change detection has to happen again in order
// for the bindings to update.
appFixture.detectChanges();
// Expect that the content of the attached portal is present.
let hostContainer = appFixture.nativeElement.querySelector('.portal-container');
expect(hostContainer.textContent).toContain('Samosa');
}));
it('should change the attached portal', fakeAsyncTest(() => {
let appFixture: ComponentFixture;
builder.createAsync(PortalTestApp).then(fixture => {
appFixture = fixture;
});
// Flush the async creation of the PortalTestApp.
flushMicrotasks();
let testAppComponent = appFixture.debugElement.componentInstance;
// Detect changes initially so that the component's ViewChildren are resolved.
appFixture.detectChanges();
// Set the selectedHost to be a ComponentPortal.
testAppComponent.selectedPortal = testAppComponent.piePortal;
appFixture.detectChanges();
// Flush the attachment of the Portal.
flushMicrotasks();
appFixture.detectChanges();
// Expect that the content of the attached portal is present.
let hostContainer = appFixture.nativeElement.querySelector('.portal-container');
expect(hostContainer.textContent).toContain('Pie');
testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg);
appFixture.detectChanges();
flushMicrotasks();
expect(hostContainer.textContent).toContain('Pizza');
}));
});
}
/** Simple component for testing ComponentPortal. */
@Component({
selector: 'pizza-msg',
template: '<p>Pizza</p>',
})
class PizzaMsg {}
/** Test-bed component that contains a portal host and a couple of template portals. */
@Component({
selector: 'portal-test',
template: `
<div class="portal-container">
<template [portalHost]="selectedPortal"></template>
</div>
<template portal>Cake</template>
<div *portal>Pie</div>
<template portal> {{fruit}} </template>
<template portal #yum="appetizer">{{yum}}</template>
`,
directives: [PortalHostDirective, TemplatePortalDirective],
})
class PortalTestApp {
@ViewChildren(TemplatePortalDirective) portals: QueryList<TemplatePortalDirective>;
selectedPortal: Portal<any>;
fruit: string = 'Banana';
get cakePortal() {
return this.portals.first;
}
get piePortal() {
return this.portals.toArray()[1];
}
get portalWithBinding() {
return this.portals.toArray()[2];
}
get portalWithLocals() {
return this.portals.last;
}
}
function fakeAsyncTest(fn: () => void) {
return inject([], fakeAsync(fn));
}
+198
View File
@@ -0,0 +1,198 @@
import {TemplateRef, Type} from 'angular2/core';
import {ElementRef} from 'angular2/core';
import {ComponentRef} from 'angular2/core';
import {BaseException} from 'angular2/src/facade/exceptions';
/**
* A `Portal` is something that you want to render somewhere else.
* It can be attach to / detached from a `PortalHost`.
*/
export abstract class Portal<T> {
private attachedHost_: PortalHost;
/** Attach this portal to a host. */
attach(host: PortalHost): Promise<T> {
if (host == null) {
throw new BaseException('Attempting to attach a portal to a null host');
}
if (host.hasAttached()) {
throw new BaseException('Host already has a portal attached');
}
this.attachedHost_ = host;
return <Promise<T>> host.attach(this);
}
/** Detach this portal from its host */
detach(): Promise<void> {
let host = this.attachedHost_;
if (host == null) {
throw new BaseException('Portal has no host from which to detach');
}
this.attachedHost_ = null;
return host.detach();
}
/** Whether this portal is attached to a host. */
get isAttached(): boolean {
return this.attachedHost_ != null;
}
/**
* Sets the PortalHost reference without performing `attach()`. This is used directly by
* the PortalHost when it is performing an `attach()` or `detatch()`.
*/
setAttachedHost(host: PortalHost) {
this.attachedHost_ = host;
}
}
/**
* A `ComponentPortal` is a portal that instantiates some Component upon attachment.
*/
export class ComponentPortal extends Portal<ComponentRef> {
/** The type of the component that will be instantiated for attachment. */
public component: Type;
/**
* [Optional] Where the attached component should live in Angular's *logical* component tree.
* This is different from where the component *renders*, which is determined by the PortalHost.
* The origin necessary when the host is outside of the Angular application context.
*/
public origin: ElementRef;
constructor(component: Type, origin: ElementRef = null) {
super();
this.component = component;
this.origin = origin;
}
}
/**
* A `TemplatePortal` is a portal that represents some embedded template (TemplateRef).
*/
export class TemplatePortal extends Portal<Map<string, any>> {
/** The embedded template that will be used to instantiate an embedded View in the host. */
templateRef: TemplateRef;
/**
* Additional locals for the instantiated embedded view.
* These locals can be seen as "exports" for the template, such as how ngFor has
* index / event / odd.
* See https://angular.io/docs/ts/latest/api/core/EmbeddedViewRef-class.html
*/
locals: Map<string, any> = new Map<string, any>();
constructor(template: TemplateRef) {
super();
this.templateRef = template;
}
get origin(): ElementRef {
return this.templateRef.elementRef;
}
attach(host: PortalHost, locals?: Map<string, any>): Promise<Map<string, any>> {
this.locals = locals == null ? new Map<string, any>() : locals;
return super.attach(host);
}
detach(): Promise<void> {
this.locals = new Map<string, any>();
return super.detach();
}
}
/**
* A `PortalHost` is an space that can contain a single `Portal`.
*/
export interface PortalHost {
attach(portal: Portal<any>): Promise<any>;
detach(): Promise<any>;
dispose(): void;
hasAttached(): boolean;
}
/**
* Partial implementation of PortalHost that only deals with attaching either a
* ComponentPortal or a TemplatePortal.
*/
export abstract class BasePortalHost implements PortalHost {
/** The portal currently attached to the host. */
private attachedPortal_: Portal<any>;
/** A function that will permanently dispose this host. */
private disposeFn_: () => void;
/** Whether this host has already been permanently disposed. */
private isDisposed_: boolean = false;
/** Whether this host has an attached portal. */
hasAttached() {
return this.attachedPortal_ != null;
}
attach(portal: Portal<any>): Promise<any> {
if (portal == null) {
throw new BaseException('Must provide a portal to attach');
}
if (this.hasAttached()) {
throw new BaseException('A portal is already attached');
}
if (this.isDisposed_) {
throw new BaseException('This PortalHost has already been disposed');
}
if (portal instanceof ComponentPortal) {
this.attachedPortal_ = portal;
return this.attachComponentPortal(portal);
} else if (portal instanceof TemplatePortal) {
this.attachedPortal_ = portal;
return this.attachTemplatePortal(portal);
}
throw new BaseException(
'Attempting to attach an unknown Portal type. ' +
'BasePortalHost accepts either a ComponentPortal or a TemplatePortal.');
}
abstract attachComponentPortal(portal: ComponentPortal): Promise<ComponentRef>;
abstract attachTemplatePortal(portal: TemplatePortal): Promise<Map<string, any>>;
detach(): Promise<void> {
this.attachedPortal_.setAttachedHost(null);
this.attachedPortal_ = null;
if (this.disposeFn_ != null) {
this.disposeFn_();
this.disposeFn_ = null;
}
return Promise.resolve(null);
}
dispose() {
if (this.hasAttached()) {
this.detach();
}
this.isDisposed_ = true;
}
setDisposeFn(fn: () => void) {
this.disposeFn_ = fn;
}
}
+16 -14
View File
@@ -1,14 +1,16 @@
<h1 class="title">Angular Material2 Demos</h1>
<ul>
<li><a [routerLink]="['ButtonDemo']">Button demo</a></li>
<li><a [routerLink]="['CardDemo']">Card demo</a></li>
<li><a [routerLink]="['SidenavDemo']">Sidenav demo</a></li>
<li><a [routerLink]="['ProgressCircleDemo']">Progress Circle demo</a></li>
</ul>
<button md-raised-button (click)="root.dir = (root.dir == 'rtl' ? 'ltr' : 'rtl')">
{{root.dir.toUpperCase()}}
</button>
<div #root="$implicit" dir="ltr">
<router-outlet></router-outlet>
</div>
<div class="demo-content">
<h1 class="title">Angular Material2 Demos</h1>
<ul>
<li><a [routerLink]="['ButtonDemo']">Button demo</a></li>
<li><a [routerLink]="['CardDemo']">Card demo</a></li>
<li><a [routerLink]="['SidenavDemo']">Sidenav demo</a></li>
<li><a [routerLink]="['ProgressCircleDemo']">Progress Circle demo</a></li>
<li><a [routerLink]="['PortalDemo']">Portal demo</a></li>
</ul>
<button md-raised-button (click)="root.dir = (root.dir == 'rtl' ? 'ltr' : 'rtl')">
{{root.dir.toUpperCase()}}
</button>
<div #root="$implicit" dir="ltr">
<router-outlet></router-outlet>
</div>
</div>
+2 -2
View File
@@ -1,4 +1,4 @@
div.content {
padding: 10px;
.demo-content {
padding: 15px;
}
+3
View File
@@ -6,6 +6,8 @@ import {ProgressCircleDemo} from './progress-circle/progress-circle-demo';
import {Route, RouteConfig, ROUTER_DIRECTIVES} from 'angular2/router';
import {Dir} from '../directives/dir/dir';
import {MdButton} from '../components/button/button';
import {PortalDemo} from './portal/portal-demo';
@Component({
selector: 'home',
@@ -27,5 +29,6 @@ export class Home {}
new Route({path: '/card', name: 'CardDemo', component: CardDemo}),
new Route({path: '/sidenav', name: 'SidenavDemo', component: SidenavDemo}),
new Route({path: '/progress-circle', name: 'ProgressCircleDemo', component: ProgressCircleDemo}),
new Route({path: '/portal', name: 'PortalDemo', component: PortalDemo}),
])
export class DemoApp { }
+31
View File
@@ -0,0 +1,31 @@
<h2> The portal host is here: </h2>
<div class="demo-portal-host">
<template [portalHost]="selectedPortal"></template>
</div>
<button type="button" (click)="selectedPortal = programmingJoke">
Programming joke
</button>
<button type="button" (click)="selectedPortal = mathJoke">
Math joke
</button>
<button type="button" (click)="selectedPortal = scienceJoke">
Science joke
</button>
<!-- Template vars on <template> elements can't be accessed _in_ the template because Angular
doesn't support grabbing the instance / TemplateRef this way because the variable may be
referring to something *in* the template (such as #item in ngFor). As such, the component
has to use @ViewChild / @ViewChildren to get these references.
See https://github.com/angular/angular/issues/7158 -->
<template portal>
<p> - Why don't jokes work in octal?</p>
<p> - Because 7 10 11.</p>
</template>
<div *portal>
<p> - Did you hear about this year's Fibonacci Conference? </p>
<p> - It's going to be as the last two put together. </p>
</div>
+7
View File
@@ -0,0 +1,7 @@
.demo-portal-host {
margin-bottom: 10px;
padding: 10px;
border: 1px dashed black;
width: 500px;
height: 100px;
}
+42
View File
@@ -0,0 +1,42 @@
import {Component} from 'angular2/core';
import {PortalHostDirective} from '../../core/portal/portal-directives';
import {TemplatePortalDirective} from '../../core/portal/portal-directives';
import {Portal} from '../../core/portal/portal';
import {ViewChildren} from 'angular2/core';
import {QueryList} from 'angular2/core';
import {ComponentPortal} from '../../core/portal/portal';
@Component({
selector: 'portal-demo',
templateUrl: 'demo-app/portal/portal-demo.html',
styleUrls: ['demo-app/portal/portal-demo.css'],
directives: [TemplatePortalDirective, PortalHostDirective]
})
export class PortalDemo {
@ViewChildren(TemplatePortalDirective) templatePortals: QueryList<Portal<any>>;
selectedPortal: Portal<any>;
constructor() {
console.log('~~ contructor ~~');
}
get programmingJoke() {
return this.templatePortals.first;
}
get mathJoke() {
return this.templatePortals.last;
}
get scienceJoke() {
return new ComponentPortal(ScienceJoke);
}
}
@Component({
selector: 'science-joke',
template: `<p> 100 kilopascals go into a bar. </p>`
})
class ScienceJoke { }
+1 -1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<title>Material2</title>
<base href="/">
<script src="vendor/systemjs/dist/system-polyfills.js"></script>
<script src="vendor/angular2/bundles/angular2-polyfills.js"></script>
{{content-for 'head'}}
<link rel="icon" type="image/x-icon" href="favicon.ico">
@@ -14,7 +15,6 @@
<demo-app>Loading...</demo-app>
<script src="vendor/systemjs/dist/system.src.js"></script>
<script src="vendor/systemjs/dist/system-polyfills.js"></script>
<script src="vendor/rxjs/bundles/Rx.js"></script>
<script src="vendor/angular2/bundles/angular2.dev.js"></script>
<script src="vendor/angular2/bundles/http.dev.js"></script>
+6 -1
View File
@@ -1,5 +1,10 @@
import {bootstrap} from 'angular2/platform/browser';
import {DemoApp} from './demo-app/demo-app';
import {ROUTER_PROVIDERS} from 'angular2/router';
import {HashLocationStrategy} from 'angular2/router';
import {LocationStrategy} from 'angular2/router';
import {provide} from 'angular2/core';
bootstrap(DemoApp, [ROUTER_PROVIDERS]);
bootstrap(DemoApp, [
ROUTER_PROVIDERS
]);
+2
View File
@@ -23,6 +23,8 @@ export function config(config) {
{pattern: 'node_modules/systemjs/dist/system-polyfills.js', included: true, watched: true},
'node_modules/angular2/es6/dev/src/testing/shims_for_IE.js',
// Angular 2 polyfills *must* be loaded after es6-shim and system-polyfills in order to
// setup the monkey-patches for zones.
{pattern: 'node_modules/angular2/bundles/angular2-polyfills.js', included: true, watched: true},
{pattern: 'node_modules/systemjs/dist/system.src.js', included: true, watched: true},
{pattern: 'node_modules/rxjs/bundles/Rx.js', included: true, watched: true},