Skip to content

Commit 322b51c

Browse files
committed
refactor(app): change alert service logic into a toast stack component
1 parent dbe16e3 commit 322b51c

File tree

8 files changed

+98
-45
lines changed

8 files changed

+98
-45
lines changed

Diff for: eslint.config.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export default tsEslint.config(
134134
'unicorn/no-null': 'off',
135135
'unicorn/prefer-global-this': 'off',
136136
'unicorn/consistent-function-scoping': 'off',
137+
'unicorn/prefer-dom-node-dataset': 'off',
137138
},
138139
},
139140
{

Diff for: src/app/app.component.html

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<app-progress-bar />
2+
<app-toast-stack />
23
<button i18n class="app__content-skip-button" type="button" (click)="focusFirstHeading()">
34
Skip to main content
45
</button>

Diff for: src/app/app.component.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import { HeaderService } from '~core/services/header.service';
1010
import { ProgressBarComponent } from '~core/components/progress-bar/progress-bar.component';
1111
import { CookiePopupComponent } from '~core/components/cookie-popup/cookie-popup.component';
1212
import { toSignal } from '@angular/core/rxjs-interop';
13-
14-
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
13+
import { ToastStackComponent } from '~core/components/toast-stack/toast-stack.component';
1514

1615
@Component({
1716
selector: 'app-root',
@@ -24,6 +23,7 @@ import '@shoelace-style/shoelace/dist/components/alert/alert.js';
2423
FooterComponent,
2524
ProgressBarComponent,
2625
CookiePopupComponent,
26+
ToastStackComponent,
2727
],
2828
})
2929
export class AppComponent {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@for (alert of alerts(); track alert.id) {
2+
<sl-alert
3+
#alertReference
4+
i18n
5+
closable
6+
[id]="alert.id"
7+
[attr.countdown]="alert.hasCountdown ? 'rtl' : null"
8+
[attr.duration]="alert.duration?.toString() || null"
9+
[attr.variant]="alert.type"
10+
[class]="'alert--' + alert.type"
11+
[innerHtml]="alert.message"
12+
(sl-after-hide)="removeFromAlerts(alert)"
13+
/>
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ElementRef } from '@angular/core';
2+
import {
3+
ChangeDetectionStrategy,
4+
Component,
5+
CUSTOM_ELEMENTS_SCHEMA,
6+
effect,
7+
inject,
8+
viewChildren,
9+
} from '@angular/core';
10+
import { AlertService } from '~core/services/alert.service';
11+
import type { Alert } from '~core/constants/alerts.constants';
12+
13+
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
14+
15+
@Component({
16+
selector: 'app-toast-stack',
17+
templateUrl: './toast-stack.component.html',
18+
changeDetection: ChangeDetectionStrategy.OnPush,
19+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
20+
})
21+
export class ToastStackComponent {
22+
private readonly alertService = inject(AlertService);
23+
private readonly toastedAlertIds = new Set<string>();
24+
private readonly alertElements = viewChildren<ElementRef>('alertReference');
25+
26+
readonly alerts = this.alertService.alerts;
27+
28+
constructor() {
29+
effect(() => {
30+
for (const element of this.alertElements()) {
31+
const native = element.nativeElement as HTMLElement & { toast?: () => void };
32+
const alertId = native.getAttribute('id');
33+
if (alertId && !this.toastedAlertIds.has(alertId)) {
34+
native.toast?.();
35+
this.toastedAlertIds.add(alertId);
36+
}
37+
}
38+
});
39+
}
40+
41+
removeFromAlerts(alert: Alert) {
42+
this.alertService.removeAlert(alert);
43+
}
44+
}

Diff for: src/app/core/constants/alerts.constants.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export enum AlertType {
2+
SUCCESS = 'success',
3+
ERROR = 'error',
4+
}
5+
6+
export type Alert = {
7+
id: string;
8+
message: string;
9+
type: AlertType;
10+
hasCountdown?: boolean;
11+
duration?: number;
12+
};

Diff for: src/app/core/services/alert.service.ts

+22-41
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,36 @@
1-
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
2-
import { Injectable, Renderer2, RendererFactory2 } from '@angular/core';
1+
import { Injectable, signal } from '@angular/core';
2+
import type { Alert } from '~core/constants/alerts.constants';
3+
import { AlertType } from '~core/constants/alerts.constants';
34

4-
enum AlertType {
5-
SUCCESS = 'success',
6-
ERROR = 'error',
7-
}
8-
9-
type Alert = {
10-
message: string;
11-
type: AlertType;
12-
hasCountdown?: boolean;
13-
duration?: number;
14-
};
15-
16-
@Injectable({
17-
providedIn: 'root',
18-
})
5+
@Injectable({ providedIn: 'root' })
196
export class AlertService {
20-
private readonly renderer: Renderer2;
7+
private readonly _alerts = signal<Alert[]>([]);
218

22-
constructor(rendererFactory: RendererFactory2) {
23-
this.renderer = rendererFactory.createRenderer(null, null);
24-
}
9+
readonly alerts = this._alerts.asReadonly();
2510

2611
createSuccessAlert(message: string) {
27-
this.createAlert({ message, type: AlertType.SUCCESS, duration: 7000, hasCountdown: true });
12+
this.createAlert({
13+
id: this.generateAlertId(),
14+
message,
15+
type: AlertType.SUCCESS,
16+
duration: 7000,
17+
hasCountdown: true,
18+
});
2819
}
2920

3021
createErrorAlert(message: string) {
31-
this.createAlert({ message, type: AlertType.ERROR });
22+
this.createAlert({ id: this.generateAlertId(), message, type: AlertType.ERROR });
23+
}
24+
25+
removeAlert(alertToRemove: Alert) {
26+
this._alerts.update((alerts) => alerts.filter((alert) => alert !== alertToRemove));
3227
}
3328

34-
private createAlert(alert: Alert): void {
35-
const alertElement = this.createAlertElement(alert);
36-
const container = document.body;
37-
this.renderer.appendChild(container, alertElement);
38-
alertElement.toast();
29+
private createAlert(alert: Alert) {
30+
this._alerts.update((alerts) => [...alerts, alert]);
3931
}
4032

41-
private createAlertElement(alert: Alert): HTMLElement & { toast: () => void } {
42-
const alertElement = this.renderer.createElement('sl-alert');
43-
alertElement.classList.add(`alert--${alert.type}`);
44-
this.renderer.setAttribute(alertElement, 'closable', '');
45-
this.renderer.setAttribute(alertElement, 'variant', alert.type);
46-
if (alert.duration) {
47-
this.renderer.setAttribute(alertElement, 'duration', alert.duration.toString());
48-
}
49-
if (alert.hasCountdown) {
50-
this.renderer.setAttribute(alertElement, 'countdown', 'rtl');
51-
}
52-
this.renderer.setProperty(alertElement, 'innerHTML', alert.message);
53-
return alertElement as HTMLElement & { toast: () => void };
33+
private generateAlertId(): string {
34+
return Math.random().toString(36).slice(2, 9) + Date.now().toString(36);
5435
}
5536
}

Diff for: src/app/core/services/theme-manager.service.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ export class ThemeManagerService {
3434
setTheme(theme: Theme): void {
3535
this._themeSelected.set(theme);
3636
this.localStorage?.setItem(THEME_SELECTED_LOCAL_STORAGE_KEY, this.themeSelected());
37-
this._setBodyClasses();
37+
this.setBodyClasses();
3838
}
3939

40-
private _setBodyClasses(): void {
40+
private setBodyClasses(): void {
4141
const documentClassList = this.document.documentElement.classList;
4242
if (this.themeSelected() === Theme.DARK) {
4343
documentClassList.add(DARK_THEME_CLASS_NAME);

0 commit comments

Comments
 (0)