mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[CL-18] toast component and service (#6490)
Update toast styles and new service to CL.
This commit is contained in:
@@ -29,6 +29,7 @@ export * from "./section";
|
||||
export * from "./select";
|
||||
export * from "./table";
|
||||
export * from "./tabs";
|
||||
export * from "./toast";
|
||||
export * from "./toggle-group";
|
||||
export * from "./typography";
|
||||
export * from "./utils/i18n-mock.service";
|
||||
|
||||
2
libs/components/src/toast/index.ts
Normal file
2
libs/components/src/toast/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./toast.module";
|
||||
export * from "./toast.service";
|
||||
24
libs/components/src/toast/toast.component.html
Normal file
24
libs/components/src/toast/toast.component.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<div
|
||||
class="tw-mb-1 tw-min-w-[--bit-toast-width] tw-text-contrast tw-flex tw-flex-col tw-justify-between tw-rounded-md tw-pointer-events-auto tw-cursor-default {{
|
||||
bgColor
|
||||
}}"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-px-2 tw-pb-1 tw-pt-2">
|
||||
<i aria-hidden="true" class="bwi tw-text-xl tw-py-1.5 tw-px-2.5 {{ iconClass }}"></i>
|
||||
<div>
|
||||
<span class="tw-sr-only">{{ variant | i18n }}</span>
|
||||
<p *ngIf="title" data-testid="toast-title" class="tw-font-semibold tw-mb-0">{{ title }}</p>
|
||||
<p *ngFor="let m of messageArray" data-testid="toast-message" class="tw-mb-2 last:tw-mb-0">
|
||||
{{ m }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="tw-ml-auto"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="contrast"
|
||||
type="button"
|
||||
(click)="this.onClose.emit()"
|
||||
></button>
|
||||
</div>
|
||||
<div class="tw-h-1 tw-w-full tw-bg-text-contrast/70" [style.width]="progressWidth + '%'"></div>
|
||||
</div>
|
||||
66
libs/components/src/toast/toast.component.ts
Normal file
66
libs/components/src/toast/toast.component.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
export type ToastVariant = "success" | "error" | "info" | "warning";
|
||||
|
||||
const variants: Record<ToastVariant, { icon: string; bgColor: string }> = {
|
||||
success: {
|
||||
icon: "bwi-check",
|
||||
bgColor: "tw-bg-success-600",
|
||||
},
|
||||
error: {
|
||||
icon: "bwi-error",
|
||||
bgColor: "tw-bg-danger-600",
|
||||
},
|
||||
info: {
|
||||
icon: "bwi-info-circle",
|
||||
bgColor: "tw-bg-info-600",
|
||||
},
|
||||
warning: {
|
||||
icon: "bwi-exclamation-triangle",
|
||||
bgColor: "tw-bg-warning-600",
|
||||
},
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "bit-toast",
|
||||
templateUrl: "toast.component.html",
|
||||
standalone: true,
|
||||
imports: [SharedModule, IconButtonModule],
|
||||
})
|
||||
export class ToastComponent {
|
||||
@Input() variant: ToastVariant = "info";
|
||||
|
||||
/**
|
||||
* The message to display
|
||||
*
|
||||
* Pass an array to render multiple paragraphs.
|
||||
**/
|
||||
@Input({ required: true })
|
||||
message: string | string[];
|
||||
|
||||
/** An optional title to display over the message. */
|
||||
@Input() title: string;
|
||||
|
||||
/**
|
||||
* The percent width of the progress bar, from 0-100
|
||||
**/
|
||||
@Input() progressWidth = 0;
|
||||
|
||||
/** Emits when the user presses the close button */
|
||||
@Output() onClose = new EventEmitter<void>();
|
||||
|
||||
protected get iconClass(): string {
|
||||
return variants[this.variant].icon;
|
||||
}
|
||||
|
||||
protected get bgColor(): string {
|
||||
return variants[this.variant].bgColor;
|
||||
}
|
||||
|
||||
protected get messageArray(): string[] {
|
||||
return Array.isArray(this.message) ? this.message : [this.message];
|
||||
}
|
||||
}
|
||||
39
libs/components/src/toast/toast.module.ts
Normal file
39
libs/components/src/toast/toast.module.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ModuleWithProviders, NgModule } from "@angular/core";
|
||||
import { DefaultNoComponentGlobalConfig, GlobalConfig, TOAST_CONFIG } from "ngx-toastr";
|
||||
|
||||
import { ToastComponent } from "./toast.component";
|
||||
import { BitwardenToastrComponent } from "./toastr.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, ToastComponent],
|
||||
declarations: [BitwardenToastrComponent],
|
||||
exports: [BitwardenToastrComponent],
|
||||
})
|
||||
export class ToastModule {
|
||||
static forRoot(config: Partial<GlobalConfig> = {}): ModuleWithProviders<ToastModule> {
|
||||
return {
|
||||
ngModule: ToastModule,
|
||||
providers: [
|
||||
{
|
||||
provide: TOAST_CONFIG,
|
||||
useValue: {
|
||||
default: BitwardenToastrGlobalConfig,
|
||||
config: config,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const BitwardenToastrGlobalConfig: GlobalConfig = {
|
||||
...DefaultNoComponentGlobalConfig,
|
||||
toastComponent: BitwardenToastrComponent,
|
||||
tapToDismiss: false,
|
||||
timeOut: 5000,
|
||||
extendedTimeOut: 2000,
|
||||
maxOpened: 5,
|
||||
autoDismiss: true,
|
||||
progressBar: true,
|
||||
};
|
||||
57
libs/components/src/toast/toast.service.ts
Normal file
57
libs/components/src/toast/toast.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { IndividualConfig, ToastrService } from "ngx-toastr";
|
||||
|
||||
import type { ToastComponent } from "./toast.component";
|
||||
import { calculateToastTimeout } from "./utils";
|
||||
|
||||
export type ToastOptions = {
|
||||
/**
|
||||
* The duration the toast will persist in milliseconds
|
||||
**/
|
||||
timeout?: number;
|
||||
} & Pick<ToastComponent, "message" | "variant" | "title">;
|
||||
|
||||
/**
|
||||
* Presents toast notifications
|
||||
**/
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class ToastService {
|
||||
constructor(private toastrService: ToastrService) {}
|
||||
|
||||
showToast(options: ToastOptions) {
|
||||
const toastrConfig: Partial<IndividualConfig> = {
|
||||
payload: {
|
||||
message: options.message,
|
||||
variant: options.variant,
|
||||
title: options.title,
|
||||
},
|
||||
timeOut:
|
||||
options.timeout != null && options.timeout > 0
|
||||
? options.timeout
|
||||
: calculateToastTimeout(options.message),
|
||||
};
|
||||
|
||||
this.toastrService.show(null, options.title, toastrConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `showToast` instead
|
||||
*
|
||||
* Converts options object from PlatformUtilsService
|
||||
**/
|
||||
_showToast(options: {
|
||||
type: "error" | "success" | "warning" | "info";
|
||||
title: string;
|
||||
text: string | string[];
|
||||
options?: {
|
||||
timeout?: number;
|
||||
};
|
||||
}) {
|
||||
this.showToast({
|
||||
message: options.text,
|
||||
variant: options.type,
|
||||
title: options.title,
|
||||
timeout: options.options?.timeout,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
libs/components/src/toast/toast.spec.ts
Normal file
16
libs/components/src/toast/toast.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { calculateToastTimeout } from "./utils";
|
||||
|
||||
describe("Toast default timer", () => {
|
||||
it("should have a minimum of 5000ms", () => {
|
||||
expect(calculateToastTimeout("")).toBe(5000);
|
||||
expect(calculateToastTimeout([""])).toBe(5000);
|
||||
expect(calculateToastTimeout(" ")).toBe(5000);
|
||||
});
|
||||
|
||||
it("should return an extra second for each 120 words", () => {
|
||||
expect(calculateToastTimeout("foo ".repeat(119))).toBe(5000);
|
||||
expect(calculateToastTimeout("foo ".repeat(120))).toBe(6000);
|
||||
expect(calculateToastTimeout("foo ".repeat(240))).toBe(7000);
|
||||
expect(calculateToastTimeout(["foo ".repeat(120), " \n foo ".repeat(120)])).toBe(7000);
|
||||
});
|
||||
});
|
||||
124
libs/components/src/toast/toast.stories.ts
Normal file
124
libs/components/src/toast/toast.stories.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { ToastComponent } from "./toast.component";
|
||||
import { BitwardenToastrGlobalConfig, ToastModule } from "./toast.module";
|
||||
import { ToastOptions, ToastService } from "./toast.service";
|
||||
|
||||
const toastServiceExampleTemplate = `
|
||||
<button bitButton type="button" (click)="toastService.showToast(toastOptions)">Show Toast</button>
|
||||
`;
|
||||
@Component({
|
||||
selector: "toast-service-example",
|
||||
template: toastServiceExampleTemplate,
|
||||
})
|
||||
export class ToastServiceExampleComponent {
|
||||
@Input()
|
||||
toastOptions: ToastOptions;
|
||||
|
||||
constructor(protected toastService: ToastService) {}
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Component Library/Toast",
|
||||
component: ToastComponent,
|
||||
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [CommonModule, BrowserAnimationsModule, ButtonModule],
|
||||
declarations: [ToastServiceExampleComponent],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
ToastModule.forRoot().providers,
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
close: "Close",
|
||||
success: "Success",
|
||||
error: "Error",
|
||||
warning: "Warning",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
onClose: action("emit onClose"),
|
||||
variant: "info",
|
||||
progressWidth: 50,
|
||||
title: "",
|
||||
message: "Hello Bitwarden!",
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<ToastComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-flex tw-flex-col tw-min-w tw-max-w-[--bit-toast-width]">
|
||||
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="success"></bit-toast>
|
||||
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="info"></bit-toast>
|
||||
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="warning"></bit-toast>
|
||||
<bit-toast [title]="title" [message]="message" [progressWidth]="progressWidth" (onClose)="onClose()" variant="error"></bit-toast>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Avoid using long messages in toasts.
|
||||
*/
|
||||
export const LongContent: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
title: "Foo",
|
||||
message: [
|
||||
"Lorem ipsum dolor sit amet, consectetur adipisci",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipisci",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Service: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
toastOptions: args,
|
||||
},
|
||||
template: `
|
||||
<toast-service-example [toastOptions]="toastOptions"></toast-service-example>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
title: "",
|
||||
message: "Hello Bitwarden!",
|
||||
variant: "error",
|
||||
timeout: BitwardenToastrGlobalConfig.timeOut,
|
||||
} as ToastOptions,
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
docs: {
|
||||
source: {
|
||||
code: toastServiceExampleTemplate,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
4
libs/components/src/toast/toast.tokens.css
Normal file
4
libs/components/src/toast/toast.tokens.css
Normal file
@@ -0,0 +1,4 @@
|
||||
:root {
|
||||
--bit-toast-width: 19rem;
|
||||
--bit-toast-width-full: 96%;
|
||||
}
|
||||
26
libs/components/src/toast/toastr.component.ts
Normal file
26
libs/components/src/toast/toastr.component.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
||||
import { Component } from "@angular/core";
|
||||
import { Toast as BaseToastrComponent } from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<bit-toast
|
||||
[title]="options?.payload?.title"
|
||||
[variant]="options?.payload?.variant"
|
||||
[message]="options?.payload?.message"
|
||||
[progressWidth]="width"
|
||||
(onClose)="remove()"
|
||||
></bit-toast>
|
||||
`,
|
||||
animations: [
|
||||
trigger("flyInOut", [
|
||||
state("inactive", style({ opacity: 0 })),
|
||||
state("active", style({ opacity: 1 })),
|
||||
state("removed", style({ opacity: 0 })),
|
||||
transition("inactive => active", animate("{{ easeTime }}ms {{ easing }}")),
|
||||
transition("active => removed", animate("{{ easeTime }}ms {{ easing }}")),
|
||||
]),
|
||||
],
|
||||
preserveWhitespaces: false,
|
||||
})
|
||||
export class BitwardenToastrComponent extends BaseToastrComponent {}
|
||||
23
libs/components/src/toast/toastr.css
Normal file
23
libs/components/src/toast/toastr.css
Normal file
@@ -0,0 +1,23 @@
|
||||
@import "~ngx-toastr/toastr";
|
||||
@import "./toast.tokens.css";
|
||||
|
||||
/* Override all default styles from `ngx-toaster` */
|
||||
.toast-container .ngx-toastr {
|
||||
all: unset;
|
||||
display: block;
|
||||
width: var(--bit-toast-width);
|
||||
|
||||
/* Needed to make hover states work in Electron, since the toast appears in the draggable region. */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Disable hover styles */
|
||||
.toast-container .ngx-toastr:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toast-container.toast-bottom-full-width .ngx-toastr {
|
||||
width: var(--bit-toast-width-full);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
14
libs/components/src/toast/utils.ts
Normal file
14
libs/components/src/toast/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Given a toast message, calculate the ideal timeout length following:
|
||||
* a minimum of 5 seconds + 1 extra second per 120 additional words
|
||||
*
|
||||
* @param message the toast message to be displayed
|
||||
* @returns the timeout length in milliseconds
|
||||
*/
|
||||
export const calculateToastTimeout = (message: string | string[]): number => {
|
||||
const paragraphs = Array.isArray(message) ? message : [message];
|
||||
const numWords = paragraphs
|
||||
.map((paragraph) => paragraph.split(/\s+/).filter((word) => word !== ""))
|
||||
.flat().length;
|
||||
return 5000 + Math.floor(numWords / 120) * 1000;
|
||||
};
|
||||
@@ -171,6 +171,9 @@
|
||||
@import "./popover/popover.component.css";
|
||||
@import "./search/search.component.css";
|
||||
|
||||
@import "./toast/toast.tokens.css";
|
||||
@import "./toast/toastr.css";
|
||||
|
||||
/**
|
||||
* tw-break-words does not work with table cells:
|
||||
* https://github.com/tailwindlabs/tailwindcss/issues/835
|
||||
|
||||
Reference in New Issue
Block a user