Skip to content

Commit 78a4dee

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 92448bc commit 78a4dee

File tree

14 files changed

+191
-9
lines changed

14 files changed

+191
-9
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 {
@@ -176,6 +177,19 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
176177
return this._portalHost.attachTemplatePortal(portal);
177178
}
178179

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

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ 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
/**
@@ -93,6 +93,28 @@ export class DomPortalOutlet extends BasePortalOutlet {
9393
return viewRef;
9494
}
9595

96+
/**
97+
* Attaches a DOM portal by transferring its content into the outlet.
98+
* @param portal Portal to be attached.
99+
*/
100+
attachDomPortal(portal: DomPortal) {
101+
// Note that we need to convert this into an array, because `childNodes`
102+
// is a live collection which will be updated as we add/remove nodes.
103+
let transferredNodes = Array.from(portal.element.childNodes);
104+
105+
for (let i = 0; i < transferredNodes.length; i++) {
106+
this.outletElement.appendChild(transferredNodes[i]);
107+
}
108+
109+
super.setDisposeFn(() => {
110+
for (let i = 0; i < transferredNodes.length; i++) {
111+
portal.element.appendChild(transferredNodes[i]);
112+
}
113+
114+
transferredNodes = null!;
115+
});
116+
}
117+
96118
/**
97119
* Clears out a portal from the DOM.
98120
*/

src/cdk/portal/portal-directives.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
TemplateRef,
2020
ViewContainerRef,
2121
} from '@angular/core';
22-
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal} from './portal';
22+
import {BasePortalOutlet, ComponentPortal, Portal, TemplatePortal, DomPortal} from './portal';
2323

2424

2525
/**
@@ -141,7 +141,7 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
141141
}
142142

143143
/**
144-
* Attach the given TemplatePortal to this PortlHost as an embedded View.
144+
* Attach the given TemplatePortal to this PortalHost as an embedded View.
145145
* @param portal Portal to be attached.
146146
* @returns Reference to the created embedded view.
147147
*/
@@ -156,6 +156,29 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestr
156156

157157
return viewRef;
158158
}
159+
160+
/**
161+
* Attaches the given DomPortal to this PortalHost by moving all of the portal content into it.
162+
* @param portal Portal to be attached.
163+
*/
164+
attachDomPortal(portal: DomPortal) {
165+
portal.setAttachedHost(this);
166+
167+
const origin = portal.element;
168+
const transferredNodes: Node[] = [];
169+
const nativeElement: Node = this._viewContainerRef.element.nativeElement;
170+
const rootNode = nativeElement.nodeType === nativeElement.ELEMENT_NODE ?
171+
nativeElement : nativeElement.parentNode!;
172+
173+
while (origin.firstChild) {
174+
transferredNodes.push(rootNode.appendChild(origin.firstChild));
175+
}
176+
177+
super.setDisposeFn(() => {
178+
transferredNodes.forEach(node => portal.element.appendChild(node));
179+
transferredNodes.length = 0;
180+
});
181+
}
159182
}
160183

161184

src/cdk/portal/portal.spec.ts

Lines changed: 51 additions & 2 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,35 @@ 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+
86+
expect(innerContent).toBeTruthy('Expected portal content to be rendered.');
87+
expect(domPortal.element.contains(innerContent))
88+
.toBe(true, 'Expected content to be inside portal on init.');
89+
expect(hostContainer.contains(innerContent))
90+
.toBe(false, 'Expected content to be outside of portal outlet.');
91+
92+
testAppComponent.selectedPortal = domPortal;
93+
fixture.detectChanges();
94+
95+
expect(domPortal.element.contains(innerContent))
96+
.toBe(false, 'Expected content to be out of the portal on attach.');
97+
expect(hostContainer.contains(innerContent))
98+
.toBe(true, 'Expected content to be inside the outlet on attach.');
99+
100+
testAppComponent.selectedPortal = undefined;
101+
fixture.detectChanges();
102+
103+
expect(domPortal.element.contains(innerContent))
104+
.toBe(true, 'Expected content to be at initial position on detach.');
105+
expect(hostContainer.contains(innerContent))
106+
.toBe(false, 'Expected content to be removed from outlet on detach.');
107+
});
108+
79109
it('should project template context bindings in the portal', () => {
80110
let testAppComponent = fixture.componentInstance;
81111
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
@@ -502,6 +532,20 @@ describe('Portals', () => {
502532
expect(spy).toHaveBeenCalled();
503533
});
504534

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

@@ -559,12 +603,17 @@ class ArbitraryViewContainerRefComponent {
559603
</ng-template>
560604
561605
<ng-template #templateRef let-data> {{fruit}} - {{ data?.status }}!</ng-template>
606+
607+
<div #domPortalContent>
608+
<p class="dom-portal-inner-content">Hello there</p>
609+
</div>
562610
`,
563611
})
564612
class PortalTestApp {
565613
@ViewChildren(CdkPortal) portals: QueryList<CdkPortal>;
566614
@ViewChild(CdkPortalOutlet, {static: true}) portalOutlet: CdkPortalOutlet;
567-
@ViewChild('templateRef', { read: TemplateRef , static: true}) templateRef: TemplateRef<any>;
615+
@ViewChild('templateRef', {read: TemplateRef, static: true}) templateRef: TemplateRef<any>;
616+
@ViewChild('domPortalContent', {static: true}) domPortalContent: ElementRef<HTMLElement>;
568617

569618
selectedPortal: Portal<any>|undefined;
570619
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 extends Portal<HTMLElement> {
162+
/** DOM node hosting the portal's content. */
163+
readonly element: HTMLElement;
164+
165+
constructor(element: HTMLElement | ElementRef<HTMLElement>) {
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 8.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 8.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') 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

src/material/bottom-sheet/bottom-sheet-container.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
ComponentPortal,
2727
TemplatePortal,
2828
CdkPortalOutlet,
29+
DomPortal,
2930
} from '@angular/cdk/portal';
3031
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
3132
import {MatBottomSheetConfig} from './bottom-sheet-config';
@@ -122,6 +123,14 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr
122123
return this._portalOutlet.attachTemplatePortal(portal);
123124
}
124125

126+
/** Attaches a DOM portal to the bottom sheet container. */
127+
attachDomPortal(portal: DomPortal) {
128+
this._validatePortalAttached();
129+
this._setPanelClass();
130+
this._savePreviouslyFocusedElement();
131+
return this._portalOutlet.attachDomPortal(portal);
132+
}
133+
125134
/** Begin animation of bottom sheet entrance into view. */
126135
enter(): void {
127136
if (!this._destroyed) {

src/material/dialog/dialog-container.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import {
2626
BasePortalOutlet,
2727
ComponentPortal,
2828
CdkPortalOutlet,
29-
TemplatePortal
29+
TemplatePortal,
30+
DomPortal
3031
} from '@angular/cdk/portal';
3132
import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y';
3233
import {MatDialogConfig} from './dialog-config';
@@ -130,6 +131,19 @@ export class MatDialogContainer extends BasePortalOutlet {
130131
return this._portalOutlet.attachTemplatePortal(portal);
131132
}
132133

134+
/**
135+
* Attaches a DOM portal to the dialog container.
136+
* @param portal Portal to be attached.
137+
*/
138+
attachDomPortal(portal: DomPortal) {
139+
if (this._portalOutlet.hasAttached()) {
140+
throwMatDialogContentAlreadyAttachedError();
141+
}
142+
143+
this._savePreviouslyFocusedElement();
144+
return this._portalOutlet.attachDomPortal(portal);
145+
}
146+
133147
/** Moves the focus inside the focus trap. */
134148
private _trapFocus() {
135149
if (!this._focusTrap) {

src/material/snack-bar/snack-bar-container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CdkPortalOutlet,
1313
ComponentPortal,
1414
TemplatePortal,
15+
DomPortal,
1516
} from '@angular/cdk/portal';
1617
import {
1718
ChangeDetectionStrategy,
@@ -107,6 +108,13 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
107108
return this._portalOutlet.attachTemplatePortal(portal);
108109
}
109110

111+
/** Attaches a DOM portal to the snack bar container. */
112+
attachDomPortal(portal: DomPortal) {
113+
this._assertNotAttached();
114+
this._applySnackBarClasses();
115+
return this._portalOutlet.attachDomPortal(portal);
116+
}
117+
110118
/** Handle end of animations, updating the state of the snackbar. */
111119
onAnimationEnd(event: AnimationEvent) {
112120
const {fromState, toState} = event;

tools/public_api_guard/material/bottom-sheet.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export declare class MatBottomSheetContainer extends BasePortalOutlet implements
3939
_onAnimationDone(event: AnimationEvent): void;
4040
_onAnimationStart(event: AnimationEvent): void;
4141
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
42+
attachDomPortal(portal: DomPortal): void;
4243
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;
4344
enter(): void;
4445
exit(): void;

tools/public_api_guard/material/dialog.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export declare class MatDialogContainer extends BasePortalOutlet {
9393
_onAnimationStart(event: AnimationEvent): void;
9494
_startExitAnimation(): void;
9595
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
96+
attachDomPortal(portal: DomPortal): void;
9697
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;
9798
}
9899

0 commit comments

Comments
 (0)