Angular2 modal component

This article is about creating a simple and re-usable modal component with angular2. Featuring @HostListener, ng-content, and some basic vanilla javascript code!

Usually a modal:

  1. is a window layout on top of everything
  2. has variable content (for example a component)
  3. can be closed and opened at will

I always start writing angular2 components with the DOM I would like to see when I use it. It really helps picturing how to separate your concerns, for example:

<my-modal name="myModal">
  <my-component [something]="input"></my-component>
</my-modal>

<button myOpenModal="myModal">Open myModal!</button>

Note that the my prefix is only there because it’s considered as a best practice. You should use your own prefix

Now let’s try to think a bit about this before going on with code. On the above snippet, we have:

For the third point, let’s do a service that stores our modals! For this, I’ll simply use a Map inside a service:

@Injectable()
export class MyModalService {
  map: Map<string, MyModalComponent> = new Map

  get(v: string): MyModalComponent {
    return this.map.get(v)
  }

  set(key: string, v: MyModalComponent): void {
    this.map.set(key, v)
  }
}

Nothing complicated so far, let’s write the MyModalComponent. We will use ng-content to output our modal content. For accessibility purpose, the modal can be closed:

  1. on click on the overlay
  2. with ESC key
  3. with a close button
@Component({
  selector: 'my-modal',
  template: `
    <div class="reveal-overlay" (click)="clickOverlay($event)" [hidden]="!show">
      <div class="reveal">
        <ng-content></ng-content>
        <button class="close-button" (click)="toggle()">
          <span>&times;</span>
        </button>
      </div>
    </div>
  `,
  styles: [
    '.reveal-overlay { background: rgba(0,0,0,0.6); position: fixed; top: 0; left: 0; right: 0; bottom: 0; }',
    '.reveal { background: white; width: 90%; margin: 40px auto; min-height: 70vh; position: relative; padding: 20px; }'
    '.close-button { position: absolute; right: 10px; top: 10px; }'
  ]
})
export class MyModalComponent implements OnInit {
  @Input() name: string
  show: boolean = false

  constructor(private myModals: MyModalService) { }

  ngOnInit() {
    this.myModals.set(this.name, this)
  }

  clickOverlay(event: Event) {
    const target = (event.target as HTMLElement)

    // only close if we clicked on the `reveal-overlay` not on it's content
    if (target.classList.contains('reveal-overlay')) {
      this.toggle()
    }
  }

  toggle() {
    this.show = !this.show

    if (this.show) {
      document.addEventListener('keyup', this.escapeListener)
    } else {
      document.removeEventListener('keyup', this.escapeListener)
    }
  }

  private escapeListener = (event: KeyboardEvent) => {
    if (event.which === 27 || event.keyCode === 27) {
      this.show = false
    }
  }
}

Now let’s write the directive that allows us to toggle the modal:

@Directive({
  selector: '[myModalOpen]'
})
export class MyModalOpenDirective {
  @Input() myModalOpen: string

  constructor(private myModals: MyModalService) { }

  @HostListener('click') onClick() {
    const modal = this.myModals.get(this.myModalOpen)

    if (!modal) {
      console.error('No modal named %s', this.myModalOpen)
      return
    }

    modal.toggle()
  }
}

Let’s try this out! Here is a plunkr with the following HTML in the main App:

<my-modal name="hello world">
	Hi
</my-modal>
<button myModalOpen="hello world">Open !</button>

This is great, now let’s go further by including a Component inside the modal! For demonstration purpose this is the component:

@Component({
  selector: 'my-custom-component',
  template: '{{foo}}'
})
export class MyCustomComponent {
  @Input() foo: string
}

And the HTML markup:

<my-modal name="hello world">
  <my-custom-component [foo]="foo"></my-custom-component>
</my-modal>
<button myModalOpen="hello world">Open !</button>

Thanks to ng-content this is still working great! Here’s a plunkr:

Now, a good addition to the modal would be to be able to listen to modalOpen/modalClose events. Indeed, from inside the modal, inside our my-custom-component we have to idea of the modal status. Sure, we could inject the myModalService and get the correct one to find a status. Though, the my-custom-component is used inside the modal, may be used elsewhere and therefore should not be dependent of myModalX.

For this to work I want to be able to do:

@Component({
  selector: 'my-custom-component',
  template: '{{foo}}'
})
export class MyCustomComponent {
  @Input() foo: string

  @HostListener('modalOpen') onModalOpen() {
    this.foo = this.foo.split('').reverse().join('')
  }
}

This is easier then it looks, with angular you’ve always access to DOM elements on which you can dispatch events. Yes, a component is nothing more then a custom DOM element!

First, I need a reference to the content (what’s inside ng-content). To do so, let’s review the MyModalComponent template and add a reference:

<div #modalContent>
  <ng-content></ng-content>
</div>

This allows us to use ViewChild('modalContent') to use the DOM! Then, when the modal gets toggled, we will use the elements inside ng-content and dispatch a modalOpen or modalClose event. Note that this has to be done in the AfterContentChecked lifecycle to avoid unwanted behavior!

Here is substract of what changed in the MyModalComponent:

export class MyModalComponent implements OnInit, AfterContentChecked {
  @Input() name: string
  @ViewChild('modalContent') modalContent

  show: boolean = false
  // store elements to notify
  private notify: HTMLElements[] = []

  // ...

  toggle() {
    this.show = !this.show

    if (this.show) {
      document.addEventListener('keyup', this.escapeListener)
    } else {
      document.removeEventListener('keyup', this.escapeListener)
    }

    // Those are the elements inside the `ng-content`
    this.notify = [].slice.call(this.modalContent.nativeElement.children)
  }

  // Dispatch events on the `DoCheck` lifecycle
  ngAfterContentChecked() {
    if (this.notify.length === 0) {
      return
    }

    const event = this.createEvent(this.show ? 'modalOpen' : 'modalClose')
    let toNotify

    while (toNotify = this.notify.shift()) {
      toNotify.dispatchEvent(event)
    }
  }

  private createEvent(name) {
    const event = document.createEvent('Events')
    event.initEvent(name, true, true)
    return event
  }
}

And here is a preview: