Sekrab Garage

Errors and toasts

Auto hiding a toast message in Angular

AngularDesignCSS May 31, 22
Subscribe to Sekrab Parts newsletter.
Series:

Console wrapper and error handling

This is not about the different flavors of console logging, this is an attempt to create an organized way to deal with it, in multiple environments in a browser JavaScript app, be it in Angular, Vue, React, or BinCurse. And later make use of it to catch errors in Angular, and create a toast component to display them.
  1. 3 years ago
    Writing a wrapper for console.log for better control in JavaScript, Part I
  2. 3 years ago
    Writing a wrapper for console.log for better control in Angular, Part II
  3. 3 years ago
    Catching and handling errors in Angular
  4. 3 years ago
    Catching and displaying UI errors with toast messages in Angular
  5. 3 years ago
    Auto hiding a toast message in Angular

Previously we built a service to handle our UI errors by producing a toast message, today we are enhancing the behavior of the toast, to timeout and auto hide.

anchorTimeout setting

The timeout is variable, but you do not want to think about it, so we create some packaged options, to define the most known timeouts. Let's begin with a property for timeout, and let's see how to deal with it.

export interface IToast {
  text?: string;
  css?: string; 
  extracss?: string;
  buttons?: IToastButton[];
  timeout?: number; // new for timeout to hide
}


@Injectable({ providedIn: 'root' })
export class Toast {
  // ...
  
  // keep track of timeout
  private isCancled: Subscription;

  // change default to have default 5 seconds delay
  private defaultOptions: IToast = {
    // ...
    timeout: 5000,
  };

  Show(code: string, options?: IToast) {
    // we need to hide before we show in case consecutive show events
    // this will reset the timer
    this.Hide();
    
    // ...
   
    // timeout and hide
    this.isCanceled = timer(_options.timeout).subscribe(() => {
      this.Hide();
    });
    
   
  }
  Hide() {
    // reset the timer
    // in case of showing two consecutive messages or user clicks dismiss 
    if (this.isCanceled) {
      this.isCanceled.unsubscribe();
    }
    this.toast.next(null);
  }

The idea is basic; create a timer to time out, and cancel (or reset) the timer before showing, or when user clicks dismiss. The usage is simple, but can be enhanced (timeout is optional):

this.toast.ShowSuccess('INVALID_VALUE', {timeout: 1000});

Instead of passing explicit timeout, we want to have options of times, mainly three: short, long, and never. We can redefine the timeout to be an enum:

// toast model
export enum EnumTimeout {
  Short = 4000, // 4 seconds
  Long = 20000, // 20 seconds
  Never = -1, // forever
}

export interface IToast {
  // ... redefine
  timeout?: EnumTimeout; // new for timeout to hide
}

// state service
@Injectable({ providedIn: 'root' })
export class Toast {
  // ...
  // we can set to the default to "short" or any number
  private defaultOptions: IToast = {
   // ...
   timeout: EnumTimeout.Short, // or you can use Config value
  };

  Show(code: string, options?: IToast) {
    // ...
    // if timeout, timeout and hide
    if (_options.timeout > EnumTimeout.Never) {
      this.isCanceled = timer(_options.timeout).subscribe(() => {
        this.Hide();
      });
    }
  }
  //...
}

To use it we can pass it as a number or as an enum:

this.toast.Show('SomeCode', {timeout: EnumTimeout.Never});

Now to some rambling about UX issues.

anchorWhy hide, and for how long

The material guideline for snackbars allows a single message to appear, on top of a previous one (in the z direction). When user dismisses the current message, the older one below it is still in place. That has a drastic pitfall when it comes to user experience. Snackbars and toasts are meant to be immediate and contextual attention grabbers. It is noisy to show a stale one. This is why I chose the above implementation which allows for one message at a time, that is overridden by newer messages.

We should carefully think about what message to show to user, when, and for how long. Otherwise, the value of the toast, is toast! The general rule is, if there are other visual cues, the message should be short. This also means that successful operations rarely have to be toasted.

Below are possible recipes you might agree with:

Invalid form fields upon submission

When user clicks to submit a form with some invalid fields, a quick notice that disappears shortly is good enough, since the form fields already have visual indication. This is helpful when the screen size does not fit all form fields, and the invalid field is above the viewport.

Successful actions with no visual reaction

Think of Facebook sharing action, the post created does not visually update the timeline. A short and sweet toast message, with an action to view the post, is ideal.

System generated messages with visual cues

When a push notification of incoming email or interaction, where another element on the page is also updated, in this case the bell icon, a short and actionable toast might be the right answer, a no toast might also be another way, think of desktop Twitter notifications.

System generated messages with no visual cues

When a PWA site has a new version, and wants to invite the user to "update," or a new user is prompted to "subscribe" to a newsletter, a long dismissible message with an action sounds right. The deciding factor is how urgent the message is, it could be a sticky message.

These contexts are rarely show-stoppers, and sometimes a refresh of the page removes any lingering issues, a toast message is there to interrupt attention, not to get a grasp of it. Now consider the following.

Stale page requires action

When a page is open for too long and the authorized user timed out, when user clicks on any action that needs authorization, redirect to the login page, and show a short toast of reason.

Stale page with optional action

If however, the authorization is optional, and the user may sign up or sign in, then the toast message should have the action buttons, and should not disappear unless user dismisses it, or another toast overrides it.

Server times out a process

When the server simply refuses to complete a process after a long time due an unknown reason, the error toast better be there to tell user the process did not go through. The user may have left the screen for a while (probably they think the site is too shy to do its thing while they're watching 😏).

API 404 errors

General API 404 errors need to linger as well, because there is no other visual cue to indicate them, but if the page redirects, no need to show any messages.

anchorAnimation

The final bit to add is animation. The main ingredients of animating is to make the toast appear first, come into view, stick around, hide from view, then disappear. There are multiple ways to get this done, here are few:

1. Animating the element without removal

First and most direct way is to drop the conditional existence of the toast, and just make it dive under the bottom of the viewport. This is to avoid having to deal with hiding an element from the DOM after it has been removed by Angular.

The CSS animation looks like this:

.toast {
  /* ...  remember the bottom: 10px */
  /*by default is should be out of view*/
  /* calculate 100% of layer height plus the margin from bottom */
  transform: translateY(calc(100% + @space));
  transition: transform 0.2s ease-in-out;
}
.toast.inview {
  /*transition back to 0*/
  transform: translateY(0);
}

In our state, and toast model, we add a new property for visibility. We initiate our state with the default false, and update that property instead of nullifying state:

// toast model
export interface IToast {
  // ...
  visible?: boolean;
}

// state
@Injectable({ providedIn: 'root' })
export class Toast {

  // ... 
  private defaultOptions: IToast = {
    // ...
    // add default visible false
    visible: false
  };

  // set upon initialization
  constructor() {
    this.toast.next(this.defaultOptions);
  }
  Show(code: string, options?: IToast) {
    // ...
    // update visible to true
    this.toast.next({ ..._options, text: message, visible: true });
    
    // ... timeout and hide
  }
  Hide() {
    // ...
    // reset with all current values
    this.toast.next({ ...this.toast.getValue(), visible: false });
 }
}

And finally in the component template, we add the inview conditional class:

 <ng-container *ngIf="toastState.toast$ | async as toast">
  <div 
    [class.inview]="toast.visible"
    class="{{toast.css}} {{toast.extracss}}">
    ...
  </div>
</ng-container>

2. Programmatically hide

We can also animate, then watch the end of animation (animationeend) before we remove the element. This is a bit twisted, but if you insist on removing the toast element after you're done with it, this is cheaper than the animation package.

In toast state, using the same property visible added above:

// toast state
@Injectable({ providedIn: 'root' })
export class Toast {
  // ...
  Show(code: string, options?: IToast): void {
    // completely remove when new message comes in
    this.Remove();
    
    // ... 
    this.toast.next({ ..._options, text: message, visible: true });
    
    // ... timeout and Hide
  }
  
  // make two distinct functions
  Hide() {

    // this is hide by adding state only and letting component do the rest (animationend)
    this.toast.next({ ...this.toast.getValue(), visible: false });
  }
  
  Remove() {
    if(this.isCanceled) {
      this.isCanceled.unsubscribe();
    }
    // this removes the element
    this.toast.next(null);
  }
}

In our css, we add the animation sequences:

.toast {
  /*...*/
  
  /*add animation immediately*/
  animation: toast-in .2s ease-in-out;
}
/*add outview animation*/
.toast.outview {
  animation: toast-out 0.1s ease-in-out;
  animation-fill-mode: forwards;
}


@keyframes toast-in {
    0% {
        transform: translateY(calc(100% + 10px);
    }
    100% {
        transform: translateY(0);
    }
}

@keyframes toast-out {
    0% {
        transform: translateY(0);
    }

    100% {
        transform: translateY(calc(100% + 10px));
    }
}

Finally, in our component, we do the twist, watch animationend to remove toast.

@Component({
    selector: 'gr-toast',
    template: `
    <ng-container *ngIf="toastState.toast$ | async as toast">
    <!-- here add outview when toast is invisible, then watch animationend -->
      <div [class.outview]="!toast.visible" (animationend)="doRemove($event)"
      class="{{ toast.css}} {{toast.extracss}}">
        <div class="text">{{toast.text }}</div>
        <div class="buttons" *ngIf="toast.buttons.length">
            <button *ngFor="let button of toast.buttons"
            [class]="button.css"
            (click)="button.click($event)" >{{button.text}}</button>
        </div>

      </div>
    </ng-container>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush,
    styleUrls: ['./toast.less'],
})
export class ToastPartialComponent {
    constructor(public toastState: Toast) {
    }
    // on animation end, remove element
    doRemove(e: AnimationEvent) {
        if (e.animationName === 'toast-out') {
            this.toastState.Remove();
        }
    }
}

Looks ugly? It does, so if we really want to remove the element, our other option is a huge boilerplate, known as Angular Animation Package.

3. Angular animation package

The animation package of Angular deals with this issue magically.

I tried to track down the code, but I could not quite figure out the mechanism, by which the ngIf is ignored until the animation ends. Can you find the rabbit? Let me know in the comments.

First undo what we did above, and add the animation package to the root. The css should no longer have any animation, and the state should simply show and hide (no visible property needed). Then in component, we add the following:

@Component({
  selector: 'gr-toast',
  template: `
  <ng-container *ngIf="toastState.stateItem$ | async as toast">
    <div @toastHideTrigger class="{{ toast.css}} {{toast.extracss}}" >
      The only change is @toastHideTrigger 
      ...
  </ng-container>
  `,
  // add animations
  animations: [
    trigger('toastHideTrigger', [
      transition(':enter', [
        // add transform to place it beneath viewport
        style({ transform: 'translateY(calc(100% + 10px))' }),
        animate('0.2s ease-in', style({transform: 'translateY(0)' })),
      ]),
      transition(':leave', [
        animate('0.2s ease-out', style({transform: 'translateY(calc(100% + 10px))'  }))
      ])
    ]),
  ]
})
// ...

You might have a preference, like using the animation package in angular, I see no added value. My preferred method is the simple one, keep it on page, never remove.

A slight enhancement

You probably noticed that we hide before we show, the change is so fast, the animation of showing a new message does not kick in. To fix that, we can delay the show by milliseconds to make sure the animation kicks in. In our Show method:

// Show method, wait milliseconds before you apply
// play a bit with the timer to get the result you desire
timer(100).subscribe(() => {
  // add visible: true if you are using the first or second method
  this.toast.next({ ..._options, text: message  });
});

This effect is most perfect when we use the second (twisted) method. Because it is the only one where two consecutive messages, forces the first to be removed without animation, which is the ideal behavior.

Have a look at the result on StackBlitz.

anchorRxJS based state management

If you were following along, I introduced RxJS based state management in Angular a while ago. This toast can make use of it as follows:

// to replace state with our State Service
// first, extend the StateService of IToast
export class Toast extends StateService<IToast> {
  
  // then remove the internal observable
  // private toast: BehaviorSubject<IToast | null> = new BehaviorSubject(null);
  // toast$: Observable<IToast | null> = this.toast.asObservable();
  
  constructor() {
    // call super
    super();
    // set initial state
    this.SetState(this.defaultOptions);
  }

  // ...
  Show(code: string, options?: IToast) {
    // ... 
    // use state instead of this
    // this.toast.next({ ..._options, text: message });
    this.SetState({ ..._options, text: message });
  }
  Hide() {
    // ...
    // use state instead
    // this.toast.next(null);
    this.RemoveState();
    
    // or update state
    this.UpdateState({ visible: false });
  }
}

The template now should watch toastState.stateItem$, instead of toastState.toast$.

That's all folks. Did you find the rabbit? Let me know.

  1. 3 years ago
    Writing a wrapper for console.log for better control in JavaScript, Part I
  2. 3 years ago
    Writing a wrapper for console.log for better control in Angular, Part II
  3. 3 years ago
    Catching and handling errors in Angular
  4. 3 years ago
    Catching and displaying UI errors with toast messages in Angular
  5. 3 years ago
    Auto hiding a toast message in Angular