diff --git a/src/cdk-experimental/dialog/dialog-container.ts b/src/cdk-experimental/dialog/dialog-container.ts index 4049e1234920..b07891e368f7 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -12,7 +12,8 @@ import { BasePortalOutlet, ComponentPortal, PortalHostDirective, - TemplatePortal + TemplatePortal, + DomPortal } from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import { @@ -176,6 +177,19 @@ 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. + */ + 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') { diff --git a/src/cdk/overlay/overlay-directives.ts b/src/cdk/overlay/overlay-directives.ts index c360c50bea7c..cb95122d28f6 100644 --- a/src/cdk/overlay/overlay-directives.ts +++ b/src/cdk/overlay/overlay-directives.ts @@ -9,7 +9,7 @@ import {Direction, Directionality} from '@angular/cdk/bidi'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {ESCAPE} from '@angular/cdk/keycodes'; -import {TemplatePortal} from '@angular/cdk/portal'; +import {TemplatePortal, Portal} from '@angular/cdk/portal'; import { Directive, ElementRef, @@ -104,7 +104,6 @@ export class CdkOverlayOrigin { }) export class CdkConnectedOverlay implements OnDestroy, OnChanges { private _overlayRef: OverlayRef; - private _templatePortal: TemplatePortal; private _hasBackdrop = false; private _lockPosition = false; private _growAfterOpen = false; @@ -198,6 +197,12 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { get push() { return this._push; } set push(value: boolean) { this._push = coerceBooleanProperty(value); } + /** + * Portal that should be projected into the overlay. If none is provided, one will be + * created automatically from the overlay element's content. + */ + @Input('cdkConnectedOverlayPortal') portal: Portal; + /** Event emitted when the backdrop is clicked. */ @Output() backdropClick = new EventEmitter(); @@ -217,11 +222,10 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { constructor( private _overlay: Overlay, - templateRef: TemplateRef, - viewContainerRef: ViewContainerRef, + private _templateRef: TemplateRef, + private _viewContainerRef: ViewContainerRef, @Inject(CDK_CONNECTED_OVERLAY_SCROLL_STRATEGY) scrollStrategyFactory: any, @Optional() private _dir: Directionality) { - this._templatePortal = new TemplatePortal(templateRef, viewContainerRef); this._scrollStrategyFactory = scrollStrategyFactory; this.scrollStrategy = this._scrollStrategyFactory(); } @@ -359,8 +363,12 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { this._overlayRef.getConfig().hasBackdrop = this.hasBackdrop; } + if (!this.portal) { + this.portal = new TemplatePortal(this._templateRef, this._viewContainerRef); + } + if (!this._overlayRef.hasAttached()) { - this._overlayRef.attach(this._templatePortal); + this._overlayRef.attach(this.portal); this.attach.emit(); } diff --git a/src/cdk/portal/dom-portal-outlet.ts b/src/cdk/portal/dom-portal-outlet.ts index d7eaadc79ed1..028bc32dbcad 100644 --- a/src/cdk/portal/dom-portal-outlet.ts +++ b/src/cdk/portal/dom-portal-outlet.ts @@ -13,7 +13,7 @@ import { ApplicationRef, Injector, } from '@angular/core'; -import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal'; +import {BasePortalOutlet, ComponentPortal, TemplatePortal, DomPortal} from './portal'; /** @@ -93,6 +93,28 @@ export class DomPortalOutlet extends BasePortalOutlet { return viewRef; } + /** + * Attaches a DOM portal by transferring its content into the outlet. + * @param portal Portal to be attached. + */ + attachDomPortal(portal: DomPortal) { + // Note that we need to convert this into an array, because `childNodes` + // is a live collection which will be updated as we add/remove nodes. + let transferredNodes = Array.from(portal.element.childNodes); + + for (let i = 0; i < transferredNodes.length; i++) { + this.outletElement.appendChild(transferredNodes[i]); + } + + super.setDisposeFn(() => { + for (let i = 0; i < transferredNodes.length; i++) { + portal.element.appendChild(transferredNodes[i]); + } + + transferredNodes = null!; + }); + } + /** * Clears out a portal from the DOM. */ diff --git a/src/cdk/portal/portal-directives.ts b/src/cdk/portal/portal-directives.ts index fab3606fc848..62f91d11ca81 100644 --- a/src/cdk/portal/portal-directives.ts +++ b/src/cdk/portal/portal-directives.ts @@ -19,7 +19,7 @@ import { TemplateRef, ViewContainerRef, } from '@angular/core'; -import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal} from './portal'; +import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal, DomPortal} from './portal'; /** @@ -141,7 +141,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. */ @@ -156,6 +156,29 @@ 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. + */ + attachDomPortal(portal: DomPortal) { + portal.setAttachedHost(this); + + const origin = portal.element; + const transferredNodes: Node[] = []; + const nativeElement: Node = this._viewContainerRef.element.nativeElement; + const rootNode = nativeElement.nodeType === nativeElement.ELEMENT_NODE ? + nativeElement : nativeElement.parentNode!; + + while (origin.firstChild) { + transferredNodes.push(rootNode.appendChild(origin.firstChild)); + } + + super.setDisposeFn(() => { + transferredNodes.forEach(node => portal.element.appendChild(node)); + transferredNodes.length = 0; + }); + } } diff --git a/src/cdk/portal/portal.spec.ts b/src/cdk/portal/portal.spec.ts index 6b42ed759f81..b047bfa46ec2 100644 --- a/src/cdk/portal/portal.spec.ts +++ b/src/cdk/portal/portal.spec.ts @@ -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,35 @@ 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); + + 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.contains(innerContent)) + .toBe(false, 'Expected content to be out of the portal 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.contains(innerContent)) + .toBe(true, 'Expected content to be at initial position 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'); @@ -502,6 +532,16 @@ 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'); + }); + }); }); @@ -559,12 +599,17 @@ class ArbitraryViewContainerRefComponent { {{fruit}} - {{ data?.status }}! + +
+

Hello there

+
`, }) class PortalTestApp { @ViewChildren(CdkPortal) portals: QueryList; @ViewChild(CdkPortalOutlet, {static: true}) portalOutlet: CdkPortalOutlet; - @ViewChild('templateRef', { read: TemplateRef , static: true}) templateRef: TemplateRef; + @ViewChild('templateRef', {read: TemplateRef, static: true}) templateRef: TemplateRef; + @ViewChild('domPortalContent', {static: true}) domPortalContent: ElementRef; selectedPortal: Portal|undefined; fruit: string = 'Banana'; diff --git a/src/cdk/portal/portal.ts b/src/cdk/portal/portal.ts index 0e71a788b541..a6b7dc1a610e 100644 --- a/src/cdk/portal/portal.ts +++ b/src/cdk/portal/portal.ts @@ -153,6 +153,22 @@ export class TemplatePortal extends Portal> { } } +/** + * A `DomPortal` is a portal whose content 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 extends Portal { + constructor(private _element: HTMLElement | ElementRef) { + super(); + } + + /** DOM node hosting the portal's content. */ + get element(): HTMLElement { + return this._element instanceof ElementRef ? this._element.nativeElement : this._element; + } +} + /** A `PortalOutlet` is an space that can contain a single `Portal`. */ export interface PortalOutlet { @@ -213,6 +229,10 @@ export abstract class BasePortalOutlet implements PortalOutlet { } else if (portal instanceof TemplatePortal) { this._attachedPortal = portal; return this.attachTemplatePortal(portal); + // @breaking-change 8.0.0 remove null check for `this.attachDomPortal`. + } else if (this.attachDomPortal && portal instanceof DomPortal) { + this._attachedPortal = portal; + return this.attachDomPortal(portal); } throwUnknownPortalTypeError(); @@ -222,6 +242,9 @@ export abstract class BasePortalOutlet implements PortalOutlet { abstract attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef; + // @breaking-change 8.0.0 `attachDomPortal` to become a required method. + abstract attachDomPortal?(portal: DomPortal): any; + /** Detaches a previously attached portal. */ detach(): void { if (this._attachedPortal) { diff --git a/src/dev-app/portal/portal-demo.html b/src/dev-app/portal/portal-demo.html index 1a19fe9bc91d..bdb0e648447c 100644 --- a/src/dev-app/portal/portal-demo.html +++ b/src/dev-app/portal/portal-demo.html @@ -15,6 +15,10 @@

The portal outlet is here:

Science joke + +