Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Shared/ToastNotification.razor
T

145 lines
4.5 KiB
Plaintext

@*
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
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1090;" aria-live="polite" aria-atomic="true">
@foreach (var toast in _toasts)
{
<div class="toast show mb-2" role="alert">
<div class="toast-header @GetHeaderClass(toast.Type)">
<strong class="me-auto">@toast.Title</strong>
<button type="button" class="btn-close btn-close-white" @onclick="() => Dismiss(toast)"></button>
</div>
<div class="toast-body">@toast.Message</div>
</div>
}
</div>
@code {
private const int DefaultAutoDismissMs = 5000;
private readonly List<ToastItem> _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();
/// <summary>Number of toasts currently displayed.</summary>
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);
}
/// <summary>
/// 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 <see cref="ObjectDisposedException"/> escapes.
/// </summary>
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; }
}
}