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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/playground/showcase/src/app/app.routes.server.ts +4 -0
  3. package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.html +1 -1
  4. package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.html +1 -1
  5. package/playground/showcase/src/app/components/toast/lib/models/toast.model.ts +11 -0
  6. package/playground/showcase/src/app/components/toast/lib/services/toast.service.spec.ts +16 -0
  7. package/playground/showcase/src/app/components/toast/lib/services/toast.service.ts +113 -0
  8. package/playground/showcase/src/app/components/toast/lib/types/toast-position.type.ts +10 -0
  9. package/playground/showcase/src/app/components/toast/lib/types/toast-type.type.ts +1 -0
  10. package/playground/showcase/src/app/components/toast/toast-container/toast-container.html +29 -0
  11. package/playground/showcase/src/app/components/toast/toast-container/toast-container.spec.ts +23 -0
  12. package/playground/showcase/src/app/components/toast/toast-container/toast-container.ts +149 -0
  13. package/playground/showcase/src/app/components/toast/toast-item/toast-item.html +137 -0
  14. package/playground/showcase/src/app/components/toast/toast-item/toast-item.spec.ts +23 -0
  15. package/playground/showcase/src/app/components/toast/toast-item/toast-item.ts +118 -0
  16. package/playground/showcase/src/app/components/toast/toast.css +0 -0
  17. package/playground/showcase/src/app/components/toast/toast.html +1 -0
  18. package/playground/showcase/src/app/components/toast/toast.spec.ts +23 -0
  19. package/playground/showcase/src/app/components/toast/toast.ts +13 -0
  20. package/playground/showcase/src/app/pages/documentation/components/components.html +42 -0
  21. package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +4 -0
  22. package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.css +0 -0
  23. package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.examples.ts +463 -0
  24. package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.html +719 -0
  25. package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.spec.ts +23 -0
  26. package/playground/showcase/src/app/pages/documentation/components/toast-docs/toast-docs.ts +248 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wally-ui",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "About Where’s Wally? Right here — bringing you ready-to-use Angular components with Wally-UI. Stop searching, start building.",
5
5
  "bin": {
6
6
  "wally": "dist/cli.js"
@@ -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()"
@@ -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,10 @@
1
+ export type ToastPosition =
2
+ | 'top-left'
3
+ | 'top-center'
4
+ | 'top-right'
5
+ | 'center-left'
6
+ | 'center'
7
+ | 'center-right'
8
+ | 'bottom-left'
9
+ | 'bottom-center'
10
+ | 'bottom-right';
@@ -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
+ }
@@ -0,0 +1 @@
1
+ <wally-toast-container></wally-toast-container>