Skip to content

Aquent | DEV6

Generic selectors
Exact matches only
Search in title
Search in content
Search in posts
Search in pages

Create your own Dialog Component in Angular

Written by: Michael Vano

With many frameworks floating around, like Material and Bootstrap, you may think “Why do I need to create my own Dialog when I can just use Material or Bootstrap?”. If you can, I suggest you do – material and bootstrap are well supported and there are many great tutorials on integrating them.

But you may encounter a project where you need custom functionality for the Dialog (also known as Modals) or have a client who does not wish to use either of those frameworks, or any at all.

This is what this post is all about; how to create your own custom Dialog Component in the Angular Framework.

This post is going to assume you are familiar enough with some architectural conventions within Angular, like components and services, and what their purposes are within the framework. Even if you’re not, this might help you become more familiar! The code sample was written in Angular 8.2 and I will be excluding the import code.

So, let’s get this thing started!

Creating a service to dynamically generate components

First off, here’s the code for this service. I’ve called it “dom.service.ts” since it manipulates and manages DOM elements.

@Injectable({
  providedIn: 'root'
})
export class DomService {
  private id = 0;
  private componentRefs: { [key: number]: ComponentRef<any> } = {};

  constructor(
    private componentFactoryResolver: ComponentFactoryResolver,
    private appRef: ApplicationRef,
    private injector: Injector
  ) {}

  appendComponentToBody(component: any): number {
    const componentRef = this.componentFactoryResolver.resolveComponentFactory(component).create(this.injector);
    this.appRef.attachView(componentRef.hostView);
    const domElem = (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
    document.body.appendChild(domElem);
    this.id++;
    this.componentRefs[this.id] = componentRef;
    return this.id;
  }

  getComponent(id: number): ComponentRef<any> | null {
    if (this.componentRefs[id]) {
      return this.componentRefs[id];
    }
    return null;
  }

  removeComponentFromBody(id: number): void {
    if (this.componentRefs[id]) {
      this.appRef.detachView(this.componentRefs[id].hostView);
      this.componentRefs[id].destroy();
    }
  }
}

Okay, now, what is going on in here? Let’s break it down. 

private id = 0;
private componentRefs: { [key: number]: ComponentRef<any> } = {};

These 2 variables are to manage all your dynamically generated components in your app. “Id” will always be incremented every time you create a new component, so you don’t have any duplicates. The “componentRefs” variable is essentially an object map for each generated component, so you always have a reference to them.

appendComponentToBody(component: any): number {
  const componentRef = this.componentFactoryResolver.resolveComponentFactory(component).create(this.injector);
  this.appRef.attachView(componentRef.hostView);
  const domElem = (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
  document.body.appendChild(domElem);
  this.id++;
  this.componentRefs[this.id] = componentRef;
  return this.id;
}

The “appendComponentToBody” method will take any component passed into it, run it through Angular’s component factory (to inject services and such), hook into Angular’s life cycle ecosystem with “this.appRef.attachView” (so it knows if a value in it has been changed), generate a DOM element, append that element to the body, increment the “id” variable so it’s always unique, store it in the “componentRefs” object and return the uniquely created id, so this component can be referenced again.

Phew, that was a mouthful.

getComponent(id: number): ComponentRef<any> | null {
  if (this.componentRefs[id]) {
    return this.componentRefs[id];
  }
  return null;
}

removeComponentFromBody(id: number): void {
  if (this.componentRefs[id]) {
    this.appRef.detachView(this.componentRefs[id].hostView);
    this.componentRefs[id].destroy();
  }
}

“getComponent” will return the reference stored in the “componentRefs” variable and “removeComponentFromBody” will unhook the component from Angular’s life cycle and destroy it.

Translucent overlay and container for the modal

So now that we got something in place to generate content, let’s make it so that we dynamically generate the overlay that the Dialog will be placed upon. We’ll call this one “overlay.component”

Here’s the HTML code for the overlay.

<div #overlay class="overlay"></div>
<ng-template appModalContent></ng-template>
<button #closeButton *ngIf="showCloseButton" (click)="closeModal($event)" class="close-button">
  <span aria-hidden="true">X</span>
</button>

We got div for the overlay, the template as a placeholder for where the modal itself will be placed and an optional close button, that can be shown or hidden through a config we’ll setup later.

Now to dress up these elements with CSS

:host {
  align-items: center;
  display: flex;
  flex-direction: column;
  height: 100vh;
  justify-content: center;
  left: 0;
  position: fixed;
  top: 0;
  width: 100vw;
  z-index: 9999;
}

.overlay {
  background-color: rgba(0, 0, 0, 0.6);
  height: 100vh;
  opacity: 0;
  position: absolute;
  transition: opacity 250ms linear;
  width: 100vw;

  &.fade-in {
    opacity: 1;
  }
}

.close-button {
  background-color: #fff;
  border-radius: 50%;
  height: 2.5rem;
  position: absolute;
  right: 1rem;
  top: 1rem;
  width: 2.5rem;
}

So we got the “:host” class, which references the parent element that Angular will generate for the component. We’re fixing this element and making it fill up the whole screen. We’re also centering all the elements within it using flex.

The “.overlay” class also fills the screen and has a translucent black background. We’ve had some transition effects to have the background fade in.

Now if you’re thinking “Why not just put the effects and background color in the :host class?”, you’re not wrong in that thinking. We could do that too, but if we were to set it up that way, then everything in the component would fade in. There may be some cases where you do not want the modal to fade in at the same time as the overlay. One example could be, the mobile version where it slides up or from the sides.

Then we got the close button, knocking it out of the flow of the elements and absolutely positioning it in the top right corner.

Finally, the typescript file.

@Component({
  selector: 'app-overlay',
  templateUrl: './overlay.component.html',
  styleUrls: ['./overlay.component.scss'],
  animations: [
    trigger('leaveTimer', [
      transition(
        ':leave',
        [
          style({ opacity: 1 }),
          animate('350ms linear', style({ opacity: 1 }))
        ]
      )
    ])
  ]
})
export class OverlayComponent implements OnInit, AfterViewInit {

  private _active = false;
  private _modal: Type<any>;

  private _componentRef: ComponentRef<any>;
  private _data: any;
  allowOverlayClick = true;
  showCloseButton = true;

  set data(data: any) {
    this._data = data;
    this.setData();
  }

  whenOverlayClicked$: EventEmitter<void> = new EventEmitter();

  @ViewChild('closeButton', { static: false }) closeButton: ElementRef;
  @ViewChild('overlay', { static: true }) overlayEl: ElementRef;
  @ViewChild(ModalContentDirective, { static: true }) contentContainer: ModalContentDirective;

  @HostBinding('@leaveTimer') delayDestroy() {}
  @HostListener('click', ['$event']) onOverlayClick(event: MouseEvent | TouchEvent) {
    if (this.allowOverlayClick && !this._componentRef.location.nativeElement.contains(event.target)) {
      this.closeModal(event);
    }
  }

  set component(component: Type<any>) {
    if (!this._modal) {
      this._modal = component;
      this.setupContent();
    }
  }

  get componentInstance(): ComponentRef<any> {
    return this._componentRef;
  }

  constructor(private componentFactoryResolver: ComponentFactoryResolver, private renderer: Renderer2) {
  }

  ngOnInit(): void {
    this._active = true;
  }

  ngAfterViewInit(): void {
    setTimeout(() => {
      this.renderer.addClass(this.overlayEl.nativeElement, 'fade-in');
    });
  }

  closeModal(event?: MouseEvent | TouchEvent): void {
    if (!!event) {
      event.stopPropagation();
      event.preventDefault();
    }
    this.renderer.removeClass(this.overlayEl.nativeElement, 'fade-in');
    this.renderer.removeClass(this._componentRef.location.nativeElement, 'slide-in');
    this.whenOverlayClicked$.emit();
  }

  setData(): void {
    if (this._componentRef && 'data' in this._componentRef.instance) {
      this._componentRef.instance.data = this._data;
    }
  }

  private setupCloseListener(): void {
    if (this._componentRef && 'closeModal' in this._componentRef.instance) {
      (this._componentRef.instance.closeModal as EventEmitter<void>)
        .pipe(takeWhile(() => this._active))
        .subscribe(() => {
        this.closeModal();
      });
    }
  }

  setupContent(): void {
    if (this._modal) {
      const componentFactory: ComponentFactory<any> = this.componentFactoryResolver.resolveComponentFactory(
        this._modal
      );

      const viewContainerRef: ViewContainerRef = this.contentContainer.viewContainerRef;
      viewContainerRef.clear();

      this._componentRef = viewContainerRef.createComponent(componentFactory);

      this.setupCloseListener();
    }
  }
}

Rather than go into detail of each line of code, here are the important things to know in here:

  • When a modal component is set in the “component” setter, Angular’s component factory is used to setup and create the component dynamically
  • After the modal component is generated, it then gets placed where the “ng-template” element is in the HTML file
  • Event listeners are then setup to listen for “closeModal” event from the modal component
  • Whenever custom data is set in the “data” setter, that data will be passed into the modal component
  • Whenever the modal is closed, it’ll do some tear down logic, setup css for animation effects and then emit an event indicating that the modal has been closed

And one more last piece of code block for the overlay component.

@Directive({
  selector: '[appModalContent]'
})
export class ModalContentDirective {
  constructor(public viewContainerRef: ViewContainerRef) {}
}

This directive is simply setup so that we can get access to the “ViewContainerRef” of the “ng-template”.

Setting up the Modal Service

The modal service layer is to help simplify the process of creating and destroying the modal in Angular. It also has some methods to allow us to reference the modal component itself.

@Injectable({
  providedIn: 'root'
})
export class ModalService {

  private openModals: { [idRef: number]: ComponentRef<OverlayComponent> } = {};

  constructor(private domService: DomService) {}

  closeModal(refId: number): void {
    this.domService.removeComponentFromBody(refId);
  }

  getModalContentInstance(refId: number): any {
    return !!this.openModals[refId] ? this.openModals[refId].instance.componentInstance.instance : null;
  }

  private setConfig(refId: number, config: IModalConfig): void {
    this.openModals[refId].instance.allowOverlayClick =
      'allowOverlayClick' in config ? !!config.allowOverlayClick : this.openModals[refId].instance.allowOverlayClick;

    this.openModals[refId].instance.showCloseButton =
      'showCloseButton' in config ? !!config.showCloseButton : this.openModals[refId].instance.showCloseButton;

    if ('whenClosed' in config) {
      this.openModals[refId].instance.whenOverlayClicked$.subscribe(() => {
        if (!!config.whenClosed) {
          config.whenClosed();
        }
      });
    }

    if ('data' in config) {
      this.openModals[refId].instance.data = config.data;
    }
  }

  showModal(content: Type<any>, config?: IModalConfig): number {
    const refId = this.domService.appendComponentToBody(OverlayComponent);
    this.openModals[refId] = this.domService.getComponent(refId) as ComponentRef<OverlayComponent>;
    this.openModals[refId].instance.component = content;
    if (config) {
      this.setConfig(refId, config);
    }

    this.openModals[refId].instance.whenOverlayClicked$.subscribe(() => {
      this.closeModal(refId);
    });
    return refId;
  }
}

This one, we can go a bit more in-depth with what’s happening.

private openModals: { [idRef: number]: ComponentRef<OverlayComponent> } = {};

This variable is to keep track of all the modals that are open, so we can have multiple modals open at the same time. We keep track of the “OverlayComponent” since that is the component essentially holding the modal.

showModal(content: Type<any>, config?: IModalConfig): number {
  const refId = this.domService.appendComponentToBody(OverlayComponent);
  this.openModals[refId] = this.domService.getComponent(refId) as ComponentRef<OverlayComponent>;
  this.openModals[refId].instance.component = content;
  if (config) {
    this.setConfig(refId, config);
  }

  this.openModals[refId].instance.whenOverlayClicked$.subscribe(() => {
    this.closeModal(refId);
  });
  return refId;
}

There is an example of how to use “showModal” later on, but for now, we’ll go into a bit more details here, since this is the one that will be used frequently.

In this method, we pass in the Component Type we wish to render, and the configuration for the modal.  Using the “DomService” we created earlier, we’ll create a brand new “OverlayComponent” and append that to the body of the web page and get the reference ID for that back.

We then store the newly created “OverlayComponent” into the “openModal” variable, so we’ll continue to have access to it while it’s open.

If there’s any configurations, we pass it into the “setConfig” method to do all the logic and set everything up accordingly.

Then we listen to the “whenOverlayClicked$” event coming from the “OverlayComponent” to close the modal. If you’re wondering about the configuration to “allowOverlayClick”, that is setup in the “setConfig” method.

private setConfig(refId: number, config: IModalConfig): void {
  this.openModals[refId].instance.allowOverlayClick =
    'allowOverlayClick' in config ? !!config.allowOverlayClick : this.openModals[refId].instance.allowOverlayClick;

  this.openModals[refId].instance.showCloseButton =
    'showCloseButton' in config ? !!config.showCloseButton : this.openModals[refId].instance.showCloseButton;

  if ('whenClosed' in config) {
    this.openModals[refId].instance.whenOverlayClicked$.subscribe(() => {
      if (!!config.whenClosed) {
        config.whenClosed();
      }
    });
  }

  if ('data' in config) {
    this.openModals[refId].instance.data = config.data;
  }
}

In the Config method, we pass in the reference ID for the overlay component. Then we access the “OverlayComponent” and set the configurations we want, like if we should allow click events on the overlay to close the modal, whether or not to show the close button, setup a callback function for when the modal is closed or to pass any data to the modal component.

Base Modal Component and the Configuration Interface

Next, we’ll setup the class that any modal components will extend from as well as the interface for configuring the modal.

@Component({
  selector: 'app-base-modal',
  template: '<div></div>',
  styles: []
})
export class BaseModalComponent {
  protected _data: any;

  @Output() closeModal: EventEmitter<void> = new EventEmitter();

  @Input() set data(value: any) {
    if (!!value) {
      this._data = value;
    }
  }

  get data(): any {
    return this._data;
  }

  constructor() {}

  close(): void {
    this.closeModal.emit();
  }
}

export interface IModalConfig {
  /**
   * If true, will close the modal when user clicks outside.
   */
  allowOverlayClick?: boolean;
  showCloseButton?: boolean;
  data?: any;
  whenClosed?: () => {};
}

We setup the “data” variable for the component to set any data passed in. We also got the “closeModal” event emitter that the overlay component looks for and listens to. A “close” method for any component extending this class to class at any time to close the modal and remove the overlay.

And the “IModalConfig” will be the config object passed into setup the various features of the modal.

Let’s create a Sample Modal to test with

So now that we’ve got the base modal component. We can create various other modals with all the same basic functionality and the setup will now automatically be handled in the base component.

The HTML template…

<h1>{{ modalTitle }}</h1>

The SCSS styling…

:host {
  background-color: #fff;
  width: 20rem;
  height: 20rem;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1;
  text-align: center;
}

And the typescript file…

@Component({
  selector: 'app-sample-modal',
  templateUrl: './sample-modal.component.html',
  styleUrls: ['./sample-modal.component.css']
})
export class SampleModalComponent extends BaseModalComponent implements OnInit {

  modalTitle: string;

  constructor() {
    super();
  }

  ngOnInit() {
    if (!!this._data && !!this._data.modalTitle) {
      this.modalTitle = this._data.modalTitle;
    }
  }
}

Now, in the typescript, on initialization of the component, we check for the data set and the set the “modalTitle” variable to the value that was passed in through the data object.

Also, take note of the fact we’re extending the BaseModalComponent we created earlier. This is important since the BaseModalComponent has variables that the overlay is looking for when creating the modal.

Using it in our app

All right. Now we got everything we need to finally get a modal created.

First off, we need to setup our App module.

@NgModule({
  declarations: [
    AppComponent,
    ModalContentDirective,
    OverlayComponent,
    SampleModalComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule
  ],
  providers: [],
  bootstrap: [AppComponent],
  entryComponents: [
    OverlayComponent,
    SampleModalComponent
  ]
})
export class AppModule { }

We need to add the “OverlayComponent” and the “SampleModalComponent” into the “entryComponents”, because we need to let Angular know that these components will be dynamically generated and are not in any HTML templates.

Let’s create a button in the app.component.html file that, when clicked on, will be show the modal.

<button (click)="showModal()">
  Show Modal!
</button>

And then in the app.component.ts file…

showModal(): void {
  this.modalService.showModal(SampleModalComponent, {
    allowOverlayClick: true,
    showCloseButton: false,
    data: {
      modalTitle: 'This is the sample modal'
    },
    whenClosed: () => {
      alert('Modal has been closed');
    }
  });
}

Here, we’re calling the “showModal” method from the “ModalService”, passing in the type of the Component we want to use and the configuration, which is optional. We are also passing in the data object.

Putting all these together, with this sample code, the modal should look more or less like the following, after clicking on the button.

And there you have it! A setup, where you can create any type of custom modal you want and modify it to your needs.

What we’ve learned

Wrapping up, let’s go over the Angular techniques used in this blog post.

In the “DomService” we used a component factory resolver to dynamically generate components and add them to the DOM. This can be utilized for any components you may want to dynamically generate that you may not want to add initially in the HTML template.

We utilized Angulars template reference to be a placeholder for dynamically generated components in the “OverlayComponent”.

We also used class inheritance through the concept of having a BaseModalComponent that all other modal components would extend from, getting rid of having the need to setup similar variables and functionality in each unique modal you may have. This can be a useful technique when you have multiple components that utilize the same variables and functions in each.

So not only have we learned how to create our own Modal system, but we picked up some other useful Angular techniques along the way!