From 3d960ef62431862714708297119d2ec75c83b637 Mon Sep 17 00:00:00 2001 From: Elain T Date: Tue, 5 Mar 2024 09:36:05 -0500 Subject: [PATCH] feat(design): add toast component (#2499) Co-authored-by: Damien Retzinger Co-authored-by: Peter Lauck --- .../design-land/src/app/app-routing.module.ts | 1 + apps/design-land/src/app/app.component.ts | 2 + apps/design-land/src/app/app.module.ts | 2 + .../src/app/toast/toast-routing-module.ts | 21 ++ .../src/app/toast/toast.component.html | 169 ++++++++++++++++ .../src/app/toast/toast.component.scss | 0 .../src/app/toast/toast.component.spec.ts | 29 +++ .../src/app/toast/toast.component.ts | 8 + .../design-land/src/app/toast/toast.module.ts | 28 +++ apps/design-land/src/assets/nav.json | 7 + .../design/modal/src/service/modal.service.ts | 3 +- libs/design/scss/theme.scss | 2 + libs/design/toast/README.md | 115 +++++++++++ libs/design/toast/examples/ng-package.json | 7 + .../default-toast.component.html | 1 + .../default-toast/default-toast.component.ts | 52 +++++ .../src/default-toast/default-toast.module.ts | 24 +++ libs/design/toast/examples/src/index.ts | 1 + libs/design/toast/examples/src/public_api.ts | 17 ++ .../toast-positions.component.html | 14 ++ .../toast-positions.component.scss | 7 + .../toast-positions.component.ts | 56 ++++++ .../toast-positions/toast-positions.module.ts | 39 ++++ .../toast-status/toast-status.component.html | 7 + .../toast-status/toast-status.component.scss | 6 + .../toast-status/toast-status.component.ts | 57 ++++++ .../src/toast-status/toast-status.module.ts | 26 +++ .../toast-with-custom-duration.component.html | 1 + .../toast-with-custom-duration.component.ts | 35 ++++ .../toast-with-custom-duration.module.ts | 22 ++ libs/design/toast/ng-package.json | 7 + libs/design/toast/src/index.ts | 1 + .../toast/src/interfaces/toast-action.ts | 51 +++++ libs/design/toast/src/interfaces/toast.ts | 28 +++ .../toast/src/options/daff-toast-options.ts | 33 +++ libs/design/toast/src/public_api.ts | 20 ++ .../design/toast/src/service/changes-focus.ts | 3 + .../toast/src/service/position-strategy.ts | 37 ++++ .../src/service/position.service.spec.ts | 33 +++ .../toast/src/service/position.service.ts | 38 ++++ .../toast/src/service/toast.service.spec.ts | 111 +++++++++++ .../design/toast/src/service/toast.service.ts | 176 ++++++++++++++++ .../toast-actions.directive.spec.ts | 52 +++++ .../toast-actions/toast-actions.directive.ts | 13 ++ .../toast-message.directive.spec.ts | 52 +++++ .../toast-message/toast-message.directive.ts | 13 ++ libs/design/toast/src/toast-theme.scss | 63 ++++++ .../toast-title/toast-title.directive.spec.ts | 52 +++++ .../src/toast-title/toast-title.directive.ts | 13 ++ libs/design/toast/src/toast.module.ts | 45 +++++ libs/design/toast/src/toast/toast-config.ts | 8 + .../src/toast/toast-template.component.ts | 188 ++++++++++++++++++ .../toast/src/toast/toast.component.html | 11 + .../toast/src/toast/toast.component.scss | 82 ++++++++ .../toast/src/toast/toast.component.spec.ts | 92 +++++++++ .../design/toast/src/toast/toast.component.ts | 112 +++++++++++ 56 files changed, 2091 insertions(+), 2 deletions(-) create mode 100644 apps/design-land/src/app/toast/toast-routing-module.ts create mode 100644 apps/design-land/src/app/toast/toast.component.html create mode 100644 apps/design-land/src/app/toast/toast.component.scss create mode 100644 apps/design-land/src/app/toast/toast.component.spec.ts create mode 100644 apps/design-land/src/app/toast/toast.component.ts create mode 100644 apps/design-land/src/app/toast/toast.module.ts create mode 100644 libs/design/toast/README.md create mode 100644 libs/design/toast/examples/ng-package.json create mode 100644 libs/design/toast/examples/src/default-toast/default-toast.component.html create mode 100644 libs/design/toast/examples/src/default-toast/default-toast.component.ts create mode 100644 libs/design/toast/examples/src/default-toast/default-toast.module.ts create mode 100644 libs/design/toast/examples/src/index.ts create mode 100644 libs/design/toast/examples/src/public_api.ts create mode 100644 libs/design/toast/examples/src/toast-positions/toast-positions.component.html create mode 100644 libs/design/toast/examples/src/toast-positions/toast-positions.component.scss create mode 100644 libs/design/toast/examples/src/toast-positions/toast-positions.component.ts create mode 100644 libs/design/toast/examples/src/toast-positions/toast-positions.module.ts create mode 100644 libs/design/toast/examples/src/toast-status/toast-status.component.html create mode 100644 libs/design/toast/examples/src/toast-status/toast-status.component.scss create mode 100644 libs/design/toast/examples/src/toast-status/toast-status.component.ts create mode 100644 libs/design/toast/examples/src/toast-status/toast-status.module.ts create mode 100644 libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.html create mode 100644 libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.ts create mode 100644 libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.module.ts create mode 100644 libs/design/toast/ng-package.json create mode 100644 libs/design/toast/src/index.ts create mode 100644 libs/design/toast/src/interfaces/toast-action.ts create mode 100644 libs/design/toast/src/interfaces/toast.ts create mode 100644 libs/design/toast/src/options/daff-toast-options.ts create mode 100644 libs/design/toast/src/public_api.ts create mode 100644 libs/design/toast/src/service/changes-focus.ts create mode 100644 libs/design/toast/src/service/position-strategy.ts create mode 100644 libs/design/toast/src/service/position.service.spec.ts create mode 100644 libs/design/toast/src/service/position.service.ts create mode 100644 libs/design/toast/src/service/toast.service.spec.ts create mode 100644 libs/design/toast/src/service/toast.service.ts create mode 100644 libs/design/toast/src/toast-actions/toast-actions.directive.spec.ts create mode 100644 libs/design/toast/src/toast-actions/toast-actions.directive.ts create mode 100644 libs/design/toast/src/toast-message/toast-message.directive.spec.ts create mode 100644 libs/design/toast/src/toast-message/toast-message.directive.ts create mode 100644 libs/design/toast/src/toast-theme.scss create mode 100644 libs/design/toast/src/toast-title/toast-title.directive.spec.ts create mode 100644 libs/design/toast/src/toast-title/toast-title.directive.ts create mode 100644 libs/design/toast/src/toast.module.ts create mode 100644 libs/design/toast/src/toast/toast-config.ts create mode 100644 libs/design/toast/src/toast/toast-template.component.ts create mode 100644 libs/design/toast/src/toast/toast.component.html create mode 100644 libs/design/toast/src/toast/toast.component.scss create mode 100644 libs/design/toast/src/toast/toast.component.spec.ts create mode 100644 libs/design/toast/src/toast/toast.component.ts diff --git a/apps/design-land/src/app/app-routing.module.ts b/apps/design-land/src/app/app-routing.module.ts index c73b260175..1f72572336 100644 --- a/apps/design-land/src/app/app-routing.module.ts +++ b/apps/design-land/src/app/app-routing.module.ts @@ -40,6 +40,7 @@ export const appRoutes: Routes = [ { path: 'quantity-field', loadChildren: () => import('./quantity-field/quantity-field.module').then(m => m.DesignLandQuantityFieldModule) }, { path: 'sidebar', loadChildren: () => import('./sidebar/sidebar.module').then(m => m.DesignLandSidebarModule) }, { path: 'radio', loadChildren: () => import('./radio/radio.module').then(m => m.DesignLandRadioModule) }, + { path: 'toast', loadChildren: () => import('./toast/toast.module').then(m => m.DesignLandToastModule) }, { path: 'typography', loadChildren: () => import('./typography/typography.module').then(m => m.DesignLandTypographyModule) }, { path: 'variables', loadChildren: () => import('./foundations/variables/variables.module').then(m => m.DesignLandVariablesModule) }, ], diff --git a/apps/design-land/src/app/app.component.ts b/apps/design-land/src/app/app.component.ts index 6463107f40..bbd4e9bc2f 100644 --- a/apps/design-land/src/app/app.component.ts +++ b/apps/design-land/src/app/app.component.ts @@ -25,6 +25,7 @@ import { PAGINATOR_EXAMPLES } from '@daffodil/design/paginator/examples'; import { QUANTITY_FIELD_EXAMPLES } from '@daffodil/design/quantity-field/examples'; import { RADIO_EXAMPLES } from '@daffodil/design/radio/examples'; import { SIDEBAR_EXAMPLES } from '@daffodil/design/sidebar/examples'; +import { TOAST_EXAMPLES } from '@daffodil/design/toast/examples'; import { createCustomElementFromExample } from './core/elements/create-element-from-example'; @@ -60,6 +61,7 @@ export class DesignLandAppComponent { ...IMAGE_EXAMPLES, ...INPUT_EXAMPLES, ...SIDEBAR_EXAMPLES, + ...TOAST_EXAMPLES, ].map((componentExample) => createCustomElementFromExample(componentExample, injector)) .map((customElement) => { // Register the custom element with the browser. diff --git a/apps/design-land/src/app/app.module.ts b/apps/design-land/src/app/app.module.ts index 2db829d002..01c56a79cf 100644 --- a/apps/design-land/src/app/app.module.ts +++ b/apps/design-land/src/app/app.module.ts @@ -10,6 +10,7 @@ import { DaffButtonModule } from '@daffodil/design/button'; import { DaffLinkSetModule } from '@daffodil/design/link-set'; import { DaffNavbarModule } from '@daffodil/design/navbar'; import { DaffSidebarModule } from '@daffodil/design/sidebar'; +import { DaffToastModule } from '@daffodil/design/toast'; import { DaffThemeSwitchButtonModule } from '@daffodil/theme-switch'; import { DesignLandAppRoutingModule } from './app-routing.module'; @@ -32,6 +33,7 @@ import { DesignLandTemplateModule } from './core/template/template.module'; FontAwesomeModule, DesignLandNavModule, DesignLandTemplateModule, + DaffToastModule, ], declarations: [ DesignLandAppComponent, diff --git a/apps/design-land/src/app/toast/toast-routing-module.ts b/apps/design-land/src/app/toast/toast-routing-module.ts new file mode 100644 index 0000000000..67ea6c737c --- /dev/null +++ b/apps/design-land/src/app/toast/toast-routing-module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { + Routes, + RouterModule, +} from '@angular/router'; + +import { DesignLandToastComponent } from './toast.component'; + +export const toastRoutes: Routes = [ + { path: '', component: DesignLandToastComponent }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(toastRoutes), + ], + exports: [ + RouterModule, + ], +}) +export class DesignLandToastRoutingModule {} diff --git a/apps/design-land/src/app/toast/toast.component.html b/apps/design-land/src/app/toast/toast.component.html new file mode 100644 index 0000000000..5fb9db8836 --- /dev/null +++ b/apps/design-land/src/app/toast/toast.component.html @@ -0,0 +1,169 @@ +

Toast

+

Toasts are small messages designed to mimic push notifications. They are used to provide users with application level information.

+ +

Overview

+

Toasts should be used to display temporary messages about actions or events that occured or in need of attention, with no relation to content on a page. For messaging that provide context in close promixity to a piece of content within a page, use the Notification component.

+ +

Basic toast

+ + + + +

Configurations

+Toasts can be configured by using the DaffToastService. + +The following is an example of a toast with a duration: + +
constructor(private toastService: DaffToastService) {}
+	
+	open() {
+		this.toast = this.toastService.open({
+			title: 'Update Complete',
+			message: 'This page has been updated to the newest version.',
+		},
+		{
+			duration: 5000,
+		});
+	}
+ +The following is an example of a toast with actions: + +
constructor(private toastService: DaffToastService) {}
+	
+open() {
+	this.toast = this.toastService.open({
+		title: 'Update Available',
+		message: 'A new version of this page is available.',
+		actions: [
+			{ content: 'Update', color: 'theme-contrast', size: 'sm', eventEmitter: this.update },
+			{ content: 'Remind me later', type: 'flat', size: 'sm', eventEmitter: this.closeToast },
+		],
+	});
+}
+ +The following configurations are available in the DaffToastService: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeDescriptionDefault
titlestringA quick overview of the toast--
messagestringAdditional details about the message that should be limited to one or two sentences--
actionsDaffToastActionAdds a daff-button that allow users to perform an action related to the message. Actions should be limited to two buttons.--
dismissiblebooleanAllows a toast to be dismissible via a close buttontrue
durationnumberThe duration in milliseconds that a toast is visible before it's dismissed5000
+ +The actions configurations are based on the properties of the DaffButtonComponent (view Button Documentation), with the addition of data and eventEmitter. + +

Dismissal

+

A toast can be dismissed via a timed duration, a close button, or the ESC key. + +

Timed duration
+

A toast with actions will persist until one of the actions have been interacted with, or is dismissed by the close button or the ESC key. Setting a duration should be avoided for toasts that have actions as users may need to interact with the actions.

+ +

Toast with custom duration

+ + + + +

By default, a toast without actions will be dismissed after 5000ms. This can be updated by setting duration through the DaffToastService.

+ +
Close button
+

The close button is shown by default but can be hidden by setting dismissible: false through the DaffToastService.

+ +
Escape key
+

A toast can be dismissed by using the ESC key if it has actions and is focus trapped.

+ +

Stacking

+

A maximum of three toasts can be shown at a time. Toasts are stacked vertically, with the most recent toast displayed on top.

+ +

Statuses

+

The status color of a toast can be updated by using the status property.

+ +

Supported statuses: warn | danger | success

+ +

Toast with statuses

+ + + + +

Positions

+ + + + + + + + + + + + + + + + + + + + + +
PropertyValueDefault
verticaltop | bottomtop
horizontalleft | center | rightright
+ +

To change the horizontal and vertical position of a toast, add the provideDaffToastOptions dependency key to the providers key in the module as shown below:

+ +
providers: [
+	provideDaffToastOptions({
+		position: {
+			vertical: 'bottom',
+			horizontal: 'center',
+		},
+		useParent: false,
+	}),
+]
+ +

The position of a toast on a mobile device will always be on the bottom center.

+ +

Toast with configurable positions

+ + + + +

Accessibility

+

By default, toasts use a role="status" to announce messages. It's the equivalent of aria-live="polite", which does not interrupt a user's current activity and waits until they are idle to make the announcement. When a toast has actions, a role="alertdialog" is used. The toast will also be focus trapped, and the focus immediately moves to the actions.

+ +

Avoid setting a duration on toasts with actions because they will disappear automatically, making it difficult for users to interact with the actions.

diff --git a/apps/design-land/src/app/toast/toast.component.scss b/apps/design-land/src/app/toast/toast.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/design-land/src/app/toast/toast.component.spec.ts b/apps/design-land/src/app/toast/toast.component.spec.ts new file mode 100644 index 0000000000..228536207a --- /dev/null +++ b/apps/design-land/src/app/toast/toast.component.spec.ts @@ -0,0 +1,29 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; + +import { DesignLandToastComponent } from './toast.component'; + +describe('DesignLandToastComponent', () => { + let component: DesignLandToastComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ DesignLandToastComponent ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DesignLandToastComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/design-land/src/app/toast/toast.component.ts b/apps/design-land/src/app/toast/toast.component.ts new file mode 100644 index 0000000000..60021ad3be --- /dev/null +++ b/apps/design-land/src/app/toast/toast.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'design-land-toast', + templateUrl: './toast.component.html', + styleUrls: ['./toast.component.scss'], +}) +export class DesignLandToastComponent {} diff --git a/apps/design-land/src/app/toast/toast.module.ts b/apps/design-land/src/app/toast/toast.module.ts new file mode 100644 index 0000000000..35b22d28c7 --- /dev/null +++ b/apps/design-land/src/app/toast/toast.module.ts @@ -0,0 +1,28 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { DaffArticleModule } from '@daffodil/design/article'; +import { DaffToastModule } from '@daffodil/design/toast'; + +import { DesignLandToastRoutingModule } from './toast-routing-module'; +import { DesignLandToastComponent } from './toast.component'; +import { DesignLandArticleEncapsulatedModule } from '../core/article-encapsulated/article-encapsulated.module'; +import { DesignLandExampleViewerModule } from '../core/code-preview/container/example-viewer.module'; + +@NgModule({ + declarations: [ + DesignLandToastComponent, + ], + imports: [ + CommonModule, + RouterModule, + DesignLandToastRoutingModule, + DesignLandExampleViewerModule, + DesignLandArticleEncapsulatedModule, + + DaffArticleModule, + DaffToastModule, + ], +}) +export class DesignLandToastModule {} diff --git a/apps/design-land/src/assets/nav.json b/apps/design-land/src/assets/nav.json index a506f9ba56..a9f1e67769 100644 --- a/apps/design-land/src/assets/nav.json +++ b/apps/design-land/src/assets/nav.json @@ -242,6 +242,13 @@ "id": "sidebar", "items": [], "data": {} + }, + { + "title": "Toast", + "url": "toast", + "id": "toast", + "items": [], + "data": {} } ], "data": {} diff --git a/libs/design/modal/src/service/modal.service.ts b/libs/design/modal/src/service/modal.service.ts index d9ac73ab05..0a3baa18e0 100644 --- a/libs/design/modal/src/service/modal.service.ts +++ b/libs/design/modal/src/service/modal.service.ts @@ -45,8 +45,7 @@ export class DaffModalService { return this.overlay.create({ hasBackdrop: true, positionStrategy: new GlobalPositionStrategy() - .centerHorizontally() - .centerVertically(), + .centerHorizontally(), scrollStrategy: this.overlay.scrollStrategies.block(), }); } diff --git a/libs/design/scss/theme.scss b/libs/design/scss/theme.scss index fb39ddf6f5..b2a6c08f7d 100644 --- a/libs/design/scss/theme.scss +++ b/libs/design/scss/theme.scss @@ -40,6 +40,7 @@ @use '../sidebar/src/sidebar-theme' as sidebar; @use '../scss/state/skeleton/mixins' as skeleton; @use '../tree/src/tree-theme' as tree; +@use '../toast/src/toast-theme' as toast; // // Generates the styles of a @daffodil/design theme. @@ -81,4 +82,5 @@ @include paginator.daff-paginator-theme($theme); @include sidebar.daff-sidebar-theme($theme); @include tree.daff-tree-theme($theme); + @include toast.daff-toast-theme($theme); } diff --git a/libs/design/toast/README.md b/libs/design/toast/README.md new file mode 100644 index 0000000000..fb2c310c1a --- /dev/null +++ b/libs/design/toast/README.md @@ -0,0 +1,115 @@ +# Toast +Toasts are small messages designed to mimic push notifications. They are used to provide users with application level information. + +## Overview +Toasts should be used to display temporary messages about actions or events that occured or in need of attention, with no relation to content on a page. For messaging that provide context in close promixity to a piece of content within a page, use the [Notification](../notification/README.md) component. + +### Basic Toast + + + + +### Configurations +Toast can be configured by using the `DaffToastService`. + +The following is an example of a toast with a duration: + +```ts +constructor(private toastService: DaffToastService) {} + +open() { + this.toast = this.toastService.open({ + title: 'Update Complete', + message: 'This page has been updated to the newest version.', + }, + { + duration: 5000, + }); +} +``` + +The following is an example of a toast with actions: + +```ts +open() { + this.toast = this.toastService.open({ + title: 'Update Available', + message: 'A new version of this page is available.', + actions: [ + { content: 'Update', color: 'theme-contrast', size: 'sm', eventEmitter: this.update }, + { content: 'Remind me later', type: 'flat', size: 'sm', eventEmitter: this.closeToast }, + ] + }); +} +``` + +The following configurations are available in the `DaffToastService`: + +| Property | Type | Description | Default | +| -------- | ------ | ------------------------------- | ------- | +| title | string | A quick overview of the toast | -- | +| message | string | Additional details about the message that should be limited to one or two sentences | -- | +| actions | `DaffToastAction` | Adds a `daff-button` that allow users to perform an action related to the message. Actions should be limited to two buttons. | -- | +| dismissible | boolean | Allows a toast to be dismissible via a close button | true | +| duration | number | The duration in milliseconds that a toast is visible before it's dismissed | 5000 | + +The `actions` configurations are based on the properties of the `DaffButtonComponent` (view [Button Documentation](../src/atoms/button/README.md)) with the addition of `data` and `eventEmitter`. + +### Dismissal +A toast can be dismissed via a timed duration, a close button, or the `ESC` key. + +##### Timed duration +A toast with actions will persist until one of the actions have been interacted with, or is dismissed by the close button or the `ESC` key. Actionable toasts should be persistent, but a duration is allowed to be set. If duration must be set, make sure it's long enough for users to engage with the actions. + +By default, a toast without actions will be dismissed after `5000ms`. This can be updated by setting `duration` through the `DaffToastService`. + +#### Toast with custom duration + + +##### Close button +The close button is shown by default but can be hidden by setting `dismissible: false` through the `DaffToastService`. + +##### Escape Key +A toast can be dismissed by using the `ESC` key if it has actions and is focus trapped. + +### Stacking +A maximum of three toasts can be shown at a time. Toasts are stacked vertically, with the most recent toast displayed on top. + +### Statuses +The status color of a toast can be updated by using the `status` property. + +Supported statuses: `warn | danger | success` + +#### Toast with statuses + + +### Positions + +| Property | Value | Default | +| ------------ | ------------------------ | ------- | +| `vertical` | `top | bottom` | top | +| `horizontal` | `left | center | right ` | right | + +To change the horizontal and vertical position of a toast, add the `provideDaffToastOptions` dependency key to the `providers` key in the module as shown below: + +```ts +providers: [ + provideDaffToastOptions({ + position: { + vertical: 'bottom', + horizontal: 'center', + } + useParent: false, + }) +] +``` + +The position of a toast on a mobile device will always be on the bottom center. + +#### Toast with configurable positions + + +### Accessibility +By default, toasts use a `role="status"` to announce messages. It's the equivalent of `aria-live="polite"`, which does not interrupt a user's current activity and waits until they are idle to make the announcement. When a toast has actions, a `role="alertdialog"` is used. The toast will be focus trapped and focus immediately moves to the actions. + +Avoid setting a duration on toasts with actions because they will disappear automatically, making it difficult for users to interact with the actions. \ No newline at end of file diff --git a/libs/design/toast/examples/ng-package.json b/libs/design/toast/examples/ng-package.json new file mode 100644 index 0000000000..1fad56732b --- /dev/null +++ b/libs/design/toast/examples/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../../src/scss"] + } +} \ No newline at end of file diff --git a/libs/design/toast/examples/src/default-toast/default-toast.component.html b/libs/design/toast/examples/src/default-toast/default-toast.component.html new file mode 100644 index 0000000000..d48b22c39e --- /dev/null +++ b/libs/design/toast/examples/src/default-toast/default-toast.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/design/toast/examples/src/default-toast/default-toast.component.ts b/libs/design/toast/examples/src/default-toast/default-toast.component.ts new file mode 100644 index 0000000000..8242d53638 --- /dev/null +++ b/libs/design/toast/examples/src/default-toast/default-toast.component.ts @@ -0,0 +1,52 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + OnInit, +} from '@angular/core'; + +import { + DaffToast, + DaffToastAction, + DaffToastService, +} from '@daffodil/design/toast'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'default-toast', + templateUrl: './default-toast.component.html', + styles: [], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DefaultToastComponent implements OnInit { + private toast: DaffToast; + + constructor(private toastService: DaffToastService) {} + + update = new EventEmitter(); + + closeToast = new EventEmitter(); + + open() { + this.toast = this.toastService.open({ + title: 'Update Available' + ' ' + this.count++, + message: 'A new version of this page is available.', + actions: [ + { content: 'Update', color: 'theme-contrast', size: 'sm', eventEmitter: this.update }, + { content: 'Remind me later', type: 'flat', size: 'sm', eventEmitter: this.closeToast }, + ], + }); + } + + ngOnInit() { + this.update.subscribe(() => { + console.log('test'); + }); + + this.closeToast.subscribe(() => { + this.toastService.close(this.toast); + }); + } + + private count = 0; +} diff --git a/libs/design/toast/examples/src/default-toast/default-toast.module.ts b/libs/design/toast/examples/src/default-toast/default-toast.module.ts new file mode 100644 index 0000000000..8e8de2c3dc --- /dev/null +++ b/libs/design/toast/examples/src/default-toast/default-toast.module.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +import { DaffButtonModule } from '@daffodil/design/button'; +import { DaffToastModule } from '@daffodil/design/toast'; + +import { DefaultToastComponent } from './default-toast.component'; + +@NgModule({ + declarations: [ + DefaultToastComponent, + ], + imports: [ + CommonModule, + DaffToastModule, + FontAwesomeModule, + DaffButtonModule, + ], + exports: [ + DefaultToastComponent, + ], +}) +export class DefaultToastModule { } diff --git a/libs/design/toast/examples/src/index.ts b/libs/design/toast/examples/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/design/toast/examples/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/design/toast/examples/src/public_api.ts b/libs/design/toast/examples/src/public_api.ts new file mode 100644 index 0000000000..7d427a7852 --- /dev/null +++ b/libs/design/toast/examples/src/public_api.ts @@ -0,0 +1,17 @@ +import { ComponentExample } from '@daffodil/design'; + +import { DefaultToastComponent } from './default-toast/default-toast.component'; +import { DefaultToastModule } from './default-toast/default-toast.module'; +import { ToastPositionsComponent } from './toast-positions/toast-positions.component'; +import { ToastPositionsModule } from './toast-positions/toast-positions.module'; +import { ToastStatusComponent } from './toast-status/toast-status.component'; +import { ToastStatusModule } from './toast-status/toast-status.module'; +import { ToastWithCustomDurationComponent } from './toast-with-custom-duration/toast-with-custom-duration.component'; +import { ToastWithCustomDurationModule } from './toast-with-custom-duration/toast-with-custom-duration.module'; + +export const TOAST_EXAMPLES: ComponentExample[] = [ + { component: DefaultToastComponent, module: DefaultToastModule }, + { component: ToastStatusComponent, module: ToastStatusModule }, + { component: ToastPositionsComponent, module: ToastPositionsModule }, + { component: ToastWithCustomDurationComponent, module: ToastWithCustomDurationModule }, +]; diff --git a/libs/design/toast/examples/src/toast-positions/toast-positions.component.html b/libs/design/toast/examples/src/toast-positions/toast-positions.component.html new file mode 100644 index 0000000000..4505cae642 --- /dev/null +++ b/libs/design/toast/examples/src/toast-positions/toast-positions.component.html @@ -0,0 +1,14 @@ +
+ + + +
+ + diff --git a/libs/design/toast/examples/src/toast-positions/toast-positions.component.scss b/libs/design/toast/examples/src/toast-positions/toast-positions.component.scss new file mode 100644 index 0000000000..8740c10ebe --- /dev/null +++ b/libs/design/toast/examples/src/toast-positions/toast-positions.component.scss @@ -0,0 +1,7 @@ +.toast-positions { + &__options { + display: flex; + gap: 8px; + margin: 0 0 16px; + } +} \ No newline at end of file diff --git a/libs/design/toast/examples/src/toast-positions/toast-positions.component.ts b/libs/design/toast/examples/src/toast-positions/toast-positions.component.ts new file mode 100644 index 0000000000..3c615b098d --- /dev/null +++ b/libs/design/toast/examples/src/toast-positions/toast-positions.component.ts @@ -0,0 +1,56 @@ +import { + ChangeDetectionStrategy, + Component, + OnInit, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { combineLatest } from 'rxjs'; + +import { + DaffToast, + DaffToastService, + DaffToastPositionService, +} from '@daffodil/design/toast'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'toast-positions', + templateUrl: './toast-positions.component.html', + styleUrls: ['./toast-positions.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ToastPositionsComponent implements OnInit { + private toast: DaffToast; + + constructor( + private toastService: DaffToastService, + private toastPositionService: DaffToastPositionService, + ) {} + + open() { + this.toast = this.toastService.open({ + title: 'Update complete' + ' ' + this.count++, + message: 'This page is now up-to-date.', + dismissible: true, + }); + } + + private count = 0; + + horizontalControl: FormControl = new FormControl('right'); + verticalControl: FormControl = new FormControl('top'); + + ngOnInit() { + combineLatest([ + this.horizontalControl.valueChanges, + this.verticalControl.valueChanges, + ]).subscribe(([ + horizontal, vertical, + ]) => { + this.toastPositionService.setPosition({ + horizontal, + vertical, + }); + }); + } +} diff --git a/libs/design/toast/examples/src/toast-positions/toast-positions.module.ts b/libs/design/toast/examples/src/toast-positions/toast-positions.module.ts new file mode 100644 index 0000000000..7e3b403713 --- /dev/null +++ b/libs/design/toast/examples/src/toast-positions/toast-positions.module.ts @@ -0,0 +1,39 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +import { DaffButtonModule } from '@daffodil/design/button'; +import { + DaffToastModule, + provideDaffToastOptions, +} from '@daffodil/design/toast'; + +import { ToastPositionsComponent } from './toast-positions.component'; + +@NgModule({ + declarations: [ + ToastPositionsComponent, + ], + imports: [ + CommonModule, + ReactiveFormsModule, + + DaffToastModule, + FontAwesomeModule, + DaffButtonModule, + ], + exports: [ + ToastPositionsComponent, + ], + providers: [ + provideDaffToastOptions({ + position: { + vertical: 'top', + horizontal: 'right', + }, + useParent: false, + }), + ], +}) +export class ToastPositionsModule { } diff --git a/libs/design/toast/examples/src/toast-status/toast-status.component.html b/libs/design/toast/examples/src/toast-status/toast-status.component.html new file mode 100644 index 0000000000..db5b3db778 --- /dev/null +++ b/libs/design/toast/examples/src/toast-status/toast-status.component.html @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/libs/design/toast/examples/src/toast-status/toast-status.component.scss b/libs/design/toast/examples/src/toast-status/toast-status.component.scss new file mode 100644 index 0000000000..728192d684 --- /dev/null +++ b/libs/design/toast/examples/src/toast-status/toast-status.component.scss @@ -0,0 +1,6 @@ +:host { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} \ No newline at end of file diff --git a/libs/design/toast/examples/src/toast-status/toast-status.component.ts b/libs/design/toast/examples/src/toast-status/toast-status.component.ts new file mode 100644 index 0000000000..f8bb44d799 --- /dev/null +++ b/libs/design/toast/examples/src/toast-status/toast-status.component.ts @@ -0,0 +1,57 @@ +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { + faCheck, + faExclamation, + faInfoCircle, +} from '@fortawesome/free-solid-svg-icons'; + +import { + DaffToastData, + DaffToastService, +} from '@daffodil/design/toast'; + +const status: Record = { + error: { + title: 'Server error', + }, + success: { + title: 'Update complete', + message: 'The app is now up-to-date with the newest version.', + }, + warn: { + title: 'The app is outdated', + message: 'Update the app now. The version you are using may have security vulnerabilities.', + }, +}; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'toast-status', + templateUrl: './toast-status.component.html', + styleUrls: ['./toast-status.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ToastStatusComponent { + faInfoCircle = faInfoCircle; + faCheck = faCheck; + faExclamation = faExclamation; + + statusControl: FormControl = new FormControl('success'); + + constructor(private toastService: DaffToastService) {} + + open() { + this.toastService.open({ + status: this.statusControl.value, + ...status[this.statusControl.value], + }, + { + duration: this.statusControl.value === 'error' ? undefined : 5000, + }, + ); + } +} diff --git a/libs/design/toast/examples/src/toast-status/toast-status.module.ts b/libs/design/toast/examples/src/toast-status/toast-status.module.ts new file mode 100644 index 0000000000..8a58c6709a --- /dev/null +++ b/libs/design/toast/examples/src/toast-status/toast-status.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +import { DaffButtonModule } from '@daffodil/design/button'; +import { DaffToastModule } from '@daffodil/design/toast'; + +import { ToastStatusComponent } from './toast-status.component'; + +@NgModule({ + declarations: [ + ToastStatusComponent, + ], + imports: [ + CommonModule, + DaffToastModule, + DaffButtonModule, + FontAwesomeModule, + ReactiveFormsModule, + ], + exports: [ + ToastStatusComponent, + ], +}) +export class ToastStatusModule { } diff --git a/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.html b/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.html new file mode 100644 index 0000000000..d48b22c39e --- /dev/null +++ b/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.ts b/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.ts new file mode 100644 index 0000000000..d3478738c5 --- /dev/null +++ b/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + OnInit, +} from '@angular/core'; + +import { + DaffToast, + DaffToastService, +} from '@daffodil/design/toast'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'toast-with-custom-duration', + templateUrl: './toast-with-custom-duration.component.html', + styles: [], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ToastWithCustomDurationComponent { + private toast: DaffToast; + + constructor(private toastService: DaffToastService) {} + + open() { + this.toast = this.toastService.open({ + title: 'Update Complete', + message: 'This page has been updated to the newest version.', + status: 'success', + }, + { + duration: 7000, + }); + } +} diff --git a/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.module.ts b/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.module.ts new file mode 100644 index 0000000000..5b343c62cd --- /dev/null +++ b/libs/design/toast/examples/src/toast-with-custom-duration/toast-with-custom-duration.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { DaffButtonModule } from '@daffodil/design/button'; +import { DaffToastModule } from '@daffodil/design/toast'; + +import { ToastWithCustomDurationComponent } from './toast-with-custom-duration.component'; + +@NgModule({ + declarations: [ + ToastWithCustomDurationComponent, + ], + imports: [ + CommonModule, + DaffToastModule, + DaffButtonModule, + ], + exports: [ + ToastWithCustomDurationComponent, + ], +}) +export class ToastWithCustomDurationModule { } diff --git a/libs/design/toast/ng-package.json b/libs/design/toast/ng-package.json new file mode 100644 index 0000000000..14833aac7d --- /dev/null +++ b/libs/design/toast/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../src/scss"] + } +} \ No newline at end of file diff --git a/libs/design/toast/src/index.ts b/libs/design/toast/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/design/toast/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/design/toast/src/interfaces/toast-action.ts b/libs/design/toast/src/interfaces/toast-action.ts new file mode 100644 index 0000000000..4546dd9444 --- /dev/null +++ b/libs/design/toast/src/interfaces/toast-action.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from '@angular/core'; + +import { + DaffPalette, + DaffStatus, +} from '@daffodil/design'; + +/** + * An interface for properties of actions, specifically the DaffButtonComponent, placed inside of the toast. + */ +export interface DaffToastAction { + /** + * The types of buttons available to be used, as defined in the {@link DaffButtonComponent}. + */ + type?: 'raised' | 'underline' | 'stroked' | 'flat' | undefined; + + /** + * The text for the button + */ + content: string; + + /** + * The size of the button, as defined in the {@link DaffButtonComponent}. + */ + size?: 'sm' | 'md' | 'lg' | undefined; + + /** + * The color of the button, as defined in the {@link DaffButtonComponent}. + * Color and status should not be used simultaneously. + */ + color?: DaffPalette; + + /** + * The status of the button, as defined in the {@link DaffButtonComponent}. + * Color and status should not be used simultaneously. + */ + status?: DaffStatus; + + data?: Record; + + /** + * Sets an EventEmitter on a DaffToastAction + */ + eventEmitter?: EventEmitter; +} + +export interface DaffToastActionEvent { + event: MouseEvent; + + action: DaffToastAction; +} diff --git a/libs/design/toast/src/interfaces/toast.ts b/libs/design/toast/src/interfaces/toast.ts new file mode 100644 index 0000000000..b92b132a8c --- /dev/null +++ b/libs/design/toast/src/interfaces/toast.ts @@ -0,0 +1,28 @@ +import { Observable } from 'rxjs'; + +import { DaffStatus } from '@daffodil/design'; + +import { DaffToastAction } from './toast-action'; + +/** Possible data that can be shown on a toast */ +export interface DaffToastData { + /** A title that provides a quick oveview of the toast */ + title: string; + + /** A short message used to provide additional details about the toast */ + message?: string; + + /** Sets a status on the toast */ + status?: DaffStatus; + + /** Used to display actions in the toast */ + actions?: DaffToastAction[]; + + /** Whether or not the toast is dismissible */ + dismissible?: boolean; +} + +export interface DaffToast extends DaffToastData { + dismiss: () => void; + dismissalStream: Observable; +} diff --git a/libs/design/toast/src/options/daff-toast-options.ts b/libs/design/toast/src/options/daff-toast-options.ts new file mode 100644 index 0000000000..ad1ad413bd --- /dev/null +++ b/libs/design/toast/src/options/daff-toast-options.ts @@ -0,0 +1,33 @@ +import { + InjectionToken, + ValueProvider, +} from '@angular/core'; + +type Without = { [P in Exclude]?: never }; +type XOR = (T | U) extends Record ? (Without & U) | (Without & T) : T | U; + +export interface VerticalPositionTypes { vertical: 'top' | 'bottom' }; + +export interface HorizontalPositionTypes { horizontal: 'left' | 'center' | 'right' }; + +export type DaffToastPosition = VerticalPositionTypes & HorizontalPositionTypes; + +export interface DaffToastOptions { + position: DaffToastPosition; + useParent: boolean; +} + +export const daffToastDefaultOptions: DaffToastOptions = { + position: { + vertical: 'top', + horizontal: 'right', + }, + useParent: true, +}; + +export const DAFF_TOAST_OPTIONS = new InjectionToken('DAFF_TOAST_OPTIONS', { providedIn: 'root', factory: () => daffToastDefaultOptions }); + +export const provideDaffToastOptions = (options: DaffToastOptions): ValueProvider => ({ provide: DAFF_TOAST_OPTIONS, useValue: { + ...daffToastDefaultOptions, + ...options, +}}); diff --git a/libs/design/toast/src/public_api.ts b/libs/design/toast/src/public_api.ts new file mode 100644 index 0000000000..df18203755 --- /dev/null +++ b/libs/design/toast/src/public_api.ts @@ -0,0 +1,20 @@ +export { DaffToastPositionService } from './service/position.service'; +export { DaffToastModule } from './toast.module'; +export { DaffToastService } from './service/toast.service'; +export { DaffToastConfiguration } from './toast/toast-config'; +export { + DaffToast, + DaffToastData, +} from './interfaces/toast'; + +export { DaffToastAction } from './interfaces/toast-action'; + +export { + DAFF_TOAST_OPTIONS, + provideDaffToastOptions, +} from './options/daff-toast-options'; + +export * from './toast/toast.component'; +export * from './toast-actions/toast-actions.directive'; +export * from './toast-title/toast-title.directive'; +export * from './toast-message/toast-message.directive'; diff --git a/libs/design/toast/src/service/changes-focus.ts b/libs/design/toast/src/service/changes-focus.ts new file mode 100644 index 0000000000..84f0c00af9 --- /dev/null +++ b/libs/design/toast/src/service/changes-focus.ts @@ -0,0 +1,3 @@ +import { DaffToast } from '../interfaces/toast'; + +export const daffToastChangesFocus = (toast: DaffToast): boolean => toast.actions?.length > 0; diff --git a/libs/design/toast/src/service/position-strategy.ts b/libs/design/toast/src/service/position-strategy.ts new file mode 100644 index 0000000000..0977bcede7 --- /dev/null +++ b/libs/design/toast/src/service/position-strategy.ts @@ -0,0 +1,37 @@ +import { + GlobalPositionStrategy, + PositionStrategy, +} from '@angular/cdk/overlay'; + +import { DaffToastPosition } from '../options/daff-toast-options'; + +export const createPositionStrategy = (position: DaffToastPosition): PositionStrategy => { + const strat = new GlobalPositionStrategy(); + + switch ( position.horizontal ) { + case 'left': + strat.left('48px'); + break; + case 'right': + strat.right('48px'); + break; + case 'center': + strat.centerHorizontally(); + break; + default: + strat.right('48px'); + } + + switch(position.vertical) { + case 'top': + strat.top('80px'); + break; + case 'bottom': + strat.bottom('48px'); + break; + default: + strat.top('80px'); + } + + return strat; +}; diff --git a/libs/design/toast/src/service/position.service.spec.ts b/libs/design/toast/src/service/position.service.spec.ts new file mode 100644 index 0000000000..c4173386da --- /dev/null +++ b/libs/design/toast/src/service/position.service.spec.ts @@ -0,0 +1,33 @@ +import { BreakpointObserver } from '@angular/cdk/layout'; +import { TestBed } from '@angular/core/testing'; + +import { DaffToastPositionService } from './position.service'; + +describe('@daffodil/design/toast | DaffToastPositionService', () => { + let service: DaffToastPositionService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + ], + providers: [ + DaffToastPositionService, + ], + }); + + service = new DaffToastPositionService( + { + position: { + horizontal: 'right', + vertical: 'bottom', + }, + useParent: false, + }, + TestBed.inject(BreakpointObserver), + ); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/libs/design/toast/src/service/position.service.ts b/libs/design/toast/src/service/position.service.ts new file mode 100644 index 0000000000..c46c07c1eb --- /dev/null +++ b/libs/design/toast/src/service/position.service.ts @@ -0,0 +1,38 @@ +import { BreakpointObserver } from '@angular/cdk/layout'; +import { + Inject, + Injectable, +} from '@angular/core'; + +import { DaffBreakpoints } from '@daffodil/design'; + +import { + DaffToastOptions, + DaffToastPosition, + DAFF_TOAST_OPTIONS, +} from '../options/daff-toast-options'; + +@Injectable() +export class DaffToastPositionService { + + constructor(@Inject(DAFF_TOAST_OPTIONS) private options: DaffToastOptions, private mediaQuery: BreakpointObserver) { + this._config = options.position; + } + + private _config: DaffToastPosition; + private _position: DaffToastPosition; + + get config(): DaffToastPosition { + return this.mediaQuery.isMatched(DaffBreakpoints.MOBILE) + ? this._position ?? this._config + : { vertical: 'bottom', horizontal: 'center' }; + } + + set config(val: DaffToastPosition) { + this._config = val; + } + + setPosition(position: DaffToastPosition) { + this._position = position; + } +} diff --git a/libs/design/toast/src/service/toast.service.spec.ts b/libs/design/toast/src/service/toast.service.spec.ts new file mode 100644 index 0000000000..5dd9d2e4af --- /dev/null +++ b/libs/design/toast/src/service/toast.service.spec.ts @@ -0,0 +1,111 @@ +import { BreakpointObserver } from '@angular/cdk/layout'; +import { + Overlay, + OverlayModule, + OverlayRef, +} from '@angular/cdk/overlay'; +import { PortalModule } from '@angular/cdk/portal'; +import { TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +import { + DaffFocusStackService, + DaffPrefixSuffixModule, +} from '@daffodil/design'; +import { DaffButtonModule } from '@daffodil/design/button'; +import { DaffToast } from '@daffodil/design/toast'; + +import { DaffToastPositionService } from './position.service'; +import { DaffToastService } from './toast.service'; +import { DaffToastTemplateComponent } from '../toast/toast-template.component'; +import { DaffToastComponent } from '../toast/toast.component'; +import { DaffToastActionsDirective } from '../toast-actions/toast-actions.directive'; +import { DaffToastMessageDirective } from '../toast-message/toast-message.directive'; +import { DaffToastTitleDirective } from '../toast-title/toast-title.directive'; + +describe('@daffodil/design/toast | DaffToastService', () => { + let service: DaffToastService; + let mockOverlayRef: jasmine.SpyObj; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + DaffPrefixSuffixModule, + DaffButtonModule, + FontAwesomeModule, + PortalModule, + OverlayModule, + NoopAnimationsModule, + ], + providers: [ + DaffToastPositionService, + ], + declarations: [ + DaffToastComponent, + DaffToastActionsDirective, + DaffToastTitleDirective, + DaffToastMessageDirective, + DaffToastTemplateComponent, + ], + }); + + const overlay = TestBed.inject(Overlay); + mockOverlayRef = jasmine.createSpyObj('OverlayRef', { + attach: { + instance: { items: []}, + destroy: () => {}, + }, + dispose: null, + }); + spyOn(overlay, 'create').and.returnValue(mockOverlayRef); + + service = new DaffToastService( + overlay, + { + position: { + horizontal: 'right', + vertical: 'bottom', + }, + useParent: false, + }, + null, + TestBed.inject(BreakpointObserver), + TestBed.inject(DaffToastPositionService), + TestBed.inject(DaffFocusStackService), + ); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('open', () => { + let result: DaffToast; + + beforeEach(() => { + result = service.open({ + title: 'title', + }); + }); + + it('should create an overlay with the toast template component', () => { + expect(mockOverlayRef.attach).toHaveBeenCalledWith(jasmine.objectContaining({ component: DaffToastTemplateComponent })); + }); + + it('should return a toast capable of dismissing the toast', () => { + result.dismiss(); + expect(() => service.close(result)).toThrowError('The Toast that you are trying to remove does not exist.'); + }); + }); + + describe('close', () => { + it('should error when trying to close a toast that has not been opened', () => { + expect(() => service.close({ + title: 'title', + dismiss: null, + dismissalStream: null, + })).toThrowError('The Toast that you are trying to remove does not exist.'); + }); + }); +}); diff --git a/libs/design/toast/src/service/toast.service.ts b/libs/design/toast/src/service/toast.service.ts new file mode 100644 index 0000000000..540775a283 --- /dev/null +++ b/libs/design/toast/src/service/toast.service.ts @@ -0,0 +1,176 @@ +import { BreakpointObserver } from '@angular/cdk/layout'; +import { + Overlay, + OverlayRef, +} from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { + ComponentRef, + EventEmitter, + Inject, + Injectable, + OnDestroy, + Optional, + SkipSelf, +} from '@angular/core'; +import { + EMPTY, + interval, + merge, + of, + Subscription, +} from 'rxjs'; +import { + delay, + filter, + map, + take, + tap, +} from 'rxjs/operators'; + +import { + DaffBreakpoints, + DaffFocusStackService, +} from '@daffodil/design'; + +import { daffToastChangesFocus } from './changes-focus'; +import { createPositionStrategy } from './position-strategy'; +import { DaffToastPositionService } from './position.service'; +import { + DaffToast, + DaffToastData, +} from '../interfaces/toast'; +import { + DAFF_TOAST_OPTIONS, + DaffToastOptions, +} from '../options/daff-toast-options'; +import { + daffDefaultToastConfiguration, + DaffToastConfiguration, +} from '../toast/toast-config'; +import { DaffToastTemplateComponent } from '../toast/toast-template.component'; +import { DaffToastModule } from '../toast.module'; + +@Injectable({ providedIn: DaffToastModule }) +export class DaffToastService implements OnDestroy { + + private _sub: Subscription; + + private _toasts: DaffToast[] = []; + + private _overlayRef?: OverlayRef; + + private _template?: ComponentRef; + + constructor( + private overlay: Overlay, + @Inject(DAFF_TOAST_OPTIONS) private options: DaffToastOptions, + @Optional() @SkipSelf() private _parentToast: DaffToastService, + private mediaQuery: BreakpointObserver, + private toastPosition: DaffToastPositionService, + private focusStack: DaffFocusStackService, + ) { + this._sub = this.mediaQuery.observe(DaffBreakpoints.MOBILE).pipe( + filter(() => this._overlayRef !== undefined), + map((position) => createPositionStrategy(this.toastPosition.config)), + tap((strategy) => this._overlayRef.updatePositionStrategy(strategy)), + ).subscribe(); + } + + ngOnDestroy(): void { + this._sub.unsubscribe(); + } + + private _attachToastTemplate( + overlayRef: OverlayRef, + ): ComponentRef { + const template = overlayRef.attach(new ComponentPortal(DaffToastTemplateComponent)); + return template; + } + + private _createOverlayRef(): OverlayRef { + return this.overlay.create({ + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.noop(), + positionStrategy: createPositionStrategy(this.toastPosition.config), + }); + } + + open( + toast: DaffToastData, + configuration?: Partial, + ): DaffToast { + if(this._parentToast && this.options.useParent) { + return this._parentToast.open(toast, configuration); + } + + const config: DaffToastConfiguration = { + ...daffDefaultToastConfiguration, + // sets the default duration to 5000ms if a toast does not have actions + duration: toast.actions?.length > 0 ? undefined : 5000, + ...configuration, + }; + if(this._toasts.length === 0) { + this._overlayRef = this._createOverlayRef(); + this._template = this._attachToastTemplate(this._overlayRef); + } + const dismissEvent = new EventEmitter(); + const _toastPlus: DaffToast = { + dismissible: true, + ...toast, + dismiss: () => { + dismissEvent.emit(); + }, + dismissalStream: merge( + config.duration ? of(undefined).pipe(delay(config.duration)) : EMPTY, + dismissEvent, + ).pipe( + take(1), + ), + }; + + _toastPlus.dismissalStream.subscribe(() => { + this.close(_toastPlus); + }); + + this._toasts = [ + _toastPlus, + ...this._toasts, + ]; + + this._template.instance.items = this._toasts; + + return _toastPlus; + } + + close(toast: DaffToast): void { + if(this._parentToast && this.options.useParent) { + this._parentToast.close(toast); + return; + } + + if(daffToastChangesFocus(toast)) { + this.focusStack.pop(); + } + + const index = this._toasts.indexOf(toast); + if (index === -1) { + throw new Error( + 'The Toast that you are trying to remove does not exist.', + ); + } + + this._toasts = this._toasts.filter(m => m !== toast); + this._template.instance.items = [...this._toasts]; + + // This currently overrides the ":leave" animation as we currently + // remove the animating element immediately after there are no more toasts, + // without waiting for the animation to complete. + if(this._toasts.length === 0) { + this._overlayRef.dispose(); + this._template.destroy(); + this._overlayRef = undefined; + this._template = undefined; + } + } +} diff --git a/libs/design/toast/src/toast-actions/toast-actions.directive.spec.ts b/libs/design/toast/src/toast-actions/toast-actions.directive.spec.ts new file mode 100644 index 0000000000..7e613342fd --- /dev/null +++ b/libs/design/toast/src/toast-actions/toast-actions.directive.spec.ts @@ -0,0 +1,52 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffToastActionsDirective } from './toast-actions.directive'; + +@Component({ + template: ` +
+ `, +}) +class WrapperComponent {} + +describe('DaffToastActionsDirective', () => { + let wrapper: WrapperComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + DaffToastActionsDirective, + WrapperComponent, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + de = fixture.debugElement.query(By.css('[daffToastActions]')); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + describe('[daffToastActions]', () => { + it('should add a class of `daff-toast__actions` to its host element', () => { + expect(de.nativeElement.classList.contains('daff-toast__actions')).toEqual(true); + }); + }); +}); diff --git a/libs/design/toast/src/toast-actions/toast-actions.directive.ts b/libs/design/toast/src/toast-actions/toast-actions.directive.ts new file mode 100644 index 0000000000..1aa5a6a5d2 --- /dev/null +++ b/libs/design/toast/src/toast-actions/toast-actions.directive.ts @@ -0,0 +1,13 @@ +import { + Directive, + HostBinding, +} from '@angular/core'; + +@Directive({ + selector: '[daffToastActions]', +}) + +export class DaffToastActionsDirective { + + @HostBinding('class.daff-toast__actions') class = true; +} diff --git a/libs/design/toast/src/toast-message/toast-message.directive.spec.ts b/libs/design/toast/src/toast-message/toast-message.directive.spec.ts new file mode 100644 index 0000000000..e59919d2a6 --- /dev/null +++ b/libs/design/toast/src/toast-message/toast-message.directive.spec.ts @@ -0,0 +1,52 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffToastMessageDirective } from './toast-message.directive'; + +@Component({ + template: ` +
Message
+ `, +}) +class WrapperComponent {} + +describe('DaffToastMessageDirective', () => { + let wrapper: WrapperComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + DaffToastMessageDirective, + WrapperComponent, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + de = fixture.debugElement.query(By.css('[daffToastMessage]')); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + describe('[daffToastMessage]', () => { + it('should add a class of `daff-toast__message` to its host element', () => { + expect(de.nativeElement.classList.contains('daff-toast__message')).toEqual(true); + }); + }); +}); diff --git a/libs/design/toast/src/toast-message/toast-message.directive.ts b/libs/design/toast/src/toast-message/toast-message.directive.ts new file mode 100644 index 0000000000..1b21661e2a --- /dev/null +++ b/libs/design/toast/src/toast-message/toast-message.directive.ts @@ -0,0 +1,13 @@ +import { + Directive, + HostBinding, +} from '@angular/core'; + +@Directive({ + selector: '[daffToastMessage]', +}) + +export class DaffToastMessageDirective { + + @HostBinding('class.daff-toast__message') class = true; +} diff --git a/libs/design/toast/src/toast-theme.scss b/libs/design/toast/src/toast-theme.scss new file mode 100644 index 0000000000..975b989b10 --- /dev/null +++ b/libs/design/toast/src/toast-theme.scss @@ -0,0 +1,63 @@ +@use 'sass:map'; +@use '../../scss/core'; +@use '../../scss/theming'; + +@mixin daff-toast-theme($theme) { + $primary: map.get($theme, primary); + $secondary: map.get($theme, secondary); + $tertiary: map.get($theme, tertiary); + $neutral: core.daff-map-deep-get($theme, 'core.neutral'); + $base: core.daff-map-deep-get($theme, 'core.base'); + $base-contrast: core.daff-map-deep-get($theme, 'core.base-contrast'); + $white: core.daff-map-deep-get($theme, 'core.white'); + $black: core.daff-map-deep-get($theme, 'core.black'); + + .daff-toast { + background: theming.daff-illuminate($base, $neutral, 1); + box-shadow: 0 -16px 24px -4px rgba($black, 0.04), 0 8px 24px -4px rgba($black, 0.10); + color: theming.daff-text-contrast($base); + + &:before { + background: theming.daff-illuminate($base, $neutral, 4); + } + + &.daff-success { + background: theming.daff-color(theming.$daff-green, 10); + color: $black; + + .daff-prefix { + color: theming.daff-color(theming.$daff-green, 60); + } + + &:before { + background: theming.daff-color(theming.$daff-green, 60); + } + } + + &.daff-warn { + background: theming.daff-color(theming.$daff-bronze, 10); + color: $black; + + .daff-prefix { + color: theming.daff-color(theming.$daff-bronze, 60); + } + + &:before { + background: theming.daff-color(theming.$daff-bronze, 60); + } + } + + &.daff-error { + background: theming.daff-color(theming.$daff-red, 10); + color: $black; + + .daff-prefix { + color: theming.daff-color(theming.$daff-red, 60); + } + + &:before { + background: theming.daff-color(theming.$daff-red, 60); + } + } + } +} diff --git a/libs/design/toast/src/toast-title/toast-title.directive.spec.ts b/libs/design/toast/src/toast-title/toast-title.directive.spec.ts new file mode 100644 index 0000000000..41a7d634f6 --- /dev/null +++ b/libs/design/toast/src/toast-title/toast-title.directive.spec.ts @@ -0,0 +1,52 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffToastTitleDirective } from './toast-title.directive'; + +@Component({ + template: ` +

Title

+ `, +}) +class WrapperComponent {} + +describe('DaffToastTitleDirective', () => { + let wrapper: WrapperComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + DaffToastTitleDirective, + WrapperComponent, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + de = fixture.debugElement.query(By.css('[daffToastTitle]')); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + describe('[daffToastTitle]', () => { + it('should add a class of `daff-toast__title` to its host element', () => { + expect(de.nativeElement.classList.contains('daff-toast__title')).toEqual(true); + }); + }); +}); diff --git a/libs/design/toast/src/toast-title/toast-title.directive.ts b/libs/design/toast/src/toast-title/toast-title.directive.ts new file mode 100644 index 0000000000..6e3877d8c0 --- /dev/null +++ b/libs/design/toast/src/toast-title/toast-title.directive.ts @@ -0,0 +1,13 @@ +import { + Directive, + HostBinding, +} from '@angular/core'; + +@Directive({ + selector: '[daffToastTitle]', +}) + +export class DaffToastTitleDirective { + + @HostBinding('class.daff-toast__title') class = true; +} diff --git a/libs/design/toast/src/toast.module.ts b/libs/design/toast/src/toast.module.ts new file mode 100644 index 0000000000..d5d9c36d06 --- /dev/null +++ b/libs/design/toast/src/toast.module.ts @@ -0,0 +1,45 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { PortalModule } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +import { DaffPrefixSuffixModule } from '@daffodil/design'; +import { DaffButtonModule } from '@daffodil/design/button'; + +import { DaffToastPositionService } from './service/position.service'; +import { DaffToastTemplateComponent } from './toast/toast-template.component'; +import { DaffToastComponent } from './toast/toast.component'; +import { DaffToastActionsDirective } from './toast-actions/toast-actions.directive'; +import { DaffToastMessageDirective } from './toast-message/toast-message.directive'; +import { DaffToastTitleDirective } from './toast-title/toast-title.directive'; + + +@NgModule({ + imports: [ + CommonModule, + DaffPrefixSuffixModule, + DaffButtonModule, + FontAwesomeModule, + PortalModule, + OverlayModule, + ], + declarations: [ + DaffToastComponent, + DaffToastActionsDirective, + DaffToastTitleDirective, + DaffToastMessageDirective, + DaffToastTemplateComponent, + ], + exports: [ + DaffToastComponent, + DaffToastActionsDirective, + DaffToastTitleDirective, + DaffToastMessageDirective, + DaffPrefixSuffixModule, + ], + providers: [ + DaffToastPositionService, + ], +}) +export class DaffToastModule { } diff --git a/libs/design/toast/src/toast/toast-config.ts b/libs/design/toast/src/toast/toast-config.ts new file mode 100644 index 0000000000..556d097c59 --- /dev/null +++ b/libs/design/toast/src/toast/toast-config.ts @@ -0,0 +1,8 @@ +export interface DaffToastConfiguration { + /** + * The duration (in milliseconds) that a toast is visible before it's dismissed. + */ + duration?: number; +} + +export const daffDefaultToastConfiguration: DaffToastConfiguration = {}; diff --git a/libs/design/toast/src/toast/toast-template.component.ts b/libs/design/toast/src/toast/toast-template.component.ts new file mode 100644 index 0000000000..c26d3f55f5 --- /dev/null +++ b/libs/design/toast/src/toast/toast-template.component.ts @@ -0,0 +1,188 @@ +import { + animate, + style, + transition, + trigger, +} from '@angular/animations'; +import { + Input, + ChangeDetectionStrategy, + Component, + ChangeDetectorRef, + Inject, + Output, + EventEmitter, +} from '@angular/core'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; + +import { DaffToast } from '../interfaces/toast'; +import { + DaffToastOptions, + DAFF_TOAST_OPTIONS, +} from '../options/daff-toast-options'; +import { DaffToastPositionService } from '../service/position.service'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
{{ item.title }}
+
{{ item.message }}
+
+ + + +
+ +
+ + + + + + + + + + + `, + animations: [ + trigger('slideIn', [ + transition(':enter', [ + style({ opacity: '0', transform: 'translate({{startX}}, {{startY}})' }), + animate('300ms ease-out', style({ opacity: '1', transform: 'translate({{endX}}, {{endY}})' })), + ], + { params: { + startX: '0', + startY: '0', + endX: '0', + endY: '0', + } }), + ]), + ], +}) +export class DaffToastTemplateComponent { + faTimes = faTimes; + + private _items: DaffToast[] = []; + + constructor( + private cd: ChangeDetectorRef, + @Inject(DAFF_TOAST_OPTIONS) + private options: DaffToastOptions, + private toastPosition: DaffToastPositionService, + ) { } + + @Output() closeToast: EventEmitter = new EventEmitter(); + + onCloseToast(event: Event) { + this.closeToast.emit(); + } + + get slideAnimation(): any { + switch (this.toastPosition.config.horizontal + '-' + this.toastPosition.config.vertical) { + case 'left-top': + case 'left-bottom': + return { + value: 0, + params: { + startX: '-100%', + endX: '0', + startY: '0', + endY: '0', + }, + }; + case 'right-top': + case 'right-bottom': + return { + value: 0, + params: { + startX: '100%', + endX: '0', + startY: '0', + endY: '0', + }, + }; + + case 'center-top': + return { + value: 0, + params: { + startX: '0', + endX: '0', + startY: '-100%', + endY: '0', + }, + }; + + case 'center-bottom': + return { + value: 0, + params: { + startX: '0', + endX: '0', + startY: '100%', + endY: '0', + }, + }; + } + } + + @Input() + get items(): DaffToast[] { + return this._items; + } + set items(value: DaffToast[]) { + this._items = value; + this.cd.markForCheck(); + } +} diff --git a/libs/design/toast/src/toast/toast.component.html b/libs/design/toast/src/toast/toast.component.html new file mode 100644 index 0000000000..0d0a109d0b --- /dev/null +++ b/libs/design/toast/src/toast/toast.component.html @@ -0,0 +1,11 @@ + + + +
+
+ + +
+ +
+ diff --git a/libs/design/toast/src/toast/toast.component.scss b/libs/design/toast/src/toast/toast.component.scss new file mode 100644 index 0000000000..77460b194e --- /dev/null +++ b/libs/design/toast/src/toast/toast.component.scss @@ -0,0 +1,82 @@ +@use '../../../scss/interactions'; +@use '../../../scss/layout'; + +.daff-toast { + $root: &; + display: flex; + border-radius: 0.25rem; + font-size: 1rem; + line-height: 1.5rem; + position: relative; + min-width: 15rem; + max-width: 90vw; + + @include layout.breakpoint(mobile) { + max-width: 22rem; + } + + // the div selectors are to add margin to toast if focus trapping is involved + + div + .daff-toast, + + div + div + .daff-toast, + + .daff-toast { + margin-top: 0.75rem; + } + + &:before { + content: ''; + border-radius: 0.25rem 0 0 0.25rem; + position: absolute; + height: 100%; + left: 0; + top: 0; + width: 0.25rem; + } + + .daff-prefix { + padding: 1rem 0 1rem 1rem; + } + + &__close-button { + @include interactions.clickable(); + appearance: none; + background: none; + border: none; + color: currentColor; + margin: 0; + min-height: 3rem; + height: 3rem; + min-width: 3rem; + width: 3rem; + } + + &__details { + display: block; + font-size: 1rem; + line-height: 1.5rem; + width: 100%; + } + + &__content { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem; + } + + &__title { + font-weight: 600; + } + + &__message { + font-weight: normal; + line-height: 1.25rem; + } + + &__actions { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 3rem; + padding: 0 1rem 1rem; + } +} diff --git a/libs/design/toast/src/toast/toast.component.spec.ts b/libs/design/toast/src/toast/toast.component.spec.ts new file mode 100644 index 0000000000..faab121a5b --- /dev/null +++ b/libs/design/toast/src/toast/toast.component.spec.ts @@ -0,0 +1,92 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; + +import { + DaffStatus, + DaffStatusEnum, +} from '@daffodil/design'; +import { DaffToast } from '@daffodil/design/toast'; + +import { DaffToastComponent } from './toast.component'; + +@Component ({ + template: ` + + `, +}) + +class WrapperComponent { + status: DaffStatus; + toast: DaffToast; +} + +describe('DaffToastComponent', () => { + let fixture: ComponentFixture; + let de: DebugElement; + let wrapper: WrapperComponent; + let component: DaffToastComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + DaffToastComponent, + WrapperComponent, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + wrapper.toast = { + title: 'title', + dismiss: () => {}, + dismissalStream: of(), + }; + de = fixture.debugElement.query(By.css('daff-toast')); + component = de.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + describe('', () => { + it('should add a class of "daff-toast" to the host element', () => { + expect(de.classes).toEqual(jasmine.objectContaining({ + 'daff-toast': true, + })); + }); + }); + + describe('using the status property of a toast', () => { + it('should not set a default status', () => { + expect(component.status).toBeFalsy(); + }); + + it('should add the class of the defined status to the host element', () => { + wrapper.status = DaffStatusEnum.Warn; + fixture.detectChanges(); + + expect(de.nativeElement.classList.contains('daff-warn')).toEqual(true); + }); + }); + + it('should have a role of status', () => { + expect(component.role).toBe('status'); + }); +}); diff --git a/libs/design/toast/src/toast/toast.component.ts b/libs/design/toast/src/toast/toast.component.ts new file mode 100644 index 0000000000..485bb6c332 --- /dev/null +++ b/libs/design/toast/src/toast/toast.component.ts @@ -0,0 +1,112 @@ +import { + ConfigurableFocusTrap, + ConfigurableFocusTrapFactory, +} from '@angular/cdk/a11y'; +import { + Component, + ElementRef, + Renderer2, + HostBinding, + ContentChild, + ViewEncapsulation, + ChangeDetectionStrategy, + AfterViewInit, + AfterContentInit, + HostListener, + Input, + OnDestroy, +} from '@angular/core'; + +import { + daffArticleEncapsulatedMixin, + DaffFocusStackService, + DaffPrefixable, + DaffPrefixDirective, + DaffStatusable, + daffStatusMixin, +} from '@daffodil/design'; + +import { DaffToast } from '../interfaces/toast'; +import { daffToastChangesFocus } from '../service/changes-focus'; +import { DaffToastActionsDirective } from '../toast-actions/toast-actions.directive'; + +/** + * An _elementRef is needed for the core mixins + */ +class DaffToastBase { + constructor(public _elementRef: ElementRef, public _renderer: Renderer2) {} +} + +const _daffToastBase = daffArticleEncapsulatedMixin(daffStatusMixin(DaffToastBase)); + +/** + * DaffToastComponent provides a way to display and + * communicate information for user actions or system updates. + */ +@Component({ + selector: 'daff-toast', + templateUrl: './toast.component.html', + styleUrls: ['./toast.component.scss'], + // todo(damienwebdev): remove once decorators hit stage 3 - https://github.com/microsoft/TypeScript/issues/7342 + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['status'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DaffToastComponent + extends _daffToastBase + implements DaffPrefixable, DaffStatusable, AfterContentInit, AfterViewInit, OnDestroy { + /** @docs-private */ + @HostBinding('class.daff-toast') class = true; + + /** @docs-private */ + @HostBinding('attr.role') role = 'status'; + + @ContentChild(DaffToastActionsDirective) + _actions: DaffToastActionsDirective; + + @ContentChild(DaffPrefixDirective) + _prefix: DaffPrefixDirective; + + @Input() toast: DaffToast; + + /** + * @docs-private + */ + @HostListener('keydown.escape') + onEscape() { + this.toast.dismiss(); + } + + private _focusTrap: ConfigurableFocusTrap; + + constructor( + private elementRef: ElementRef, + private renderer: Renderer2, + private _focusTrapFactory: ConfigurableFocusTrapFactory, + private _focusStack: DaffFocusStackService, + ) { + super(elementRef, renderer); + } + + ngAfterContentInit() { + if(daffToastChangesFocus(this.toast)) { + this._focusTrap = this._focusTrapFactory.create( + this._elementRef.nativeElement, + ); + } + } + + ngAfterViewInit() { + if(daffToastChangesFocus(this.toast)) { + this._focusStack.push(); + this._focusTrap.focusFirstTabbableElementWhenReady(); + } + } + + ngOnDestroy() { + if(daffToastChangesFocus(this.toast)) { + this._focusTrap.destroy(); + } + } +}