Components: Multiple stackable snackbars

Created on 9 Feb 2018  路  10Comments  路  Source: angular/components

Bug, feature request, or proposal:

Feature request

What is the expected behavior?

Multiple stackable snackbars. Whenever a snackbar would be dismissed, the next snackbar should move to the next available position.

What is the current behavior?

Multiple snackbar messages are getting overridden, as stated clearly by the documentation: "Only one snack-bar can ever be opened at one time".

What are the steps to reproduce?

n/a

What is the use-case or motivation for changing an existing behavior?

With an application loading multiple things in parallel, I have the need for a component that allows to feedback multiple messages. For example, whenever you deeplink a page that both loads (base) information as well as submits information, there's a need to show an error message (i.e. 'failed to load data') as well as show a validation message. This requires 2 different snackbar-like elements, with potential different colors, status indicators, etc.
I'd fully agree that this is not ideal from a UI perspective, and whenever possible we should group messages and potentially link to another place where more details can be found.

Which versions of Angular, Material, OS, TypeScript, browsers are affected?

all

Is there anything else we should know?

Happy to get some input on how others fixed this. If there's an alternative UI pattern (available or on the roadmap), let me know.

Most helpful comment

What should be the other solution? I want to achieve UX like mac or windows notification center

Based on the material guideline:
"When multiple snackbar updates are necessary, they should appear one at a time."

Yes, but if actions, like making an HTTP request to a service, are fast enough this can cause some notifications to appear and disappear so fast that the user won't have time to read them, so... I see an opportunity of improvement here.

All 10 comments

Closing as this is not something we plan to do as it is explicitly stated in the Material Spec as something not do for usability reasons.

  1. From the material spec:
    > For usability reasons, snackbars should not contain the only way to access a core use case. They should not be persistent or be stacked, as they are above other elements on screen.

What should be the other solution? I want to achieve UX like mac or windows notification center

What should be the other solution? I want to achieve UX like mac or windows notification center

Based on the material guideline:
"When multiple snackbar updates are necessary, they should appear one at a time."

What should be the other solution? I want to achieve UX like mac or windows notification center

I think you would need to log the messages shown in the snackbar in another component or service. That component could be a stack of mat-cards showing the previous snackbar messages.
Therefor you need to make the messages persistent in your backend or in your frontend if just needed for a single session.

What should be the other solution? I want to achieve UX like mac or windows notification center

Based on the material guideline:
"When multiple snackbar updates are necessary, they should appear one at a time."

Yes, but if actions, like making an HTTP request to a service, are fast enough this can cause some notifications to appear and disappear so fast that the user won't have time to read them, so... I see an opportunity of improvement here.

I reckon it would be far more usable if they could be stacked to be honest, say i have a notification for one action, then a real time notification comes in, it dismisses the old snack bar and replaces it with the new one, leaving the user not much time to react to the previous snack bar. This is a reason to stack them, whilst i know there is guide lines to follow, this one is pretty limiting.

What should be the other solution? I want to achieve UX like mac or windows notification center

I think you would need to log the messages shown in the snackbar in another component or service. That component could be a stack of mat-cards showing the previous snackbar messages.
Therefor you need to make the messages persistent in your backend or in your frontend if just needed for a single session.

This does make sense though. Simulating this using the local storage should not be a bad idea, persisting the data in the front-end, in this case.

So the way I was able to do this (even though angular doesn't allow) is to pass an array of data to the component, and display the toasts on top of each other that way. The user needs to take action to close one out and the #of toasts is being tracked in the component. Once there is only one left to close that's when I dismiss the snackbar. I target the snackbar and get rid of the styling and apply it to the component instead. The main caveat is if I have to display the 2nd toast while the 1st one is currently being shown - the 1st toast will reload because the 2nd toast needs to be shown.

(*Note: showing snippets of code, not all)

results:
Screen Shot 2019-06-13 at 3 10 19 PM

toast.service.ts

notifyNumber(numberOfReports: number, numberOfTickets: number) {
    this.snackbar.openFromComponent(ToastNotificationComponent, {
      duration: 9999999,
      verticalPosition: 'top',
      horizontalPosition: 'right',
      data: [
        {
          notifyNumber: numberOfReports,
          routerLinkUrl: '/reports',
          routerLinkText: 'Go Download Reports',
          message: 'reports are ready to download in the "Reports" section.',
          icon: 'si-document-f',
        },
        {
          notifyNumber: numberOfTickets,
          routerLinkUrl: '/support/tickets',
          routerLinkText: 'View Tickets',
          message: 'support ticket needs your attention.',
          icon: 'si-document-f', // todo: find correct icon reference
        },
      ],
    });

toast.component.ts

export class ToastComponent implements OnInit, OnDestroy {
  count = 0;

  constructor(
    public snackbar: MatSnackBar,
    @Inject(MAT_SNACK_BAR_DATA) public data: any,
  ) {}

  ngOnInit() {
    this.getCount();
  }

  getCount() {
    for (let i = 0; i < this.data.length; i++)
      if (this.data[i].notifyNumber > 0) this.count++;
  }

  close($event: any) {
    this.count = this.count - 1;
    if (this.count >= 1) $event.path[2].hidden = true; // target .toast-notification-component
    if (this.count === 0) this.snackbar.dismiss();
  }

  ngOnDestroy() {
    this.close();
  }
}

toast.component.html

<ng-container *ngFor="let item of data; let i = index">
  <div
    *ngIf="item.notifyNumber > 0"
    class="toast-component"
  >
    <div class="icon">
      <span class="si {{ item?.icon }}"></span>
      <span
        class="notify-number"
        [ngClass]="item?.notifyNumber > 99 ? 'above-99' : ''"
      >
        {{ item?.notifyNumber > 99 ? '99+' : item?.notifyNumber }}
      </span>
    </div>
    <div class="text">
      {{ item?.notifyNumber }}
      {{ item?.message }}
      <br />
      <a [routerLink]="item?.routerLinkUrl" (click)="close($event)">
        {{ item?.routerLinkText }}
      </a>
    </div>

    <button
      class="close-button"
      (click)="close($event)"
    >
      <span class="si si-x"></span>
    </button>
  </div>
</ng-container>

Override Angular Material scss

::ng-deep {
  .mat-snack-bar-container {
    background: transparent !important;
    box-shadow: none !important;
    width: 100%;
    max-width: 500px !important;
  }

  .mat-snack-bar-handset {
    max-width: 500px !important;
  }
}

Or you can use service in combination with ngrx and call simply queueSnackBar() method. And simply dispatch one snack bar from queue after the other.

export interface SnackBarQueueItem {
  message: string;
  beingDispatched: boolean;
  configParams?: SnackbarConfigParams;
}

@Injectable({
  providedIn: 'root',
})
export class SnackbarService implements OnDestroy {

  private readonly snackBarQueue = new BehaviorSubject<SnackBarQueueItem[]>([]);
  private readonly snackBarQueue$ = this.snackBarQueue.asObservable();
  private readonly ngDestroy = new Subject();


  constructor(
    private matSnackBar: MatSnackBar,
    private store: Store<AppState>,
  ) {
    /* Dispatches all queued snack bars one by one */
    this.snackBarQueue$
     .pipe(
       filter(queue => queue.length > 0 && !queue[0].beingDispatched),
       tap(() => {
         const updatedQueue = this.snackBarQueue.value;
         updatedQueue[0].beingDispatched = true;
         this.snackBarQueue.next(updatedQueue);
       }),
       map(queue => queue[0]),
       takeUntil(this.ngDestroy))
     .subscribe(snackBarItem => this.showSnackbar(snackBarItem.message, snackBarItem.configParams));
  }

  public ngOnDestroy() {
    this.snackBarQueue.next([]);
    this.snackBarQueue.complete();
    this.ngDestroy.next();
    this.ngDestroy.complete();
  }

  public queueSnackBar(message: string, configParams?: SnackbarConfigParams) {
    this.snackBarQueue.next(
      this.snackBarQueue.value.concat([{ message, configParams, beingDispatched: false }]),
    );
  }

  private showSnackbar(message: string, configParams?: SnackbarConfigParams) {
    const duration = this.getDuration(configParams);
    this.removeDismissedSnackBar(
      this.matSnackBar.open(message, 'OK', { duration }).afterDismissed(),
    );
    this.triggerAction(configParams);
  }

  /* Remove dismissed snack bar from queue and triggers another one to appear */
  private removeDismissedSnackBar(dismissed: Observable<MatSnackBarDismiss>) {
    dismissed
      .pipe(
        delay(1000),
        take(1))
      .subscribe(() => {
        const updatedQueue = this.snackBarQueue.value;
        if (updatedQueue[0].beingDispatched) updatedQueue.shift();
        this.snackBarQueue.next(updatedQueue);
      });
  }

  private getDuration(configParams?: SnackbarConfigParams): number {
    if (configParams && configParams.duration) return configParams.duration;
    else return 10000;
  }

  /* In case you would like to dispatch actions in nxrx when snack bar is shown, etc */
  private triggerAction(configParams?: SnackbarConfigParams) {
    if (configParams && configParams.triggerAction) {
      if (configParams.triggerAction === 'FREE_LIMIT_WARNING_SHOWN') {
        this.store.dispatch(new FreeLimitWarningShown());
      }
    }
  }
}

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

_This action has been performed automatically by a bot._

Was this page helpful?
0 / 5 - 0 ratings