How to build a reusable Modal Overlay/Dialog Using Angular CDK

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: For backdrop click to dismiss the modal overlay to work, you might need
to disable pointer-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
of modal ➡ modal-background ➡ modal-content, but rather modal ➡ modal-content, while the backdrop is a separate div tag not nested under
modal, but on a level above modal 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.

Additional Resources