Creating an Angular Component Library - Toaster Component

Published on

The next component we are going to create for our component library is a toaster. A toaster is essentially a component where can give the use feedback in a non disruptive way. A small message will appear on the screen with a notification message. Generally toast messages appear in one of the corners of screen.

Our approach for this component will be similar to how we created a progress bar. We will use a service to push new toast messages to our toaster component. This will allow us to inject our toaster service anywhere in our application and push new toast messages to the screen.

A full screen demo of what we’re going to build

 

Prerequisites

 

Generating Our Progress Bar Component

For our toaster we are going to need a module, a component, and a service.

ng generate module components/toaster --project=foo
ng generate component components/toaster --project=foo
ng generate service components/toaster/toaster --project=foo

Now we can update our module to export our component to make it accessible to other modules importing our toater module.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ToasterComponent } from './toaster.component';

@NgModule({
  declarations: [
    ToasterComponent
  ],
  imports: [
    CommonModule
  ],
  exports: [
    ToasterComponent
  ]
})
export class ToasterModule { }

We are going to create three additional itmes for our toaster. We will need a class to model our toast messages, an enum to model our toast severity type, and another enum to model our toaster positioning on the page.

touch projects/foo/src/lib/components/toaster/toast-message.model.ts \
    projects/foo/src/lib/components/toaster/toast-type.enum.ts \
    projects/foo/src/lib/components/toaster/toaster-position.enum.ts

And now we can update our components directory’s public_api.ts file to export what we want accessable.

// Toaster Component
export * from './toaster/toaster.module';
export * from './toaster/toaster.component';
export * from './toaster/toaster.service';
export * from './toaster/toaster-position.enum';
export * from './toaster/toast-type.enum';
export * from './toaster/toast-message.model';

 

Creating Our Models

Our models are pretty straight forward. We are going to continue with using enums mapped to CSS classes for configurable styles. Our toast messages will have the generic severity levels each being a different color.

export enum ToastType {
  PRIMARY = 'toast-primary',
  SECONDARY = 'toast-secondary',
  SUCCESS = 'toast-success',
  INFO = 'toast-info',
  WARNING = 'toast-warning',
  DANGER = 'toast-danger'
}

Our message model will have a message, the toast type, and a duration for the message to display. All messages will default to 2000 milliseconds.

import { ToastType } from './toast-type.enum';

export class ToastMessage {
  public id: number;
  constructor(
    public message: string,
    public type: ToastType,
    public duration: number = 2000
  ) {
    this.id = new Date().getTime();
  }
}

We’re also going to use an enum for our configurable toaster posistion which will also map to CSS classes.

export enum ToasterPosition {
  TOP_LEFT = 'toaster-top-left',
  TOP_CENTER = 'toaster-top-center',
  TOP_RIGHT = 'toaster-top-right',
  BOTTOM_LEFT = 'toaster-bottom-left',
  BOTTOM_CENTER = 'toaster-bottom-center',
  BOTTOM_RIGHT = 'toaster-bottom-right'
}

 

Creating Our Toaster Component

Our toaster component will have a very simple structure. We will have a div container representing our toaster which will contain multiple inner divs being our toast messages. We will also make use of the *ngFOr structural directive to loop through all of our toast messages and render them in our toaster container.

You will also notice in the HTML template that we’re making use of our SHOW_HIDE animation we [created with our alert component][7]. This will give us the same animation effect that our alert component uses when a new toast message appears.

<div class="toaster" [ngClass]="position">
  <div class="toast-message" 
      *ngFor="let message of messages" 
      [ngClass]="message.type" 
      [@showHide]>
    {{ message.message }}
  </div>
</div>

Our SCSS is pretty lengthy but is actaully very simple as well. Most of the SCSS is creating styles for our different toast message types and the different positions for our toaster. We also added a media query at the bottom to always center the toaster across the X axis on small devices.

@import '../_shared/scss/variables';

.toaster {
  position: fixed;
  z-index: 200;
  min-width: 300px;
  max-width: 300px;

  .toast-message {
    padding: 1rem;
    margin-bottom: 1rem;
    border-radius: $border-radius;
    word-break: break-all;

    &:last-child {
      margin-bottom: 0;
    }

    &.toast-primary {
      background: lighten($primary, 20%);
      color: darken($primary, 10%);
    }

    &.toast-secondary {
      background: lighten($secondary, 20%);
      color: darken($secondary, 10%);
    }

    &.toast-success {
      background: lighten($success, 20%);
      color: darken($success, 10%);
    }

    &.toast-info {
      background: lighten($info, 20%);
      color: darken($info, 10%);
    }

    &.toast-warning {
      background: lighten($warning, 20%);
      color: darken($warning, 10%);
    }

    &.toast-danger {
      background: lighten($danger, 20%);
      color: darken($danger, 10%);
    }
  }
  
  &.toaster-top-left {
    margin-top: 1rem;
    margin-left: 1rem;
    top: 0;
    left: 0;
  }

  &.toaster-top-center {
    margin-top: 1rem;
    top: 0;
    left: 50%;
    transform: translateX(-50%);
  }

  &.toaster-top-right {
    margin-top: 1rem;
    margin-right: 1rem;
    top: 0;
    right: 0;
  }

  &.toaster-bottom-left {
    margin-bottom: 1rem;
    margin-left: 1rem;
    bottom: 0;
    left: 0;
  }

  &.toaster-bottom-center {
    margin-bottom: 1rem;
    bottom: 0;
    left: 50%;
    transform: translateX(-50%);
  }

  &.toaster-bottom-right {
    margin-bottom: 1rem;
    margin-right: 1rem;
    bottom: 0;
    right: 0;
  }
}

@media (max-width: 478px) {
  .toaster {
    margin-left: 0 !important;
    margin-right: 0 !important;
    left: 50% !important;
    transform: translateX(-50%) !important;
  }
}

And finally our toaster componet class…

import { Component, OnInit, Input, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { ToastMessage } from './toast-message.model';
import { ToasterPosition } from './toaster-position.enum';
import { ToasterService } from './toaster.service';
import { SHOW_HIDE } from '../_shared/animations/show-hide.animation';

@Component({
  selector: 'foo-toaster',
  templateUrl: './toaster.component.html',
  styleUrls: ['./toaster.component.scss'],
  animations: [SHOW_HIDE]
})
export class ToasterComponent implements OnInit, OnDestroy {
  @Input()
  public position: ToasterPosition;

  private _toasterSubject$: Subject<void>;
  public messages: ToastMessage[];

  constructor(private _toasterService: ToasterService) {
    this.position = ToasterPosition.BOTTOM_RIGHT;
    this._toasterSubject$ = new Subject<void>();
    this.messages = [];
  }

  ngOnInit(): void {
    this._toasterService.onToastMessage()
      .pipe(takeUntil(this._toasterSubject$))
      .subscribe(message => this._handleToastMessage(message))
  }

  private _handleToastMessage(message: ToastMessage) {
    if (this._isToasterPositionTop()) {
      this.messages.unshift(message);
    } else {
      this.messages.push(message);
    }
    setTimeout(() => this._removeMessage(message), message.duration);
  }

  private _isToasterPositionTop() {
    return this.position === ToasterPosition.TOP_LEFT ||
      this.position === ToasterPosition.TOP_CENTER ||
      this.position === ToasterPosition.TOP_RIGHT;
  }

  private _removeMessage(message: ToastMessage) {
    const index: number = this.messages.findIndex(e => e.id === message.id);
    if (index > -1) {
      this.messages.splice(index, 1);
    }
  }

  ngOnDestroy(): void {
    this._toasterSubject$.next();
    this._toasterSubject$.complete();
  }
}

Our component class will have one @Input to configure the position of our toaster. It also maintains a list of active toaster messages. On component initialization we subscribe to onToastMessage() of our toaster service. When our component receives a new message it is added to the message list and sets a timeout with the message duration which calls a function to remove the message from our toast message list after the specified duration.

You will also notice that we handle the message differently based on how the toaster position is configured. We either unshift, add to the beginning of the list, or push, add to the end of the list, based on the position. We do this because we want the new message to come from the top down when the toaster is configure at the top of the page and likewise we want new message to come from the bottom up when configured for the bottom.

 

Creating Our Toaster Service

Now all that is left is creating our service to interact with out toaster component. Our toaster service is what going to allow us to interact/push new toast message to out toaster component throughout our application.

Our service is going to look something like the following…

import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { ToastMessage } from './toast-message.model';
import { ToastType } from './toast-type.enum';

@Injectable({
  providedIn: 'root'
})
export class ToasterService {
  private _defaultDuration: number;
  private _toastMessageSource: Subject<ToastMessage>

  constructor() {
    this._defaultDuration = 2000;
    this._toastMessageSource = new Subject<ToastMessage>();
  }

  public toast(message: string, type: ToastType, duration: number = this._defaultDuration): void {
    this._toastMessageSource.next(new ToastMessage(message, type, 2000));
  }

  public success(message: string, duration: number = this._defaultDuration): void {
    this.toast(message, ToastType.SUCCESS, duration);
  }

  public info(message: string, duration: number = this._defaultDuration): void {
    this.toast(message, ToastType.INFO, duration);
  }

  public warning(message: string, duration: number = this._defaultDuration): void {
    this.toast(message, ToastType.WARNING, duration);
  }

  public danger(message: string, duration: number = this._defaultDuration): void {
    this.toast(message, ToastType.DANGER, duration);
  }

  public onToastMessage(): Observable<ToastMessage> {
    return this._toastMessageSource.asObservable();
  }
}

We’re using an rxjs Subject to broadcast new toast messages. We also have a public method, onToastMessage(), so we can subscribe to toast message broadcasts elsewhere if we choose to.

Our service has several public interface methods for pushing new toast messages to our toaster component. Most are just convenience methods such as success(), info(), warning() and danger() which will take a message string along with an optional duration. All messages are defaulted to 2000 milliseconds but you can pass in another duration if you choose. There is also a toast() method which take a message, toast type, and an optional duration.

 

Using Our Toaster Component

Now that we have our toaster component, lets use it. We’ll update our demo application to produce a toast message on a button click. We will have buttons for each toast type. Our toaster will be configured for the lower left corner of our screen.

Lets update our demo’s app component HTML file…

<div class="container">
  <div>
    <button (click)="sendToast(ToastType.PRIMARY)">Primary</button>
    <button (click)="sendToast(ToastType.SECONDARY)">Secondary</button>
    <button (click)="sendToast(ToastType.SUCCESS)">Success</button>
    <button (click)="sendToast(ToastType.INFO)">Info</button>
    <button (click)="sendToast(ToastType.WARNING)">Warning</button>
    <button (click)="sendToast(ToastType.DANGER)">Danger</button>
  </div>
</div>
<foo-toaster [position]="ToasterPosition.BOTTOM_LEFT"></foo-toaster>

Our app component class…

import { Component, OnInit } from '@angular/core';
import { take } from 'rxjs/operators';
import { ToasterPosition, ToastType, ToasterService } from 'foo';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  public ToasterPosition = ToasterPosition;
  public ToastType = ToastType;

  constructor(private _toasterService: ToasterService) {}

  ngOnInit() {
  }

  public sendToast(type: ToastType) {
    this._toasterService.toast("This is a test", type);
  }
}

And import our ToasterModule in our app module class…

import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgModule } from '@angular/core';

import { ToasterModule } from 'foo';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

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

Now all we have to do is build our library and start our demo project

npm run build
npm start

If we click on any one of the buttons, we should see a toast message appear in the lower left corner and disappear after two seconds.

 

Stackblitz Result

I’ve also embeded a Stackblitz showing our toaster with our dashabord layout we create a few posts back. I have added buttons to the dashboard to trigger all the different toast messages to appear in the lower right corner of the page.

A full screen demo can also be viewed here

 

 

The completed github repository can be found here

 

comments powered by Disqus