Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 7, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/cdk-experimental/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
BasePortalOutlet,
ComponentPortal,
CdkPortalOutlet,
TemplatePortal
TemplatePortal,
DomPortal,
} from '@angular/cdk/portal';
import {DOCUMENT} from '@angular/common';
import {
Expand Down Expand Up @@ -182,6 +183,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') {
Expand Down
3 changes: 2 additions & 1 deletion src/cdk/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
40 changes: 38 additions & 2 deletions src/cdk/portal/dom-portal-outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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.
*/
Expand Down
47 changes: 44 additions & 3 deletions src/cdk/portal/portal-directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


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

Expand All @@ -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. */
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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 | '';
}

Expand Down
57 changes: 54 additions & 3 deletions src/cdk/portal/portal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


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

});
});

Expand Down Expand Up @@ -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';
Expand Down
22 changes: 22 additions & 0 deletions src/cdk/portal/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions src/dev-app/portal/portal-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>
4 changes: 4 additions & 0 deletions src/dev-app/portal/portal-demo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
width: 500px;
height: 100px;
}

.demo-dad-joke {
opacity: 0.25;
}
9 changes: 7 additions & 2 deletions src/dev-app/portal/portal-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -18,6 +18,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>;

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

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


Expand Down
Loading