Skip to content

Commit d3d8859

Browse files
crisbetommalerba
authored andcommitted
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.
1 parent 466903e commit d3d8859

File tree

18 files changed

+264
-18
lines changed

18 files changed

+264
-18
lines changed

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
BasePortalOutlet,
1313
ComponentPortal,
1414
CdkPortalOutlet,
15-
TemplatePortal
15+
TemplatePortal,
16+
DomPortal,
1617
} from '@angular/cdk/portal';
1718
import {DOCUMENT} from '@angular/common';
1819
import {
@@ -181,6 +182,21 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
181182
return this._portalHost.attachTemplatePortal(portal);
182183
}
183184

185+
/**
186+
* Attaches a DOM portal to the dialog container.
187+
* @param portal Portal to be attached.
188+
* @deprecated To be turned into a method.
189+
* @breaking-change 10.0.0
190+
*/
191+
attachDomPortal = (portal: DomPortal) => {
192+
if (this._portalHost.hasAttached()) {
193+
throwDialogContentAlreadyAttachedError();
194+
}
195+
196+
this._savePreviouslyFocusedElement();
197+
return this._portalHost.attachDomPortal(portal);
198+
}
199+
184200
/** Emit lifecycle events based on animation `start` callback. */
185201
_onAnimationStart(event: AnimationEvent) {
186202
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: 38 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 10.0.0
36+
*/
37+
_document?: any) {
3038
super();
39+
this._document = _document;
3140
}
3241

3342
/**
@@ -93,6 +102,33 @@ 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+
* @deprecated To be turned into a method.
109+
* @breaking-change 10.0.0
110+
*/
111+
attachDomPortal = (portal: DomPortal) => {
112+
// @breaking-change 10.0.0 Remove check and error once the
113+
// `_document` constructor parameter is required.
114+
if (!this._document) {
115+
throw Error('Cannot attach DOM portal without _document constructor parameter');
116+
}
117+
118+
// Anchor used to save the element's previous position so
119+
// that we can restore it when the portal is detached.
120+
let anchorNode = this._document.createComment('dom-portal');
121+
let element = portal.element;
122+
123+
element.parentNode!.insertBefore(anchorNode, element);
124+
this.outletElement.appendChild(element);
125+
126+
super.setDisposeFn(() => {
127+
// We can't use `replaceWith` here because IE doesn't support it.
128+
anchorNode.parentNode!.replaceChild(element, anchorNode);
129+
});
130+
}
131+
96132
/**
97133
* Clears out a portal from the DOM.
98134
*/

src/cdk/portal/portal-directives.ts

Lines changed: 44 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
/**
@@ -69,6 +71,8 @@ export type CdkPortalOutletAttachedRef = ComponentRef<any> | EmbeddedViewRef<any
6971
inputs: ['portal: cdkPortalOutlet']
7072
})
7173
export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestroy {
74+
private _document: Document;
75+
7276
/** Whether the portal component is initialized. */
7377
private _isInitialized = false;
7478

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

7882
constructor(
7983
private _componentFactoryResolver: ComponentFactoryResolver,
80-
private _viewContainerRef: ViewContainerRef) {
84+
private _viewContainerRef: ViewContainerRef,
85+
86+
/**
87+
* @deprecated `_document` parameter to be made required.
88+
* @breaking-change 9.0.0
89+
*/
90+
@Inject(DOCUMENT) _document?: any) {
8191
super();
92+
this._document = _document;
8293
}
8394

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

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

185+
/**
186+
* Attaches the given DomPortal to this PortalHost by moving all of the portal content into it.
187+
* @param portal Portal to be attached.
188+
* @deprecated To be turned into a method.
189+
* @breaking-change 10.0.0
190+
*/
191+
attachDomPortal = (portal: DomPortal) => {
192+
// @breaking-change 9.0.0 Remove check and error once the
193+
// `_document` constructor parameter is required.
194+
if (!this._document) {
195+
throw Error('Cannot attach DOM portal without _document constructor parameter');
196+
}
197+
198+
// Anchor used to save the element's previous position so
199+
// that we can restore it when the portal is detached.
200+
let anchorNode = this._document.createComment('dom-portal');
201+
let element = portal.element;
202+
const nativeElement: Node = this._viewContainerRef.element.nativeElement;
203+
const rootNode = nativeElement.nodeType === nativeElement.ELEMENT_NODE ?
204+
nativeElement : nativeElement.parentNode!;
205+
206+
portal.setAttachedHost(this);
207+
element.parentNode!.insertBefore(anchorNode, element);
208+
rootNode.appendChild(element);
209+
210+
super.setDisposeFn(() => {
211+
anchorNode.parentNode!.replaceChild(element, anchorNode);
212+
});
213+
}
214+
174215
static ngAcceptInputType_portal: Portal<any> | null | undefined | '';
175216
}
176217

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 DOM element 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 {
@@ -218,6 +233,10 @@ export abstract class BasePortalOutlet implements PortalOutlet {
218233
} else if (portal instanceof TemplatePortal) {
219234
this._attachedPortal = portal;
220235
return this.attachTemplatePortal(portal);
236+
// @breaking-change 10.0.0 remove null check for `this.attachDomPortal`.
237+
} else if (this.attachDomPortal && portal instanceof DomPortal) {
238+
this._attachedPortal = portal;
239+
return this.attachDomPortal(portal);
221240
}
222241

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

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

249+
// @breaking-change 10.0.0 `attachDomPortal` to become a required abstract method.
250+
readonly attachDomPortal: null | ((portal: DomPortal) => any) = null;
251+
230252
/** Detaches a previously attached portal. */
231253
detach(): void {
232254
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({
@@ -17,6 +17,7 @@ import {Component, QueryList, ViewChildren} from '@angular/core';
1717
})
1818
export class PortalDemo {
1919
@ViewChildren(CdkPortal) templatePortals: QueryList<Portal<any>>;
20+
@ViewChild('domPortalSource', {static: false}) domPortalSource: ElementRef<HTMLElement>;
2021

2122
selectedPortal: Portal<any>;
2223

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

3641

0 commit comments

Comments
 (0)