diff --git a/src/cdk-experimental/dialog/dialog-container.ts b/src/cdk-experimental/dialog/dialog-container.ts index 3f160d359278..2f35e2c3866b 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -12,7 +12,8 @@ import { BasePortalOutlet, ComponentPortal, CdkPortalOutlet, - TemplatePortal + TemplatePortal, + DomPortal, } from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import { @@ -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') { diff --git a/src/cdk/overlay/overlay.ts b/src/cdk/overlay/overlay.ts index 548507417a32..20c9d38c35d7 100644 --- a/src/cdk/overlay/overlay.ts +++ b/src/cdk/overlay/overlay.ts @@ -121,6 +121,7 @@ export class Overlay { this._appRef = this._injector.get(ApplicationRef); } - return new DomPortalOutlet(pane, this._componentFactoryResolver, this._appRef, this._injector); + return new DomPortalOutlet(pane, this._componentFactoryResolver, this._appRef, this._injector, + this._document); } } diff --git a/src/cdk/portal/dom-portal-outlet.ts b/src/cdk/portal/dom-portal-outlet.ts index 39cbb799dae0..9c889f539689 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'; /** @@ -21,13 +21,22 @@ import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal'; * 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. */ diff --git a/src/cdk/portal/portal-directives.ts b/src/cdk/portal/portal-directives.ts index cb1639d58140..b08e894de973 100644 --- a/src/cdk/portal/portal-directives.ts +++ b/src/cdk/portal/portal-directives.ts @@ -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 | EmbeddedViewRef { + // @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 | null | undefined | ''; } diff --git a/src/cdk/portal/portal.spec.ts b/src/cdk/portal/portal.spec.ts index 6b42ed759f81..b3d8decc8e19 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,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 { {{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 1e807a64f359..0d02211a7fe6 100644 --- a/src/cdk/portal/portal.ts +++ b/src/cdk/portal/portal.ts @@ -153,6 +153,21 @@ export class TemplatePortal extends Portal> { } } +/** + * 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 extends Portal { + /** DOM node hosting the portal's content. */ + readonly element: T; + + constructor(element: T | ElementRef) { + 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(portal: TemplatePortal): EmbeddedViewRef; + // @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) { 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 + +