Today, I am going to show you how to create a reusable modal overlay/dialog
using Angular CDK Overlay that can be reused inside our Angular project
multiple times with ease. We are going to try and match the behavior and
the functionality of Angular Material Dialog component, but using the UI Framework
of our choice.
This is going to be the first of a few articles, focusing on ways you can
utilize Angular CDK to implement common interaction patterns within your
application.
Demo and Source Code
You can find the link to the demo here and the GitHub repo for this post here.
Prerequisites
Install Angular CLI and create a new Angular project – link.
Setup Bulma inside your Angular Project.
Install Angular CDK – npm i @angular/cdk
or yarn add @angular/cdk
The article is divided into two sections:
- The Basics - a Quick look at how to use Angular CDK Overlay
- Building a Reusable Modal Overlay - A detailed guide into building a reusable
modal overlay.
The Basics
Let us get the basics out of the way first. Assuming you have installed Angular CDK you will need to import OverlayModule
into your app module.
import {OverlayModule} from '@angular/cdk/overlay';
And then, inject the Overlay
service and ViewContainerRef into your component.
constructor(private overlay: Overlay, private viewContainerRef: ViewContainerRef) {}
To show a modal overlay, you need either a Template or an Angular Component holding the content you would like to show. Let’s look at how you can use both below:
Using a Template
Inside the template of our component, let’s define a new template and add our overlay content:
<ng-template #tpl>
<div class="modal-card">
<header class="modal-card-head">
…
</header>
<section class="modal-card-body">
…
</section>
<footer class="modal-card-foot">
…
</footer>
</div>
</ng-template>
Then, add a method to show the overlay and it accepts an ng-template
reference as the parameter.
openWithTemplate(tpl: TemplateRef<any>) {}
Then, Inside the above method, we are going to create an overlay. We will start by defining the configs of the overlay – OverlayConfig
. In our case, we will just set the hasBackdrop
and backdropClass
properties. For backdropClass
we are using modal-background
a Bulma CSS Framework class. You can find all the Overlay Configurations you can add here.
const configs = new OverlayConfig({
hasBackdrop: true,
panelClass: ['modal', 'is-active'],
backdropClass: 'modal-background'
});
NB: Forbackdrop
click to dismiss the modal overlay to work, you might need
to disablepointer-events
for your modal, for some CSS Frameworks like
Bulma. This is because CDK Overlay doesn't add them to DOM in the nested order
ofmodal ➡ modal-background ➡ modal-content
, but rathermodal ➡ modal-content
, while the backdrop is a separate div tag not nested undermodal
, but on a level abovemodal
class div tag.
For Bulma CSS framework case, you can override the default behavior like this:
.modal {
pointer-events: none !important;
}
.modal-content {
pointer-events: all !important;
}
Then, let’s create an OverlayRef
, by using the create
method of the Overlay service and pass the configs we just created above:
const overlayRef = this.overlay.create(configs);
And then we can attach our template, using TemplatePortal, passing our template and ViewContainerRef
which we injected into our component:
overlayRef.attach(new TemplatePortal(tpl, this.viewContainerRef));
Now, we can trigger the method with a click of a button:
<button (click)="openWithTemplate(tpl)" >
Show
</button>
Using a Component
The difference between the two is that we will be using ComponentPortal
instead of TemplatePortal
to attach the component to the OverlayRef
.
this.overlayRef.attach(
new ComponentPortal(OverlayComponent, this.viewContainerRef)
);
NB: The component must be added to the list of entryComponents
in your App Module.
Closing Modal Overlay on Backdrop Clip
You can close the overlay when the backdrop is clicked by subscribing to the backdropClick()
and then calling the dispose
method.
overlayRef.backdropClick().subscribe(() => overlayRef.dispose());
Making a Re-usable Modal Overlay
Building modal overlays as shown above works very well if you are building one or two of them, however, it does not really scale very well. Wouldn’t it be nice if you could build a re-usable modal overlay that you can then use across your Angular project or projects? Wouldn’t it also be nice if we could be able to pass data to and receive data from the modal?
Objective
A service to open the modal, than can be injected into any component
A way to subscribe to when the modal is closed and access the response.
Pass data to the modal
Pass data as a String, Template or Component.
Overlay Ref Class
We are going to start by extending the OverlayRef
. We will create a custom OverlayRef
, creatively named MyOverlayRef
, which is going to accept the OverlayRef
, content
and the data to pass to our modal overlay. The content can be of type string
, TemplateRef
or Component
.
// R = Response Data Type, T = Data passed to Modal Type
export class MyOverlayRef<R = any, T = any> {
…
constructor(public overlay: OverlayRef, public content: string | TemplateRef<any> | Type<any>, public data: T ) {
…
}
…
}
Then, Inside the MyOverlayRef
class, we are going to add a BehaviorSubject
property named afterClosed$
which we can subscribe to get data once the overlay is closed.
afterClosed$ = new Subject<OverlayCloseEvent<R>>();
The behavior subject is going to pass back an OverlayCloseEvent
, which contains the data from modal and how the modal was closed. Feel free to modify this to cover your needs.
export interface OverlayCloseEvent<R> {
type: 'backdropClick' | 'close';
data: R;
}
Next, we need to add a private method to close the overlay. The method will dispose off the overlay, pass the OverlayCloseEvent
back to the subscriber and complete the afterClosed$
Observable.
private _close(type: 'backdropClick' | 'close', data: R) {
this.overlay.dispose();
this.afterClosed$.next({
type,
data
});
this.afterClosed$.complete();
}
And then, we are going to add a second public close method. It will only accept data
as a parameter and will call the private _close
method, to close the modal.
close(data?: R) {
this._close('close', data);
}
And finally, we are going to subscribe to backdropClick
and close the modal when clicked. We are adding this subscriber to the MyOverlayRef
constructor.
overlay.backdropClick().subscribe(() => this._close('backdropClick', null));
Overlay Component
Next, we are going to add a special component that we will use to show our modal content. If it is a simple string, we will bind it to a div, while we can use either ngTemplateOutlet
and ngComponentOutlet
to load the template
and component
respectively.
Component Class
We are going to start by injecting an instance of our MyOverlayRef
into the component.
constructor(private ref: MyOverlayRef) {}
And then, let’s define 3 more properties inside our component class:
contentType: 'template' | 'string' | 'component' = 'component';
content: string | TemplateRef<any> | Type<any>;
context;
Then, OnInit
, we need to determine the content type and set the above properties appropriately.
ngOnInit() {
if (typeof this.content === 'string') {
this.contentType = 'string';
} else if (this.content instanceof TemplateRef) {
this.contentType = 'template';
this.context = {
close: this.ref.close.bind(this.ref)
};
} else {
this.contentType = 'component';
}
}
And finally, we are going to be adding a global close button, so let’s add a close
method:
close() {
this.ref.close(null);
}
And finally, remember to add the Overlay component as an entryComponent
in your app module.
Component Template
In the template, we are going to use ngSwitch
to switch between content type and add a global close button for our modal.
<div class="modal-content">
<ng-container [ngSwitch]="contentType">
<ng-container *ngSwitchCase="'string'">
<div class="box">
<div [innerHTML]="content"></div>
</div>
</ng-container>
<ng-container *ngSwitchCase="'template'">
…
</ng-container>
<ng-container *ngSwitchCase="'component'">
…
</ng-container>
</ng-container>
</div>
<!-- You can also add a global close button -->
<button (click)="close()" class="modal-close is-large" aria-label="close"></button>
For template content type, we will use ngTemplateOutlet
, passing the template, which is the content
and then pass the context
:
<ng-container *ngTemplateOutlet="content; context: context"></ng-container>
And while for component content type, we will use ngComponentOutlet
, passing the Component, which is the content:
<ng-container *ngComponentOutlet="content"></ng-container>
The Overlay Service
Next, we are going to create an Overlay service, which we can inject into any component we want to use our modal overlay. Where we are going to inject Overlay
Service and the Injector
.
export class OverlayService {
constructor(private overlay: Overlay, private injector: Injector) {}
}
Then, add an open
method, which will accept content and data. The data
param, in this case, is the data you would like to pass to your modal.
open<R = any, T = any>(
content: string | TemplateRef<any> | Type<any>,data: T): MyOverlayRef<R> {
…
}
Inside the method, first, we need to create an OverlayRef
object by using the Overlay
create
method. Feel free to customize the configs to suit your needs.
const configs = new OverlayConfig({
hasBackdrop: true,
panelClass: ['modal', 'is-active'],
backdropClass: 'modal-background'
});
const overlayRef = this.overlay.create(configs);
Then, let’s instantiate our MyOverlayRef
Class passing the OverlayRef
object we created above, then content and the data, both from the method parameters.
const myOverlayRef = new MyOverlayRef<R, T>(overlayRef, content, data);
Create an injector using PortalInjector
, so that we can inject our custom MyOverlayRef
object to the overlay component, we created above.
const injector = this.createInjector(myOverlayRef, this.injector);
And finally use ComponentPortal
, to attach the OverlayComponent
we created above and the newly created injector and return a MyOverlayRef
object.
overlayRef.attach(new ComponentPortal(OverlayComponent, null, injector));
return myOverlayRef;
And here is the method for creating a custom injector using PortalInjector
:
createInjector(ref: MyOverlayRef, inj: Injector) {
const injectorTokens = new WeakMap([[MyOverlayRef, ref]]);
return new PortalInjector(inj, injectorTokens);
}
And that’s it, we now have a re-usable modal overlay, that we can use anywhere inside our application.
Usage
First, inject the Overlay Service in the component where you would like to open a new modal overlay.
constructor(private overlayService: OverlayService) {}
Then, inside the method you would want to trigger the modal overlay, you just
use the open method and pass the content and any data you want to pass to the
modal overlay.
const ref = this.overlayService.open(content, null);
ref.afterClosed$.subscribe(res => {
console.log(res);
});
The content can be a simple string or a Template
or a Component
.
String
const ref = this.overlayService.open("Hello World", null);
Template
<ng-template #tpl let-close="close">
<div class="modal-card">
<section class="modal-card-body">
A yes no dialog, using template
</section>
<footer class="modal-card-foot">
<div class="buttons">
<button (click)="close('yes')" type="button" class="button is-success">Yes</button>
<button (click)="close('no')" type="button" class="button is-danger">No</button>
</div>
</footer>
</div>
</ng-template>
And a button to open the modal:
<button (click)="open(tpl)" class="button is-small is-primary">
Show
</button>
And then open the overlay with an open
method:
open(content: TemplateRef<any>) {
const ref = this.overlayService.open(content, null);
ref.afterClosed$.subscribe(res => {
console.log(res);
});
}
Component
open() {
const ref = this.overlayService.open(YesNoDialogComponent, null);
ref.afterClosed$.subscribe(res => {
console.log(res);
});
}
You can also inject the MyOverlayRef
inside the component to access data and the close method.
constructor(private ref: MyOverlayRef) {}
This allows you to pass data to the component and trigger the closing of the modal from within the component.
close(value: string) {
this.ref.close(value);
}
NB: Please remember to add the component as an entryComponent
inside your App Module.
You can find all of the above code here and the demo here.