Skip to content
Permalink
Browse files

feat(portal): add new portal that projects DOM nodes (#16101)

Adds a new type of portal called `DomPortal` which transfers the contents of a portal into the portal outlet and then restores them on destroy.

This was implemented initially for #14430.
  • Loading branch information...
crisbeto authored and mmalerba committed Nov 7, 2019
1 parent 466903e commit d3d8859659705c96972517352b333e5cdecc8c8e
@@ -12,7 +12,8 @@ import {
BasePortalOutlet,
ComponentPortal,
CdkPortalOutlet,
TemplatePortal
TemplatePortal,
DomPortal,
} from '@angular/cdk/portal';
import {DOCUMENT} from '@angular/common';
import {
@@ -181,6 +182,21 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
return this._portalHost.attachTemplatePortal(portal);
}

/**
* Attaches a DOM portal to the dialog container.
* @param portal Portal to be attached.
* @deprecated To be turned into a method.
* @breaking-change 10.0.0
*/
attachDomPortal = (portal: DomPortal) => {
if (this._portalHost.hasAttached()) {
throwDialogContentAlreadyAttachedError();
}

this._savePreviouslyFocusedElement();
return this._portalHost.attachDomPortal(portal);
}

/** Emit lifecycle events based on animation `start` callback. */
_onAnimationStart(event: AnimationEvent) {
if (event.toState === 'enter') {
@@ -121,6 +121,7 @@ export class Overlay {
this._appRef = this._injector.get<ApplicationRef>(ApplicationRef);
}

return new DomPortalOutlet(pane, this._componentFactoryResolver, this._appRef, this._injector);
return new DomPortalOutlet(pane, this._componentFactoryResolver, this._appRef, this._injector,
this._document);
}
}
@@ -13,21 +13,30 @@ import {
ApplicationRef,
Injector,
} from '@angular/core';
import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal';
import {BasePortalOutlet, ComponentPortal, TemplatePortal, DomPortal} from './portal';


/**
* A PortalOutlet for attaching portals to an arbitrary DOM element outside of the Angular
* application context.
*/
export class DomPortalOutlet extends BasePortalOutlet {
private _document: Document;

constructor(
/** Element into which the content is projected. */
public outletElement: Element,
private _componentFactoryResolver: ComponentFactoryResolver,
private _appRef: ApplicationRef,
private _defaultInjector: Injector) {
private _defaultInjector: Injector,

/**
* @deprecated `_document` Parameter to be made required.
* @breaking-change 10.0.0
*/
_document?: any) {
super();
this._document = _document;
}

/**
@@ -93,6 +102,33 @@ export class DomPortalOutlet extends BasePortalOutlet {
return viewRef;
}

/**
* Attaches a DOM portal by transferring its content into the outlet.
* @param portal Portal to be attached.
* @deprecated To be turned into a method.
* @breaking-change 10.0.0
*/
attachDomPortal = (portal: DomPortal) => {
// @breaking-change 10.0.0 Remove check and error once the
// `_document` constructor parameter is required.
if (!this._document) {
throw Error('Cannot attach DOM portal without _document constructor parameter');
}

// Anchor used to save the element's previous position so
// that we can restore it when the portal is detached.
let anchorNode = this._document.createComment('dom-portal');
let element = portal.element;

element.parentNode!.insertBefore(anchorNode, element);
this.outletElement.appendChild(element);

super.setDisposeFn(() => {
// We can't use `replaceWith` here because IE doesn't support it.
anchorNode.parentNode!.replaceChild(element, anchorNode);
});
}

/**
* Clears out a portal from the DOM.
*/
@@ -18,8 +18,10 @@ import {
Output,
TemplateRef,
ViewContainerRef,
Inject,
} from '@angular/core';
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal} from './portal';
import {DOCUMENT} from '@angular/common';
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal, DomPortal} from './portal';


/**
@@ -69,6 +71,8 @@ export type CdkPortalOutletAttachedRef = ComponentRef<any> | EmbeddedViewRef<any
inputs: ['portal: cdkPortalOutlet']
})
export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestroy {
private _document: Document;

/** Whether the portal component is initialized. */
private _isInitialized = false;

@@ -77,8 +81,15 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr

constructor(
private _componentFactoryResolver: ComponentFactoryResolver,
private _viewContainerRef: ViewContainerRef) {
private _viewContainerRef: ViewContainerRef,

/**
* @deprecated `_document` parameter to be made required.
* @breaking-change 9.0.0
*/
@Inject(DOCUMENT) _document?: any) {
super();
this._document = _document;
}

/** Portal associated with the Portal outlet. */
@@ -155,7 +166,7 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
}

/**
* Attach the given TemplatePortal to this PortlHost as an embedded View.
* Attach the given TemplatePortal to this PortalHost as an embedded View.
* @param portal Portal to be attached.
* @returns Reference to the created embedded view.
*/
@@ -171,6 +182,36 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
return viewRef;
}

/**
* Attaches the given DomPortal to this PortalHost by moving all of the portal content into it.
* @param portal Portal to be attached.
* @deprecated To be turned into a method.
* @breaking-change 10.0.0
*/
attachDomPortal = (portal: DomPortal) => {
// @breaking-change 9.0.0 Remove check and error once the
// `_document` constructor parameter is required.
if (!this._document) {
throw Error('Cannot attach DOM portal without _document constructor parameter');
}

// Anchor used to save the element's previous position so
// that we can restore it when the portal is detached.
let anchorNode = this._document.createComment('dom-portal');
let element = portal.element;
const nativeElement: Node = this._viewContainerRef.element.nativeElement;
const rootNode = nativeElement.nodeType === nativeElement.ELEMENT_NODE ?
nativeElement : nativeElement.parentNode!;

portal.setAttachedHost(this);
element.parentNode!.insertBefore(anchorNode, element);
rootNode.appendChild(element);

super.setDisposeFn(() => {
anchorNode.parentNode!.replaceChild(element, anchorNode);
});
}

static ngAcceptInputType_portal: Portal<any> | null | undefined | '';
}

@@ -12,10 +12,11 @@ import {
ApplicationRef,
TemplateRef,
ComponentRef,
ElementRef,
} from '@angular/core';
import {CommonModule} from '@angular/common';
import {CdkPortal, CdkPortalOutlet, PortalModule} from './portal-directives';
import {Portal, ComponentPortal, TemplatePortal} from './portal';
import {Portal, ComponentPortal, TemplatePortal, DomPortal} from './portal';
import {DomPortalOutlet} from './dom-portal-outlet';


@@ -76,6 +77,36 @@ describe('Portals', () => {
.toHaveBeenCalledWith(testAppComponent.portalOutlet.attachedRef);
});

it('should load a DOM portal', () => {
const testAppComponent = fixture.componentInstance;
const hostContainer = fixture.nativeElement.querySelector('.portal-container');
const innerContent = fixture.nativeElement.querySelector('.dom-portal-inner-content');
const domPortal = new DomPortal(testAppComponent.domPortalContent);
const initialParent = domPortal.element.parentNode!;

expect(innerContent).toBeTruthy('Expected portal content to be rendered.');
expect(domPortal.element.contains(innerContent))
.toBe(true, 'Expected content to be inside portal on init.');
expect(hostContainer.contains(innerContent))
.toBe(false, 'Expected content to be outside of portal outlet.');

testAppComponent.selectedPortal = domPortal;
fixture.detectChanges();

expect(domPortal.element.parentNode)
.not.toBe(initialParent, 'Expected portal to be out of the initial parent on attach.');
expect(hostContainer.contains(innerContent))
.toBe(true, 'Expected content to be inside the outlet on attach.');

testAppComponent.selectedPortal = undefined;
fixture.detectChanges();

expect(domPortal.element.parentNode)
.toBe(initialParent, 'Expected portal to be back inside initial parent on detach.');
expect(hostContainer.contains(innerContent))
.toBe(false, 'Expected content to be removed from outlet on detach.');
});

it('should project template context bindings in the portal', () => {
let testAppComponent = fixture.componentInstance;
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
@@ -351,7 +382,8 @@ describe('Portals', () => {

beforeEach(() => {
someDomElement = document.createElement('div');
host = new DomPortalOutlet(someDomElement, componentFactoryResolver, appRef, injector);
host = new DomPortalOutlet(someDomElement, componentFactoryResolver, appRef, injector,
document);

someFixture = TestBed.createComponent(ArbitraryViewContainerRefComponent);
someViewContainerRef = someFixture.componentInstance.viewContainerRef;
@@ -502,6 +534,20 @@ describe('Portals', () => {
expect(spy).toHaveBeenCalled();
});

it('should attach and detach a DOM portal', () => {
const fixture = TestBed.createComponent(PortalTestApp);
fixture.detectChanges();
const portal = new DomPortal(fixture.componentInstance.domPortalContent);

portal.attach(host);

expect(someDomElement.textContent).toContain('Hello there');

host.detach();

expect(someDomElement.textContent!.trim()).toBe('');
});

});
});

@@ -559,12 +605,17 @@ class ArbitraryViewContainerRefComponent {
</ng-template>
<ng-template #templateRef let-data> {{fruit}} - {{ data?.status }}!</ng-template>
<div #domPortalContent>
<p class="dom-portal-inner-content">Hello there</p>
</div>
`,
})
class PortalTestApp {
@ViewChildren(CdkPortal) portals: QueryList<CdkPortal>;
@ViewChild(CdkPortalOutlet, {static: true}) portalOutlet: CdkPortalOutlet;
@ViewChild('templateRef', { read: TemplateRef , static: true}) templateRef: TemplateRef<any>;
@ViewChild('templateRef', {read: TemplateRef, static: true}) templateRef: TemplateRef<any>;
@ViewChild('domPortalContent', {static: true}) domPortalContent: ElementRef<HTMLElement>;

selectedPortal: Portal<any>|undefined;
fruit: string = 'Banana';
@@ -153,6 +153,21 @@ export class TemplatePortal<C = any> extends Portal<EmbeddedViewRef<C>> {
}
}

/**
* A `DomPortal` is a portal whose DOM element will be taken from its current position
* in the DOM and moved into a portal outlet, when it is attached. On detach, the content
* will be restored to its original position.
*/
export class DomPortal<T = HTMLElement> extends Portal<T> {
/** DOM node hosting the portal's content. */
readonly element: T;

constructor(element: T | ElementRef<T>) {
super();
this.element = element instanceof ElementRef ? element.nativeElement : element;
}
}


/** A `PortalOutlet` is an space that can contain a single `Portal`. */
export interface PortalOutlet {
@@ -218,6 +233,10 @@ export abstract class BasePortalOutlet implements PortalOutlet {
} else if (portal instanceof TemplatePortal) {
this._attachedPortal = portal;
return this.attachTemplatePortal(portal);
// @breaking-change 10.0.0 remove null check for `this.attachDomPortal`.
} else if (this.attachDomPortal && portal instanceof DomPortal) {
this._attachedPortal = portal;
return this.attachDomPortal(portal);
}

throwUnknownPortalTypeError();
@@ -227,6 +246,9 @@ export abstract class BasePortalOutlet implements PortalOutlet {

abstract attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;

// @breaking-change 10.0.0 `attachDomPortal` to become a required abstract method.
readonly attachDomPortal: null | ((portal: DomPortal) => any) = null;

/** Detaches a previously attached portal. */
detach(): void {
if (this._attachedPortal) {
@@ -15,6 +15,10 @@ <h2> The portal outlet is here: </h2>
Science joke
</button>

<button type="button" (click)="selectedPortal = dadJoke">
Dad joke
</button>

<!-- Template vars on <ng-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
@@ -29,3 +33,8 @@ <h2> The portal outlet is here: </h2>
<p> - Did you hear about this year's Fibonacci Conference? </p>
<p> - It's going to be as big as the last two put together. </p>
</div>

<div class="demo-dad-joke" #domPortalSource>
<p> - Scientists got bored of watching the moon for 24 hours </p>
<p> - So they called it a day. </p>
</div>
@@ -5,3 +5,7 @@
width: 500px;
height: 100px;
}

.demo-dad-joke {
opacity: 0.25;
}
@@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentPortal, Portal, CdkPortal} from '@angular/cdk/portal';
import {Component, QueryList, ViewChildren} from '@angular/core';
import {ComponentPortal, Portal, CdkPortal, DomPortal} from '@angular/cdk/portal';
import {Component, QueryList, ViewChildren, ElementRef, ViewChild} from '@angular/core';


@Component({
@@ -17,6 +17,7 @@ import {Component, QueryList, ViewChildren} from '@angular/core';
})
export class PortalDemo {
@ViewChildren(CdkPortal) templatePortals: QueryList<Portal<any>>;
@ViewChild('domPortalSource', {static: false}) domPortalSource: ElementRef<HTMLElement>;

selectedPortal: Portal<any>;

@@ -31,6 +32,10 @@ export class PortalDemo {
get scienceJoke() {
return new ComponentPortal(ScienceJoke);
}

get dadJoke() {
return new DomPortal(this.domPortalSource);
}
}


0 comments on commit d3d8859

Please sign in to comment.
You can’t perform that action at this time.