@* Reusable toast notification component. z-index ladder: Toast container 1090 (this component, on top) ConfirmDialog modal element 1055 (Bootstrap default for .modal) ConfirmDialog backdrop 1040 (Bootstrap default for .modal-backdrop) Toasts intentionally float above ConfirmDialog so confirmation feedback (Success/Error) is visible even while a dialog is open. *@ @implements IDisposable
@foreach (var toast in _toasts) { }
@code { private const int DefaultAutoDismissMs = 5000; private readonly List _toasts = new(); private readonly object _lock = new(); // Cancels all pending auto-dismiss delays when the component is disposed // (CentralUI-010) so their continuations never touch a disposed component. private readonly CancellationTokenSource _disposalCts = new(); /// Number of toasts currently displayed. public int ToastCount { get { lock (_lock) { return _toasts.Count; } } } public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null) { AddToast(title, message, ToastType.Success, autoDismissMs); } public void ShowError(string message, string title = "Error", int? autoDismissMs = null) { AddToast(title, message, ToastType.Error, autoDismissMs); } public void ShowWarning(string message, string title = "Warning", int? autoDismissMs = null) { AddToast(title, message, ToastType.Warning, autoDismissMs); } public void ShowInfo(string message, string title = "Info", int? autoDismissMs = null) { AddToast(title, message, ToastType.Info, autoDismissMs); } private void AddToast(string title, string message, ToastType type, int? autoDismissMs) { // If the component is already disposed, do not add or schedule anything. if (_disposalCts.IsCancellationRequested) return; var toast = new ToastItem { Title = title, Message = message, Type = type }; lock (_lock) { _toasts.Add(toast); } StateHasChanged(); var dismissMs = autoDismissMs ?? DefaultAutoDismissMs; _ = AutoDismissAsync(toast, dismissMs, _disposalCts.Token); } /// /// Removes a toast after its dismiss delay. The delay is bound to the /// component's disposal token (CentralUI-010): if the host page is disposed /// first, the delay is cancelled and the continuation never touches the /// disposed component — no escapes. /// private async Task AutoDismissAsync(ToastItem toast, int dismissMs, CancellationToken token) { try { await Task.Delay(dismissMs, token); } catch (OperationCanceledException) { return; } if (token.IsCancellationRequested) return; lock (_lock) { _toasts.Remove(toast); } try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { // Component disposed between the token check and the render — ignore. } } private void Dismiss(ToastItem toast) { lock (_lock) { _toasts.Remove(toast); } } private static string GetHeaderClass(ToastType type) => type switch { ToastType.Success => "bg-success text-white", ToastType.Error => "bg-danger text-white", ToastType.Warning => "bg-warning text-dark", ToastType.Info => "bg-info text-dark", _ => "bg-secondary text-white" }; public void Dispose() { _disposalCts.Cancel(); _disposalCts.Dispose(); } private enum ToastType { Success, Error, Warning, Info } private class ToastItem { public string Title { get; init; } = ""; public string Message { get; init; } = ""; public ToastType Type { get; init; } } }