@scania-nl/tegel-angular-extensions 0.0.1-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # @scania-nl/tegel-angular-extensions
2
+
3
+ Angular services for working with the [Tegel Angular 17](https://www.npmjs.com/package/@scania/tegel-angular-17) component library.
4
+ Provides simple wrappers for toast and modal (TBC) functionality using Angular 19+ **standalone components** and **dependency injection configuration**.
5
+
6
+ ---
7
+
8
+ ## ✨ Features
9
+
10
+ - ✅ Drop-in `ToastService` for displaying toasts
11
+ - ✅ Zero boilerplate — no Angular modules required
12
+ - ✅ Fully typed and configurable via DI
13
+ - ✅ Built for Angular 19+ standalone architecture
14
+
15
+ ---
16
+
17
+ ## 📦 Installation
18
+
19
+ ```bash
20
+ npm install @scania-nl/tegel-angular-extensions @scania/tegel-angular-17
21
+ ```
22
+
23
+ > Note: `@scania/tegel-angular-17` is a peer dependency and must be installed separately.
24
+
25
+ The following peer dependencies should be included automatically when creating an Angular 19+ project:
26
+
27
+ ```json
28
+ {
29
+ "@angular/common": "^19.0.0",
30
+ "@angular/core": "^19.0.0",
31
+ "@angular/router": "^19.0.0",
32
+ "rxjs": "~7.8.0"
33
+ }
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 🚀 Quick Start
39
+
40
+ ### 1. Add Providers
41
+ In your `app.config.ts`, specifiy the provider with `provideToast()`:
42
+
43
+ ```ts
44
+ import { provideToast } from '@scania-nl/tegel-angular-extensions';
45
+
46
+ export const appConfig: ApplicationConfig = {
47
+ providers: [
48
+ provideToast({
49
+ type: 'information', // Default toast type
50
+ title: 'Notification', // Default title
51
+ description: '', // Default description
52
+ duration: 7500, // Auto-dismiss delay (ms)
53
+ closeDuration: 300, // Fade-out animation duration (ms)
54
+ closable: true, // Show a close button
55
+ }),
56
+ ],
57
+ };
58
+ ```
59
+ > Note: The configuration is optional, all values shown above are the default settings.
60
+
61
+ ### 2. Use in components
62
+ In any standalone component:
63
+
64
+ ```ts
65
+ @Component({
66
+ standalone: true,
67
+ selector: 'my-toast-demo',
68
+ template: `<button (click)="showToast()">Show Toast</button>`,
69
+ })
70
+ export class MyToastDemoComponent {
71
+ private readonly toastService = inject(ToastService);
72
+
73
+ showToast() {
74
+ this.toastService.create({
75
+ type: 'success',
76
+ title: 'Hello Toast',
77
+ description: 'Toast created successfully!'
78
+ });
79
+ }
80
+ }
81
+ ```
82
+
83
+ ---
84
+
85
+ ## ⚙️ Configuration Options
86
+
87
+ You can configure the default appearance and behavior of toasts by passing a `ToastConfig` object to `provideToast()` in your `app.config.ts`.
88
+
89
+ All options are optional. Defaults will be applied if values are not provided.
90
+
91
+ | Property | Type | Default | Description |
92
+ | --------------- | ---------------------------------------------------- | ---------------- | -------------------------------------------------------------------------- |
93
+ | `type` | `'information' \| 'success' \| 'warning' \| 'error'` | `'information'` | Default toast type for `create()` calls |
94
+ | `title` | `string` | `'Notification'` | Default title text for toasts |
95
+ | `description` | `string` | `''` | Default description text |
96
+ | `duration` | `number` | `7500` | Duration (ms) before a toast auto-closes (0 = stays until manually closed) |
97
+ | `closeDuration` | `number` | `300` | Duration (ms) for fade-out animation (0 = remove instantly) |
98
+ | `closable` | `boolean` | `true` | Whether a close button is shown |
99
+
100
+ > Note: You can override these defaults per toast when using `create()` or convenience methods like `success()`.
101
+
102
+ ---
103
+
104
+ ## 🧩 ToastService API
105
+
106
+ The `ToastService` provides a signal-based API to create, manage, and dismiss toast notifications in Angular standalone apps. It is automatically available after registering `provideToast()` in your `app.config.ts`.
107
+
108
+ ---
109
+
110
+ ### 📦 Properties
111
+
112
+ | Property | Type | Description |
113
+ | -------------- | ----------------- | ----------------------------------------------------- |
114
+ | `toasts` | `Signal<Toast[]>` | Read-only list of all toasts (including closed) |
115
+ | `activeToasts` | `Signal<Toast[]>` | List of currently active toasts (`Open` or `Closing`) |
116
+
117
+ ---
118
+
119
+ ### 🔧 Methods
120
+
121
+ #### `create(toastOptions: Partial<ToastOptions>): number`
122
+
123
+ Creates a custom toast with full control over appearance and behavior.
124
+
125
+ Example:
126
+
127
+ ```ts
128
+ toastService.create({
129
+ type: 'success',
130
+ title: 'Saved!',
131
+ description: 'Your changes have been saved.',
132
+ duration: 5000,
133
+ });
134
+ ```
135
+
136
+ Returns the unique toast ID.
137
+
138
+ ---
139
+
140
+ #### Convenience Methods
141
+
142
+ Creates a toast of a specific type:
143
+
144
+ ```ts
145
+ toastService.success({ title: 'All good!' });
146
+ toastService.error({ title: 'Oops!', description: 'Something went wrong.' });
147
+ toastService.warning({ title: 'Heads up!' });
148
+ toastService.info({ title: 'FYI' });
149
+ ```
150
+
151
+ ---
152
+
153
+ #### `getToast(id: number): Toast | undefined`
154
+
155
+ Gets a toast by its ID.
156
+
157
+ ---
158
+
159
+ #### `createRandomToast(props?: Partial<ToastOptions>): number`
160
+
161
+ Creates a random toast with random type and title. Useful for testing. Returns the toast's unique ID.
162
+
163
+ ```
164
+ toastService.createRandomToast();
165
+ ```
166
+
167
+ ---
168
+
169
+ #### `close(id: number): void`
170
+
171
+ Triggers the fade-out animation and schedules removal.
172
+
173
+ ---
174
+
175
+ #### `closeAll(): void`
176
+
177
+ Closes all currently open toasts.
178
+
179
+ ---
180
+
181
+ #### `remove(id: number): void`
182
+
183
+ Immediately removes a toast (no animation).
184
+
185
+ ---
186
+
187
+ #### `removeAll(): void`
188
+
189
+ Force-removes all toasts instantly (no animations).
190
+
191
+ ---
192
+
193
+ ### 🔁 Toast Lifecycle Hooks
194
+
195
+ Each toast supports optional lifecycle callbacks:
196
+
197
+ | Callback | Description |
198
+ | ------------------ | -------------------------------------------- |
199
+ | `onCreated(toast)` | Called immediately after toast is created |
200
+ | `onClose(toast)` | Called when toast is closed (before removal) |
201
+ | `onRemoved(toast)` | Called when toast is fully removed |
202
+
203
+ Example:
204
+
205
+ ```ts
206
+ toastService.success({
207
+ title: 'Logged out',
208
+ duration: 5000,
209
+ onRemoved: (toast) => console.log(`Toast ${toast.id} removed`)
210
+ });
211
+ ```
212
+
213
+ ---
214
+
215
+ ## 📄 License
216
+
217
+ All CSS, HTML and JS code are available under the MIT license. The Scania brand identity, logos and photographs found in this repository are copyrighted Scania CV AB and are not available on an open source basis or to be used as examples or in any other way, if not specifically ordered by Scania CV AB.
@@ -0,0 +1,48 @@
1
+ import nx from '@nx/eslint-plugin';
2
+ import baseConfig from '../../eslint.base.config.mjs';
3
+
4
+ export default [
5
+ ...baseConfig,
6
+ {
7
+ files: ['**/*.json'],
8
+ rules: {
9
+ '@nx/dependency-checks': [
10
+ 'error',
11
+ {
12
+ ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'],
13
+ },
14
+ ],
15
+ },
16
+ languageOptions: {
17
+ parser: await import('jsonc-eslint-parser'),
18
+ },
19
+ },
20
+ ...nx.configs['flat/angular'],
21
+ ...nx.configs['flat/angular-template'],
22
+ {
23
+ files: ['**/*.ts'],
24
+ rules: {
25
+ '@angular-eslint/directive-selector': [
26
+ 'error',
27
+ {
28
+ type: 'attribute',
29
+ prefix: 'lib',
30
+ style: 'camelCase',
31
+ },
32
+ ],
33
+ '@angular-eslint/component-selector': [
34
+ 'error',
35
+ {
36
+ type: 'element',
37
+ prefix: 'tds-ext',
38
+ style: 'kebab-case',
39
+ },
40
+ ],
41
+ },
42
+ },
43
+ {
44
+ files: ['**/*.html'],
45
+ // Override or add rules here
46
+ rules: {},
47
+ },
48
+ ];
package/jest.config.ts ADDED
@@ -0,0 +1,21 @@
1
+ export default {
2
+ displayName: 'tegel-angular-extensions',
3
+ preset: '../../jest.preset.js',
4
+ setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
5
+ coverageDirectory: '../../coverage/libs/tegel-angular-extensions',
6
+ transform: {
7
+ '^.+\\.(ts|mjs|js|html)$': [
8
+ 'jest-preset-angular',
9
+ {
10
+ tsconfig: '<rootDir>/tsconfig.spec.json',
11
+ stringifyContentPathRegex: '\\.(html|svg)$',
12
+ },
13
+ ],
14
+ },
15
+ transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
16
+ snapshotSerializers: [
17
+ 'jest-preset-angular/build/serializers/no-ng-attributes',
18
+ 'jest-preset-angular/build/serializers/ng-snapshot',
19
+ 'jest-preset-angular/build/serializers/html-comment',
20
+ ],
21
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/libs/tegel-angular-extensions",
4
+ "lib": {
5
+ "entryFile": "src/index.ts"
6
+ }
7
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@scania-nl/tegel-angular-extensions",
3
+ "version": "0.0.1-0",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "peerDependencies": {
9
+ "@angular/common": "^19.0.0",
10
+ "@angular/core": "^19.0.0",
11
+ "@angular/router": "^19.0.0",
12
+ "@scania/tegel-angular-17": "^1.0.0",
13
+ "rxjs": "~7.8.0"
14
+ },
15
+ "sideEffects": false,
16
+ "author": {
17
+ "name": "Patrick Groot Koerkamp",
18
+ "email": "patrick.groot.koerkamp@scania.com"
19
+ }
20
+ }
package/project.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "tegel-angular-extensions",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/tegel-angular-extensions/src",
5
+ "prefix": "lib",
6
+ "projectType": "library",
7
+ "tags": [],
8
+ "targets": {
9
+ "build": {
10
+ "executor": "@nx/angular:ng-packagr-lite",
11
+ "outputs": ["{workspaceRoot}/dist/{projectRoot}"],
12
+ "options": {
13
+ "project": "libs/tegel-angular-extensions/ng-package.json"
14
+ },
15
+ "configurations": {
16
+ "production": {
17
+ "tsConfig": "libs/tegel-angular-extensions/tsconfig.lib.prod.json"
18
+ },
19
+ "development": {
20
+ "tsConfig": "libs/tegel-angular-extensions/tsconfig.lib.json"
21
+ }
22
+ },
23
+ "defaultConfiguration": "production"
24
+ },
25
+ "test": {
26
+ "executor": "@nx/jest:jest",
27
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
28
+ "options": {
29
+ "jestConfig": "libs/tegel-angular-extensions/jest.config.ts"
30
+ }
31
+ },
32
+ "lint": {
33
+ "executor": "@nx/eslint:lint"
34
+ }
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './lib/toast/provide-toast';
2
+ export * from './lib/toast/toast.config';
3
+ export * from './lib/toast/toast.service';
4
+ export * from './lib/toast/models/toast.model'
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Represents the current lifecycle state of a toast.
3
+ */
4
+ export enum ToastState {
5
+ /**
6
+ * The toast is fully visible and active.
7
+ */
8
+ Open = 'open',
9
+
10
+ /**
11
+ * The toast is transitioning out (e.g., fading out).
12
+ */
13
+ Closing = 'closing',
14
+
15
+ /**
16
+ * The toast is fully removed or dismissed.
17
+ */
18
+ Closed = 'closed',
19
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * List of available toast types (inherited from Tegel)
3
+ */
4
+ export const TOAST_TYPES = ['success', 'information', 'warning', 'error'] as const;
5
+
6
+ /**
7
+ * Type representing valid toast type values.
8
+ */
9
+ export type ToastType = (typeof TOAST_TYPES)[number];
@@ -0,0 +1,87 @@
1
+ import { ToastState } from './toast-state.enum';
2
+ import { ToastType } from './toast-type';
3
+
4
+ /**
5
+ * Defines the base structure for a toast message.
6
+ */
7
+ export interface ToastOptions {
8
+ /**
9
+ * The visual style of the toast (e.g., success, error).
10
+ */
11
+ type: ToastType;
12
+
13
+ /**
14
+ * The main title text displayed in the toast.
15
+ */
16
+ title: string;
17
+
18
+ /**
19
+ * Optional description text providing additional context.
20
+ */
21
+ description?: string;
22
+
23
+ /**
24
+ * Duration in milliseconds before the toast auto-dismisses.
25
+ * Use `0` for persistent toasts.
26
+ */
27
+ duration: number;
28
+
29
+ /**
30
+ * Duration in milliseconds for the fade-out transition.
31
+ */
32
+ closeDuration: number;
33
+
34
+ /**
35
+ * Whether the toast can be manually closed by the user.
36
+ */
37
+ closable?: boolean;
38
+
39
+ /**
40
+ * Optional router link to navigate to when the toast is clicked.
41
+ */
42
+ link?: string;
43
+
44
+ /**
45
+ * Custom link text shown if a link is provided.
46
+ */
47
+ linkText?: string;
48
+
49
+ /**
50
+ * Optional callback triggered when the linkText is clicked.
51
+ * Used as an alternative to `link` for invoking custom behavior.
52
+ */
53
+ action?: () => void;
54
+
55
+ /**
56
+ * Optional callback triggered when the toast is created.
57
+ * @param toast The created toast instance.
58
+ */
59
+ onCreated?: (toast: Toast) => void;
60
+
61
+ /**
62
+ * Optional callback triggered when the toast begins to close.
63
+ * @param toast The toast that is closing.
64
+ */
65
+ onClose?: (toast: Toast) => void;
66
+
67
+ /**
68
+ * Optional callback triggered when the toast has been fully removed.
69
+ * @param toast The removed toast instance.
70
+ */
71
+ onRemoved?: (toast: Toast) => void;
72
+ }
73
+
74
+ /**
75
+ * Represents a fully instantiated toast, including unique ID and current state.
76
+ */
77
+ export interface Toast extends ToastOptions {
78
+ /**
79
+ * Unique identifier for the toast instance.
80
+ */
81
+ id: number;
82
+
83
+ /**
84
+ * The current state of the toast (open, closing, or closed).
85
+ */
86
+ state: ToastState;
87
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ EnvironmentProviders,
3
+ makeEnvironmentProviders,
4
+ provideAppInitializer,
5
+ } from '@angular/core';
6
+ import { bootstrapGlobalComponent } from '../utils/bootstrap-global-component';
7
+ import { ToastComponent } from './toast.component';
8
+ import {
9
+ DEFAULT_TOAST_CONFIG,
10
+ TOAST_CONFIG,
11
+ ToastConfig,
12
+ } from './toast.config';
13
+
14
+ export function provideToast(
15
+ config: Partial<ToastConfig> = {}
16
+ ): EnvironmentProviders {
17
+ return makeEnvironmentProviders([
18
+ {
19
+ provide: TOAST_CONFIG,
20
+ useFactory: () => ({
21
+ ...DEFAULT_TOAST_CONFIG,
22
+ ...(config ?? {}),
23
+ }),
24
+ },
25
+ provideAppInitializer(() => {
26
+ bootstrapGlobalComponent(ToastComponent, { reuseIfExists: true });
27
+ }),
28
+ ]);
29
+ }
@@ -0,0 +1,41 @@
1
+ <ul class="toast-list" aria-live="polite" aria-atomic="true">
2
+ @for (toast of toastsSignal(); track toast.id) {
3
+ <li
4
+ class="toast-item"
5
+ [class]="'toast-' + toast.type"
6
+ [class.toast-closing]="toast.state === 'closing'"
7
+ [style.--duration.ms]="toast.duration"
8
+ [style.--close-duration.ms]="toast.closeDuration"
9
+ role="status"
10
+ >
11
+ <tds-toast
12
+ [variant]="toast.type"
13
+ [header]="toast.title"
14
+ [subheader]="toast.description"
15
+ [closable]="toast.closable"
16
+ tds-close-aria-label="Toast close button"
17
+ >
18
+ @if (toast.link) {
19
+ <tds-link slot="actions">
20
+ <a [routerLink]="toast.link">
21
+ {{ toast.linkText ?? 'Click here' }}
22
+ </a>
23
+ </tds-link>
24
+ } @else if (toast.action) {
25
+ <tds-link slot="actions">
26
+ <a href="#" (click)="toast.action()">
27
+ {{ toast.linkText ?? 'Click here' }}
28
+ </a>
29
+ </tds-link>
30
+ }
31
+ </tds-toast>
32
+ @if (toast.closable) {
33
+ <button
34
+ class="toast-close"
35
+ (click)="closeToast(toast)"
36
+ aria-label="Close toast"
37
+ ></button>
38
+ }
39
+ </li>
40
+ }
41
+ </ul>
@@ -0,0 +1,118 @@
1
+ $default-close-duration: 300ms;
2
+ $default-duration: 7000ms;
3
+ $item-gap: 6px;
4
+
5
+ :host {
6
+ position: fixed;
7
+ right: 0;
8
+ bottom: 0;
9
+ overflow: hidden;
10
+ z-index: 9999;
11
+ }
12
+
13
+ .toast-list {
14
+ display: grid;
15
+ grid-template-columns: 1fr;
16
+ gap: $item-gap;
17
+ /* List resets */
18
+ list-style: none;
19
+ padding: $item-gap;
20
+ margin: 0;
21
+ }
22
+
23
+ .toast-item {
24
+ position: relative;
25
+ animation: fadeIn $default-close-duration ease-in forwards;
26
+
27
+ &.toast-closing {
28
+ animation-name: fadeOut;
29
+ animation-duration: var(--close-duration, $default-close-duration);
30
+ animation-fill-mode: forwards;
31
+ pointer-events: none;
32
+ }
33
+
34
+ &::before {
35
+ content: '';
36
+ position: absolute;
37
+ height: 3px;
38
+ width: 100%;
39
+ bottom: 0;
40
+ left: 4px; /* Toast border itself */
41
+ right: 0;
42
+
43
+ animation: progress var(--duration, $default-duration)
44
+ linear forwards;
45
+
46
+ border-top-right-radius: 4px;
47
+ border-bottom-right-radius: 4px;
48
+ }
49
+
50
+ &.toast-information::before {
51
+ background: var(--tds-information);
52
+ }
53
+ &.toast-success::before {
54
+ background: var(--tds-positive);
55
+ }
56
+ &.toast-warning::before {
57
+ background: var(--tds-warning);
58
+ }
59
+ &.toast-error::before {
60
+ background: var(--tds-negative);
61
+ }
62
+
63
+ /*
64
+ Custom Close element for TDS, which is just a transparent block covering the close-button of the TdsToast element
65
+
66
+ Height, width, and positioning values are equal to TdsToast's button.close element for a perfect overlap
67
+ */
68
+ .toast-close {
69
+ height: 20px;
70
+ width: 20px;
71
+
72
+ box-sizing: border-box;
73
+ cursor: pointer;
74
+ position: absolute;
75
+ top: 14px;
76
+ right: 14px;
77
+
78
+ border: 0;
79
+ background: transparent;
80
+
81
+ &:active {
82
+ border: 2px solid var(--tds-blue-400);
83
+ outline-offset: -2px;
84
+ }
85
+ }
86
+ }
87
+
88
+ @keyframes fadeIn {
89
+ 0% {
90
+ transform: translateY(40%);
91
+ opacity: 0;
92
+ margin-bottom: -25%;
93
+ }
94
+ 100% {
95
+ transform: translateY(0);
96
+ opacity: 1;
97
+ margin-bottom: 0%;
98
+ }
99
+ }
100
+
101
+ @keyframes fadeOut {
102
+ 0% {
103
+ transform: translateY(0);
104
+ opacity: 1;
105
+ margin-top: 0;
106
+ }
107
+ 100% {
108
+ opacity: 0;
109
+ transform: translateY(75%);
110
+ margin-top: -25%;
111
+ }
112
+ }
113
+
114
+ @keyframes progress {
115
+ 100% {
116
+ width: 0%;
117
+ }
118
+ }
@@ -0,0 +1,28 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { provideToast } from './provide-toast';
3
+ import { ToastComponent } from './toast.component';
4
+
5
+ describe('ToastComponent', () => {
6
+ let component: ToastComponent;
7
+ let fixture: ComponentFixture<ToastComponent>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ declarations: [],
12
+ imports: [ToastComponent],
13
+ providers: [
14
+ provideToast()
15
+ ]
16
+ }).compileComponents();
17
+ });
18
+
19
+ beforeEach(() => {
20
+ fixture = TestBed.createComponent(ToastComponent);
21
+ component = fixture.componentInstance;
22
+ fixture.detectChanges();
23
+ });
24
+
25
+ it('should create', () => {
26
+ expect(component).toBeTruthy();
27
+ });
28
+ });
@@ -0,0 +1,37 @@
1
+ import { CommonModule } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
3
+ import { RouterLink } from '@angular/router';
4
+ import { TegelModule } from '@scania/tegel-angular-17';
5
+
6
+ import { Toast } from './models/toast.model';
7
+ import { ToastService } from './toast.service';
8
+
9
+ /**
10
+ * Displays toast notifications provided by the ToastService.
11
+ *
12
+ * Toasts are non-blocking messages that automatically disappear after a set duration
13
+ * or can be dismissed manually by the user.
14
+ */
15
+ @Component({
16
+ selector: 'tds-ext-toast',
17
+ templateUrl: './toast.component.html',
18
+ styleUrls: ['./toast.component.scss'],
19
+ imports: [CommonModule, TegelModule, RouterLink],
20
+ changeDetection: ChangeDetectionStrategy.OnPush,
21
+ })
22
+ export class ToastComponent {
23
+ private readonly toastService = inject(ToastService);
24
+
25
+ /**
26
+ * A reactive signal of all currently active toasts (open or closing).
27
+ */
28
+ readonly toastsSignal = this.toastService.activeToasts;
29
+
30
+ /**
31
+ * Initiates the closing process for the given toast.
32
+ *
33
+ * @param toast The toast to be closed.
34
+ * @returns void
35
+ */
36
+ closeToast = (toast: Toast) => this.toastService.close(toast.id);
37
+ }
@@ -0,0 +1,50 @@
1
+ import { InjectionToken } from '@angular/core';
2
+ import { ToastType } from './models/toast-type';
3
+
4
+ /**
5
+ * Global default configuration for toast behavior.
6
+ * Used when individual toast properties are not explicitly set.
7
+ */
8
+ export interface ToastConfig {
9
+ /**
10
+ * Default toast type (e.g., 'success', 'information', etc.).
11
+ */
12
+ type: ToastType;
13
+
14
+ /**
15
+ * Default title to use when none is provided.
16
+ */
17
+ title: string;
18
+
19
+ /**
20
+ * Default description to use when none is provided.
21
+ */
22
+ description: string;
23
+
24
+ /**
25
+ * Default duration (ms) before auto-dismiss.
26
+ * Use `0` for persistent toasts.
27
+ */
28
+ duration: number;
29
+
30
+ /**
31
+ * Default duration (ms) for the fade-out animation.
32
+ */
33
+ closeDuration: number;
34
+
35
+ /**
36
+ * Whether toasts are closable by default.
37
+ */
38
+ closable: boolean;
39
+ }
40
+
41
+ export const TOAST_CONFIG = new InjectionToken<ToastConfig>('ToastConfig');
42
+
43
+ export const DEFAULT_TOAST_CONFIG: Required<ToastConfig> = {
44
+ type: 'information',
45
+ title: 'Notification',
46
+ description: '',
47
+ duration: 7500,
48
+ closeDuration: 300,
49
+ closable: true,
50
+ };
@@ -0,0 +1,275 @@
1
+ import { computed, inject, Injectable, signal } from '@angular/core';
2
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3
+ import { delay, filter, mergeMap, of, Subject, tap } from 'rxjs';
4
+ import { ToastState } from './models/toast-state.enum';
5
+ import { TOAST_TYPES } from './models/toast-type';
6
+ import { Toast, ToastOptions } from './models/toast.model';
7
+ import { TOAST_CONFIG, ToastConfig } from './toast.config';
8
+
9
+ /**
10
+ * Service for creating, managing, and removing toast notifications.
11
+ * Supports automatic dismissal, manual control, and lifecycle hooks.
12
+ */
13
+ @Injectable({
14
+ providedIn: 'root',
15
+ })
16
+ export class ToastService {
17
+ private readonly config = inject<ToastConfig>(TOAST_CONFIG);
18
+
19
+ //*------------------------------------------------------------
20
+ //* Section: Internal variables
21
+ //*------------------------------------------------------------
22
+
23
+ /** Internal ID tracker for unique toast IDs */
24
+ private id = 0;
25
+
26
+ /** Signal state holding all toasts */
27
+ private readonly _toasts = signal<Toast[]>([]);
28
+
29
+ /** Public signal of all toasts */
30
+ readonly toasts = this._toasts.asReadonly();
31
+
32
+ /** Signal of toasts that are not yet closed (open or closing) */
33
+ readonly activeToasts = computed(() =>
34
+ this._toasts().filter((toast) => toast.state !== ToastState.Closed)
35
+ );
36
+
37
+ /** Internal stream for auto-closing toasts */
38
+ private readonly autoCloseSubject = new Subject<Toast>();
39
+
40
+ /** Internal stream for fade-out/removal of toasts */
41
+ private readonly closeSubject = new Subject<Toast>();
42
+
43
+ constructor() {
44
+ // Auto-close after toast.duration
45
+ this.autoCloseSubject
46
+ .pipe(
47
+ takeUntilDestroyed(),
48
+ filter((toast) => toast.duration > 0),
49
+ mergeMap((toast: Toast) =>
50
+ of(toast).pipe(
51
+ delay(toast.duration),
52
+ filter(() => this.shouldAutoClose(toast.id)),
53
+ tap(() => this.close(toast.id))
54
+ )
55
+ )
56
+ )
57
+ .subscribe();
58
+
59
+ // Remove after toast.closeDuration (fade-out)
60
+ this.closeSubject
61
+ .pipe(
62
+ takeUntilDestroyed(),
63
+ mergeMap((toast: Toast) =>
64
+ of(toast).pipe(
65
+ delay(toast.closeDuration),
66
+ filter(() => this.shouldRemove(toast.id)),
67
+ tap(() => this.remove(toast.id))
68
+ )
69
+ )
70
+ )
71
+ .subscribe();
72
+ }
73
+
74
+ //*------------------------------------------------------------
75
+ //* Section: Public methods
76
+ //*------------------------------------------------------------
77
+
78
+ /**
79
+ * Creates and adds a new toast.
80
+ * @param toastOptions Partial toast definition.
81
+ * @returns The toast's unique ID.
82
+ */
83
+ create(toastOptions: Partial<ToastOptions>): number {
84
+ const id = this.createId();
85
+ const toast: Toast = {
86
+ ...this.config,
87
+ ...toastOptions,
88
+ id,
89
+ state: ToastState.Open,
90
+ duration: this.normalizeDuration(
91
+ toastOptions.duration,
92
+ this.config.duration
93
+ ),
94
+ closeDuration: this.normalizeDuration(
95
+ toastOptions.closeDuration,
96
+ this.config.closeDuration
97
+ ),
98
+ };
99
+
100
+ this.addToast(toast);
101
+ toast.onCreated?.(toast);
102
+
103
+ // Schedule auto-close if duration > 0
104
+ if (toast.duration > 0) {
105
+ this.autoCloseSubject.next(toast);
106
+ }
107
+
108
+ return id;
109
+ }
110
+
111
+ /**
112
+ * Initiates the fade-out transition for a toast.
113
+ * @param id The toast ID.
114
+ */
115
+ close(id: number): void {
116
+ let toast = this.getToast(id);
117
+ if (!toast || toast.state !== ToastState.Open) return;
118
+
119
+ toast = this.updateToastState(toast.id, ToastState.Closing);
120
+ toast?.onClose?.(toast);
121
+
122
+ // Schedule removal after close duration
123
+ if (toast) this.closeSubject.next(toast);
124
+ }
125
+
126
+ /**
127
+ * Immediately marks a toast as closed and removes it from display.
128
+ * @param id The toast ID.
129
+ */
130
+ remove(id: number): void {
131
+ const toast = this.updateToastState(id, ToastState.Closed);
132
+ toast?.onRemoved?.(toast);
133
+ }
134
+
135
+ /**
136
+ * Closes and removes all toasts immediately without fade-out.
137
+ */
138
+ removeAll(): void {
139
+ const closedToasts: Toast[] = [];
140
+
141
+ this._toasts.update((toasts) =>
142
+ toasts.map((toast) => {
143
+ if (toast.state !== ToastState.Closed) {
144
+ const updated = { ...toast, state: ToastState.Closed };
145
+ closedToasts.push(updated);
146
+ return updated;
147
+ }
148
+ return toast;
149
+ })
150
+ );
151
+
152
+ closedToasts.forEach((toast) => toast.onRemoved?.(toast));
153
+ }
154
+
155
+ /**
156
+ * Initiates closing process for all open toasts.
157
+ */
158
+ closeAll(): void {
159
+ const openToasts = this.toasts().filter(
160
+ (toast) => toast.state === ToastState.Open
161
+ );
162
+ openToasts.forEach((toast) => this.close(toast.id));
163
+ }
164
+
165
+ /**
166
+ * Gets a toast by ID.
167
+ * @param id The toast ID.
168
+ * @returns The toast instance or undefined.
169
+ */
170
+ getToast(id: number): Toast | undefined {
171
+ return this._toasts().find((toast) => toast.id === id);
172
+ }
173
+
174
+ //*------------------------------------------------------------
175
+ //* Section: Internal methods
176
+ //*------------------------------------------------------------
177
+
178
+ /** Whether the toast is eligible for auto-closing */
179
+ private shouldAutoClose(id: number): boolean {
180
+ const currentToast = this.getToast(id);
181
+ return currentToast?.state === ToastState.Open;
182
+ }
183
+
184
+ /** Whether the toast is eligible for final removal */
185
+ private shouldRemove(id: number): boolean {
186
+ const currentToast = this.getToast(id);
187
+ return currentToast?.state === ToastState.Closing;
188
+ }
189
+
190
+ /** Add toast to signal list */
191
+ private addToast(toast: Toast): void {
192
+ this._toasts.update((prev) => [...prev, toast]);
193
+ }
194
+
195
+ /**
196
+ * Updates the state of a toast.
197
+ * @param id Toast ID
198
+ * @param state New state
199
+ * @returns The updated toast or undefined
200
+ */
201
+ private updateToastState(id: number, state: ToastState): Toast | undefined {
202
+ let updatedToast: Toast | undefined;
203
+
204
+ this._toasts.update((prev) =>
205
+ prev.map((toast) => {
206
+ if (toast.id === id) {
207
+ updatedToast = { ...toast, state };
208
+ return updatedToast;
209
+ }
210
+ return toast;
211
+ })
212
+ );
213
+
214
+ return updatedToast;
215
+ }
216
+
217
+ /**
218
+ * Creates a unique id
219
+ * @returns New id
220
+ */
221
+ private createId(): number {
222
+ return ++this.id;
223
+ }
224
+
225
+ //*------------------------------------------------------------
226
+ //* Section: Public convenience methods
227
+ //*------------------------------------------------------------
228
+
229
+ /**
230
+ * Creates a success toast.
231
+ * @param props Toast props without type.
232
+ */
233
+ readonly success = (props: Partial<Omit<ToastOptions, 'type'>> = {}) =>
234
+ this.create({ ...props, type: 'success' });
235
+
236
+ /**
237
+ * Creates an error toast.
238
+ * @param props Toast props without type.
239
+ */
240
+ readonly error = (props: Partial<Omit<ToastOptions, 'type'>> = {}) =>
241
+ this.create({ ...props, type: 'error' });
242
+
243
+ /**
244
+ * Creates a warning toast.
245
+ * @param props Toast props without type.
246
+ */
247
+ readonly warning = (props: Partial<Omit<ToastOptions, 'type'>> = {}) =>
248
+ this.create({ ...props, type: 'warning' });
249
+
250
+ /**
251
+ * Creates an informational toast.
252
+ * @param props Toast props without type.
253
+ */
254
+ readonly info = (props: Partial<Omit<ToastOptions, 'type'>> = {}) =>
255
+ this.create({ ...props, type: 'information' });
256
+
257
+ /**
258
+ * Creates a random toast for testing/demo purposes.
259
+ * @param props Optional overrides
260
+ */
261
+ readonly createRandomToast = (props: Partial<ToastOptions> = {}) =>
262
+ this.create({
263
+ type: TOAST_TYPES[~~(Math.random() * 4)],
264
+ title: `Random Toast ${Math.random().toString(36).substring(7)}`,
265
+ ...props,
266
+ });
267
+
268
+ //*------------------------------------------------------------
269
+ //* Section: Helper methods
270
+ //*------------------------------------------------------------
271
+ private normalizeDuration(value: unknown, fallback: number): number {
272
+ const num = typeof value === 'number' ? value : fallback;
273
+ return !Number.isFinite(num) || num < 0 ? 0 : num;
274
+ }
275
+ }
@@ -0,0 +1,73 @@
1
+ import {
2
+ ApplicationRef,
3
+ ComponentRef,
4
+ EmbeddedViewRef,
5
+ EnvironmentInjector,
6
+ Type,
7
+ createComponent,
8
+ inject,
9
+ } from '@angular/core';
10
+
11
+ /**
12
+ * Internal map to track which global components have been bootstrapped.
13
+ */
14
+ const bootstrappedComponents = new WeakMap<
15
+ Type<unknown>,
16
+ ComponentRef<unknown>
17
+ >();
18
+
19
+ interface BootstrapGlobalComponentOptions {
20
+ /**
21
+ * If true, avoids re-creating the component if it's already mounted.
22
+ */
23
+ reuseIfExists?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Bootstraps a global Angular component directly into the <body> element.
28
+ * Useful for toasts, modals, and other global overlays.
29
+ *
30
+ * @param component - The component class to bootstrap.
31
+ * @param options - Optional settings like preventing duplicates.
32
+ * @returns The created ComponentRef.
33
+ */
34
+ export function bootstrapGlobalComponent<T>(
35
+ component: Type<T>,
36
+ options?: BootstrapGlobalComponentOptions
37
+ ): ComponentRef<T> {
38
+ const appRef = inject(ApplicationRef);
39
+ const injector = inject(EnvironmentInjector);
40
+
41
+ const existing = bootstrappedComponents.get(component);
42
+ if (existing && options?.reuseIfExists) {
43
+ return existing as ComponentRef<T>;
44
+ }
45
+
46
+ const cmpRef = createComponent(component, {
47
+ environmentInjector: injector,
48
+ });
49
+
50
+ appRef.attachView(cmpRef.hostView);
51
+
52
+ const viewRef = cmpRef.hostView as EmbeddedViewRef<unknown>;
53
+ const element = viewRef.rootNodes[0] as HTMLElement;
54
+
55
+ document.body.appendChild(element);
56
+
57
+ bootstrappedComponents.set(component, cmpRef);
58
+
59
+ return cmpRef;
60
+ }
61
+
62
+ /**
63
+ * Destroys a previously bootstrapped global component.
64
+ *
65
+ * @param component - The component class to remove from DOM.
66
+ */
67
+ export function destroyGlobalComponent<T>(component: Type<T>): void {
68
+ const cmpRef = bootstrappedComponents.get(component);
69
+ if (cmpRef) {
70
+ cmpRef.destroy();
71
+ bootstrappedComponents.delete(component);
72
+ }
73
+ }
@@ -0,0 +1,6 @@
1
+ import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
2
+
3
+ setupZoneTestEnv({
4
+ errorOnUnknownElements: true,
5
+ errorOnUnknownProperties: true,
6
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "forceConsistentCasingInFileNames": true,
5
+ "strict": true,
6
+ "noImplicitOverride": true,
7
+ "noPropertyAccessFromIndexSignature": true,
8
+ "noImplicitReturns": true,
9
+ "noFallthroughCasesInSwitch": true
10
+ },
11
+ "files": [],
12
+ "include": [],
13
+ "references": [
14
+ {
15
+ "path": "./tsconfig.lib.json"
16
+ },
17
+ {
18
+ "path": "./tsconfig.spec.json"
19
+ }
20
+ ],
21
+ "extends": "../../tsconfig.base.json",
22
+ "angularCompilerOptions": {
23
+ "enableI18nLegacyMessageIdFormat": false,
24
+ "strictInjectionParameters": true,
25
+ "strictInputAccessModifiers": true,
26
+ "strictTemplates": true
27
+ }
28
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "inlineSources": true,
8
+ "types": []
9
+ },
10
+ "exclude": [
11
+ "src/**/*.spec.ts",
12
+ "src/test-setup.ts",
13
+ "jest.config.ts",
14
+ "src/**/*.test.ts"
15
+ ],
16
+ "include": ["src/**/*.ts"]
17
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "./tsconfig.lib.json",
3
+ "compilerOptions": {
4
+ "declarationMap": false
5
+ },
6
+ "angularCompilerOptions": {}
7
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "target": "es2016",
7
+ "types": ["jest", "node"]
8
+ },
9
+ "files": ["src/test-setup.ts"],
10
+ "include": [
11
+ "jest.config.ts",
12
+ "src/**/*.test.ts",
13
+ "src/**/*.spec.ts",
14
+ "src/**/*.d.ts"
15
+ ]
16
+ }