Ayyash
Posted on May 31, 2022
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.
Timeout 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.
Why 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.
Animation
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- Programmaticall yhide
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.
RxJS 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.
RESOURCES
Posted on May 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.