Skip to content

Commit 982b920

Browse files
committed
feat(portal): add new portal that projects DOM nodes
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.
1 parent 43a01e9 commit 982b920

File tree

17 files changed

+231
-16
lines changed

17 files changed

+231
-16
lines changed

src/cdk-experimental/dialog/dialog-container.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
BasePortalOutlet,
1313
ComponentPortal,
1414
PortalHostDirective,
15-
TemplatePortal
15+
TemplatePortal,
16+
DomPortal
1617
} from '@angular/cdk/portal';
1718
import {DOCUMENT} from '@angular/common';
1819
import {
@@ -178,6 +179,19 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
178179
return this._portalHost.attachTemplatePortal(portal);
179180
}
180181

182+
/**
183+
* Attaches a DOM portal to the dialog container.
184+
* @param portal Portal to be attached.
185+
*/
186+
attachDomPortal(portal: DomPortal) {
187+
if (this._portalHost.hasAttached()) {
188+
throwDialogContentAlreadyAttachedError();
189+
}
190+
191+
this._savePreviouslyFocusedElement();
192+
return this._portalHost.attachDomPortal(portal);
193+
}
194+
181195
/** Emit lifecycle events based on animation `start` callback. */
182196
_onAnimationStart(event: AnimationEvent) {
183197
if (event.toState === 'enter') {

src/cdk/overlay/overlay.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export class Overlay {
121121
this._appRef = this._injector.get<ApplicationRef>(ApplicationRef);
122122
}
123123

124-
return new DomPortalOutlet(pane, this._componentFactoryResolver, this._appRef, this._injector);
124+
return new DomPortalOutlet(pane, this._componentFactoryResolver, this._appRef, this._injector,
125+
this._document);
125126
}
126127
}

src/cdk/portal/dom-portal-outlet.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,30 @@ import {
1313
ApplicationRef,
1414
Injector,
1515
} from '@angular/core';
16-
import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal';
16+
import {BasePortalOutlet, ComponentPortal, TemplatePortal, DomPortal} from './portal';
1717

1818

1919
/**
2020
* A PortalOutlet for attaching portals to an arbitrary DOM element outside of the Angular
2121
* application context.
2222
*/
2323
export class DomPortalOutlet extends BasePortalOutlet {
24+
private _document: Document;
25+
2426
constructor(
2527
/** Element into which the content is projected. */
2628
public outletElement: Element,
2729
private _componentFactoryResolver: ComponentFactoryResolver,
2830
private _appRef: ApplicationRef,
29-
private _defaultInjector: Injector) {
31+
private _defaultInjector: Injector,
32+
33+
/**
34+
* @deprecated `_document` Parameter to be made required.
35+
* @breaking-change 9.0.0
36+
*/
37+
_document?: any) {
3038
super();
39+
this._document = _document;
3140
}
3241

3342
/**
@@ -93,6 +102,29 @@ export class DomPortalOutlet extends BasePortalOutlet {
93102
return viewRef;
94103
}
95104

105+
/**
106+
* Attaches a DOM portal by transferring its content into the outlet.
107+
* @param portal Portal to be attached.
108+
*/
109+
attachDomPortal(portal: DomPortal) {
110+
// @breaking-change 9.0.0 Remove check and error once the
111+
// `_document` constructor parameter is required.
112+
if (!this._document) {
113+
throw Error('Cannot attach DOM portal without _document constructor parameter');
114+
}
115+
116+
let element = portal.element;
117+
let anchorNode = this._document.createComment('dom-portal');
118+
119+
element.parentNode!.insertBefore(anchorNode, element);
120+
this.outletElement.appendChild(element);
121+
122+
super.setDisposeFn(() => {
123+
// We can't use `replaceWith` here because IE doesn't support it.
124+
anchorNode.parentNode!.replaceChild(element, anchorNode);
125+
});
126+
}
127+
96128
/**
97129
* Clears out a portal from the DOM.
98130
*/

src/cdk/portal/portal-directives.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import {
1818
Output,
1919
TemplateRef,
2020
ViewContainerRef,
21+
Inject,
2122
} from '@angular/core';
22-
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal} from './portal';
23+
import {DOCUMENT} from '@angular/common';
24+
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal, DomPortal} from './portal';
2325

2426

2527
/**
@@ -55,6 +57,8 @@ export type CdkPortalOutletAttachedRef = ComponentRef<any> | EmbeddedViewRef<any
5557
inputs: ['portal: cdkPortalOutlet']
5658
})
5759
export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestroy {
60+
private _document: Document;
61+
5862
/** Whether the portal component is initialized. */
5963
private _isInitialized = false;
6064

@@ -63,8 +67,15 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
6367

6468
constructor(
6569
private _componentFactoryResolver: ComponentFactoryResolver,
66-
private _viewContainerRef: ViewContainerRef) {
70+
private _viewContainerRef: ViewContainerRef,
71+
72+
/**
73+
* @deprecated `_document` parameter to be made required.
74+
* @breaking-change 9.0.0
75+
*/
76+
@Inject(DOCUMENT) _document?: any) {
6777
super();
78+
this._document = _document;
6879
}
6980

7081
/** Portal associated with the Portal outlet. */
@@ -141,7 +152,7 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
141152
}
142153

143154
/**
144-
* Attach the given TemplatePortal to this PortlHost as an embedded View.
155+
* Attach the given TemplatePortal to this PortalHost as an embedded View.
145156
* @param portal Portal to be attached.
146157
* @returns Reference to the created embedded view.
147158
*/
@@ -156,6 +167,32 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
156167

157168
return viewRef;
158169
}
170+
171+
/**
172+
* Attaches the given DomPortal to this PortalHost by moving all of the portal content into it.
173+
* @param portal Portal to be attached.
174+
*/
175+
attachDomPortal(portal: DomPortal) {
176+
// @breaking-change 9.0.0 Remove check and error once the
177+
// `_document` constructor parameter is required.
178+
if (!this._document) {
179+
throw Error('Cannot attach DOM portal without _document constructor parameter');
180+
}
181+
182+
let element = portal.element;
183+
let anchorNode = this._document.createComment('dom-portal');
184+
const nativeElement: Node = this._viewContainerRef.element.nativeElement;
185+
const rootNode = nativeElement.nodeType === nativeElement.ELEMENT_NODE ?
186+
nativeElement : nativeElement.parentNode!;
187+
188+
portal.setAttachedHost(this);
189+
element.parentNode!.insertBefore(anchorNode, element);
190+
rootNode.appendChild(element);
191+
192+
super.setDisposeFn(() => {
193+
anchorNode.parentNode!.replaceChild(element, anchorNode);
194+
});
195+
}
159196
}
160197

161198

src/cdk/portal/portal.spec.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import {
1212
ApplicationRef,
1313
TemplateRef,
1414
ComponentRef,
15+
ElementRef,
1516
} from '@angular/core';
1617
import {CommonModule} from '@angular/common';
1718
import {CdkPortal, CdkPortalOutlet, PortalModule} from './portal-directives';
18-
import {Portal, ComponentPortal, TemplatePortal} from './portal';
19+
import {Portal, ComponentPortal, TemplatePortal, DomPortal} from './portal';
1920
import {DomPortalOutlet} from './dom-portal-outlet';
2021

2122

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

80+
it('should load a DOM portal', () => {
81+
const testAppComponent = fixture.componentInstance;
82+
const hostContainer = fixture.nativeElement.querySelector('.portal-container');
83+
const innerContent = fixture.nativeElement.querySelector('.dom-portal-inner-content');
84+
const domPortal = new DomPortal(testAppComponent.domPortalContent);
85+
const initialParent = domPortal.element.parentNode!;
86+
87+
expect(innerContent).toBeTruthy('Expected portal content to be rendered.');
88+
expect(domPortal.element.contains(innerContent))
89+
.toBe(true, 'Expected content to be inside portal on init.');
90+
expect(hostContainer.contains(innerContent))
91+
.toBe(false, 'Expected content to be outside of portal outlet.');
92+
93+
testAppComponent.selectedPortal = domPortal;
94+
fixture.detectChanges();
95+
96+
expect(domPortal.element.parentNode)
97+
.not.toBe(initialParent, 'Expected portal to be out of the initial parent on attach.');
98+
expect(hostContainer.contains(innerContent))
99+
.toBe(true, 'Expected content to be inside the outlet on attach.');
100+
101+
testAppComponent.selectedPortal = undefined;
102+
fixture.detectChanges();
103+
104+
expect(domPortal.element.parentNode)
105+
.toBe(initialParent, 'Expected portal to be back inside initial parent on detach.');
106+
expect(hostContainer.contains(innerContent))
107+
.toBe(false, 'Expected content to be removed from outlet on detach.');
108+
});
109+
79110
it('should project template context bindings in the portal', () => {
80111
let testAppComponent = fixture.componentInstance;
81112
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
@@ -351,7 +382,8 @@ describe('Portals', () => {
351382

352383
beforeEach(() => {
353384
someDomElement = document.createElement('div');
354-
host = new DomPortalOutlet(someDomElement, componentFactoryResolver, appRef, injector);
385+
host = new DomPortalOutlet(someDomElement, componentFactoryResolver, appRef, injector,
386+
document);
355387

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

537+
it('should attach and detach a DOM portal', () => {
538+
const fixture = TestBed.createComponent(PortalTestApp);
539+
fixture.detectChanges();
540+
const portal = new DomPortal(fixture.componentInstance.domPortalContent);
541+
542+
portal.attach(host);
543+
544+
expect(someDomElement.textContent).toContain('Hello there');
545+
546+
host.detach();
547+
548+
expect(someDomElement.textContent!.trim()).toBe('');
549+
});
550+
505551
});
506552
});
507553

@@ -559,12 +605,17 @@ class ArbitraryViewContainerRefComponent {
559605
</ng-template>
560606
561607
<ng-template #templateRef let-data> {{fruit}} - {{ data?.status }}!</ng-template>
608+
609+
<div #domPortalContent>
610+
<p class="dom-portal-inner-content">Hello there</p>
611+
</div>
562612
`,
563613
})
564614
class PortalTestApp {
565615
@ViewChildren(CdkPortal) portals: QueryList<CdkPortal>;
566616
@ViewChild(CdkPortalOutlet, {static: true}) portalOutlet: CdkPortalOutlet;
567-
@ViewChild('templateRef', { read: TemplateRef , static: true}) templateRef: TemplateRef<any>;
617+
@ViewChild('templateRef', {read: TemplateRef, static: true}) templateRef: TemplateRef<any>;
618+
@ViewChild('domPortalContent', {static: true}) domPortalContent: ElementRef<HTMLElement>;
568619

569620
selectedPortal: Portal<any>|undefined;
570621
fruit: string = 'Banana';

src/cdk/portal/portal.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,21 @@ export class TemplatePortal<C = any> extends Portal<EmbeddedViewRef<C>> {
153153
}
154154
}
155155

156+
/**
157+
* A `DomPortal` is a portal whose content will be taken from its current position
158+
* in the DOM and moved into a portal outlet, when it is attached. On detach, the content
159+
* will be restored to its original position.
160+
*/
161+
export class DomPortal<T = HTMLElement> extends Portal<T> {
162+
/** DOM node hosting the portal's content. */
163+
readonly element: T;
164+
165+
constructor(element: T | ElementRef<T>) {
166+
super();
167+
this.element = element instanceof ElementRef ? element.nativeElement : element;
168+
}
169+
}
170+
156171

157172
/** A `PortalOutlet` is an space that can contain a single `Portal`. */
158173
export interface PortalOutlet {
@@ -213,6 +228,10 @@ export abstract class BasePortalOutlet implements PortalOutlet {
213228
} else if (portal instanceof TemplatePortal) {
214229
this._attachedPortal = portal;
215230
return this.attachTemplatePortal(portal);
231+
// @breaking-change 9.0.0 remove null check for `this.attachDomPortal`.
232+
} else if (this.attachDomPortal && portal instanceof DomPortal) {
233+
this._attachedPortal = portal;
234+
return this.attachDomPortal(portal);
216235
}
217236

218237
throwUnknownPortalTypeError();
@@ -222,6 +241,9 @@ export abstract class BasePortalOutlet implements PortalOutlet {
222241

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

244+
// @breaking-change 9.0.0 `attachDomPortal` to become a required method.
245+
abstract attachDomPortal?(portal: DomPortal): any;
246+
225247
/** Detaches a previously attached portal. */
226248
detach(): void {
227249
if (this._attachedPortal) {

src/dev-app/portal/portal-demo.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ <h2> The portal outlet is here: </h2>
1515
Science joke
1616
</button>
1717

18+
<button type="button" (click)="selectedPortal = dadJoke">
19+
Dad joke
20+
</button>
21+
1822
<!-- Template vars on <ng-template> elements can't be accessed _in_ the template because Angular
1923
doesn't support grabbing the instance / TemplateRef this way because the variable may be
2024
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>
2933
<p> - Did you hear about this year's Fibonacci Conference? </p>
3034
<p> - It's going to be as big as the last two put together. </p>
3135
</div>
36+
37+
<div class="demo-dad-joke" #domPortalSource>
38+
<p> - Scientists got bored of watching the moon for 24 hours </p>
39+
<p> - So they called it a day. </p>
40+
</div>

src/dev-app/portal/portal-demo.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@
55
width: 500px;
66
height: 100px;
77
}
8+
9+
.demo-dad-joke {
10+
opacity: 0.25;
11+
}

src/dev-app/portal/portal-demo.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

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

1212

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

2223
selectedPortal: Portal<any>;
2324

@@ -32,6 +33,10 @@ export class PortalDemo {
3233
get scienceJoke() {
3334
return new ComponentPortal(ScienceJoke);
3435
}
36+
37+
get dadJoke() {
38+
return new DomPortal(this.domPortalSource);
39+
}
3540
}
3641

3742

0 commit comments

Comments
 (0)