Custom & Reusable Toast Component with Angular Animations, Async Pipe, and RxJS' BehaviorSubject

Ria Pacheco - May 15 '22 - - Dev Community

Available in my GitHub repo


Objectives

  • Create a toast component with a conditional styling mechanism
  • Define states with stored strings to feed it
  • Use cool animations from Angular's core packages
  • Trigger the toast from anywhere in the app (by any service or from any parent component that calls it)

Execution Workflow

  1. Style the Component - we'll style the component itself and in doing so, revisit concepts for conditional styling (utilizing Angular's built-in directives)
  2. Create the Toast Service - We'll create the actual service that will be called on by other services or components when needing to trigger and populate the toast
  3. Two-Way Bind the Component - We'll then make adjustments to the component so that its conditional variables / properties can be accessed by the toast service

Skip Ahead

Creating and Styling the Component
Toast Setup and Initial Data Binding
Adding SCSS Styles
Binding an Array as a Class
Adding Angular Animations to the Template
Adding Animation Syntax to the Component File
Creating the Toast Service
Accessing Service's BehaviorSubjects with the Async Pipe
Two-Way Bind the Component
Using the Async Pipe in the Template


Creating and Styling the Component

First, create new component in the terminal by running:



  $ ng g c components/ui/toast


Enter fullscreen mode Exit fullscreen mode

Global Access

To access this component from anywhere in the app, add it to the app.component.html file. The service we create will ensure that it doesn't show up until we call it.



<!--in the app.component.html file-->
<app-toast></app-toast> <!--This is the selector for the toast component-->

<router-outlet></router-outlet>


Enter fullscreen mode Exit fullscreen mode

Quick tip for Z-Index

We want the toast to appear in front of every item in the app. But sometimes, the app gets confused. To fix this, you can simply add (and keep track) of your z-indexed items in the styles.scss file. Note: to ensure this consistently works, don't forget to add the parent class you end up creating for the element too!



// style.scss

app-root {   z-index: 1; }  
app-toast, .toast-class {   z-index: 2; }



Enter fullscreen mode Exit fullscreen mode

Toast Setup and Initial Data Binding

  • We'll create a div with an *ngIf directive. This makes it so the toast doesn't appear unless the property showsToast is true. We'll add this property to the component file next.
  • The {{ toastMessage }} string is binding a message string assigned to the toastMessage property that we'll also find in the component file next!


<!--toast.component.html-->
<div *ngIf="showsToast" class="toast-class">  
  <div style="max-width: 160px;">    
    {{ toastMessage }}  
  </div> 

  <!--button-->
  <a
    class="close-btn" 
    (click)="showsToast = !showsToast">    
    <small>      
      Dismiss    
    </small>
  </a>
</div>


Enter fullscreen mode Exit fullscreen mode


// toast.component.ts
import { Component, OnInit } from '@angular/core';

@Component({  
  selector: 'app-toast',  
  templateUrl: './toast.component.html',  
  styleUrls: ['./toast.component.scss']
})

export class ToastComponent implements OnInit {  
  toastMessage = 'This is a toast'; // This is the string the template is already bound to  
  showsToast = true; // This is what toggles the component to show or hide  

  constructor() { }  

  ngOnInit(): void {  }
}



Enter fullscreen mode Exit fullscreen mode

Adding SCSS Styles

Conditional styles usually revolves around adding or removing a class (in my experience). So, an element might be styled a certain way, and when given a different condition will add new styles (that might cancel out former ones).

An awesome feature that I don't think many people know about are placeholder selectors! If you don't know about them, you can read about them here.

Feel free to use my SCSS package on npm for this example, as this is what I'm doing to keep things high-level! I'm only going to use it for this example here for colors.

Run $ npm install @riapacheco/yutes (more instruction is on the npm page)

Then, add this to the toast component's SCSS file:



// toast.component.scss
@import '~@riapacheco/yutes/combos.scss';

%default-toast { 
  // You indicate a placeholder selected with a preceding '%'
  position: absolute;
  top: 0;
  right: 0rem;  
  margin: 2rem;  
  display: inline-flex;  
  min-width: 260px;    
  min-height: 70px;  
  max-height: 70px;  
  box-shadow: 6px 6px 12px #00000040;  

  flex-flow: row nowrap;  
  align-items: center;  
  justify-content: space-between;  
  border-left: 6px solid black;  
  padding: 1.5rem;  
  border-radius: 4px;  
  font-size: 0.9rem;
}

// Default toast
.toast-class {  
  @extend %default-toast; // You then add the styles to another selector with the @extend decorator
}

// Now we can make our state-specific classes

// Success
.success-toast {  
  @extend %default-toast;  
  border-left: 6px solid $success;
}

// Warning
.warning-toast {  
  @extend %default-toast;  
  border-left: 6px solid $warning;
}

// Danger
.danger-toast {  
  @extend %default-toast;  
  border-left: 6px solid $danger;
}


Enter fullscreen mode Exit fullscreen mode

Your default toast looks like this:
Image description

And if you change the class on the element from toast-class to warning-toast, it will look like this:

Image description


Binding an Array as a Class

Now that you see how the component changes with a different class, we can use binding [] to bind a class property to the template (that can change from inside the component).



// toast.component.ts
import { Component, OnInit } from '@angular/core';

@Component({  
  selector: 'app-toast',  
  templateUrl: './toast.component.html',  
  styleUrls: ['./toast.component.scss']
})

export class ToastComponent implements OnInit {  
  toastClass = ['toast-class']; // Class lists can be added as an array  
  toastMessage = 'This is a toast';  
  showsToast = true;

  constructor() { }  

  ngOnInit(): void {  }
}


Enter fullscreen mode Exit fullscreen mode


<!--toast.component.html-->
<div
  *ngIf="showsToast"
  [class]="toastClass"> 
  <!--We bind it by surrounding 'class' with square-brackets and referencing the property from the template-->  
  <div style="max-width: 160px;">    
    {{ toastMessage }}  
  </div>  

  <a
    class="close-btn" 
    (click)="showsToast = !showsToast">    
      <small>      
        Dismiss    
      </small>  
  </a>
</div>



Enter fullscreen mode Exit fullscreen mode

Adding Angular Animations to the Template

To add animations from scratch, you add the BrowserAnimationsModule from angular's core package to your app.module.ts file like so:



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

@NgModule({  
  declarations: [    
    AppComponent,    
    ToastComponent  
  ],  
  imports: [    
    BrowserAnimationsModule,
    CommonModule  ],  

  providers: [],  
  bootstrap: [
    AppComponent
  ]
})

export class AppModule { }


Enter fullscreen mode Exit fullscreen mode

Then in the toast.component.html file, we can add the animation trigger that tells the app which element we'll define with animation specs. Since it has a conditional trigger baked in, we no longer need the *ngIf directive that we added earlier. (cool, eh?)



<!--toast.component.html-->
<div
  [@toastTrigger]="showsToast ? 'open' : 'close'" 
  [class]="toastClass">  
  <div style="max-width: 160px;">    
    {{ toastMessage }}  
  </div>  

  <a
    class="close-btn"
    (click)="showsToast = !showsToast">    
    <small>      
      Dismiss    
    </small>  
  </a>
</div>


Enter fullscreen mode Exit fullscreen mode

Animations require a trigger that you name with [@<name>]. This is then bound to the conditional property we created earlier, but now followed by a ternary operator. So showsToast ? 'open' : 'close' really means: if the property showsToast is true, then use open as the animation state... else use close. We'll now define the animations states and styles in the component itself.

Adding Animation Syntax to the Component File

First, we have to import, into the component, the different elements of the animation we'll be using. You can learn more about these triggers from angular's documentation.



import { Component, OnInit } from '@angular/core';
// import this ⤵
import { animate, state, style, transition, trigger } from '@angular/animations';

@Component({  
  selector: 'app-toast',  
  templateUrl: './toast.component.html',  
  styleUrls: ['./toast.component.scss'],  
  // And then these ⤵
  animations: [    
    trigger('toastTrigger', [ // This refers to the @trigger we created in the template      
      state('open', style({ transform: 'translateY(0%)' })), // This is how the 'open' state is styled      
      state('close', style({ transform: 'translateY(-200%)' })), // This is how the 'close' state is styled      
      transition('open <=> close', [ // This is how they're expected to transition from one to the other         
        animate('300ms ease-in-out')
      ])    
    ])  
  ]
})

export class ToastComponent implements OnInit {  
  toastClass = ['toast-class'];  
  toastMessage = 'This is a toast';  
  showsToast = true;  

  constructor() { }  

  ngOnInit(): void {  }
}


Enter fullscreen mode Exit fullscreen mode

You can test to see if the animation works by changing the showsToast property to false, and adding a setTimeout() function to change the property to true after 1000ms (1 second):



export class ToastComponent implements OnInit {  
  toastClass = ['toast-class'];  
  toastMessage = 'This is a toast';  
  showsToast = false;  

  constructor() { }  

  ngOnInit(): void {    
    setTimeout(() => {      
      this.showsToast = true;    
    }, 1000);  
  }
}


Enter fullscreen mode Exit fullscreen mode

Run $ ng serve in your terminal and wait a second for the toast to appear!

Image description

So now we need to have something trigger the service!

3 Things to Remember

The toast component needs three inputs:

  1. A class (string) that's either success-toast, warning-toast, or danger-toast.
  2. A boolean result for showsToast to either or show (true) or hide (false) which impacts the animation
  3. A message (string) that binds to the toastMessage so that the message will display

Creating the Toast Service

Add the service with the following command in your terminal



$ ng g s services/toast


Enter fullscreen mode Exit fullscreen mode

(Don't forget to add the service to the providers: [] array in your app.module.ts file!

Adding State Variables

In the toast.service file, we'll first add a constant that defines our toast states as strings. These strings will be what's passed to the component and thus match the classes (e.g. success-toast) that we made earlier. This will help keep things in check and make it easier to refer to the states from anywhere in the app (without having to go revisit what they're called or type out a specific string



import { Injectable } from '@angular/core';

// Add this constant ⤵
export const TOAST_STATE = {  
  success: 'success-toast',  
  warning: 'warning-toast',  
  danger: 'danger-toast'
};

@Injectable({  
  providedIn: 'root'
})

export class ToastService {  
  constructor() { }
}


Enter fullscreen mode Exit fullscreen mode

Accessing Service's BehaviorSubjects with the Async Pipe

A super handy feature that comes with Angular is its async pipe. Instead of having to subscribe manually to an observable from inside a component, if the service/provider's observable is public, you can get access to what's in the observable from directly inside the template.

Think of an observable as a stream of data that you can then watch and make appear in different places.

Angular often uses the RxJS library (comes with react) for handling state management. So we'll be using BehaviorSubjects since you can store default properties initially.



// toast.service.ts

export class ToastService {  
  // The boolean that drives the toast's 'open' vs. 'close' behavior  
  public showsToast$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);  

  // The message string that'll bind and display on the toast  . 
  public toastMessage$: BehaviorSubject<string> = new BehaviorSubject<string>('Default Toast Message');  

  // The state that will add a style class to the component  . 
  public toastState$: BehaviorSubject<string> = new BehaviorSubject<string>(TOAST_STATE.success);   

  constructor() { }
}


Enter fullscreen mode Exit fullscreen mode

Now we'll add two methods to trigger the component and populate the BehaviorSubjects with different data.

  1. A showToast() method will trigger the toast and pass through it the toast state and toast message string (it will be assumed that if the function is called, the toast will want to be opened)
  2. A dismissToast() to indicate that the toast is back to false ```typescript

// toast.service.ts

export class ToastService {

public showsToast$: BehaviorSubject = new BehaviorSubject(false);

public toastMessage$: BehaviorSubject = new BehaviorSubject('Default Toast Message');

public toastState$: BehaviorSubject = new BehaviorSubject(TOAST_STATE.success);

constructor() { }

showToast(toastState: string, toastMsg: string): void {

// Observables use '.next()' to indicate what they want done with observable

// This will update the toastState to the toastState passed into the function 
this.toastState$.next(toastState);

// This updates the toastMessage to the toastMsg passed into the function    
this.toastMessage$.next(toastMsg);    

// This will update the showsToast trigger to 'true'
this.showsToast$.next(true);   
Enter fullscreen mode Exit fullscreen mode

}

// This updates the showsToast behavioursubject to 'false'

dismissToast(): void {

this.showsToast$.next(false);

}
}



---

# Two-Way Bind the Component
Now we can go back to the toast component and replace the variables in the template with the observables we have in the component.
1. In the component itself, since we define and store our values in the service, we can remove the values from the component properties and replace them with their types.
2. We want to inject the toast.service as a 'public' injectable within the constructor
3. We'll want to add a `dismiss()` method that can call on the toast.service within the component and access its `dismissToast()` method
```typescript


import { ToastService } from 'src/app/services/toast.service';

export class ToastComponent implements OnInit {  
  // Change the default values to types  
  toastClass: string[];  
  toastMessage: string;  
  showsToast: boolean;  

  constructor(public toast: ToastService ) { } // We inject the toast.service here as 'public'  

  ngOnInit(): void {  }  

  // We'll add this to the dismiss button in the template  
  dismiss(): void {    
    this.toast.dismissToast();  
  }
}


Enter fullscreen mode Exit fullscreen mode

Using the Async Pipe in the Template

To use the async pipe, you just have to add the service as an object followed by the observable name you're accessing; and follow those with | async.



<!--We canged the toastTrigger and class-->
<div
  [@toastTrigger]="(toast.showsToast$ | async) ? 'open' : 'close'" 
  [class]="toast.toastState$ | async">  
    <div style="max-width: 160px;">
      <!--We access the toastMessage$ observable in the service-->    
      {{ toast.toastMessage$ | async }}  
    </div>  

  <a
    class="close-btn" 
    (click)="dismiss()">    
    <small>      
      Dismiss    
    </small>  
  </a>
</div>


Enter fullscreen mode Exit fullscreen mode

Test it!

Since behavior subjects can hold initial values, we can test the toast by changing the default showsToast$ value to true and adding whatever else you want to the other values!



export class ToastService {  
  public showsToast$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);  
  public toastMessage$: BehaviorSubject<string> = new BehaviorSubject<string>('This is a test message!');  
  public toastState$: BehaviorSubject<string> = new BehaviorSubject<string>(TOAST_STATE.danger);  

  constructor() { }  

  // .. more code
}


Enter fullscreen mode Exit fullscreen mode

Conclusion

This was a lot -- but it's also the article I wish I read before I knew all this. We walked through the idea of services being separated, animations, scss styling, and more.

Now, when you're completing a process that needs a toast triggered, you can call on the toast to appear (and maybe even add a setTimeout to dismiss it!). Here's an example in a standard firebase user auth service:



export class AuthService {  

  constructor(
    private fireAuth: AngularFireAuth,    
    private toast: ToastService  
  ) {}  

  registerUser(email: string, password: string): void {    
    this.fireAuth.createUserWithEmailAndPassword(email, password)      
      .then(res => {        
        this.toast.showToast(          
          TOAST_STATE.success,          
          'You have successfully registered!');        
        this.dismissError();        
        console.log('Registered', res);
      })
      .catch(error => {        
        this.toast.showToast(          
          TOAST_STATE.danger,          
          'Something went wrong, could not register'        
        );        
        this.dismissError();        
        console.log('Something went wrong', error.message);      
      });  
  }  

  private dismissError(): void {    
    setTimeout(() => {      
      this.toast.dismissToast();    
    }, 2000);  
  }
}


Enter fullscreen mode Exit fullscreen mode

That's it. That's all.

Ria

Ps. Check out this code in my my GitHub repo

. . . . . . . . . . . . . . . . .