wally-ui 1.18.0 → 1.19.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/package.json +1 -1
- package/playground/showcase/src/app/app.routes.server.ts +4 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.html +1 -1
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.html +1 -1
- package/playground/showcase/src/app/components/toast/lib/models/toast.model.ts +11 -0
- package/playground/showcase/src/app/components/toast/lib/services/toast.service.spec.ts +16 -0
- package/playground/showcase/src/app/components/toast/lib/services/toast.service.ts +113 -0
- package/playground/showcase/src/app/components/toast/lib/types/toast-position.type.ts +10 -0
- package/playground/showcase/src/app/components/toast/lib/types/toast-type.type.ts +1 -0
- package/playground/showcase/src/app/components/toast/toast-container/toast-container.html +29 -0
- package/playground/showcase/src/app/components/toast/toast-container/toast-container.spec.ts +23 -0
- package/playground/showcase/src/app/components/toast/toast-container/toast-container.ts +149 -0
- package/playground/showcase/src/app/components/toast/toast-item/toast-item.html +137 -0
- package/playground/showcase/src/app/components/toast/toast-item/toast-item.spec.ts +23 -0
- package/playground/showcase/src/app/components/toast/toast-item/toast-item.ts +118 -0
- package/playground/showcase/src/app/components/toast/toast.css +0 -0
- package/playground/showcase/src/app/components/toast/toast.html +1 -0
- package/playground/showcase/src/app/components/toast/toast.spec.ts +23 -0
- package/playground/showcase/src/app/components/toast/toast.ts +13 -0
- package/playground/showcase/src/app/pages/documentation/components/components.html +42 -0
- package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +4 -0
- package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.css +0 -0
- package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.examples.ts +463 -0
- package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.html +719 -0
- package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.spec.ts +23 -0
- package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.ts +248 -0
package/package.json
CHANGED
|
@@ -49,6 +49,10 @@ export const serverRoutes: ServerRoute[] = [
|
|
|
49
49
|
path: 'documentation/components/dialog',
|
|
50
50
|
renderMode: RenderMode.Prerender,
|
|
51
51
|
},
|
|
52
|
+
{
|
|
53
|
+
path: 'documentation/components/toast',
|
|
54
|
+
renderMode: RenderMode.Prerender,
|
|
55
|
+
},
|
|
52
56
|
{
|
|
53
57
|
path: 'documentation/chat-sdk',
|
|
54
58
|
renderMode: RenderMode.Prerender,
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
<input
|
|
28
28
|
#inputElement
|
|
29
29
|
type="text"
|
|
30
|
-
class="flex-1 min-w-[120px] outline-none bg-transparent text-[#0a0a0a] dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 text-sm"
|
|
30
|
+
class="flex-1 min-w-[120px] outline-none bg-transparent text-[#0a0a0a] dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 text-base md:text-sm"
|
|
31
31
|
[placeholder]="comboboxService.selectedItems().length === 0 ? comboboxService.placeholder() : ''"
|
|
32
32
|
[value]="comboboxService.searchQuery()"
|
|
33
33
|
[disabled]="comboboxService.disabled()"
|
package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.html
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<div class="p-2 border-b border-neutral-200 dark:border-neutral-700">
|
|
2
2
|
<input
|
|
3
3
|
type="text"
|
|
4
|
-
class="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-[#0a0a0a] dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 transition-all duration-200"
|
|
4
|
+
class="w-full px-3 py-2 text-base md:text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-[#0a0a0a] dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 transition-all duration-200"
|
|
5
5
|
[placeholder]="comboboxService.placeholder()"
|
|
6
6
|
[value]="comboboxService.searchQuery()"
|
|
7
7
|
(input)="onInput($event)"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ToastPosition } from '../types/toast-position.type';
|
|
2
|
+
import { ToastType } from '../types/toast-type.type';
|
|
3
|
+
|
|
4
|
+
export interface Toast {
|
|
5
|
+
id: number;
|
|
6
|
+
type: ToastType;
|
|
7
|
+
title?: string;
|
|
8
|
+
message: string;
|
|
9
|
+
duration?: number;
|
|
10
|
+
position?: ToastPosition;
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { ToastService } from './toast.service';
|
|
4
|
+
|
|
5
|
+
describe('ToastService', () => {
|
|
6
|
+
let service: ToastService;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
TestBed.configureTestingModule({});
|
|
10
|
+
service = TestBed.inject(ToastService);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should be created', () => {
|
|
14
|
+
expect(service).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Injectable, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
import { ToastPosition } from '../types/toast-position.type';
|
|
4
|
+
import { Toast } from '../models/toast.model';
|
|
5
|
+
|
|
6
|
+
@Injectable({
|
|
7
|
+
providedIn: 'root',
|
|
8
|
+
})
|
|
9
|
+
export class ToastService {
|
|
10
|
+
private _toasts = signal<Toast[]>([]);
|
|
11
|
+
private _defaultPosition = signal<ToastPosition>('top-center');
|
|
12
|
+
private _timeouts = new Map<number, { timeout: ReturnType<typeof setTimeout>, remainingTime: number, startTime: number }>();
|
|
13
|
+
private _pausedToasts = new Set<number>();
|
|
14
|
+
|
|
15
|
+
readonly toasts = this._toasts.asReadonly();
|
|
16
|
+
readonly defaultPosition = this._defaultPosition.asReadonly();
|
|
17
|
+
|
|
18
|
+
setDefaultPosition(position: ToastPosition): void {
|
|
19
|
+
this._defaultPosition.set(position);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a new toast notification.
|
|
24
|
+
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* - Loading and neutral toasts have duration=0 (no auto-dismiss, must be removed manually)
|
|
27
|
+
* - Other types default to 5000ms auto-dismiss
|
|
28
|
+
*
|
|
29
|
+
* @returns The toast ID - save this to remove loading toasts manually
|
|
30
|
+
*/
|
|
31
|
+
create(toast: Omit<Toast, "id">): number {
|
|
32
|
+
const id: number = Date.now();
|
|
33
|
+
const duration: number = (toast.type === 'loading' || toast.type === 'neutral') ? 0 : (toast.duration ?? 5000);
|
|
34
|
+
|
|
35
|
+
const newToast: Toast = {
|
|
36
|
+
id: id,
|
|
37
|
+
duration: duration,
|
|
38
|
+
position: toast.position || this._defaultPosition(),
|
|
39
|
+
...toast
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this._toasts.update(currentToasts => [...currentToasts, newToast]);
|
|
43
|
+
|
|
44
|
+
if (duration > 0) {
|
|
45
|
+
this.scheduleRemoval(id, duration);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private scheduleRemoval(id: number, duration: number): void {
|
|
52
|
+
const timeout = setTimeout(() => {
|
|
53
|
+
this.remove(id);
|
|
54
|
+
this._timeouts.delete(id);
|
|
55
|
+
}, duration);
|
|
56
|
+
|
|
57
|
+
this._timeouts.set(id, {
|
|
58
|
+
timeout,
|
|
59
|
+
remainingTime: duration,
|
|
60
|
+
startTime: Date.now()
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pauses auto-dismiss timer for a toast (e.g., when hovering).
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* Calculates elapsed time since timer started and subtracts from remaining time.
|
|
69
|
+
* This allows resuming with the correct remaining duration.
|
|
70
|
+
*/
|
|
71
|
+
pause(id: number): void {
|
|
72
|
+
const timeoutData = this._timeouts.get(id);
|
|
73
|
+
|
|
74
|
+
if (timeoutData && !this._pausedToasts.has(id)) {
|
|
75
|
+
clearTimeout(timeoutData.timeout);
|
|
76
|
+
const elapsed = Date.now() - timeoutData.startTime;
|
|
77
|
+
timeoutData.remainingTime = Math.max(0, timeoutData.remainingTime - elapsed);
|
|
78
|
+
this._pausedToasts.add(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resumes auto-dismiss timer for a paused toast.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* Creates a new setTimeout with the remaining time calculated during pause.
|
|
87
|
+
* Updates startTime to now for accurate future pause calculations.
|
|
88
|
+
*/
|
|
89
|
+
resume(id: number): void {
|
|
90
|
+
const timeoutData = this._timeouts.get(id);
|
|
91
|
+
|
|
92
|
+
if (timeoutData && this._pausedToasts.has(id)) {
|
|
93
|
+
this._pausedToasts.delete(id);
|
|
94
|
+
timeoutData.startTime = Date.now();
|
|
95
|
+
timeoutData.timeout = setTimeout(() => {
|
|
96
|
+
this.remove(id);
|
|
97
|
+
this._timeouts.delete(id);
|
|
98
|
+
}, timeoutData.remainingTime);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
remove(id: number): void {
|
|
103
|
+
const timeoutData = this._timeouts.get(id);
|
|
104
|
+
|
|
105
|
+
if (timeoutData) {
|
|
106
|
+
clearTimeout(timeoutData.timeout);
|
|
107
|
+
this._timeouts.delete(id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this._pausedToasts.delete(id);
|
|
111
|
+
this._toasts.update(current => current.filter(toast => toast.id !== id));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'loading' | 'neutral';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
@for (position of visiblePositions(); track position) {
|
|
2
|
+
<div
|
|
3
|
+
class="fixed z-50 pointer-events-none"
|
|
4
|
+
[class]="getPositionClasses(position)"
|
|
5
|
+
>
|
|
6
|
+
<div
|
|
7
|
+
class="relative pointer-events-auto transition-all duration-500 ease-in-out w-[456px] max-w-[calc(100vw-2rem)]"
|
|
8
|
+
[class.flex]="getHoverSignal(position)()"
|
|
9
|
+
[class.flex-col]="getHoverSignal(position)()"
|
|
10
|
+
[class.gap-2]="getHoverSignal(position)()"
|
|
11
|
+
[style.height]="getContainerHeight(position)"
|
|
12
|
+
[style.transform-origin]="getTransformOrigin(position)"
|
|
13
|
+
(mouseenter)="onMouseEnter(position)"
|
|
14
|
+
(mouseleave)="onMouseLeave(position)"
|
|
15
|
+
>
|
|
16
|
+
@for (
|
|
17
|
+
toast of getToastsForPosition(position);
|
|
18
|
+
track trackByToastId($index, toast)
|
|
19
|
+
) {
|
|
20
|
+
<wally-toast-item
|
|
21
|
+
[toast]="toast"
|
|
22
|
+
[visualIndex]="getVisualIndex(toast, position)"
|
|
23
|
+
[totalVisible]="getToastsForPosition(position).length"
|
|
24
|
+
[isHovered]="getHoverSignal(position)()"
|
|
25
|
+
/>
|
|
26
|
+
}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { ToastContainer } from './toast-container';
|
|
4
|
+
|
|
5
|
+
describe('ToastContainer', () => {
|
|
6
|
+
let component: ToastContainer;
|
|
7
|
+
let fixture: ComponentFixture<ToastContainer>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [ToastContainer]
|
|
12
|
+
})
|
|
13
|
+
.compileComponents();
|
|
14
|
+
|
|
15
|
+
fixture = TestBed.createComponent(ToastContainer);
|
|
16
|
+
component = fixture.componentInstance;
|
|
17
|
+
fixture.detectChanges();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should create', () => {
|
|
21
|
+
expect(component).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Component, computed, inject, Signal, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
import { ToastPosition } from '../lib/types/toast-position.type';
|
|
4
|
+
import { ToastService } from '../lib/services/toast.service';
|
|
5
|
+
import { ToastItem } from '../toast-item/toast-item';
|
|
6
|
+
import { Toast } from '../lib/models/toast.model';
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'wally-toast-container',
|
|
10
|
+
imports: [ToastItem],
|
|
11
|
+
templateUrl: './toast-container.html',
|
|
12
|
+
// standalone: true, (If your application is lower than Angular 19, uncomment this line)
|
|
13
|
+
})
|
|
14
|
+
export class ToastContainer {
|
|
15
|
+
private toastService = inject(ToastService);
|
|
16
|
+
|
|
17
|
+
private readonly MAX_VISIBLE = 5;
|
|
18
|
+
|
|
19
|
+
private hoverStates = new Map<ToastPosition, ReturnType<typeof signal<boolean>>>();
|
|
20
|
+
|
|
21
|
+
toastsByPosition: Signal<Record<ToastPosition, Toast[]>> = computed(() => {
|
|
22
|
+
const toasts = this.toastService.toasts();
|
|
23
|
+
const grouped: Record<ToastPosition, Toast[]> = {
|
|
24
|
+
'top-left': [],
|
|
25
|
+
'top-center': [],
|
|
26
|
+
'top-right': [],
|
|
27
|
+
'center-left': [],
|
|
28
|
+
'center': [],
|
|
29
|
+
'center-right': [],
|
|
30
|
+
'bottom-left': [],
|
|
31
|
+
'bottom-center': [],
|
|
32
|
+
'bottom-right': []
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
toasts.forEach(toast => {
|
|
36
|
+
const position = toast.position || 'top-center';
|
|
37
|
+
grouped[position].push(toast);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return grouped;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
visiblePositions: Signal<ToastPosition[]> = computed(() => {
|
|
44
|
+
const grouped = this.toastsByPosition();
|
|
45
|
+
return (Object.keys(grouped) as ToastPosition[]).filter(
|
|
46
|
+
pos => grouped[pos].length > 0
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
getPositionClasses(position: ToastPosition): string {
|
|
51
|
+
const positionMap: Record<ToastPosition, string> = {
|
|
52
|
+
'top-left': 'top-4 left-4',
|
|
53
|
+
'top-center': 'top-4 left-1/2 -translate-x-1/2',
|
|
54
|
+
'top-right': 'top-4 right-4',
|
|
55
|
+
'center-left': 'top-1/2 left-4 -translate-y-1/2',
|
|
56
|
+
'center': 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
|
57
|
+
'center-right': 'top-1/2 right-4 -translate-y-1/2',
|
|
58
|
+
'bottom-left': 'bottom-4 left-4',
|
|
59
|
+
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
|
|
60
|
+
'bottom-right': 'bottom-4 right-4'
|
|
61
|
+
};
|
|
62
|
+
return positionMap[position];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getTransformOrigin(position: ToastPosition): string {
|
|
66
|
+
if (position.startsWith('top')) return 'top';
|
|
67
|
+
if (position.startsWith('bottom')) return 'bottom';
|
|
68
|
+
return 'center';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Gets toasts for a position, limiting to MAX_VISIBLE (5) newest.
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* - slice(-5): Gets last 5 toasts (newest)
|
|
76
|
+
* - reverse(): Puts newest first (index 0 = front of stack)
|
|
77
|
+
*/
|
|
78
|
+
getToastsForPosition(position: ToastPosition): Toast[] {
|
|
79
|
+
const toasts = this.toastsByPosition()[position];
|
|
80
|
+
return toasts.slice(-this.MAX_VISIBLE).reverse();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getVisualIndex(toast: Toast, position: ToastPosition): number {
|
|
84
|
+
const toasts = this.getToastsForPosition(position);
|
|
85
|
+
return toasts.indexOf(toast);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
trackByToastId(_index: number, toast: Toast): number {
|
|
89
|
+
return toast.id;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getHoverSignal(position: ToastPosition): Signal<boolean> {
|
|
93
|
+
if (!this.hoverStates.has(position)) {
|
|
94
|
+
this.hoverStates.set(position, signal(false));
|
|
95
|
+
}
|
|
96
|
+
return this.hoverStates.get(position)!;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
onMouseEnter(position: ToastPosition): void {
|
|
100
|
+
const hoverSignal = this.hoverStates.get(position);
|
|
101
|
+
if (hoverSignal) {
|
|
102
|
+
hoverSignal.set(true);
|
|
103
|
+
} else {
|
|
104
|
+
this.hoverStates.set(position, signal(true));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const toasts = this.getToastsForPosition(position);
|
|
108
|
+
toasts.forEach(toast => {
|
|
109
|
+
this.toastService.pause(toast.id);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onMouseLeave(position: ToastPosition): void {
|
|
114
|
+
const hoverSignal = this.hoverStates.get(position);
|
|
115
|
+
if (hoverSignal) {
|
|
116
|
+
hoverSignal.set(false);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toasts = this.getToastsForPosition(position);
|
|
120
|
+
toasts.forEach(toast => {
|
|
121
|
+
this.toastService.resume(toast.id);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Calculates container height for stacking effect.
|
|
127
|
+
*
|
|
128
|
+
* @remarks
|
|
129
|
+
* Hovered: 'auto' - flexbox with gap-3 handles spacing
|
|
130
|
+
*
|
|
131
|
+
* Stacked: 64px base + 8px per additional toast
|
|
132
|
+
* - 1 toast: 64px
|
|
133
|
+
* - 2 toasts: 64 + 8 = 72px
|
|
134
|
+
* - 5 toasts: 64 + 32 = 96px
|
|
135
|
+
*
|
|
136
|
+
* The 8px matches the visible border offset between stacked toasts
|
|
137
|
+
*/
|
|
138
|
+
getContainerHeight(position: ToastPosition): string {
|
|
139
|
+
const toasts = this.getToastsForPosition(position);
|
|
140
|
+
const count = toasts.length;
|
|
141
|
+
const hovered = this.getHoverSignal(position)();
|
|
142
|
+
|
|
143
|
+
if (hovered) {
|
|
144
|
+
return 'auto';
|
|
145
|
+
} else {
|
|
146
|
+
return `${64 + (count - 1) * 8}px`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<div
|
|
2
|
+
class="transition-all duration-500 ease-in-out origin-top w-full sm:w-[456px] max-w-[calc(100vw-2rem)]"
|
|
3
|
+
[class.absolute]="!isHovered()"
|
|
4
|
+
[style.z-index]="zIndex()"
|
|
5
|
+
[style.transform]="transformStyle()"
|
|
6
|
+
[style.opacity]="isClosing() ? 0 : opacityStyle()"
|
|
7
|
+
role="alert"
|
|
8
|
+
aria-live="polite"
|
|
9
|
+
>
|
|
10
|
+
<div
|
|
11
|
+
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-white dark:bg-[#262626] border border-neutral-300 dark:border-neutral-700 shadow-lg transition-all duration-200 w-full antialiased"
|
|
12
|
+
>
|
|
13
|
+
@if (toast().type !== "neutral") {
|
|
14
|
+
<div class="flex-shrink-0" [class]="iconColorClasses()">
|
|
15
|
+
@switch (toast().type) {
|
|
16
|
+
@case ("success") {
|
|
17
|
+
<svg
|
|
18
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
19
|
+
fill="none"
|
|
20
|
+
viewBox="0 0 24 24"
|
|
21
|
+
stroke-width="2"
|
|
22
|
+
stroke="currentColor"
|
|
23
|
+
class="w-4 h-4"
|
|
24
|
+
>
|
|
25
|
+
<path
|
|
26
|
+
stroke-linecap="round"
|
|
27
|
+
stroke-linejoin="round"
|
|
28
|
+
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
|
29
|
+
/>
|
|
30
|
+
</svg>
|
|
31
|
+
}
|
|
32
|
+
@case ("error") {
|
|
33
|
+
<svg
|
|
34
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
35
|
+
fill="none"
|
|
36
|
+
viewBox="0 0 24 24"
|
|
37
|
+
stroke-width="2"
|
|
38
|
+
stroke="currentColor"
|
|
39
|
+
class="w-4 h-4"
|
|
40
|
+
>
|
|
41
|
+
<path
|
|
42
|
+
stroke-linecap="round"
|
|
43
|
+
stroke-linejoin="round"
|
|
44
|
+
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
|
45
|
+
/>
|
|
46
|
+
</svg>
|
|
47
|
+
}
|
|
48
|
+
@case ("info") {
|
|
49
|
+
<svg
|
|
50
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
51
|
+
fill="none"
|
|
52
|
+
viewBox="0 0 24 24"
|
|
53
|
+
stroke-width="2"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
class="w-4 h-4"
|
|
56
|
+
>
|
|
57
|
+
<path
|
|
58
|
+
stroke-linecap="round"
|
|
59
|
+
stroke-linejoin="round"
|
|
60
|
+
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
|
61
|
+
/>
|
|
62
|
+
</svg>
|
|
63
|
+
}
|
|
64
|
+
@case ("warning") {
|
|
65
|
+
<svg
|
|
66
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
67
|
+
fill="none"
|
|
68
|
+
viewBox="0 0 24 24"
|
|
69
|
+
stroke-width="2"
|
|
70
|
+
stroke="currentColor"
|
|
71
|
+
class="w-4 h-4"
|
|
72
|
+
>
|
|
73
|
+
<path
|
|
74
|
+
stroke-linecap="round"
|
|
75
|
+
stroke-linejoin="round"
|
|
76
|
+
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
|
77
|
+
/>
|
|
78
|
+
</svg>
|
|
79
|
+
}
|
|
80
|
+
@case ("loading") {
|
|
81
|
+
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
82
|
+
<circle
|
|
83
|
+
class="opacity-25"
|
|
84
|
+
cx="12"
|
|
85
|
+
cy="12"
|
|
86
|
+
r="10"
|
|
87
|
+
stroke="currentColor"
|
|
88
|
+
stroke-width="4"
|
|
89
|
+
></circle>
|
|
90
|
+
<path
|
|
91
|
+
class="opacity-75"
|
|
92
|
+
fill="currentColor"
|
|
93
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
94
|
+
></path>
|
|
95
|
+
</svg>
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</div>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
<div class="flex-1 min-w-0">
|
|
102
|
+
@if (toast().title) {
|
|
103
|
+
<div
|
|
104
|
+
class="font-semibold text-base text-neutral-900 dark:text-white mb-1"
|
|
105
|
+
>
|
|
106
|
+
{{ toast().title }}
|
|
107
|
+
</div>
|
|
108
|
+
}
|
|
109
|
+
<div
|
|
110
|
+
class="text-[15px] text-neutral-600 dark:text-neutral-300 leading-snug"
|
|
111
|
+
>
|
|
112
|
+
{{ toast().message }}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class="flex-shrink-0 p-1 rounded-md hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
|
|
119
|
+
(click)="onClose()"
|
|
120
|
+
aria-label="Close notification"
|
|
121
|
+
>
|
|
122
|
+
<svg
|
|
123
|
+
class="w-4 h-4"
|
|
124
|
+
fill="none"
|
|
125
|
+
stroke="currentColor"
|
|
126
|
+
viewBox="0 0 24 24"
|
|
127
|
+
>
|
|
128
|
+
<path
|
|
129
|
+
stroke-linecap="round"
|
|
130
|
+
stroke-linejoin="round"
|
|
131
|
+
stroke-width="2"
|
|
132
|
+
d="M6 18L18 6M6 6l12 12"
|
|
133
|
+
></path>
|
|
134
|
+
</svg>
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { ToastItem } from './toast-item';
|
|
4
|
+
|
|
5
|
+
describe('ToastItem', () => {
|
|
6
|
+
let component: ToastItem;
|
|
7
|
+
let fixture: ComponentFixture<ToastItem>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [ToastItem]
|
|
12
|
+
})
|
|
13
|
+
.compileComponents();
|
|
14
|
+
|
|
15
|
+
fixture = TestBed.createComponent(ToastItem);
|
|
16
|
+
component = fixture.componentInstance;
|
|
17
|
+
fixture.detectChanges();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should create', () => {
|
|
21
|
+
expect(component).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Component, computed, effect, inject, input, Signal, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
import { ToastService } from '../lib/services/toast.service';
|
|
4
|
+
import { ToastType } from '../lib/types/toast-type.type';
|
|
5
|
+
import { Toast } from '../lib/models/toast.model';
|
|
6
|
+
|
|
7
|
+
@Component({
|
|
8
|
+
selector: 'wally-toast-item',
|
|
9
|
+
imports: [],
|
|
10
|
+
templateUrl: './toast-item.html',
|
|
11
|
+
// standalone: true, (If your application is lower than Angular 19, uncomment this line)
|
|
12
|
+
})
|
|
13
|
+
export class ToastItem {
|
|
14
|
+
private toastService = inject(ToastService);
|
|
15
|
+
|
|
16
|
+
toast = input.required<Toast>();
|
|
17
|
+
visualIndex = input.required<number>();
|
|
18
|
+
totalVisible = input.required<number>();
|
|
19
|
+
isHovered = input<boolean>(false);
|
|
20
|
+
|
|
21
|
+
isClosing = signal(false);
|
|
22
|
+
remainingTime = signal(100);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calculates CSS transform for stacking effect.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Stacked (not hovered):
|
|
29
|
+
* - Index 0 (newest): No offset, full scale
|
|
30
|
+
* - Index 1+: Moves DOWN by 13px per level, scales down by 2% per level
|
|
31
|
+
*
|
|
32
|
+
* Hovered: Flexbox handles positioning, only removes scale
|
|
33
|
+
*/
|
|
34
|
+
transformStyle: Signal<string> = computed(() => {
|
|
35
|
+
const index = this.visualIndex();
|
|
36
|
+
const hovered = this.isHovered();
|
|
37
|
+
|
|
38
|
+
if (hovered) {
|
|
39
|
+
return 'scale(1)';
|
|
40
|
+
} else {
|
|
41
|
+
if (index === 0) {
|
|
42
|
+
return 'translateY(0) scale(1)';
|
|
43
|
+
} else {
|
|
44
|
+
// Push older toasts DOWN to show stacked borders behind newest
|
|
45
|
+
// 13px = enough to show ~6-8px of the toast border below
|
|
46
|
+
const yOffset = index * 13;
|
|
47
|
+
const scale = 1 - (index * 0.02); // 2% smaller per level (0.98, 0.96, 0.94, 0.92)
|
|
48
|
+
return `translateY(${yOffset}px) scale(${scale})`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Calculates opacity for depth perception in stack.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* - Index 0 or hovered: Full opacity (1.0)
|
|
58
|
+
* - Index 1+: Reduces by 12% per level (0.88, 0.76, 0.64, 0.52)
|
|
59
|
+
*/
|
|
60
|
+
opacityStyle: Signal<number> = computed(() => {
|
|
61
|
+
const index = this.visualIndex();
|
|
62
|
+
const hovered = this.isHovered();
|
|
63
|
+
|
|
64
|
+
if (hovered || index === 0) {
|
|
65
|
+
return 1;
|
|
66
|
+
} else {
|
|
67
|
+
return 1 - (index * 0.12); // 12% opacity reduction per level
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
zIndex: Signal<number> = computed(() => {
|
|
72
|
+
return 50 - this.visualIndex();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
iconColorClasses: Signal<string> = computed(() => {
|
|
76
|
+
const colorMap: Record<ToastType, string> = {
|
|
77
|
+
success: 'text-green-500',
|
|
78
|
+
error: 'text-red-500',
|
|
79
|
+
info: 'text-blue-500',
|
|
80
|
+
warning: 'text-yellow-500',
|
|
81
|
+
loading: 'text-neutral-400',
|
|
82
|
+
neutral: 'text-neutral-400'
|
|
83
|
+
};
|
|
84
|
+
return colorMap[this.toast().type];
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
constructor() {
|
|
88
|
+
effect(() => {
|
|
89
|
+
const toast = this.toast();
|
|
90
|
+
if (toast.duration && toast.duration > 0) {
|
|
91
|
+
this.startProgressTimer(toast.duration);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private startProgressTimer(duration: number): void {
|
|
97
|
+
const interval = 50;
|
|
98
|
+
const steps = duration / interval;
|
|
99
|
+
let currentStep = 0;
|
|
100
|
+
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
currentStep++;
|
|
103
|
+
const percentage = 100 - (currentStep / steps) * 100;
|
|
104
|
+
this.remainingTime.set(Math.max(0, percentage));
|
|
105
|
+
|
|
106
|
+
if (currentStep >= steps) {
|
|
107
|
+
clearInterval(timer);
|
|
108
|
+
}
|
|
109
|
+
}, interval);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onClose(): void {
|
|
113
|
+
this.isClosing.set(true);
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
this.toastService.remove(this.toast().id);
|
|
116
|
+
}, 200);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<wally-toast-container></wally-toast-container>
|