From 2441919981b06bb21aa249fc2e2715ce20148d07 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Thu, 18 Jun 2026 17:53:07 +0200 Subject: [PATCH 01/43] Refactor MessageBarService to NotificationService --- .../Message/CustomizedMessageBar.razor | 2 +- .../MessageBarServiceCustomized.razor | 10 +- .../Examples/MessageBarServiceDefault.razor | 10 +- .../Examples/MessageBarServiceOptions.razor | 4 +- .../Components/MessageBar/FluentMessageBar.md | 18 ++-- .../MessageBar/FluentMessageBar.razor.cs | 2 +- .../MessageBar/FluentMessageBarProvider.razor | 2 +- .../FluentMessageBarProvider.razor.cs | 10 +- .../Services/IMessageBarInstance.cs | 2 +- ...eBarService.cs => INotificationService.cs} | 20 ++-- .../MessageBar/Services/MessageBarInstance.cs | 16 ++-- .../MessageBar/Services/MessageBarOptions.cs | 2 +- .../MessageBar/Services/MessageBarResult.cs | 2 +- ....cs => NotificationService.Subscribers.cs} | 4 +- ...geBarService.cs => NotificationService.cs} | 58 ++++++------ src/Core/Events/MessageBarEventArgs.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../FluentMessageBarProviderTests.razor | 94 +++++++++---------- .../MessageBar/MessageBarEventArgsTests.cs | 4 +- .../MessageBar/MessageBarInstanceTests.cs | 22 ++--- .../MessageBar/MessageBarServiceTests.cs | 22 ++--- 21 files changed, 157 insertions(+), 157 deletions(-) rename src/Core/Components/MessageBar/Services/{IMessageBarService.cs => INotificationService.cs} (82%) rename src/Core/Components/MessageBar/Services/{MessageBarService.Subscribers.cs => NotificationService.Subscribers.cs} (95%) rename src/Core/Components/MessageBar/Services/{MessageBarService.cs => NotificationService.cs} (73%) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/Message/CustomizedMessageBar.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/Message/CustomizedMessageBar.razor index a04473aac9..c7889c5d95 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/Message/CustomizedMessageBar.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/Message/CustomizedMessageBar.razor @@ -13,7 +13,7 @@ @code { // If you want to use this razor component in standalone mode, // you can use a nullable IMessageBarInstance property. - // If the value is not null, the component is running using the MessageBarService. + // If the value is not null, the component is running using the NotificationService. // `public IMessageBarInstance? MessageBar { get; set; }` [CascadingParameter] public required IMessageBarInstance MessageBar { get; set; } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceCustomized.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceCustomized.razor index 6d4ba7367d..4d8645c980 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceCustomized.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceCustomized.razor @@ -1,4 +1,4 @@ -@inject IMessageBarService MessageBarService +@inject INotificationService NotificationService @@ -20,12 +20,12 @@ async Task ShowCustomizedMessageBar() { - var result = await MessageBarService.ShowMessageAsync(options => + var result = await NotificationService.ShowMessageBarAsync(options => { options.Section = MESSAGEBAR_SECTION; options.Intent = MessageBarIntent.Success; - options.Title = "Hello from the MessageBarService!"; + options.Title = "Hello from the NotificationService!"; options.Lifetime = TimeSpan.FromSeconds(10); }); @@ -34,11 +34,11 @@ async Task ShowCustomizedComponent() { - var result = await MessageBarService.ShowMessageAsync(options => + var result = await NotificationService.ShowMessageBarAsync(options => { options.Section = MESSAGEBAR_SECTION; - options.Title = "Hello from the MessageBarService!"; + options.Title = "Hello from the NotificationService!"; options.Lifetime = TimeSpan.FromSeconds(5); }); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceDefault.razor index 573da30f51..a07b4c487b 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceDefault.razor @@ -1,21 +1,21 @@ -@inject IMessageBarService MessageBarService +@inject INotificationService NotificationService - + Success - + Warning - + Error - + Info diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceOptions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceOptions.razor index 91f710f818..9a62852b52 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceOptions.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarServiceOptions.razor @@ -1,4 +1,4 @@ -@inject IMessageBarService MessageBarService +@inject INotificationService NotificationService @@ -14,7 +14,7 @@ async Task ShowMessageBar() { - var result = await MessageBarService.ShowMessageAsync(options => + var result = await NotificationService.ShowMessageBarAsync(options => { options.Section = MESSAGEBAR_SECTION; diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md index 37601171c3..30361ac7b1 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md @@ -41,16 +41,16 @@ If you want to display an Action and a TimeStamp, you can use the `ActionsTempla {{ MessageBarLayouts }} -## Message Service +## Notification Service -Use the `MessageBarService` to display message bars from C# code (for example, from an event handler, a service, or after an API call). +Use the `NotificationService` to display message bars from C# code (for example, from an event handler, a service, or after an API call). The service is registered automatically when you call `AddFluentUIComponents()` in your `Program.cs`: You can then inject it into any component or service: ```csharp -@inject IMessageBarService MessageBarService +@inject INotificationService NotificationService ``` **FluentMessageBarProvider** @@ -76,10 +76,10 @@ and a local provider scoped to a specific panel or dialog), and to route each me The simplest way to display a message is to use one of the typed helpers, passing the target section and the message text: -- `MessageBarService.ShowSuccessMessageAsync("SECTION", "Title", "Message")` -- `MessageBarService.ShowWarningMessageAsync("SECTION", "Title", "Message")` -- `MessageBarService.ShowErrorMessageAsync("SECTION", "Title", "Message")` -- `MessageBarService.ShowInfoMessageAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowSuccessMessageAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowWarningMessageAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowErrorMessageAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowInfoMessageAsync("SECTION", "Title", "Message")` {{ MessageBarServiceDefault }} @@ -101,9 +101,9 @@ This is useful when the default layout is not enough and you need to render rich {{ API Type=FluentMessageBar }} -## API MessageBarService +## API NotificationService -{{ API Type=MessageBarService }} +{{ API Type=NotificationService }} ## API MessageBarOptions diff --git a/src/Core/Components/MessageBar/FluentMessageBar.razor.cs b/src/Core/Components/MessageBar/FluentMessageBar.razor.cs index 64bca39993..bed834f2ee 100644 --- a/src/Core/Components/MessageBar/FluentMessageBar.razor.cs +++ b/src/Core/Components/MessageBar/FluentMessageBar.razor.cs @@ -29,7 +29,7 @@ public FluentMessageBar(LibraryConfiguration configuration) : base(configuration .Build(); /// - /// Gets the instance, if the message is rendered using the . Otherwise, returns null. + /// Gets the instance, if the message is rendered using the . Otherwise, returns null. /// [CascadingParameter] internal IMessageBarInstance? MessageBarInstance { get; set; } diff --git a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor index 11fba2a8e2..984dbb8621 100644 --- a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor +++ b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor @@ -8,7 +8,7 @@ class="@ClassValue" style="@StyleValue" @attributes="AdditionalAttributes"> - @if (MessageBarService != null) + @if (NotificationService != null) { @foreach (var messageBar in GetRenderedMessageBars()) { diff --git a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs index 87ae90e7fd..ada6470c9c 100644 --- a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs +++ b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs @@ -8,7 +8,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Container component that renders all message bars registered with the . +/// Container component that renders all message bars registered with the . /// public partial class FluentMessageBarProvider : FluentComponentBase, IDisposable { @@ -36,14 +36,14 @@ public FluentMessageBarProvider(LibraryConfiguration configuration) : base(confi public required string Section { get; set; } /// - protected virtual IMessageBarService? MessageBarService => GetCachedServiceOrNull(); + protected virtual INotificationService? NotificationService => GetCachedServiceOrNull(); /// protected override void OnInitialized() { base.OnInitialized(); - if (MessageBarService is MessageBarService service) + if (NotificationService is NotificationService service) { // Register this provider as a subscriber. Multiple providers can coexist: // each one is notified and decides (via Section) which messages to render. @@ -54,7 +54,7 @@ protected override void OnInitialized() /// public void Dispose() { - if (MessageBarService is MessageBarService service && !string.IsNullOrEmpty(Id)) + if (NotificationService is NotificationService service && !string.IsNullOrEmpty(Id)) { service.Unsubscribe(Id); } @@ -62,7 +62,7 @@ public void Dispose() /// private IEnumerable GetRenderedMessageBars() - => MessageBarService?.Items.Values + => NotificationService?.Items.Values .Where(messageBar => string.Compare(messageBar.Options.Section, Section, StringComparison.OrdinalIgnoreCase) == 0 && messageBar.LifecycleStatus == MessageBarLifecycleStatus.Visible) .OrderBy(messageBar => messageBar.Index) diff --git a/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs b/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs index d021f645e5..1ba43ddeb5 100644 --- a/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs +++ b/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs @@ -5,7 +5,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Interface for a message bar instance managed by the . +/// Interface for a message bar instance managed by the . /// public interface IMessageBarInstance { diff --git a/src/Core/Components/MessageBar/Services/IMessageBarService.cs b/src/Core/Components/MessageBar/Services/INotificationService.cs similarity index 82% rename from src/Core/Components/MessageBar/Services/IMessageBarService.cs rename to src/Core/Components/MessageBar/Services/INotificationService.cs index edf0dec505..cb00f7bcfa 100644 --- a/src/Core/Components/MessageBar/Services/IMessageBarService.cs +++ b/src/Core/Components/MessageBar/Services/INotificationService.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// /// Interface for the MessageBar service. /// -public interface IMessageBarService : IFluentServiceBase +public partial interface INotificationService : IFluentServiceBase { /// /// Shows a success message bar with the specified title and message and waits for the close result. @@ -19,7 +19,7 @@ public interface IMessageBarService : IFluentServiceBase /// The title of the message bar. /// The message content of the message bar. /// A task that represents the asynchronous operation. The task result contains the close result of the message bar. - Task ShowSuccessMessageAsync(string section, string? title = null, string? message = null); + Task ShowSuccessBarAsync(string section, string? title = null, string? message = null); /// /// Shows a warning message bar with the specified title and message and waits for the close result. @@ -28,7 +28,7 @@ public interface IMessageBarService : IFluentServiceBase /// The title of the message bar. /// The message content of the message bar. /// A task that represents the asynchronous operation. The task result contains the close result of the message bar. - Task ShowWarningMessageAsync(string section, string? title = null, string? message = null); + Task ShowWarningBarAsync(string section, string? title = null, string? message = null); /// /// Shows an error message bar with the specified title and message and waits for the close result. @@ -37,7 +37,7 @@ public interface IMessageBarService : IFluentServiceBase /// The title of the message bar. /// The message content of the message bar. /// A task that represents the asynchronous operation. The task result contains the close result of the message bar. - Task ShowErrorMessageAsync(string section, string? title = null, string? message = null); + Task ShowErrorBarAsync(string section, string? title = null, string? message = null); /// /// Shows an informational message bar with the specified title and message and waits for the close result. @@ -46,19 +46,19 @@ public interface IMessageBarService : IFluentServiceBase /// The title of the message bar. /// The message content of the message bar. /// A task that represents the asynchronous operation. The task result contains the close result of the message bar. - Task ShowInfoMessageAsync(string section, string? title = null, string? message = null); + Task ShowInfoBarAsync(string section, string? title = null, string? message = null); /// /// Shows a message bar using the supplied options and waits for the close result. /// /// Options to configure the message bar. - Task ShowMessageAsync(MessageBarOptions options); + Task ShowMessageBarAsync(MessageBarOptions options); /// /// Shows a message bar by configuring an options object and waits for the close result. /// /// Action used to configure the message bar. - Task ShowMessageAsync(Action options); + Task ShowMessageBarAsync(Action options); /// /// Shows a custom message bar component and waits for the close result. @@ -66,7 +66,7 @@ public interface IMessageBarService : IFluentServiceBase /// /// A Blazor component type used to render the message bar. /// Options used to configure the message bar. - Task ShowMessageAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(MessageBarOptions options) + Task ShowMessageBarAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(MessageBarOptions options) where TMessageBar : ComponentBase; /// @@ -75,7 +75,7 @@ public interface IMessageBarService : IFluentServiceBase /// /// A Blazor component type used to render the message bar. /// Action used to configure the message bar. - Task ShowMessageAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(Action options) + Task ShowMessageBarAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(Action options) where TMessageBar : ComponentBase; /// @@ -97,5 +97,5 @@ public interface IMessageBarService : IFluentServiceBase /// Closes all current message bars. /// /// The number of message bars that were closed. - Task CloseAllAsync(); + Task CloseAllMessageBarsAsync(); } diff --git a/src/Core/Components/MessageBar/Services/MessageBarInstance.cs b/src/Core/Components/MessageBar/Services/MessageBarInstance.cs index 43c870de1e..ebd5517c3c 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarInstance.cs +++ b/src/Core/Components/MessageBar/Services/MessageBarInstance.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Represents a message bar instance used with the . +/// Represents a message bar instance used with the . /// public class MessageBarInstance : IMessageBarInstance, IDisposable { @@ -18,16 +18,16 @@ public class MessageBarInstance : IMessageBarInstance, IDisposable private bool _disposed; /// - internal MessageBarInstance(IMessageBarService messageBarService, MessageBarOptions options) - : this(messageBarService, componentType: null, options) + internal MessageBarInstance(INotificationService notificationService, MessageBarOptions options) + : this(notificationService, componentType: null, options) { } /// - internal MessageBarInstance(IMessageBarService messageBarService, Type? componentType, MessageBarOptions options) + internal MessageBarInstance(INotificationService notificationService, Type? componentType, MessageBarOptions options) { Options = options; - MessageBarService = messageBarService; + NotificationService = notificationService; _componentType = componentType; Id = string.IsNullOrEmpty(options.Id) ? Identifier.NewId() : options.Id; Index = Interlocked.Increment(ref _counter); @@ -37,7 +37,7 @@ internal MessageBarInstance(IMessageBarService messageBarService, Type? componen Type? IMessageBarInstance.ComponentType => _componentType; /// - internal IMessageBarService MessageBarService { get; } + internal INotificationService NotificationService { get; } /// /// Gets the cancellation token used to cancel the auto-dismiss timer when the message bar @@ -63,13 +63,13 @@ internal MessageBarInstance(IMessageBarService messageBarService, Type? componen /// public Task CloseAsync() { - return MessageBarService.CloseAsync(this); + return NotificationService.CloseAsync(this); } /// public Task CloseAsync(MessageBarResult result) { - return MessageBarService.CloseAsync(this, result); + return NotificationService.CloseAsync(this, result); } /// diff --git a/src/Core/Components/MessageBar/Services/MessageBarOptions.cs b/src/Core/Components/MessageBar/Services/MessageBarOptions.cs index 494d7ecdcd..d30a268c3f 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarOptions.cs +++ b/src/Core/Components/MessageBar/Services/MessageBarOptions.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Options for configuring a message bar displayed by the . +/// Options for configuring a message bar displayed by the . /// public class MessageBarOptions : IFluentComponentBase { diff --git a/src/Core/Components/MessageBar/Services/MessageBarResult.cs b/src/Core/Components/MessageBar/Services/MessageBarResult.cs index 8b6e71e4e8..f18c6baa91 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarResult.cs +++ b/src/Core/Components/MessageBar/Services/MessageBarResult.cs @@ -5,7 +5,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Represents the result of a message bar managed by the . +/// Represents the result of a message bar managed by the . /// public class MessageBarResult { diff --git a/src/Core/Components/MessageBar/Services/MessageBarService.Subscribers.cs b/src/Core/Components/MessageBar/Services/NotificationService.Subscribers.cs similarity index 95% rename from src/Core/Components/MessageBar/Services/MessageBarService.Subscribers.cs rename to src/Core/Components/MessageBar/Services/NotificationService.Subscribers.cs index e1cebca060..1433fcd7bc 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarService.Subscribers.cs +++ b/src/Core/Components/MessageBar/Services/NotificationService.Subscribers.cs @@ -7,11 +7,11 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Subscriber-management portion of the . +/// Subscriber-management portion of the . /// Allows multiple instances to receive /// update notifications from the same service (e.g. when scoped by Section). /// -public partial class MessageBarService +public partial class NotificationService { private readonly ConcurrentDictionary> _subscribers = new(StringComparer.Ordinal); diff --git a/src/Core/Components/MessageBar/Services/MessageBarService.cs b/src/Core/Components/MessageBar/Services/NotificationService.cs similarity index 73% rename from src/Core/Components/MessageBar/Services/MessageBarService.cs rename to src/Core/Components/MessageBar/Services/NotificationService.cs index 2f9103e665..3fd9f59a42 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarService.cs +++ b/src/Core/Components/MessageBar/Services/NotificationService.cs @@ -10,23 +10,23 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// /// Service for showing message bars. /// -public partial class MessageBarService : FluentServiceBase, IMessageBarService +public partial class NotificationService : FluentServiceBase, INotificationService { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarEventArgs))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarInstance))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IMessageBarInstance))] - public MessageBarService() + public NotificationService() { ServiceProvider.OnUpdatedAsync = DispatchOnUpdatedAsync; } - /// - public Task ShowSuccessMessageAsync(string section, string? title = null, string? message = null) + /// + public Task ShowSuccessBarAsync(string section, string? title = null, string? message = null) { - return ShowMessageAsync(options => + return ShowMessageBarAsync(options => { options.Section = section; options.Intent = MessageBarIntent.Success; @@ -35,10 +35,10 @@ public Task ShowSuccessMessageAsync(string section, string? ti }); } - /// - public Task ShowWarningMessageAsync(string section, string? title = null, string? message = null) + /// + public Task ShowWarningBarAsync(string section, string? title = null, string? message = null) { - return ShowMessageAsync(options => + return ShowMessageBarAsync(options => { options.Section = section; options.Intent = MessageBarIntent.Warning; @@ -47,10 +47,10 @@ public Task ShowWarningMessageAsync(string section, string? ti }); } - /// - public Task ShowErrorMessageAsync(string section, string? title = null, string? message = null) + /// + public Task ShowErrorBarAsync(string section, string? title = null, string? message = null) { - return ShowMessageAsync(options => + return ShowMessageBarAsync(options => { options.Section = section; options.Intent = MessageBarIntent.Error; @@ -59,10 +59,10 @@ public Task ShowErrorMessageAsync(string section, string? titl }); } - /// - public Task ShowInfoMessageAsync(string section, string? title = null, string? message = null) + /// + public Task ShowInfoBarAsync(string section, string? title = null, string? message = null) { - return ShowMessageAsync(options => + return ShowMessageBarAsync(options => { options.Section = section; options.Intent = MessageBarIntent.Info; @@ -71,20 +71,20 @@ public Task ShowInfoMessageAsync(string section, string? title }); } - /// - public async Task ShowMessageAsync(MessageBarOptions options) + /// + public async Task ShowMessageBarAsync(MessageBarOptions options) { var instance = ShowMessageInstanceCore(componentType: null, options); await ServiceProvider.OnUpdatedAsync.Invoke(instance); return await instance.Result; } - /// - public Task ShowMessageAsync(Action options) - => ShowMessageAsync(new MessageBarOptions(options)); + /// + public Task ShowMessageBarAsync(Action options) + => ShowMessageBarAsync(new MessageBarOptions(options)); - /// - public async Task ShowMessageAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(MessageBarOptions options) + /// + public async Task ShowMessageBarAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(MessageBarOptions options) where TMessageBar : ComponentBase { var instance = ShowMessageInstanceCore(typeof(TMessageBar), options); @@ -92,12 +92,12 @@ public Task ShowMessageAsync(Action options return await instance.Result; } - /// - public Task ShowMessageAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(Action options) + /// + public Task ShowMessageBarAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TMessageBar>(Action options) where TMessageBar : ComponentBase - => ShowMessageAsync(new MessageBarOptions(options)); + => ShowMessageBarAsync(new MessageBarOptions(options)); - /// + /// public Task CloseAsync(IMessageBarInstance messageBar, object? data = null) { if (data is not null and MessageBarResult result) @@ -108,7 +108,7 @@ public Task CloseAsync(IMessageBarInstance messageBar, object? data = null) return CloseCoreAsync(messageBar, MessageBarResult.OfProgrammatic(data)); } - /// + /// public async Task CloseAsync(string messageBarId, object? data = null) { if (string.IsNullOrWhiteSpace(messageBarId) || !ServiceProvider.Items.TryGetValue(messageBarId, out var messageBar)) @@ -120,8 +120,8 @@ public async Task CloseAsync(string messageBarId, object? data = null) return true; } - /// - public async Task CloseAllAsync() + /// + public async Task CloseAllMessageBarsAsync() { var messageBars = ServiceProvider.Items.Values.ToList(); diff --git a/src/Core/Events/MessageBarEventArgs.cs b/src/Core/Events/MessageBarEventArgs.cs index 87f014192d..35085736e0 100644 --- a/src/Core/Events/MessageBarEventArgs.cs +++ b/src/Core/Events/MessageBarEventArgs.cs @@ -5,7 +5,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Event arguments for the FluentMessageBar component when displayed by the . +/// Event arguments for the FluentMessageBar component when displayed by the . /// public class MessageBarEventArgs : EventArgs { @@ -28,7 +28,7 @@ internal MessageBarEventArgs(IMessageBarInstance instance, MessageBarLifecycleSt public MessageBarLifecycleStatus Status { get; } /// - /// Gets the instance used by the . + /// Gets the instance used by the . /// public IMessageBarInstance? Instance { get; } } diff --git a/src/Core/Extensions/ServiceCollectionExtensions.cs b/src/Core/Extensions/ServiceCollectionExtensions.cs index 8d8f24d0c3..2387221d2f 100644 --- a/src/Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Extensions/ServiceCollectionExtensions.cs @@ -22,8 +22,8 @@ public static class ServiceCollectionExtensions [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DialogService))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IToastService))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ToastService))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IMessageBarService))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarService))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(INotificationService))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NotificationService))] public static IServiceCollection AddFluentUIComponents(this IServiceCollection services, LibraryConfiguration? configuration = null) { var options = configuration ?? new(); @@ -38,7 +38,7 @@ public static IServiceCollection AddFluentUIComponents(this IServiceCollection s services.Add(provider => options ?? new(), serviceLifetime); services.Add(serviceLifetime); services.Add(serviceLifetime); - services.Add(serviceLifetime); + services.Add(serviceLifetime); services.Add(provider => options?.Localizer ?? FluentLocalizerInternal.Default, serviceLifetime); services.Add(serviceLifetime); services.Add(serviceLifetime); diff --git a/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor b/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor index b7418475b2..ffd61d4e1f 100644 --- a/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor +++ b/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor @@ -12,13 +12,13 @@ JSInterop.Mode = JSRuntimeMode.Loose; Services.AddFluentUIComponents(); - MessageBarService = Services.GetRequiredService(); + NotificationService = Services.GetRequiredService(); } /// /// Gets the message bar service. /// - public IMessageBarService MessageBarService { get; } + public INotificationService NotificationService { get; } [Fact] public void FluentMessageBarProvider_RenderEmpty() @@ -48,7 +48,7 @@ var cut = Render(@); // Act - _ = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "Info title", "Info message"); + _ = NotificationService.ShowInfoBarAsync(TEST_SECTION, "Info title", "Info message"); // Don't wait for the message bar to be closed await Task.CompletedTask; @@ -69,7 +69,7 @@ var cut = Render(@); // Act - _ = MessageBarService.ShowSuccessMessageAsync(TEST_SECTION, "Success title"); + _ = NotificationService.ShowSuccessBarAsync(TEST_SECTION, "Success title"); await Task.CompletedTask; @@ -84,7 +84,7 @@ var cut = Render(@); // Act - _ = MessageBarService.ShowWarningMessageAsync(TEST_SECTION, "Warning title"); + _ = NotificationService.ShowWarningBarAsync(TEST_SECTION, "Warning title"); await Task.CompletedTask; @@ -99,7 +99,7 @@ var cut = Render(@); // Act - _ = MessageBarService.ShowErrorMessageAsync(TEST_SECTION, "Error title"); + _ = NotificationService.ShowErrorBarAsync(TEST_SECTION, "Error title"); await Task.CompletedTask; @@ -122,7 +122,7 @@ }; // Act - _ = MessageBarService.ShowMessageAsync(options); + _ = NotificationService.ShowMessageBarAsync(options); await Task.CompletedTask; @@ -142,7 +142,7 @@ var cutB = Render(@); // Act - _ = MessageBarService.ShowInfoMessageAsync("section-A", "For A"); + _ = NotificationService.ShowInfoBarAsync("section-A", "For A"); await Task.CompletedTask; @@ -158,7 +158,7 @@ var cut = Render(@); // Act - _ = MessageBarService.ShowInfoMessageAsync("lower", "Case insensitive"); + _ = NotificationService.ShowInfoBarAsync("lower", "Case insensitive"); await Task.CompletedTask; @@ -172,7 +172,7 @@ // Arrange var cut = Render(@); - var task = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "Dismiss me"); + var task = NotificationService.ShowInfoBarAsync(TEST_SECTION, "Dismiss me"); cut.WaitForAssertion(() => Assert.Contains("Dismiss me", cut.Markup)); // Act @@ -192,7 +192,7 @@ // Arrange var cut = Render(@); - var task = MessageBarService.ShowMessageAsync(o => + var task = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Id = "to-close"; @@ -201,7 +201,7 @@ cut.WaitForAssertion(() => Assert.Contains("Close me", cut.Markup)); // Act - var closed = await MessageBarService.CloseAsync("to-close", "payload"); + var closed = await NotificationService.CloseAsync("to-close", "payload"); var result = await task; // Assert @@ -217,7 +217,7 @@ Render(@); // Act - var closed = await MessageBarService.CloseAsync("unknown"); + var closed = await NotificationService.CloseAsync("unknown"); // Assert Assert.False(closed); @@ -233,7 +233,7 @@ Render(@); // Act - var closed = await MessageBarService.CloseAsync(id!); + var closed = await NotificationService.CloseAsync(id!); // Assert Assert.False(closed); @@ -246,7 +246,7 @@ var cut = Render(@); IMessageBarInstance? captured = null; - var task = MessageBarService.ShowMessageAsync(o => + var task = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Title = "Instance close"; @@ -262,7 +262,7 @@ cut.WaitForAssertion(() => Assert.NotNull(captured)); // Act - await MessageBarService.CloseAsync(captured!); + await NotificationService.CloseAsync(captured!); var result = await task; // Assert @@ -276,7 +276,7 @@ var cut = Render(@); IMessageBarInstance? captured = null; - var task = MessageBarService.ShowMessageAsync(o => + var task = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Title = "With result"; @@ -293,7 +293,7 @@ // Act var customResult = MessageBarResult.OfDismissed("custom"); - await MessageBarService.CloseAsync(captured!, customResult); + await NotificationService.CloseAsync(captured!, customResult); var result = await task; // Assert @@ -310,7 +310,7 @@ var fake = new FakeMessageBarInstance(); // Act & Assert (must not throw) - await MessageBarService.CloseAsync(fake); + await NotificationService.CloseAsync(fake); } [Fact(Timeout = TEST_TIMEOUT)] @@ -319,13 +319,13 @@ // Arrange var cut = Render(@); - var task1 = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "One"); - var task2 = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "Two"); + var task1 = NotificationService.ShowInfoBarAsync(TEST_SECTION, "One"); + var task2 = NotificationService.ShowInfoBarAsync(TEST_SECTION, "Two"); cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("fluent-message-bar").Count)); // Act - var closed = await MessageBarService.CloseAllAsync(); + var closed = await NotificationService.CloseAllMessageBarsAsync(); var r1 = await task1; var r2 = await task2; @@ -343,7 +343,7 @@ var cut = Render(@); IMessageBarInstance? captured = null; - var task = MessageBarService.ShowMessageAsync(o => + var task = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Title = "Double"; @@ -358,9 +358,9 @@ cut.WaitForAssertion(() => Assert.NotNull(captured)); // Act: close twice - await MessageBarService.CloseAsync(captured!); + await NotificationService.CloseAsync(captured!); var result = await task; - await MessageBarService.CloseAsync(captured!); + await NotificationService.CloseAsync(captured!); // Assert: only one result was produced Assert.Equal(MessageBarCloseReason.Programmatic, result.Reason); @@ -373,7 +373,7 @@ var cut = Render(@); // Act - var task = MessageBarService.ShowMessageAsync(o => + var task = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Title = "Auto"; @@ -393,7 +393,7 @@ var cut = Render(@); // Act: a zero (or negative) lifetime should NOT schedule auto-dismiss - var task = MessageBarService.ShowMessageAsync(o => + var task = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Title = "NoAuto"; @@ -407,7 +407,7 @@ cut.WaitForAssertion(() => Assert.Contains("NoAuto", cut.Markup)); // Cleanup - await MessageBarService.CloseAllAsync(); + await NotificationService.CloseAllMessageBarsAsync(); await task; } @@ -419,7 +419,7 @@ var statuses = new List(); - var task = MessageBarService.ShowMessageAsync(o => + var task = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Title = "Events"; @@ -429,7 +429,7 @@ cut.WaitForAssertion(() => Assert.Single(statuses)); // Act - await MessageBarService.CloseAllAsync(); + await NotificationService.CloseAllMessageBarsAsync(); await task; // Assert: Visible, Dismissed, Unmounted @@ -444,7 +444,7 @@ // Arrange Render(@); - var first = MessageBarService.ShowMessageAsync(o => + var first = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Id = "dupe"; @@ -454,7 +454,7 @@ // Act var ex = await Assert.ThrowsAsync(async () => { - await MessageBarService.ShowMessageAsync(o => + await NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Id = "dupe"; @@ -466,7 +466,7 @@ Assert.Contains("dupe", ex.Message); // Cleanup - await MessageBarService.CloseAllAsync(); + await NotificationService.CloseAllMessageBarsAsync(); await first; } @@ -479,7 +479,7 @@ // Act & Assert await Assert.ThrowsAsync(async () => { - await MessageBarService.ShowMessageAsync((MessageBarOptions)null!); + await NotificationService.ShowMessageBarAsync((MessageBarOptions)null!); }); } @@ -495,7 +495,7 @@ // Act & Assert await Assert.ThrowsAsync(async () => { - await MessageBarService.ShowMessageAsync(o => + await NotificationService.ShowMessageBarAsync(o => { o.Section = section!; o.Title = "No section"; @@ -511,7 +511,7 @@ // Act & Assert await Assert.ThrowsAsync>(async () => { - await MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "No provider"); + await NotificationService.ShowInfoBarAsync(TEST_SECTION, "No provider"); }); } @@ -526,7 +526,7 @@ var second = Render(@); - _ = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "After dispose"); + _ = NotificationService.ShowInfoBarAsync(TEST_SECTION, "After dispose"); await Task.CompletedTask; @@ -546,7 +546,7 @@ // Assert: ProviderId is reset, ShowMessage throws await Assert.ThrowsAsync>(async () => { - await MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "Nope"); + await NotificationService.ShowInfoBarAsync(TEST_SECTION, "Nope"); }); } @@ -557,7 +557,7 @@ var cut = Render(@); // Act - _ = MessageBarService.ShowMessageAsync(o => + _ = NotificationService.ShowMessageBarAsync(o => { o.Section = TEST_SECTION; o.Parameters[nameof(Templates.CustomMessageBarTemplate.Text)] = "from-test"; @@ -582,7 +582,7 @@ options.Parameters[nameof(Templates.CustomMessageBarTemplate.Text)] = "options-text"; // Act - _ = MessageBarService.ShowMessageAsync(options); + _ = NotificationService.ShowMessageBarAsync(options); await Task.CompletedTask; @@ -597,7 +597,7 @@ var cut = Render(@); // Act: Show a message with Title only (no Message) - _ = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "OnlyTitle"); + _ = NotificationService.ShowInfoBarAsync(TEST_SECTION, "OnlyTitle"); await Task.CompletedTask; @@ -613,9 +613,9 @@ var cut = Render(@); // Act - _ = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "First"); - _ = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "Second"); - _ = MessageBarService.ShowInfoMessageAsync(TEST_SECTION, "Third"); + _ = NotificationService.ShowInfoBarAsync(TEST_SECTION, "First"); + _ = NotificationService.ShowInfoBarAsync(TEST_SECTION, "Second"); + _ = NotificationService.ShowInfoBarAsync(TEST_SECTION, "Third"); await Task.CompletedTask; @@ -635,8 +635,8 @@ var cutB = Render(@); // Act - _ = MessageBarService.ShowInfoMessageAsync("A", "OnlyA"); - _ = MessageBarService.ShowInfoMessageAsync("B", "OnlyB"); + _ = NotificationService.ShowInfoBarAsync("A", "OnlyA"); + _ = NotificationService.ShowInfoBarAsync("B", "OnlyB"); await Task.CompletedTask; @@ -648,7 +648,7 @@ } /// - /// A fake used to verify that + /// A fake used to verify that /// is a no-op when the instance is not a . /// private sealed class FakeMessageBarInstance : IMessageBarInstance diff --git a/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs b/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs index 56bfd55262..52e2dfc0dd 100644 --- a/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs @@ -12,7 +12,7 @@ public class MessageBarEventArgsTests public void MessageBarEventArgs_SetsAllProperties() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var options = new MessageBarOptions { Section = "section-A", Id = "my-id" }; var instance = new MessageBarInstance(service, options); @@ -32,7 +32,7 @@ public void MessageBarEventArgs_SetsAllProperties() public void MessageBarEventArgs_KeepsStatus(MessageBarLifecycleStatus status) { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act diff --git a/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs b/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs index 7f08969ed8..b4edad8971 100644 --- a/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs @@ -12,7 +12,7 @@ public class MessageBarInstanceTests public void MessageBarInstance_AutoGeneratedId() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var options = new MessageBarOptions { Section = "section" }; // Act @@ -30,7 +30,7 @@ public void MessageBarInstance_AutoGeneratedId() public void MessageBarInstance_UsesProvidedId() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var options = new MessageBarOptions { Section = "section", Id = "fixed-id" }; // Act @@ -44,7 +44,7 @@ public void MessageBarInstance_UsesProvidedId() public void MessageBarInstance_IncrementsIndex() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); // Act var first = new MessageBarInstance(service, new MessageBarOptions { Section = "s" }); @@ -58,7 +58,7 @@ public void MessageBarInstance_IncrementsIndex() public void MessageBarInstance_ComponentType_DefaultsToNull() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); // Act IMessageBarInstance instance = new MessageBarInstance(service, new MessageBarOptions { Section = "s" }); @@ -71,7 +71,7 @@ public void MessageBarInstance_ComponentType_DefaultsToNull() public void MessageBarInstance_ComponentType_FromCtor() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); // Act IMessageBarInstance instance = new MessageBarInstance(service, typeof(FluentMessageBar), new MessageBarOptions { Section = "s" }); @@ -84,7 +84,7 @@ public void MessageBarInstance_ComponentType_FromCtor() public async Task MessageBarInstance_CloseAsync_NoResult_CallsService() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var options = new MessageBarOptions { Section = "section", Id = "to-close" }; var instance = new MessageBarInstance(service, options); ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); @@ -104,7 +104,7 @@ public async Task MessageBarInstance_CloseAsync_NoResult_CallsService() public async Task MessageBarInstance_CloseAsync_WithResult_PassesReason() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var options = new MessageBarOptions { Section = "section" }; var instance = new MessageBarInstance(service, options); ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); @@ -123,7 +123,7 @@ public async Task MessageBarInstance_CloseAsync_WithResult_PassesReason() public void MessageBarInstance_Dispose_DisposesLifetimeCts() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act @@ -137,7 +137,7 @@ public void MessageBarInstance_Dispose_DisposesLifetimeCts() public void MessageBarInstance_Dispose_Twice_DoesNotThrow() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act @@ -152,7 +152,7 @@ public void MessageBarInstance_Dispose_Twice_DoesNotThrow() public void MessageBarInstance_CancelLifetime_AfterDispose_DoesNotThrow() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); instance.Dispose(); @@ -165,7 +165,7 @@ public void MessageBarInstance_CancelLifetime_AfterDispose_DoesNotThrow() public void MessageBarInstance_CancelLifetime_Twice_DoesNotThrow() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act diff --git a/tests/Core/Components/MessageBar/MessageBarServiceTests.cs b/tests/Core/Components/MessageBar/MessageBarServiceTests.cs index 0961fcdf03..5f22ccb2d9 100644 --- a/tests/Core/Components/MessageBar/MessageBarServiceTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarServiceTests.cs @@ -12,7 +12,7 @@ public class MessageBarServiceTests public void MessageBarService_Subscribe_NullProviderId_NoOp() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var serviceBase = (IFluentServiceBase)service; // Act @@ -27,7 +27,7 @@ public void MessageBarService_Subscribe_NullProviderId_NoOp() public void MessageBarService_Subscribe_EmptyProviderId_NoOp() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var serviceBase = (IFluentServiceBase)service; // Act @@ -41,7 +41,7 @@ public void MessageBarService_Subscribe_EmptyProviderId_NoOp() public void MessageBarService_Subscribe_NullCallback_NoOp() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var serviceBase = (IFluentServiceBase)service; // Act @@ -55,7 +55,7 @@ public void MessageBarService_Subscribe_NullCallback_NoOp() public void MessageBarService_Unsubscribe_NullProviderId_NoOp() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); service.Subscribe("provider-1", _ => Task.CompletedTask); // Act @@ -70,7 +70,7 @@ public void MessageBarService_Unsubscribe_NullProviderId_NoOp() public void MessageBarService_Unsubscribe_EmptyProviderId_NoOp() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); service.Subscribe("provider-1", _ => Task.CompletedTask); // Act @@ -85,7 +85,7 @@ public void MessageBarService_Unsubscribe_EmptyProviderId_NoOp() public void MessageBarService_Unsubscribe_OnlyProvider_ResetsProviderId() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); @@ -101,7 +101,7 @@ public void MessageBarService_Unsubscribe_OnlyProvider_ResetsProviderId() public void MessageBarService_Unsubscribe_FirstOfMany_SwitchesProviderId() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); service.Subscribe("provider-2", _ => Task.CompletedTask); @@ -119,7 +119,7 @@ public void MessageBarService_Unsubscribe_FirstOfMany_SwitchesProviderId() public void MessageBarService_Unsubscribe_NotCurrentProviderId_KeepsCurrent() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); service.Subscribe("provider-2", _ => Task.CompletedTask); @@ -137,7 +137,7 @@ public void MessageBarService_Unsubscribe_NotCurrentProviderId_KeepsCurrent() public async Task MessageBarService_Dispatch_SwallowsSubscriberExceptions() { // Arrange - var service = new MessageBarService(); + var service = new NotificationService(); var goodCalled = false; service.Subscribe("bad", _ => throw new InvalidOperationException("boom")); @@ -148,7 +148,7 @@ public async Task MessageBarService_Dispatch_SwallowsSubscriberExceptions() }); // Act: this throws inside one subscriber, but should not propagate to the show call. - var task = service.ShowInfoMessageAsync("section-X", "title"); + var task = service.ShowInfoBarAsync("section-X", "title"); await Task.CompletedTask; @@ -156,7 +156,7 @@ public async Task MessageBarService_Dispatch_SwallowsSubscriberExceptions() Assert.True(goodCalled); // Cleanup - await service.CloseAllAsync(); + await service.CloseAllMessageBarsAsync(); await task; } } From bb5217e8d8c0696bcfd98b83040ff7763742e701 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Thu, 18 Jun 2026 17:57:04 +0200 Subject: [PATCH 02/43] Update NotificationService method names in documentation to reflect new naming conventions --- .../Components/MessageBar/FluentMessageBar.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md index 30361ac7b1..6f8bd8abfe 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md @@ -76,10 +76,10 @@ and a local provider scoped to a specific panel or dialog), and to route each me The simplest way to display a message is to use one of the typed helpers, passing the target section and the message text: -- `NotificationService.ShowSuccessMessageAsync("SECTION", "Title", "Message")` -- `NotificationService.ShowWarningMessageAsync("SECTION", "Title", "Message")` -- `NotificationService.ShowErrorMessageAsync("SECTION", "Title", "Message")` -- `NotificationService.ShowInfoMessageAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowSuccessBarAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowWarningBarAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowErrorBarAsync("SECTION", "Title", "Message")` +- `NotificationService.ShowInfoBarAsync("SECTION", "Title", "Message")` {{ MessageBarServiceDefault }} From 07695ac94378b88a70ffda3d2f86789ecae722b5 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Thu, 18 Jun 2026 20:17:05 +0200 Subject: [PATCH 03/43] Refactor - Part 1 --- .../Examples/FluentToastCustomDismiss.razor | 2 +- .../Toast/Examples/FluentToastDefault.razor | 2 +- .../Toast/Examples/FluentToastInverted.razor | 2 +- src/Core/Components/Dialog/DialogEventArgs.cs | 66 ++++--- src/Core/Components/Toast/FluentToast.razor | 50 ++--- .../Components/Toast/FluentToast.razor.cs | 183 +++++++----------- .../Toast/FluentToastProvider.razor | 55 +++--- .../Toast/FluentToastProvider.razor.cs | 15 +- .../Toast/Services/LibraryToastOptions.cs | 2 +- .../Components/Toast/Services/ToastOptions.cs | 14 +- src/Core/Enums/ToastType.cs | 31 --- src/Core/Events/ToastEventArgs.cs | 40 ++-- .../Toast/FluentToastProviderTests.razor | 8 +- .../Components/Toast/FluentToastTests.razor | 22 +-- 14 files changed, 211 insertions(+), 281 deletions(-) delete mode 100644 src/Core/Enums/ToastType.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor index 278ed9c660..23337eb574 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor @@ -14,7 +14,7 @@ options.Intent = ToastIntent.Success; options.Title = $"Toast title {++clickCount}"; options.Body = "This toast has a custom dismiss action."; - options.IsDismissable = true; + options.AllowDismiss = true; options.DismissAction = "Undo"; options.DismissActionCallback = () => { diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index 7cf1304b11..8969c85462 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -26,7 +26,7 @@ Console.WriteLine("Action 2 executed."); return Task.CompletedTask; }; - options.IsDismissable = true; + options.AllowDismiss = true; options.OnStatusChange = (e) => { Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor index 2488fadcdf..c52fcba6fc 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor @@ -21,7 +21,7 @@ Console.WriteLine("Action 1 executed."); return Task.CompletedTask; }; - options.IsDismissable = true; + options.AllowDismiss = true; options.DismissAction = "Close"; options.OnStatusChange = (e) => { diff --git a/src/Core/Components/Dialog/DialogEventArgs.cs b/src/Core/Components/Dialog/DialogEventArgs.cs index f214fcd885..ed676c9216 100644 --- a/src/Core/Components/Dialog/DialogEventArgs.cs +++ b/src/Core/Components/Dialog/DialogEventArgs.cs @@ -20,33 +20,7 @@ internal DialogEventArgs(FluentDialog dialog, string? id, string? eventType, str { Id = id ?? string.Empty; Instance = dialog.Instance; - - if (string.Equals(eventType, "toggle", StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(newState, "open", StringComparison.OrdinalIgnoreCase)) - { - State = DialogState.Open; - } - else if (string.Equals(newState, "closed", StringComparison.OrdinalIgnoreCase)) - { - State = DialogState.Closed; - } - } - else if (string.Equals(eventType, "beforetoggle", StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(oldState, "closed", StringComparison.OrdinalIgnoreCase)) - { - State = DialogState.Opening; - } - else if (string.Equals(oldState, "open", StringComparison.OrdinalIgnoreCase)) - { - State = DialogState.Closing; - } - } - else - { - State = DialogState.Closed; - } + State = GetDialogState(eventType, oldState, newState); } /// @@ -71,4 +45,42 @@ internal DialogEventArgs(IDialogInstance instance, DialogState state) /// Gets the instance used by the . /// public IDialogInstance? Instance { get; } + + /// + /// Determines the based on the provided event type and states. + /// + /// + /// + /// + /// + internal static DialogState GetDialogState(string? eventType, string? oldState, string? newState) + { + if (string.Equals(eventType, "toggle", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(newState, "open", StringComparison.OrdinalIgnoreCase)) + { + return DialogState.Open; + } + + if (string.Equals(newState, "closed", StringComparison.OrdinalIgnoreCase)) + { + return DialogState.Closed; + } + } + + if (string.Equals(eventType, "beforetoggle", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(oldState, "closed", StringComparison.OrdinalIgnoreCase)) + { + return DialogState.Opening; + } + + if (string.Equals(oldState, "open", StringComparison.OrdinalIgnoreCase)) + { + return DialogState.Closing; + } + } + + return DialogState.Closed; + } } diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 23d6e4d6d9..abe8059a33 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -6,7 +6,7 @@ class="@ClassValue" style="@StyleValue" opened="@(Opened ? "true" : "false")" - timeout="@Timeout" + timeout="@Lifetime" position="@Position.ToAttributeValue()" inverted="@(Inverted ? "true" : null)" intent="@Intent.ToAttributeValue()" @@ -17,39 +17,42 @@ horizontal-offset="@HorizontalOffset" role="listitem" aria-labelledby="@(string.IsNullOrEmpty(Title) ? null : $"{Id}-title")" - aria-describedby="@((string.IsNullOrEmpty(Body) && BodyContent is null) ? null : $"{Id}-body")" + aria-describedby="@(ChildContent is null ? null : $"{Id}-body")" @attributes="@AdditionalAttributes" @ondialogtoggle="@OnToggleAsync" @ondialogbeforetoggle="@OnToggleAsync"> - @if (Icon is not null) - { - - } - else if (Type == ToastType.IndeterminateProgress) + + @* Slot Media: spinner or icon *@ + @* @if (Type == ToastType.IndeterminateProgress) {
- +
} - else + else *@ { -
-public partial class FluentToast +public partial class FluentToast : FluentComponentBase { + internal static readonly Icon DismissIcon = new CoreIcons.Regular.Size20.Dismiss(); + /// public FluentToast(LibraryConfiguration configuration) : base(configuration) { @@ -24,11 +26,17 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Inject] private IToastService ToastService { get; set; } = default!; + /// + protected string? ClassValue => DefaultClassBuilder.Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder.Build(); + /// - /// Gets or sets the toast instance associated with this component. + /// Gets the instance, if the toast is rendered using the . Otherwise, returns null. /// - [Parameter] - public IToastInstance? Instance { get; set; } + [CascadingParameter] + internal IToastInstance? ToastInstance { get; set; } /// /// Gets or sets a value indicating whether the component is currently open. @@ -48,11 +56,13 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) public EventCallback OpenedChanged { get; set; } /// - /// Gets or sets the duration in milliseconds before the toast automatically closes. Set this to less or equal to 0 - /// to disable automatic closing. + /// Gets or sets the lifetime of the toast. + /// When set to a positive value, the toast is automatically dismissed after this duration elapses, + /// triggering the appropriate lifecycle events. + /// When `null`, the toast stays visible until it is dismissed programmatically or by the user. /// [Parameter] - public int Timeout { get; set; } = 7000; + public TimeSpan? Lifetime { get; set; } /// /// Gets or sets the on the screen where the toast notification is displayed. @@ -72,13 +82,6 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public int HorizontalOffset { get; set; } = 20; - /// - /// Gets or sets the of toast notification to display - /// (e.g., Type="ToastType.Communication"). - /// - [Parameter] - public ToastType Type { get; set; } = ToastType.Communication; - /// /// Gets or sets a value indicating whether the toast uses inverted colors. /// @@ -94,7 +97,7 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// nature of the message. /// [Parameter] - public ToastIntent Intent { get; set; } = ToastIntent.Info; + public ToastIntent? Intent { get; set; } /// /// Gets or sets the level of notification politeness for assistive technologies. @@ -108,27 +111,17 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) public ToastPoliteness? Politeness { get; set; } /// - /// Gets or sets a value indicating whether the timeout countdown pauses when the user hovers over the component. + /// Gets or sets a value indicating whether the pauses when the user hovers over the component. /// [Parameter] public bool PauseOnHover { get; set; } /// - /// Gets or sets a value indicating whether the timeout countdown is paused when the browser window loses focus. + /// Gets or sets a value indicating whether the pauses when the browser window loses focus. /// [Parameter] public bool PauseOnWindowBlur { get; set; } - /// - /// Gets or sets the callback that is invoked when the toggle state changes. - /// - /// - /// The callback receives a Boolean value indicating the new state of the toggle. Use this parameter to handle - /// toggle events in the parent component. - /// - [Parameter] - public EventCallback OnToggle { get; set; } - /// /// Gets or sets the callback that is invoked when the toast status changes. /// @@ -141,80 +134,62 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) public EventCallback OnStatusChange { get; set; } /// - /// Gets or sets the title displayed in the toast. - /// - [Parameter] - public string? Title { get; set; } - - /// - /// Gets or sets the plain-text body message displayed in the toast. - /// For rich content, use instead. - /// - [Parameter] - public string? Body { get; set; } - - /// - /// Gets or sets the optional subtitle displayed below the body. - /// - [Parameter] - public string? Subtitle { get; set; } - - /// - /// Gets or sets the first quick action label (e.g., QuickAction1="Undo"). - /// See also . + /// Gets or sets the icon rendered in the toast header. + /// When set, this overrides the default icon determined by the . /// [Parameter] - public string? QuickAction1 { get; set; } + public Icon? Icon { get; set; } /// - /// Gets or sets the second quick action label (e.g., QuickAction2="Dismiss"). - /// See also . + /// Gets or sets the title displayed in the toast. /// [Parameter] - public string? QuickAction2 { get; set; } + public string? Title { get; set; } /// - /// Gets or sets a value indicating whether the toast can be dismissed by the user. + /// Gets or sets a value indicating whether the toast can be dismissed by the user. Default is . /// When , a dismiss button is rendered; use to customize its label. /// [Parameter] - public bool IsDismissable { get; set; } + public bool AllowDismiss { get; set; } = true; /// - /// Gets or sets the label for the dismiss action button (e.g., DismissAction="Close"). - /// Only relevant when is . + /// Gets or sets the label for the dismiss action button (e.g., `DismissAction="Close"`). + /// Only relevant when is . /// [Parameter] public string? DismissAction { get; set; } /// - /// Gets or sets the icon rendered in the media slot of the toast. - /// - [Parameter] - public Icon? Icon { get; set; } - - /// - /// Gets or sets custom content rendered in the toast body, such as progress content managed through - /// . - /// For plain-text body messages, use instead. + /// Gets or sets the content rendered in the toast body. /// [Parameter] - public RenderFragment? BodyContent { get; set; } + public RenderFragment? ChildContent { get; set; } - // - internal static Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); - - internal Icon IntentIcon => Intent switch + /// + private Icon GetTitleIcon() { - ToastIntent.Success => new CoreIcons.Filled.Size20.CheckmarkCircle(), - ToastIntent.Warning => new CoreIcons.Filled.Size20.Warning(), - ToastIntent.Error => new CoreIcons.Filled.Size20.DismissCircle(), - _ => new CoreIcons.Filled.Size20.Info(), - }; + if (Icon is not null) + { + return Icon; + } - internal string? ClassValue => DefaultClassBuilder.Build(); + var iconColor = Intent switch + { + ToastIntent.Success => Inverted ? Color.SuccessInverted : Color.Success, + ToastIntent.Warning => Inverted ? Color.WarningInverted : Color.Warning, + ToastIntent.Error => Inverted ? Color.ErrorInverted : Color.Error, + _ => Inverted ? Color.InfoInverted : Color.Info, + }; - internal string? StyleValue => DefaultStyleBuilder.Build(); + return Intent switch + { + ToastIntent.Success => new CoreIcons.Filled.Size20.CheckmarkCircle().WithColor(iconColor), + ToastIntent.Warning => new CoreIcons.Filled.Size20.Warning().WithColor(iconColor), + ToastIntent.Error => new CoreIcons.Filled.Size20.DismissCircle().WithColor(iconColor), + _ => new CoreIcons.Filled.Size20.Info().WithColor(iconColor), + }; + } /// /// Raises the status change event asynchronously using the specified dialog toggle event arguments. @@ -225,7 +200,7 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// status change. /// public Task RaiseOnStatusChangeAsync(DialogToggleEventArgs args) - => RaiseOnStatusChangeAsync(new ToastEventArgs(this, args)); + => RaiseOnStatusChangeAsync(new ToastEventArgs(this.ToastInstance, args)); /// /// Raises the status change event for the specified toast instance asynchronously. @@ -260,25 +235,25 @@ internal Task RequestCloseAsync() return InvokeAsync(StateHasChanged); } - internal async Task OnDismissActionClickedAsync() + internal async Task DismissClickAsync() { - await Instance!.DismissAsync(); + await ToastInstance!.DismissAsync(); - if (Instance?.Options.DismissActionCallback is not null) + if (ToastInstance?.Options.DismissActionCallback is not null) { - await Instance.Options.DismissActionCallback(); + await ToastInstance.Options.DismissActionCallback(); } } internal Task OnQuickAction1ClickedAsync() - => HandleQuickActionClickedAsync(Instance?.Options.QuickAction1Callback); + => HandleQuickActionClickedAsync(ToastInstance?.Options.QuickAction1Callback); internal Task OnQuickAction2ClickedAsync() - => HandleQuickActionClickedAsync(Instance?.Options.QuickAction2Callback); + => HandleQuickActionClickedAsync(ToastInstance?.Options.QuickAction2Callback); private async Task HandleQuickActionClickedAsync(Func? callback) { - await Instance!.CloseAsync(ToastCloseReason.QuickAction); + await ToastInstance!.CloseAsync(ToastCloseReason.QuickAction); if (callback is not null) { @@ -286,33 +261,10 @@ private async Task HandleQuickActionClickedAsync(Func? callback) } } - internal Color GetIntentColor(ToastIntent intent) - { - if (Inverted) - { - return intent switch - { - ToastIntent.Success => Color.SuccessInverted, - ToastIntent.Warning => Color.WarningInverted, - ToastIntent.Error => Color.ErrorInverted, - _ => Color.InfoInverted, - }; - - } - - return intent switch - { - ToastIntent.Success => Color.Success, - ToastIntent.Warning => Color.Warning, - ToastIntent.Error => Color.Error, - _ => Color.Info, - }; - } - /// protected override Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && Instance is ToastInstance instance) + if (firstRender && ToastInstance is ToastInstance instance) { instance.FluentToast = this; @@ -328,18 +280,18 @@ protected override Task OnAfterRenderAsync(bool firstRender) private async Task HandleToggleAsync(DialogToggleEventArgs args) { - var expectedId = Instance?.Id ?? Id; + var expectedId = ToastInstance?.Id ?? Id; if (string.CompareOrdinal(args.Id, expectedId) != 0) { return; } - if (Instance is not ToastInstance toastInstance) + if (ToastInstance is not ToastInstance toastInstance) { return; } - var toastEventArgs = new ToastEventArgs(this, args); + var toastEventArgs = new ToastEventArgs(this.ToastInstance, args); if (toastEventArgs.Status == ToastLifecycleStatus.Dismissed) { toastInstance.LifecycleStatus = ToastLifecycleStatus.Dismissed; @@ -351,11 +303,6 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) { Opened = toggled; - if (OnToggle.HasDelegate) - { - await OnToggle.InvokeAsync(toggled); - } - if (OpenedChanged.HasDelegate) { await OpenedChanged.InvokeAsync(toggled); @@ -371,7 +318,7 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) if (ToastService is ToastService toastService) { - await toastService.RemoveToastFromProviderAsync(Instance); + await toastService.RemoveToastFromProviderAsync(ToastInstance); } await RaiseOnStatusChangeAsync(toastInstance, ToastLifecycleStatus.Unmounted); diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index b8fded6c7c..e71d2ab8b4 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -9,33 +9,34 @@ { @foreach (var toast in GetRenderedToasts()) { - + + + } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 9d6a596bfd..58c243d770 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -81,8 +81,8 @@ private bool GetPauseOnHover(IToastInstance toast) private bool GetPauseOnWindowBlur(IToastInstance toast) => toast.Options.PauseOnWindowBlur ?? configuration.Toast.PauseOnWindowBlur; - private bool GetIsDismissable(IToastInstance toast) - => toast.Options.IsDismissable ?? configuration.Toast.IsDismissable; + private bool GetAllowDismiss(IToastInstance toast) + => toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss; private bool GetInverted(IToastInstance toast) => toast.Options.Inverted ?? configuration.Toast.Inverted; @@ -93,6 +93,17 @@ private IEnumerable GetRenderedToasts() .OrderByDescending(toast => toast.Index) ?? Enumerable.Empty(); + /// + private RenderFragment RenderToastContent(IToastInstance? toast) => builder => + { + if (toast is null || string.IsNullOrEmpty(toast.Options.Message)) + { + return; + } + + builder.AddContent(0, new MarkupStringSanitized(toast.Options.Message, MarkupStringSanitized.Formats.Html, LibraryConfiguration)); + }; + private void SynchronizeToastQueue() { if (ToastService is null) diff --git a/src/Core/Components/Toast/Services/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs index 93e7e949cc..747c539f01 100644 --- a/src/Core/Components/Toast/Services/LibraryToastOptions.cs +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -63,7 +63,7 @@ internal LibraryToastOptions() /// /// Gets or sets a value indicating whether visible toasts can be dismissed by the user. /// - public bool IsDismissable { get; set; } = _defaultIsDismissable; + public bool AllowDismiss { get; set; } = _defaultIsDismissable; /// /// Gets or sets a value indicating whether the toast uses inverted colors. diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 9846e2bc6e..10759b2c72 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -2,7 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -121,9 +120,10 @@ public ToastOptions(Action implementationFactory) public string? Title { get; set; } /// - /// Gets or sets the body text of the toast. + /// Gets or sets the message displayed in the toast. + /// For security reasons, the content is sanitized using the configured before rendering. /// - public string? Body { get; set; } + public string? Message { get; set; } /// /// Gets or sets the subtitle of the toast. @@ -153,7 +153,7 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets a value indicating whether the toast can be dismissed by the user. /// - public bool? IsDismissable { get; set; } + public bool? AllowDismiss { get; set; } /// /// Gets or sets dismiss action label. @@ -170,12 +170,6 @@ public ToastOptions(Action implementationFactory) /// public Icon? Icon { get; set; } - /// - /// Gets or sets custom content rendered in the default slot, such as progress content updated through - /// . - /// - public RenderFragment? BodyContent { get; set; } - /// /// Gets or sets the action raised when the toast lifecycle status changes. /// diff --git a/src/Core/Enums/ToastType.cs b/src/Core/Enums/ToastType.cs deleted file mode 100644 index 51150648db..0000000000 --- a/src/Core/Enums/ToastType.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// Describes the type of toast. -/// -public enum ToastType -{ - /// - /// A confirmation toast. - /// - Confirmation, - - /// - /// A communication toast. - /// - Communication, - - /// - /// A determinate progress toast. - /// - DeterminateProgress, - - /// - /// An indeterminate progress toast. - /// - IndeterminateProgress, -} diff --git a/src/Core/Events/ToastEventArgs.cs b/src/Core/Events/ToastEventArgs.cs index 744d395dc9..20d6f2075c 100644 --- a/src/Core/Events/ToastEventArgs.cs +++ b/src/Core/Events/ToastEventArgs.cs @@ -10,40 +10,31 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastEventArgs : EventArgs { /// - internal ToastEventArgs(FluentToast toast, DialogToggleEventArgs args) - : this(toast, args.Id, args.Type, args.OldState, args.NewState) + internal ToastEventArgs(IToastInstance? instance, ToastLifecycleStatus status) { + Id = instance?.Id ?? string.Empty; + Instance = instance; + Status = status; } /// - internal ToastEventArgs(FluentToast toast, string? id, string? eventType, string? oldState, string? newState) + internal ToastEventArgs(IToastInstance? instance, DialogToggleEventArgs args) + : this(instance, args.Id, args.Type, args.OldState, args.NewState) { - Id = id ?? string.Empty; - Instance = toast.Instance; - Status = ToastLifecycleStatus.Queued; - - if (string.Equals(eventType, "toggle", StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(newState, "open", StringComparison.OrdinalIgnoreCase)) - { - Status = ToastLifecycleStatus.Visible; - } - } - else if (string.Equals(eventType, "beforetoggle", StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(oldState, "open", StringComparison.OrdinalIgnoreCase)) - { - Status = ToastLifecycleStatus.Dismissed; - } - } } /// - internal ToastEventArgs(IToastInstance instance, ToastLifecycleStatus status) + internal ToastEventArgs(IToastInstance? instance, string? id, string? eventType, string? oldState, string? newState) { - Id = instance.Id; + Id = id ?? string.Empty; Instance = instance; - Status = status; + + Status = DialogEventArgs.GetDialogState(eventType, oldState, newState) switch + { + DialogState.Open => ToastLifecycleStatus.Visible, + DialogState.Closing => ToastLifecycleStatus.Dismissed, + _ => ToastLifecycleStatus.Queued, + }; } /// @@ -58,6 +49,7 @@ internal ToastEventArgs(IToastInstance instance, ToastLifecycleStatus status) /// /// Gets the instance used by the . + /// This value may be null if the toast is not managed by the . /// public IToastInstance? Instance { get; } } diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index a5257c341d..4c8f55f787 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -16,7 +16,7 @@ config.Toast.HorizontalOffset = 55; config.Toast.PauseOnHover = true; config.Toast.PauseOnWindowBlur = true; - config.Toast.IsDismissable = false; + config.Toast.AllowDismiss = false; config.Toast.Inverted = false; }); @@ -60,7 +60,7 @@ await Task.CompletedTask; var toast = provider.FindComponent(); - Assert.Equal(1234, toast.Instance.Timeout); + Assert.Equal(1234, toast.Instance.Lifetime); Assert.Equal(ToastPosition.TopEnd, toast.Instance.Position); Assert.Equal(44, toast.Instance.VerticalOffset); Assert.Equal(55, toast.Instance.HorizontalOffset); @@ -84,14 +84,14 @@ options.HorizontalOffset = 22; options.PauseOnHover = true; options.PauseOnWindowBlur = true; - options.IsDismissable = true; + options.AllowDismiss = true; options.Inverted = true; }); await Task.CompletedTask; var toast = provider.FindComponent(); - Assert.Equal(4321, toast.Instance.Timeout); + Assert.Equal(4321, toast.Instance.Lifetime); Assert.Equal(ToastPosition.BottomStart, toast.Instance.Position); Assert.Equal(11, toast.Instance.VerticalOffset); Assert.Equal(22, toast.Instance.HorizontalOffset); diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 7bef35ff1e..d0dbb51ebb 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -76,7 +76,7 @@ { var icon = new CoreIcons.Regular.Size20.Dismiss(); var cut = Render(parameters => parameters - .Add(p => p.Body, "Toast body") + .Add(p => p.Message, "Toast body") .Add(p => p.Icon, icon)); Assert.Same(icon, cut.Instance.Icon); @@ -88,7 +88,7 @@ public void FluentToast_Inverted_RendersInvertedAttribute() { var cut = Render(parameters => parameters - .Add(p => p.Body, "Toast body") + .Add(p => p.Message, "Toast body") .Add(p => p.Inverted, true)); Assert.True(cut.Instance.Inverted); @@ -99,7 +99,7 @@ public void FluentToast_IndeterminateProgress_RendersSpinner() { var cut = Render(parameters => parameters - .Add(p => p.Body, "Toast body") + .Add(p => p.Message, "Toast body") .Add(p => p.Type, ToastType.IndeterminateProgress)); Assert.Equal(ToastType.IndeterminateProgress, cut.Instance.Type); @@ -143,7 +143,7 @@ [Fact] public void FluentToast_Subtitle_RendersWhenNotEmpty() { - var cut = Render(@); + var cut = Render(@); Assert.Contains("Toast subtitle", cut.Markup); } @@ -151,7 +151,7 @@ [Fact] public void FluentToast_IsDismissable_TrueWithDismissAction_RendersDismissLink() { - var cut = Render(@); + var cut = Render(@); Assert.Contains("Dismiss now", cut.Markup); Assert.Contains("fluent-link", cut.Markup); @@ -160,7 +160,7 @@ [Fact] public void FluentToast_IsDismissable_TrueWithNullDismissAction_RendersDismissButton() { - var cut = Render(@); + var cut = Render(@); Assert.Contains("Title=\"Dismiss\"", cut.Markup, StringComparison.OrdinalIgnoreCase); Assert.Contains("fluent-button", cut.Markup); @@ -172,7 +172,7 @@ var toastTask = ToastService.ShowToastAsync(options => { options.Body = "Dismiss button body"; - options.IsDismissable = true; + options.AllowDismiss = true; }); await Task.CompletedTask; @@ -212,7 +212,7 @@ var toastTask = ToastService.ShowToastAsync(options => { options.Body = "Dismiss callback body"; - options.IsDismissable = true; + options.AllowDismiss = true; options.DismissAction = "Dismiss now"; options.DismissActionCallback = () => { @@ -344,7 +344,7 @@ [Fact] public async Task FluentToast_RequestCloseAsync_WhenNotOpened_DoesNothing() { - var cut = Render(parameters => parameters.Add(p => p.Body, "Toast body")); + var cut = Render(parameters => parameters.Add(p => p.Message, "Toast body")); var method = typeof(FluentToast).GetMethod("RequestCloseAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; var task = (Task)method.Invoke(cut.Instance, null)!; @@ -652,7 +652,7 @@ [InlineData(ToastIntent.Error, Color.Error)] public void FluentToast_Intent_MapsToExpectedColor(ToastIntent intent, Color expectedColor) { - var cut = Render(parameters => parameters.Add(p => p.Body, "test")); + var cut = Render(parameters => parameters.Add(p => p.Message, "test")); var color = cut.Instance.GetIntentColor(intent); Assert.Equal(expectedColor, color); @@ -666,7 +666,7 @@ public void FluentToast_Inverted_Intent_MapsToExpectedColor(ToastIntent intent, Color expectedColor) { var cut = Render(parameters => parameters - .Add(p => p.Body, "test") + .Add(p => p.Message, "test") .Add(p => p.Inverted, true)); var color = cut.Instance.GetIntentColor(intent); From 6301a5cb09904badfae49edbd5a533417bc09601 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Fri, 19 Jun 2026 15:54:20 +0200 Subject: [PATCH 04/43] Refactor Toast component structure and enhance slot definitions --- src/Core/Components/Base/FluentSlot.cs | 25 ++ src/Core/Components/Toast/FluentToast.razor | 85 +++---- .../Components/Toast/FluentToast.razor.cs | 229 +++++++++--------- .../Toast/FluentToastProvider.razor | 3 - .../Components/Toast/Services/ToastOptions.cs | 5 - .../Components/Toast/Services/ToastService.cs | 2 +- src/Core/Enums/ToastIntent.cs | 21 +- 7 files changed, 196 insertions(+), 174 deletions(-) diff --git a/src/Core/Components/Base/FluentSlot.cs b/src/Core/Components/Base/FluentSlot.cs index 39c685da6c..2b0cd3ebfe 100644 --- a/src/Core/Components/Base/FluentSlot.cs +++ b/src/Core/Components/Base/FluentSlot.cs @@ -122,4 +122,29 @@ public static class FluentSlot /// Slot for the Action element of a fluent-message-bar /// internal const string Actions = "actions"; + + /// + /// Slot for the Media element of a fluent-toast-b + /// + internal const string ToastMedia = "media"; + + /// + /// Slot for the Title element of a fluent-toast-b + /// + internal const string ToastTitle = "title"; + + /// + /// Slot for the Action element of a fluent-toast-b + /// + internal const string ToastAction = "action"; + + /// + /// Slot for the Subtitle element of a fluent-toast-b + /// + internal const string ToastSubtitle = "subtitle"; + + /// + /// Slot for the Footer element of a fluent-toast-b + /// + internal const string ToastFooter = "footer"; } diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index abe8059a33..e614b96483 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -23,77 +23,72 @@ @ondialogbeforetoggle="@OnToggleAsync"> @* Slot Media: spinner or icon *@ - @* @if (Type == ToastType.IndeterminateProgress) + @if (Intent is not null || Icon is not null) { -
- -
- } - else *@ - { -
public partial class FluentToast : FluentComponentBase { - internal static readonly Icon DismissIcon = new CoreIcons.Regular.Size20.Dismiss(); - /// public FluentToast(LibraryConfiguration configuration) : base(configuration) { @@ -135,20 +133,30 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// /// Gets or sets the icon rendered in the toast header. - /// When set, this overrides the default icon determined by the . + /// When set, this overrides the default icon determined by the + /// (Warning, Error, Success, Info) of the toast. /// [Parameter] public Icon? Icon { get; set; } /// - /// Gets or sets the title displayed in the toast. + /// Gets or sets the title displayed in the toast header. + /// For security reasons, the content is sanitized using the configured before rendering. + /// For formatted content with markup, use instead. /// [Parameter] public string? Title { get; set; } + /// + /// Gets or sets the subtitle displayed in the toast, below the . + /// + [Parameter] + public string? Subtitle { get; set; } + /// /// Gets or sets a value indicating whether the toast can be dismissed by the user. Default is . - /// When , a dismiss button is rendered; use to customize its label. + /// When , a dismiss button is rendered; + /// Use to customize its label. /// [Parameter] public bool AllowDismiss { get; set; } = true; @@ -166,102 +174,13 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public RenderFragment? ChildContent { get; set; } - /// - private Icon GetTitleIcon() - { - if (Icon is not null) - { - return Icon; - } - - var iconColor = Intent switch - { - ToastIntent.Success => Inverted ? Color.SuccessInverted : Color.Success, - ToastIntent.Warning => Inverted ? Color.WarningInverted : Color.Warning, - ToastIntent.Error => Inverted ? Color.ErrorInverted : Color.Error, - _ => Inverted ? Color.InfoInverted : Color.Info, - }; - - return Intent switch - { - ToastIntent.Success => new CoreIcons.Filled.Size20.CheckmarkCircle().WithColor(iconColor), - ToastIntent.Warning => new CoreIcons.Filled.Size20.Warning().WithColor(iconColor), - ToastIntent.Error => new CoreIcons.Filled.Size20.DismissCircle().WithColor(iconColor), - _ => new CoreIcons.Filled.Size20.Info().WithColor(iconColor), - }; - } - - /// - /// Raises the status change event asynchronously using the specified dialog toggle event arguments. - /// - /// The event data associated with the dialog toggle action. Cannot be null. - /// - /// A task that represents the asynchronous operation. The task result contains the event arguments for the toast - /// status change. - /// - public Task RaiseOnStatusChangeAsync(DialogToggleEventArgs args) - => RaiseOnStatusChangeAsync(new ToastEventArgs(this.ToastInstance, args)); - - /// - /// Raises the status change event for the specified toast instance asynchronously. - /// - /// - /// The toast instance for which the status change event is being raised. Cannot be null. - /// - /// The new status to associate with the toast instance. - /// - /// A task that represents the asynchronous operation. The task result contains the event arguments for the status - /// change. - /// - public Task RaiseOnStatusChangeAsync(IToastInstance instance, ToastLifecycleStatus status) - => RaiseOnStatusChangeAsync(new ToastEventArgs(instance, status)); - /// - /// Raises the toggle event asynchronously using the specified dialog toggle event arguments. + /// Gets or sets the content rendered in the toast footer section, typically used for displaying additional information or actions. /// - /// The event data associated with the dialog toggle action. Cannot be null. - /// A task that represents the asynchronous operation. - public Task OnToggleAsync(DialogToggleEventArgs args) - => HandleToggleAsync(args); - - internal Task RequestCloseAsync() - { - if (!Opened) - { - return Task.CompletedTask; - } - - Opened = false; - return InvokeAsync(StateHasChanged); - } - - internal async Task DismissClickAsync() - { - await ToastInstance!.DismissAsync(); - - if (ToastInstance?.Options.DismissActionCallback is not null) - { - await ToastInstance.Options.DismissActionCallback(); - } - } - - internal Task OnQuickAction1ClickedAsync() - => HandleQuickActionClickedAsync(ToastInstance?.Options.QuickAction1Callback); - - internal Task OnQuickAction2ClickedAsync() - => HandleQuickActionClickedAsync(ToastInstance?.Options.QuickAction2Callback); - - private async Task HandleQuickActionClickedAsync(Func? callback) - { - await ToastInstance!.CloseAsync(ToastCloseReason.QuickAction); - - if (callback is not null) - { - await callback(); - } - } + [Parameter] + public RenderFragment? FooterTemplate { get; set; } - /// + /// protected override Task OnAfterRenderAsync(bool firstRender) { if (firstRender && ToastInstance is ToastInstance instance) @@ -278,39 +197,53 @@ protected override Task OnAfterRenderAsync(bool firstRender) return Task.CompletedTask; } - private async Task HandleToggleAsync(DialogToggleEventArgs args) + /// + /// Handles the toggle event for the toast component. + /// + /// The event data associated with the dialog toggle action. + /// A task that represents the asynchronous operation. + private async Task OnToggleAsync(DialogToggleEventArgs args) { + // Ensure that the event is for the current toast instance by comparing the IDs. var expectedId = ToastInstance?.Id ?? Id; if (string.CompareOrdinal(args.Id, expectedId) != 0) { return; } - if (ToastInstance is not ToastInstance toastInstance) - { - return; - } - + var toastInstance = ToastInstance as ToastInstance; var toastEventArgs = new ToastEventArgs(this.ToastInstance, args); - if (toastEventArgs.Status == ToastLifecycleStatus.Dismissed) + + if (toastInstance is not null && toastEventArgs.Status == ToastLifecycleStatus.Dismissed) { toastInstance.LifecycleStatus = ToastLifecycleStatus.Dismissed; await RaiseOnStatusChangeAsync(toastEventArgs); } - var toggled = string.Equals(args.NewState, "open", StringComparison.OrdinalIgnoreCase); - if (Opened != toggled) + var toastState = DialogEventArgs.GetDialogState(args.Type, args.OldState, args.NewState); + + // If the toast state is either Open or Closed, + // update the Opened property + // and invoke the OpenedChanged callback if necessary. + if (toastState == DialogState.Open || toastState == DialogState.Closed) { - Opened = toggled; + var isOpen = toastState == DialogState.Open; - if (OpenedChanged.HasDelegate) + if (Opened != isOpen) { - await OpenedChanged.InvokeAsync(toggled); + Opened = isOpen; + + if (OpenedChanged.HasDelegate) + { + await OpenedChanged.InvokeAsync(isOpen); + } } } - if (string.Equals(args.Type, "toggle", StringComparison.OrdinalIgnoreCase) - && string.Equals(args.NewState, "closed", StringComparison.OrdinalIgnoreCase)) + // If the toast instance is defined and the toast state is Closed, + // set the result of the ResultCompletion task to the pending close reason or TimedOut, + // reset the pending close reason, update the lifecycle status to Unmounted, + if (toastInstance is not null && toastState == DialogState.Closed) { toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingCloseReason ?? ToastCloseReason.TimedOut); toastInstance.PendingCloseReason = null; @@ -321,17 +254,81 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) await toastService.RemoveToastFromProviderAsync(ToastInstance); } - await RaiseOnStatusChangeAsync(toastInstance, ToastLifecycleStatus.Unmounted); + await RaiseOnStatusChangeAsync(new ToastEventArgs(toastInstance, ToastLifecycleStatus.Unmounted)); } } - private async Task RaiseOnStatusChangeAsync(ToastEventArgs args) + /// + /// Determines the appropriate icon to display based on the current of the toast. + /// If the is not set or is , no icon is displayed. + /// + /// The icon to display, or null if no icon should be displayed. + protected virtual Icon? GetIntentIcon() + { + if (Intent is null || Intent == ToastIntent.Progress) + { + return null; + } + + var iconColor = Intent switch + { + ToastIntent.Success => Inverted ? Color.SuccessInverted : Color.Success, + ToastIntent.Warning => Inverted ? Color.WarningInverted : Color.Warning, + ToastIntent.Error => Inverted ? Color.ErrorInverted : Color.Error, + _ => Inverted ? Color.InfoInverted : Color.Info, + }; + + return Intent switch + { + ToastIntent.Success => new CoreIcons.Filled.Size20.CheckmarkCircle().WithColor(iconColor), + ToastIntent.Warning => new CoreIcons.Filled.Size20.Warning().WithColor(iconColor), + ToastIntent.Error => new CoreIcons.Filled.Size20.DismissCircle().WithColor(iconColor), + _ => new CoreIcons.Filled.Size20.Info().WithColor(iconColor), + }; + } + + /// + /// Closes the toast component. + /// + internal Task CloseAsync() + { + if (!Opened) + { + return Task.CompletedTask; + } + + Opened = false; + return InvokeAsync(StateHasChanged); + } + + /// + /// Handles the ToastAction click event, dismissing the toast. + /// + /// + private async Task DismissClickAsync() + { + if (ToastInstance is null) + { + await CloseAsync(); + return; + } + + await ToastInstance.DismissAsync(); + + if (ToastInstance.Options.DismissActionCallback is not null) + { + await ToastInstance.Options.DismissActionCallback(); + } + } + + /// + /// Raises the status change event asynchronously using the specified toast event arguments. + /// + private async Task RaiseOnStatusChangeAsync(ToastEventArgs args) { if (OnStatusChange.HasDelegate) { await InvokeAsync(() => OnStatusChange.InvokeAsync(args)); } - - return args; } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index e71d2ab8b4..85391d7bee 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -20,7 +20,6 @@ Position="@GetPosition(toast)" VerticalOffset="@GetVerticalOffset(toast)" HorizontalOffset="@GetHorizontalOffset(toast)" - Type="@toast.Options.Type" Inverted="@GetInverted(toast)" Intent="@toast.Options.Intent" Politeness="@toast.Options.Politeness" @@ -31,8 +30,6 @@ Title="@toast.Options.Title" ChildContent="@RenderToastContent(toast)" Subtitle="@toast.Options.Subtitle" - QuickAction1="@toast.Options.QuickAction1" - QuickAction2="@toast.Options.QuickAction2" AllowDismiss="@GetAllowDismiss(toast)" DismissAction="@toast.Options.DismissAction" AdditionalAttributes="@toast.Options.AdditionalAttributes" /> diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 10759b2c72..97a11be4c1 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -84,11 +84,6 @@ public ToastOptions(Action implementationFactory) /// public int? HorizontalOffset { get; set; } - /// - /// Gets or sets the toast type, which determines things like a default icon and styling of the toast. - /// - public ToastType Type { get; set; } = ToastType.Communication; - /// /// Gets or sets a value indicating whether the toast uses inverted colors. /// diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index 3a4fa4e5da..723d2caba7 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -38,7 +38,7 @@ public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) if (ToastInstance?.FluentToast is FluentToast fluentToast) { ToastInstance.PendingCloseReason = reason; - await fluentToast.RequestCloseAsync(); + await fluentToast.CloseAsync(); return; } diff --git a/src/Core/Enums/ToastIntent.cs b/src/Core/Enums/ToastIntent.cs index 4f74ffc3cf..54fd5f0d16 100644 --- a/src/Core/Enums/ToastIntent.cs +++ b/src/Core/Enums/ToastIntent.cs @@ -9,15 +9,28 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public enum ToastIntent { - /// + /// + /// Indicates that the toast is providing informational messages. + /// Info, - /// + /// + /// Indicates that the toast is providing success messages. + /// Success, - /// + /// + /// Indicates that the toast is providing warning messages. + /// Warning, - /// + /// + /// Indicates that the toast is providing error messages. + /// Error, + + /// + /// Indicates that the toast is displaying progress information, typically used for long-running operations or tasks. + /// + Progress, } From b1625e7d7bbbedef40f3c5f5e703d0d519c5d805 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Fri, 19 Jun 2026 18:45:13 +0200 Subject: [PATCH 05/43] Refactor Toast System to Notification System --- .../FluentMessageBarProvider.razor.cs | 13 +- .../Services/IMessageBarInstance.cs | 24 +-- .../Services/INotificationInstance.cs | 33 ++++ .../Services/INotificationService.cs | 12 +- .../MessageBar/Services/MessageBarInstance.cs | 8 +- .../MessageBar/Services/MessageBarOptions.cs | 2 +- .../NotificationService.Subscribers.cs | 6 +- .../Services/NotificationService.cs | 39 ++-- .../Components/Toast/FluentToast.razor.cs | 23 ++- .../Toast/FluentToastProvider.razor | 32 ++-- .../Toast/FluentToastProvider.razor.cs | 82 ++++----- .../Toast/Services/INotificationService.cs | 57 ++++++ .../Toast/Services/IToastInstance.cs | 47 +---- .../Toast/Services/IToastService.cs | 71 -------- .../Toast/Services/LibraryToastOptions.cs | 41 ++--- .../Toast/Services/NotificationService.cs | 162 +++++++++++++++++ .../Toast/Services/ToastInstance.cs | 53 +++--- .../Components/Toast/Services/ToastOptions.cs | 67 ++++--- .../Components/Toast/Services/ToastResult.cs | 33 ++++ .../Components/Toast/Services/ToastService.cs | 170 ------------------ src/Core/Events/ToastEventArgs.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 3 - 22 files changed, 479 insertions(+), 503 deletions(-) create mode 100644 src/Core/Components/MessageBar/Services/INotificationInstance.cs create mode 100644 src/Core/Components/Toast/Services/INotificationService.cs delete mode 100644 src/Core/Components/Toast/Services/IToastService.cs create mode 100644 src/Core/Components/Toast/Services/NotificationService.cs create mode 100644 src/Core/Components/Toast/Services/ToastResult.cs delete mode 100644 src/Core/Components/Toast/Services/ToastService.cs diff --git a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs index ada6470c9c..0010924088 100644 --- a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs +++ b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs @@ -61,12 +61,17 @@ public void Dispose() } /// - private IEnumerable GetRenderedMessageBars() + private IEnumerable MessageBarItems => NotificationService?.Items.Values - .Where(messageBar => string.Compare(messageBar.Options.Section, Section, StringComparison.OrdinalIgnoreCase) == 0 && + .Where(item => item is IMessageBarInstance) + .Cast() + ?? []; + + /// + private IEnumerable GetRenderedMessageBars() + => MessageBarItems.Where(messageBar => string.Compare(messageBar.Options.Section, Section, StringComparison.OrdinalIgnoreCase) == 0 && messageBar.LifecycleStatus == MessageBarLifecycleStatus.Visible) - .OrderBy(messageBar => messageBar.Index) - ?? Enumerable.Empty(); + .OrderBy(messageBar => messageBar.Index); /// private RenderFragment RenderMessageBarContent(IMessageBarInstance? messageBar) => builder => diff --git a/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs b/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs index 1ba43ddeb5..9726e15c9c 100644 --- a/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs +++ b/src/Core/Components/MessageBar/Services/IMessageBarInstance.cs @@ -7,25 +7,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// /// Interface for a message bar instance managed by the . /// -public interface IMessageBarInstance +public interface IMessageBarInstance : INotificationInstance { - /// - /// Gets the optional component type rendered for this message bar. - /// When , the default is rendered. - /// - internal Type? ComponentType { get; } - - /// - /// Gets the unique identifier for the MessageBar. If this value is not set in the , - /// a new identifier is generated. - /// - string Id { get; } - - /// - /// Gets the index of the MessageBar (sequential number). - /// - long Index { get; } - /// /// Gets the options used to configure the MessageBar. /// @@ -41,11 +24,6 @@ public interface IMessageBarInstance /// MessageBarLifecycleStatus LifecycleStatus { get; } - /// - /// Closes the MessageBar programmatically. - /// - Task CloseAsync(); - /// /// Closes the MessageBar with the specified result. /// diff --git a/src/Core/Components/MessageBar/Services/INotificationInstance.cs b/src/Core/Components/MessageBar/Services/INotificationInstance.cs new file mode 100644 index 0000000000..1b68c2df48 --- /dev/null +++ b/src/Core/Components/MessageBar/Services/INotificationInstance.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Interface for a notification instance managed by the . +/// +public partial interface INotificationInstance +{ + /// + /// Gets the optional component type rendered for this notification. + /// When , the default notification component is rendered. + /// + internal Type? ComponentType { get; } + + /// + /// Gets the unique identifier for the notification. If this value is not set in the options, + /// a new identifier is generated. + /// + string Id { get; } + + /// + /// Gets the index of the notification (sequential number). + /// + long Index { get; } + + /// + /// Closes the notification programmatically. + /// + Task CloseAsync(); +} diff --git a/src/Core/Components/MessageBar/Services/INotificationService.cs b/src/Core/Components/MessageBar/Services/INotificationService.cs index cb00f7bcfa..f5681e94f7 100644 --- a/src/Core/Components/MessageBar/Services/INotificationService.cs +++ b/src/Core/Components/MessageBar/Services/INotificationService.cs @@ -8,9 +8,9 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Interface for the MessageBar service. +/// Interface for the Notification service. /// -public partial interface INotificationService : IFluentServiceBase +public partial interface INotificationService : IFluentServiceBase { /// /// Shows a success message bar with the specified title and message and waits for the close result. @@ -86,12 +86,12 @@ public partial interface INotificationService : IFluentServiceBase - /// Closes the message bar with the specified identifier. + /// Closes the notification (message bar or toast) with the specified identifier. /// - /// The identifier of the message bar to close. + /// The identifier of the notification to close. /// Optional data to include with the close result. - /// when a matching message bar was found; otherwise . - Task CloseAsync(string messageBarId, object? data = null); + /// when a matching notification was found; otherwise . + Task CloseAsync(string id, object? data = null); /// /// Closes all current message bars. diff --git a/src/Core/Components/MessageBar/Services/MessageBarInstance.cs b/src/Core/Components/MessageBar/Services/MessageBarInstance.cs index ebd5517c3c..f9f0ec0b30 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarInstance.cs +++ b/src/Core/Components/MessageBar/Services/MessageBarInstance.cs @@ -34,7 +34,7 @@ internal MessageBarInstance(INotificationService notificationService, Type? comp } /// - Type? IMessageBarInstance.ComponentType => _componentType; + Type? INotificationInstance.ComponentType => _componentType; /// internal INotificationService NotificationService { get; } @@ -54,13 +54,13 @@ internal MessageBarInstance(INotificationService notificationService, Type? comp /// public MessageBarLifecycleStatus LifecycleStatus { get; internal set; } = MessageBarLifecycleStatus.Visible; - /// + /// public string Id { get; } - /// + /// public long Index { get; } - /// + /// public Task CloseAsync() { return NotificationService.CloseAsync(this); diff --git a/src/Core/Components/MessageBar/Services/MessageBarOptions.cs b/src/Core/Components/MessageBar/Services/MessageBarOptions.cs index d30a268c3f..9bae56c97f 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarOptions.cs +++ b/src/Core/Components/MessageBar/Services/MessageBarOptions.cs @@ -22,7 +22,7 @@ public MessageBarOptions() /// Initializes a new instance of the class /// using the specified implementation factory. /// - /// + /// Action used to configure the message bar options. public MessageBarOptions(Action implementationFactory) { implementationFactory.Invoke(this); diff --git a/src/Core/Components/MessageBar/Services/NotificationService.Subscribers.cs b/src/Core/Components/MessageBar/Services/NotificationService.Subscribers.cs index 1433fcd7bc..4d023b03c4 100644 --- a/src/Core/Components/MessageBar/Services/NotificationService.Subscribers.cs +++ b/src/Core/Components/MessageBar/Services/NotificationService.Subscribers.cs @@ -13,12 +13,12 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public partial class NotificationService { - private readonly ConcurrentDictionary> _subscribers = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _subscribers = new(StringComparer.Ordinal); /// /// Registers a provider callback so it gets notified when the items collection changes. /// - internal void Subscribe(string? providerId, Func callback) + internal void Subscribe(string? providerId, Func callback) { if (string.IsNullOrEmpty(providerId) || callback is null) { @@ -59,7 +59,7 @@ internal void Unsubscribe(string? providerId) /// Invokes every registered subscriber. Each provider decides whether the /// instance is relevant to it (typically via the Section filter). /// - private async Task DispatchOnUpdatedAsync(IMessageBarInstance instance) + private async Task DispatchOnUpdatedAsync(INotificationInstance instance) { foreach (var callback in _subscribers.Values) { diff --git a/src/Core/Components/MessageBar/Services/NotificationService.cs b/src/Core/Components/MessageBar/Services/NotificationService.cs index 3fd9f59a42..a5ef2f2a5f 100644 --- a/src/Core/Components/MessageBar/Services/NotificationService.cs +++ b/src/Core/Components/MessageBar/Services/NotificationService.cs @@ -8,21 +8,10 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Service for showing message bars. +/// Service for showing message bars and toasts. /// -public partial class NotificationService : FluentServiceBase, INotificationService +public partial class NotificationService : FluentServiceBase, INotificationService { - /// - /// Initializes a new instance of the class. - /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarInstance))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IMessageBarInstance))] - public NotificationService() - { - ServiceProvider.OnUpdatedAsync = DispatchOnUpdatedAsync; - } - /// public Task ShowSuccessBarAsync(string section, string? title = null, string? message = null) { @@ -109,21 +98,35 @@ public Task CloseAsync(IMessageBarInstance messageBar, object? data = null) } /// - public async Task CloseAsync(string messageBarId, object? data = null) + public async Task CloseAsync(string id, object? data = null) { - if (string.IsNullOrWhiteSpace(messageBarId) || !ServiceProvider.Items.TryGetValue(messageBarId, out var messageBar)) + if (string.IsNullOrWhiteSpace(id)) { return false; } - await CloseCoreAsync(messageBar, MessageBarResult.OfProgrammatic(data)); - return true; + if (ServiceProvider.Items.TryGetValue(id, out var notification)) + { + if (notification is IMessageBarInstance messageBar) + { + await CloseCoreAsync((IMessageBarInstance)messageBar, MessageBarResult.OfProgrammatic(data)); + return true; + } + + if (notification is IToastInstance toast) + { + await CloseCoreAsync((IToastInstance)toast, ToastResult.OfProgrammatic(data)); + return true; + } + } + + return false; } /// public async Task CloseAllMessageBarsAsync() { - var messageBars = ServiceProvider.Items.Values.ToList(); + var messageBars = ServiceProvider.Items.Values.Where(item => item is IMessageBarInstance).Cast().ToList(); foreach (var messageBar in messageBars) { diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 3314f89508..8b333c53f8 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -22,7 +22,7 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) } [Inject] - private IToastService ToastService { get; set; } = default!; + private INotificationService? NotificationService { get; set; } = default!; /// protected string? ClassValue => DefaultClassBuilder.Build(); @@ -31,7 +31,7 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) protected string? StyleValue => DefaultStyleBuilder.Build(); /// - /// Gets the instance, if the toast is rendered using the . Otherwise, returns null. + /// Gets the instance, if the toast is rendered using the . Otherwise, returns null. /// [CascadingParameter] internal IToastInstance? ToastInstance { get; set; } @@ -185,8 +185,6 @@ protected override Task OnAfterRenderAsync(bool firstRender) { if (firstRender && ToastInstance is ToastInstance instance) { - instance.FluentToast = this; - if (!Opened) { Opened = true; @@ -241,17 +239,16 @@ private async Task OnToggleAsync(DialogToggleEventArgs args) } // If the toast instance is defined and the toast state is Closed, - // set the result of the ResultCompletion task to the pending close reason or TimedOut, - // reset the pending close reason, update the lifecycle status to Unmounted, + // set the result of the ResultCompletion task if (toastInstance is not null && toastState == DialogState.Closed) { - toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingCloseReason ?? ToastCloseReason.TimedOut); - toastInstance.PendingCloseReason = null; + // TODO: Need to change the ToastResult + toastInstance.ResultCompletion.TrySetResult(ToastResult.OfTimedOut()); toastInstance.LifecycleStatus = ToastLifecycleStatus.Unmounted; - if (ToastService is ToastService toastService) + if (NotificationService is NotificationService notificationService) { - await toastService.RemoveToastFromProviderAsync(ToastInstance); + await notificationService.RemoveToastFromProviderAsync(toastInstance); } await RaiseOnStatusChangeAsync(new ToastEventArgs(toastInstance, ToastLifecycleStatus.Unmounted)); @@ -313,11 +310,11 @@ private async Task DismissClickAsync() return; } - await ToastInstance.DismissAsync(); + await ToastInstance.CloseAsync(); - if (ToastInstance.Options.DismissActionCallback is not null) + if (ToastInstance.Options.OnStatusChange is not null) { - await ToastInstance.Options.DismissActionCallback(); + ToastInstance.Options.OnStatusChange(new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Dismissed)); } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 85391d7bee..2f77a46648 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -1,38 +1,50 @@ @namespace Microsoft.FluentUI.AspNetCore.Components @inherits FluentComponentBase +@{ +#pragma warning disable IL2111 +}
- @if (ToastService != null) + @if (NotificationService != null) { @foreach (var toast in GetRenderedToasts()) { - + } + else + { + + } } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 58c243d770..37483d73e2 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -9,7 +9,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -public partial class FluentToastProvider : FluentComponentBase +public partial class FluentToastProvider : FluentComponentBase, IDisposable { private readonly LibraryConfiguration configuration; @@ -30,68 +30,50 @@ public FluentToastProvider(LibraryConfiguration configuration) : base(configurat .AddStyle("z-index", ZIndex.Toast.ToString(CultureInfo.InvariantCulture)) .Build(); - /// - /// Gets or sets the injected service provider. - /// - [Inject] - public IServiceProvider? ServiceProvider { get; set; } - /// - protected virtual IToastService? ToastService => GetCachedServiceOrNull(); + protected virtual INotificationService? NotificationService => GetCachedServiceOrNull(); /// protected override void OnInitialized() { base.OnInitialized(); - if (ToastService is not null) + if (NotificationService is NotificationService service) { - ToastService.ProviderId = Id; - ToastService.OnUpdatedAsync = async (item) => + // Register this provider as a subscriber. Multiple providers can coexist: + service.Subscribe(Id, async _ => { SynchronizeToastQueue(); await InvokeAsync(StateHasChanged); - }; + }); SynchronizeToastQueue(); } } /// - internal static Action EmptyOnStatusChange => (_) => { }; - - private EventCallback GetOnStatusChangeCallback(IToastInstance toast) - => EventCallback.Factory.Create(this, toast.Options.OnStatusChange ?? EmptyOnStatusChange); - - private int GetTimeout(IToastInstance toast) - => toast.Options.Timeout ?? configuration.Toast.Timeout; - - private ToastPosition? GetPosition(IToastInstance toast) - => toast.Options.Position ?? configuration.Toast.Position; - - private int GetVerticalOffset(IToastInstance toast) - => toast.Options.VerticalOffset ?? configuration.Toast.VerticalOffset; - - private int GetHorizontalOffset(IToastInstance toast) - => toast.Options.HorizontalOffset ?? configuration.Toast.HorizontalOffset; - - private bool GetPauseOnHover(IToastInstance toast) - => toast.Options.PauseOnHover ?? configuration.Toast.PauseOnHover; - - private bool GetPauseOnWindowBlur(IToastInstance toast) - => toast.Options.PauseOnWindowBlur ?? configuration.Toast.PauseOnWindowBlur; - - private bool GetAllowDismiss(IToastInstance toast) - => toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss; + public void Dispose() + { + if (NotificationService is NotificationService service && !string.IsNullOrEmpty(Id)) + { + service.Unsubscribe(Id); + } + } - private bool GetInverted(IToastInstance toast) - => toast.Options.Inverted ?? configuration.Toast.Inverted; + /// + private IEnumerable ToastItems + => NotificationService?.Items.Values + .Where(item => item is IToastInstance) + .Cast() + ?? []; + /// private IEnumerable GetRenderedToasts() - => ToastService?.Items.Values - .Where(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed) - .OrderByDescending(toast => toast.Index) - ?? Enumerable.Empty(); + => ToastItems.Where(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed) + .OrderBy(toast => toast.Index); + + private EventCallback GetOnStatusChangeCallback(IToastInstance toast) + => EventCallback.Factory.Create(this, toast.Options.OnStatusChange ?? ((_) => { })); /// private RenderFragment RenderToastContent(IToastInstance? toast) => builder => @@ -104,19 +86,21 @@ private RenderFragment RenderToastContent(IToastInstance? toast) => builder => builder.AddContent(0, new MarkupStringSanitized(toast.Options.Message, MarkupStringSanitized.Formats.Html, LibraryConfiguration)); }; + /// + /// Synchronizes the toast queue by promoting queued toasts to visible status based on the maximum allowed toast count. + /// private void SynchronizeToastQueue() { - if (ToastService is null) + if (NotificationService is NotificationService service) { return; } var maxToastCount = configuration.Toast.MaxToastCount; - var activeCount = ToastService.Items.Values.Count(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed); - var queuedToasts = ToastService.Items.Values - .Where(toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued) - .OrderByDescending(toast => toast.Index) - .ToList(); + var activeCount = ToastItems.Count(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed); + var queuedToasts = ToastItems.Where(toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued) + .OrderByDescending(toast => toast.Index) + .ToList(); foreach (var toast in queuedToasts) { diff --git a/src/Core/Components/Toast/Services/INotificationService.cs b/src/Core/Components/Toast/Services/INotificationService.cs new file mode 100644 index 0000000000..b3e1743cab --- /dev/null +++ b/src/Core/Components/Toast/Services/INotificationService.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Interface for the Notification service. +/// +public partial interface INotificationService : IFluentServiceBase +{ + /// + /// Shows a toast using the supplied options and waits for the close result. + /// + /// Options to configure the toast. + Task ShowToastAsync(ToastOptions options); + + /// + /// Shows a toast by configuring an options object and waits for the close result. + /// + /// Action used to configure the toast. + Task ShowToastAsync(Action options); + + /// + /// Shows a custom toast component and waits for the close result. + /// The component receives the current through a cascading parameter. + /// + /// A Blazor component type used to render the toast. + /// Options used to configure the toast. + Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(ToastOptions options) + where TToast : ComponentBase; + + /// + /// Shows a custom toast component and waits for the close result. + /// The component receives the current through a cascading parameter. + /// + /// A Blazor component type used to render the toast. + /// Action used to configure the toast. + Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(Action options) + where TToast : ComponentBase; + + /// + /// Closes the specified toast instance. + /// + /// Instance of the toast to close. + /// Optional data to include with the close result. + Task CloseAsync(IToastInstance toast, object? data = null); + + /// + /// Closes all current toasts. + /// + /// The number of toasts that were closed. + Task CloseAllToastsAsync(); +} \ No newline at end of file diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index b3e572d82f..1deed4cda7 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -5,30 +5,19 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Interface for ToastReference +/// Interface for a toast instance managed by the . /// -public interface IToastInstance +public interface IToastInstance : INotificationInstance { /// - /// Gets the unique identifier for the Toast. If this value is not set in the , a new - /// identifier is generated. - /// - string Id { get; } - - /// - /// Gets the index of the Toast (sequential number). - /// - long Index { get; } - - /// - /// Gets the options used to configure the Toast. + /// Gets the options used to configure the toast. /// ToastOptions Options { get; } /// - /// Gets the close reason of the Toast. + /// Gets the result of the toast. /// - Task Result { get; } + Task Result { get; } /// /// Gets the lifecycle status of the toast. @@ -36,28 +25,8 @@ public interface IToastInstance ToastLifecycleStatus LifecycleStatus { get; } /// - /// Closes the Toast programmatically. - /// - /// - Task CloseAsync(); - - /// - /// Closes the Toast with the specified reason. - /// - /// Reason to close the Toast with. - /// - Task CloseAsync(ToastCloseReason reason); - - /// - /// Dismisses the Toast. - /// - /// - Task DismissAsync(); - - /// - /// Updates the toast options while the toast is shown. + /// Closes the toast with the specified result. /// - /// The action that mutates the current options. - /// - Task UpdateAsync(Action update); + /// Result associated with the close action. + Task CloseAsync(ToastResult result); } diff --git a/src/Core/Components/Toast/Services/IToastService.cs b/src/Core/Components/Toast/Services/IToastService.cs deleted file mode 100644 index 9eb0b46ed6..0000000000 --- a/src/Core/Components/Toast/Services/IToastService.cs +++ /dev/null @@ -1,71 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// Interface for ToastService -/// -public partial interface IToastService : IFluentServiceBase -{ - /// - /// Closes the toast with the specified reason. - /// - /// Instance of the toast to close. - /// Reason for closing the toast. - /// - Task CloseAsync(IToastInstance Toast, ToastCloseReason reason); - - /// - /// Dismisses the specified toast instance. - /// - /// Instance of the toast to dismiss. - /// - Task DismissAsync(IToastInstance Toast); - - /// - /// Dismisses the toast with the specified identifier. - /// - /// The identifier of the toast to dismiss. - /// true when a matching toast was found; otherwise false. - Task DismissAsync(string toastId); - - /// - /// Dismisses all current toasts. - /// - /// The number of toasts that were dismissed. - Task DismissAllAsync(); - - /// - /// Shows a toast using the supplied options. - /// - /// Options to configure the toast. - Task ShowToastAsync(ToastOptions? options = null); - - /// - /// Shows a toast by configuring an options object. - /// - /// Action used to configure the toast. - Task ShowToastAsync(Action options); - - /// - /// Shows a toast using the supplied options and returns the live toast instance. - /// - /// Options to configure the toast. - Task ShowToastInstanceAsync(ToastOptions? options = null); - - /// - /// Shows a toast by configuring an options object and returns the live toast instance. - /// - /// Action used to configure the toast. - Task ShowToastInstanceAsync(Action options); - - /// - /// Updates a shown toast. - /// - /// The toast instance to update. - /// The action that mutates the current options. - /// - Task UpdateToastAsync(IToastInstance toast, Action update); -} diff --git a/src/Core/Components/Toast/Services/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs index 747c539f01..a3cd6257f6 100644 --- a/src/Core/Components/Toast/Services/LibraryToastOptions.cs +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -8,16 +8,6 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public class LibraryToastOptions { - private const int _defaultMaxToastCount = 4; - private const int _defaultTimeout = 7000; - private const ToastPosition _defaultPosition = ToastPosition.BottomEnd; - private const int _defaultVerticalOffset = 16; - private const int _defaultHorizontalOffset = 20; - private const bool _defaultPauseOnHover = true; - private const bool _defaultPauseOnWindowBlur = true; - private const bool _defaultIsDismissable = false; - private const bool _defaultInverted = false; - /// /// Initializes a new instance of the class. /// @@ -27,46 +17,57 @@ internal LibraryToastOptions() /// /// Gets or sets the maximum number of toasts displayed at the same time. + /// Default is 4 toasts, which is the recommended maximum number of toasts to be displayed according to Fluent UI design guidelines. + /// When the maximum count is reached, the oldest toast is dismissed when a new toast is added. + /// Setting this value to 0 allows an unlimited number of toasts to be displayed, which can lead to a poor user experience and is not recommended. /// - public int MaxToastCount { get; set; } = _defaultMaxToastCount; + public int MaxToastCount { get; set; } = 4; /// - /// Gets or sets the default timeout duration in milliseconds for visible toasts. + /// Gets or sets the lifetime of the toast. + /// When set to a positive value, the toast is automatically removed after this duration elapses. + /// When `null`, the toast stays visible until it is dismissed programmatically or by the user. /// - public int Timeout { get; set; } = _defaultTimeout; + public TimeSpan? Lifetime { get; set; } /// /// Gets or sets the default toast position. /// - public ToastPosition? Position { get; set; } = _defaultPosition; + public ToastPosition? Position { get; set; } = ToastPosition.BottomEnd; /// /// Gets or sets the default vertical offset in pixels. + /// Default is 16px, which is the recommended offset according to Fluent UI design guidelines. /// - public int VerticalOffset { get; set; } = _defaultVerticalOffset; + public int VerticalOffset { get; set; } = 16; /// /// Gets or sets the default horizontal offset in pixels. + /// Default is 20px, which is the recommended offset according to Fluent UI design guidelines. /// - public int HorizontalOffset { get; set; } = _defaultHorizontalOffset; + public int HorizontalOffset { get; set; } = 20; /// /// Gets or sets a value indicating whether visible toasts pause timeout while hovered. + /// Default is `true`, which is the recommended behavior according to Fluent UI design guidelines. /// - public bool PauseOnHover { get; set; } = _defaultPauseOnHover; + public bool PauseOnHover { get; set; } = true; /// /// Gets or sets a value indicating whether visible toasts pause timeout while the window is blurred. + /// Default is `true`, which is the recommended behavior according to Fluent UI design guidelines. /// - public bool PauseOnWindowBlur { get; set; } = _defaultPauseOnWindowBlur; + public bool PauseOnWindowBlur { get; set; } = true; /// /// Gets or sets a value indicating whether visible toasts can be dismissed by the user. + /// Default is `false`, which is the recommended behavior according to Fluent UI design guidelines. /// - public bool AllowDismiss { get; set; } = _defaultIsDismissable; + public bool AllowDismiss { get; set; } /// /// Gets or sets a value indicating whether the toast uses inverted colors. + /// Default is `false`, which is the recommended behavior according to Fluent UI design guidelines. /// - public bool Inverted { get; set; } = _defaultInverted; + public bool Inverted { get; set; } } diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs new file mode 100644 index 0000000000..8b09b32a7a --- /dev/null +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -0,0 +1,162 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Service for showing Toasts. +/// +public partial class NotificationService : FluentServiceBase, INotificationService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IJSRuntime _jsRuntime; + + /// + /// Initializes a new instance of the class. + /// + /// List of services available in the application. + /// Localizer for the application. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarInstance))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(INotificationInstance))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IMessageBarInstance))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IToastInstance))] + public NotificationService(IServiceProvider serviceProvider, IFluentLocalizer? localizer) + { + _serviceProvider = serviceProvider; + _jsRuntime = serviceProvider.GetRequiredService(); + Localizer = localizer ?? FluentLocalizerInternal.Default; + ServiceProvider.OnUpdatedAsync = DispatchOnUpdatedAsync; + } + + /// + protected IFluentLocalizer Localizer { get; } + + /// + public async Task ShowToastAsync(ToastOptions options) + { + var instance = ShowToastInstanceCore(componentType: null, options); + await ServiceProvider.OnUpdatedAsync.Invoke(instance); + return await instance.Result; + } + + /// + public Task ShowToastAsync(Action options) + => ShowToastAsync(new ToastOptions(options)); + + /// + public async Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(ToastOptions options) + where TToast : ComponentBase + { + var instance = ShowToastInstanceCore(typeof(TToast), options); + await ServiceProvider.OnUpdatedAsync.Invoke(instance); + return await instance.Result; + } + + /// + public Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(Action options) + where TToast : ComponentBase + => ShowToastAsync(new ToastOptions(options)); + + /// + public Task CloseAsync(IToastInstance toast, object? data = null) + { + if (data is not null and ToastResult result) + { + return CloseCoreAsync(toast, result); + } + + return CloseCoreAsync(toast, ToastResult.OfProgrammatic(data)); + } + + /// + public async Task CloseAllToastsAsync() + { + var toasts = ServiceProvider.Items.Values.Where(item => item is IToastInstance).Cast().ToList(); + + foreach (var toast in toasts) + { + await CloseCoreAsync(toast, ToastResult.OfProgrammatic()); + } + + return toasts.Count; + } + + /// + private ToastInstance ShowToastInstanceCore(Type? componentType, ToastOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (this.ProviderNotAvailable()) + { + throw new FluentServiceProviderException(); + } + + var instance = new ToastInstance(this, componentType, options); + + // Add the Toast to the service. + if (!ServiceProvider.Items.TryAdd(instance.Id, instance)) + { + throw new InvalidOperationException($"A Toast with the ID '{instance.Id}' is already registered."); + } + + options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Queued)); + + return instance; + } + + /// + public async Task CloseCoreAsync(IToastInstance toast, ToastResult result) + { + if (toast is not ToastInstance instance) + { + return; + } + + if (instance.LifecycleStatus == ToastLifecycleStatus.Unmounted) + { + return; + } + + // TODO: How to close? + // ... + + instance.LifecycleStatus = ToastLifecycleStatus.Dismissed; + instance.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Dismissed)); + + // Remove the Toast from the ToastProvider. + await RemoveToastFromProviderAsync(instance); + + instance.LifecycleStatus = ToastLifecycleStatus.Unmounted; + + // Set the result of the Toast. + instance.ResultCompletion.TrySetResult(result); + + // Raise the final ToastLifecycleStatus.Unmounted event. + instance.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Unmounted)); + } + + /// + /// Removes the Toast from the ToastProvider. + /// + internal async Task RemoveToastFromProviderAsync(IToastInstance? toast) + { + if (toast is null) + { + return; + } + + if (!ServiceProvider.Items.TryRemove(toast.Id, out _)) + { + return; + } + + await ServiceProvider.OnUpdatedAsync.Invoke(toast); + } +} diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 25d534df1a..a36561fd00 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -7,67 +7,60 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Represents a toast instance used with the . +/// Represents a toast instance used with the . /// public class ToastInstance : IToastInstance { private static long _counter; - internal readonly TaskCompletionSource ResultCompletion = new(); + internal readonly TaskCompletionSource ResultCompletion = new(); + private readonly Type? _componentType; /// - internal ToastInstance(IToastService toastService, ToastOptions options) + internal ToastInstance(INotificationService notificationService, ToastOptions options) + : this(notificationService, componentType: null, options) + { + } + + /// + internal ToastInstance(INotificationService notificationService, Type? componentType, ToastOptions options) { Options = options; - ToastService = toastService; + NotificationService = notificationService; + _componentType = componentType; Id = string.IsNullOrEmpty(options.Id) ? Identifier.NewId() : options.Id; Index = Interlocked.Increment(ref _counter); } /// - internal IToastService ToastService { get; } - - /// - internal FluentToast? FluentToast { get; set; } + Type? INotificationInstance.ComponentType => _componentType; /// - internal ToastCloseReason? PendingCloseReason { get; set; } + internal INotificationService NotificationService { get; } /// public ToastOptions Options { get; internal set; } /// - public Task Result => ResultCompletion.Task; + public Task Result => ResultCompletion.Task; /// - public ToastLifecycleStatus LifecycleStatus { get; internal set; } = ToastLifecycleStatus.Queued; + public ToastLifecycleStatus LifecycleStatus { get; internal set; } = ToastLifecycleStatus.Visible; - /// + /// public string Id { get; } - /// + /// public long Index { get; } - /// + /// public Task CloseAsync() { - return ToastService.CloseAsync(this, ToastCloseReason.Programmatic); - } - - /// - public Task CloseAsync(ToastCloseReason reason) - { - return ToastService.CloseAsync(this, reason); - } - - /// - public Task DismissAsync() - { - return ToastService.DismissAsync(this); + return NotificationService.CloseAsync(this); } - /// - public Task UpdateAsync(Action update) + /// + public Task CloseAsync(ToastResult result) { - return ToastService.UpdateToastAsync(this, update); + return NotificationService.CloseAsync(this, result); } } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 97a11be4c1..d980458f0d 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Options for configuring a Toast. +/// Options for configuring a toast displayed by the . /// public class ToastOptions : IFluentComponentBase { @@ -19,9 +19,10 @@ public ToastOptions() } /// - /// Initializes a new instance of the class using the specified implementation factory. + /// Initializes a new instance of the class + /// using the specified implementation factory. /// - /// + /// Action used to configure the toast options. public ToastOptions(Action implementationFactory) { implementationFactory.Invoke(this); @@ -65,9 +66,17 @@ public ToastOptions(Action implementationFactory) public IReadOnlyDictionary? AdditionalAttributes { get; set; } /// - /// Gets or sets the timeout duration for the Toast in milliseconds. + /// Gets a list of toast parameters. + /// Each parameter must correspond to a [Parameter] property defined in the toast component. /// - public int? Timeout { get; set; } + public IDictionary Parameters { get; set; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets or sets the lifetime of the toast. + /// When set to a positive value, the toast is automatically removed after this duration elapses. + /// When `null`, the toast stays visible until it is dismissed programmatically or by the user. + /// + public TimeSpan? Lifetime { get; set; } /// /// Gets or sets the toast position on screen. @@ -92,25 +101,16 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets the toast intent. /// - public ToastIntent Intent { get; set; } = ToastIntent.Info; + public ToastIntent? Intent { get; set; } /// /// Gets or sets the politeness level used for accessibility. /// public ToastPoliteness? Politeness { get; set; } - /// - /// Gets or sets a value indicating whether the timeout pauses while hovering the toast. - /// - public bool? PauseOnHover { get; set; } - - /// - /// Gets or sets a value indicating whether the timeout pauses while the window is blurred. - /// - public bool? PauseOnWindowBlur { get; set; } - - /// + /// /// Gets or sets the toast title. + /// For security reasons, the content is sanitized using the configured before rendering. /// public string? Title { get; set; } @@ -122,28 +122,19 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets the subtitle of the toast. + /// For security reasons, the content is sanitized using the configured before rendering. /// public string? Subtitle { get; set; } /// - /// Gets or sets the first quick action label. - /// - public string? QuickAction1 { get; set; } - - /// - /// Gets or sets the callback invoked when the first quick action is clicked. - /// - public Func? QuickAction1Callback { get; set; } - - /// - /// Gets or sets the second quick action label. + /// Gets or sets a value indicating whether the timeout pauses while hovering the toast. /// - public string? QuickAction2 { get; set; } + public bool? PauseOnHover { get; set; } /// - /// Gets or sets the callback invoked when the second quick action is clicked. + /// Gets or sets a value indicating whether the timeout pauses while the window is blurred. /// - public Func? QuickAction2Callback { get; set; } + public bool? PauseOnWindowBlur { get; set; } /// /// Gets or sets a value indicating whether the toast can be dismissed by the user. @@ -156,19 +147,21 @@ public ToastOptions(Action implementationFactory) public string? DismissAction { get; set; } /// - /// Gets or sets the callback invoked when the dismiss action is clicked. + /// Gets or sets the timestamp when the toast was created. /// - public Func? DismissActionCallback { get; set; } + public DateTime? TimeStamp { get; set; } /// - /// Gets or sets the icon rendered in the media slot. + /// Gets or sets the action raised when the toast lifecycle status changes. /// - public Icon? Icon { get; set; } + public Action? OnStatusChange { get; set; } /// - /// Gets or sets the action raised when the toast lifecycle status changes. + /// Gets or sets the icon rendered in the toast header. + /// When set, this overrides the default icon determined by the + /// (Warning, Error, Success, Info) of the toast. /// - public Action? OnStatusChange { get; set; } + public Icon? Icon { get; set; } /// /// Gets the class, including the optional and values. diff --git a/src/Core/Components/Toast/Services/ToastResult.cs b/src/Core/Components/Toast/Services/ToastResult.cs new file mode 100644 index 0000000000..e7c111742e --- /dev/null +++ b/src/Core/Components/Toast/Services/ToastResult.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents the result of a toast managed by the . +/// +public class ToastResult +{ + internal static ToastResult OfDismissed(object? data = null) => new(ToastCloseReason.Dismissed, data); + internal static ToastResult OfQuickAction(object? data = null) => new(ToastCloseReason.QuickAction, data); + internal static ToastResult OfProgrammatic(object? data = null) => new(ToastCloseReason.Programmatic, data); + internal static ToastResult OfTimedOut(object? data = null) => new(ToastCloseReason.TimedOut, data); + + /// + protected internal ToastResult(ToastCloseReason reason, object? data) + { + Reason = reason; + Data = data; + } + + /// + /// Gets the reason the toast was closed. + /// + public ToastCloseReason Reason { get; } + + /// + /// Gets the optional data associated with the result. + /// + public object? Data { get; } +} diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs deleted file mode 100644 index 723d2caba7..0000000000 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ /dev/null @@ -1,170 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.JSInterop; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// Service for showing Toasts. -/// -public partial class ToastService : FluentServiceBase, IToastService -{ - private readonly IServiceProvider _serviceProvider; - private readonly IJSRuntime _jsRuntime; - - /// - /// Initializes a new instance of the class. - /// - /// List of services available in the application. - /// Localizer for the application. - public ToastService(IServiceProvider serviceProvider, IFluentLocalizer? localizer) - { - _serviceProvider = serviceProvider; - _jsRuntime = serviceProvider.GetRequiredService(); - Localizer = localizer ?? FluentLocalizerInternal.Default; - } - - /// - protected IFluentLocalizer Localizer { get; } - - /// - public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) - { - var ToastInstance = Toast as ToastInstance; - - if (ToastInstance?.FluentToast is FluentToast fluentToast) - { - ToastInstance.PendingCloseReason = reason; - await fluentToast.CloseAsync(); - return; - } - - if (ToastInstance is not null) - { - ToastInstance.LifecycleStatus = ToastLifecycleStatus.Unmounted; - } - - // Remove the Toast from the ToastProvider - await RemoveToastFromProviderAsync(Toast); - - // Set the result of the Toast - ToastInstance?.ResultCompletion.TrySetResult(reason); - - // Raise the final ToastLifecycleStatus.Unmounted event - if (ToastInstance is not null) - { - ToastInstance.Options.OnStatusChange?.Invoke(new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Unmounted)); - } - } - - /// - public async Task DismissAsync(IToastInstance Toast) - { - await CloseAsync(Toast, ToastCloseReason.Dismissed); - } - - /// - public async Task DismissAsync(string toastId) - { - if (string.IsNullOrWhiteSpace(toastId) || !ServiceProvider.Items.TryGetValue(toastId, out var toast)) - { - return false; - } - - await CloseAsync(toast, ToastCloseReason.Dismissed); - return true; - } - - /// - public async Task DismissAllAsync() - { - var toasts = ServiceProvider.Items.Values.ToList(); - - foreach (var toast in toasts) - { - await CloseAsync(toast, ToastCloseReason.Dismissed); - } - - return toasts.Count; - } - - /// - public async Task ShowToastAsync(ToastOptions? options = null) - { - var instance = await ShowToastInstanceCoreAsync(options ?? new ToastOptions()); - return await instance.Result; - } - - /// - public Task ShowToastAsync(Action options) - { - return ShowToastAsync(new ToastOptions(options)); - } - - /// - public async Task ShowToastInstanceAsync(ToastOptions? options = null) - { - return await ShowToastInstanceCoreAsync(options ?? new ToastOptions()); - } - - /// - public Task ShowToastInstanceAsync(Action options) - { - return ShowToastInstanceAsync(new ToastOptions(options)); - } - - /// - public async Task UpdateToastAsync(IToastInstance toast, Action update) - { - if (toast is not ToastInstance instance) - { - throw new ArgumentException($"{nameof(toast)} must be a {nameof(ToastInstance)}.", nameof(toast)); - } - - update(instance.Options); - await ServiceProvider.OnUpdatedAsync.Invoke(instance); - } - - /// - private async Task ShowToastInstanceCoreAsync(ToastOptions options) - { - if (this.ProviderNotAvailable()) - { - throw new FluentServiceProviderException(); - } - - var instance = new ToastInstance(this, options); - options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Queued)); - - // Add the Toast to the service, and render it. - ServiceProvider.Items.TryAdd(instance?.Id ?? "", instance ?? throw new InvalidOperationException("Failed to create FluentToast.")); - await ServiceProvider.OnUpdatedAsync.Invoke(instance); - - return instance; - } - - /// - /// Removes the Toast from the ToastProvider. - /// - /// - /// - /// - internal async Task RemoveToastFromProviderAsync(IToastInstance? Toast) - { - if (Toast is null) - { - return; - } - - // Remove the HTML code from the ToastProvider - if (!ServiceProvider.Items.TryRemove(Toast.Id, out _)) - { - throw new InvalidOperationException($"Failed to remove Toast from ToastProvider: the ID '{Toast.Id}' doesn't exist in the ToastServiceProvider."); - } - - await ServiceProvider.OnUpdatedAsync.Invoke(Toast); - } -} diff --git a/src/Core/Events/ToastEventArgs.cs b/src/Core/Events/ToastEventArgs.cs index 20d6f2075c..6fbe91e5f2 100644 --- a/src/Core/Events/ToastEventArgs.cs +++ b/src/Core/Events/ToastEventArgs.cs @@ -48,8 +48,8 @@ internal ToastEventArgs(IToastInstance? instance, string? id, string? eventType, public ToastLifecycleStatus Status { get; } /// - /// Gets the instance used by the . - /// This value may be null if the toast is not managed by the . + /// Gets the instance used by the . + /// This value may be null if the toast is not managed by the . /// public IToastInstance? Instance { get; } } diff --git a/src/Core/Extensions/ServiceCollectionExtensions.cs b/src/Core/Extensions/ServiceCollectionExtensions.cs index 2387221d2f..138e4f6b35 100644 --- a/src/Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Extensions/ServiceCollectionExtensions.cs @@ -20,8 +20,6 @@ public static class ServiceCollectionExtensions /// Library configuration [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IDialogService))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DialogService))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IToastService))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ToastService))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(INotificationService))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NotificationService))] public static IServiceCollection AddFluentUIComponents(this IServiceCollection services, LibraryConfiguration? configuration = null) @@ -37,7 +35,6 @@ public static IServiceCollection AddFluentUIComponents(this IServiceCollection s // Add services services.Add(provider => options ?? new(), serviceLifetime); services.Add(serviceLifetime); - services.Add(serviceLifetime); services.Add(serviceLifetime); services.Add(provider => options?.Localizer ?? FluentLocalizerInternal.Default, serviceLifetime); services.Add(serviceLifetime); From 6a8086a4f055c10f14b370b0741df432810bb396 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 09:26:36 +0200 Subject: [PATCH 06/43] Refactor Toast component to use NotificationService and update styles --- .../Examples/TextInputPrefixSuffix.razor | 4 +- .../Examples/FluentToastCustomDismiss.razor | 4 +- .../Toast/Examples/FluentToastDefault.razor | 33 +-- .../FluentToastDeterminateProgress.razor | 4 +- .../FluentToastIndeterminateProgress.razor | 4 +- .../Toast/Examples/FluentToastInverted.razor | 4 +- .../Components/Toast/FluentToast-Styles.ts | 228 +++++++++++++++++ .../src/Components/Toast/FluentToast.ts | 230 +----------------- src/Core/Components/Toast/FluentToast.razor | 3 +- .../Components/Toast/FluentToast.razor.cs | 8 +- 10 files changed, 263 insertions(+), 259 deletions(-) create mode 100644 src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TextInput/Examples/TextInputPrefixSuffix.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TextInput/Examples/TextInputPrefixSuffix.razor index 1dfc914612..1b7c67cf59 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TextInput/Examples/TextInputPrefixSuffix.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TextInput/Examples/TextInputPrefixSuffix.razor @@ -1,5 +1,5 @@ @inject IJSRuntime JSRuntime -@inject IToastService ToastService +@inject INotificationService NotificationService @@ -38,7 +38,7 @@ { await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", EmailAddress); - await ToastService.ShowToastAsync(options => { + await NotificationService.ShowToastAsync(options => { options.Intent = ToastIntent.Success; options.Title = "Email address copied to clipboard!"; }); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor index 23337eb574..6c145684ef 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor @@ -1,4 +1,4 @@ -@inject IToastService ToastService +@* @inject IToastService ToastService Make toast @@ -30,4 +30,4 @@ Console.WriteLine($"Toast result: {result}"); } } - + *@ diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index 8969c85462..a8467e21b5 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1,20 +1,26 @@ -@inject IToastService ToastService +@inject INotificationService NotificationService - Make toast + Make toast @code { - int clickCount = 0; - private async Task OpenToastAsync() { - var result = await ToastService.ShowToastAsync(options => + var result = await NotificationService.ShowToastAsync(options => { - options.Title = $"Toast title {++clickCount}"; - options.Body = "Toasts are used to show brief messages to the user."; + options.Intent = ToastIntent.Success; + options.Title = $"Toast title"; + options.Message = "Toasts are used to show brief messages to the user."; options.Subtitle = "subtitle"; - options.QuickAction1 = "Action"; + options.AllowDismiss = true; + options.Lifetime = TimeSpan.FromSeconds(5); + options.OnStatusChange = (e) => + { + Console.WriteLine($"Toast status changed: {e.Status}"); + }; + + @* options.QuickAction1 = "Action"; options.QuickAction1Callback = () => { Console.WriteLine("Action 1 executed."); @@ -25,15 +31,10 @@ { Console.WriteLine("Action 2 executed."); return Task.CompletedTask; - }; - options.AllowDismiss = true; - options.OnStatusChange = (e) => - { - Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); - }; - + }; *@ }); - Console.WriteLine($"Toast result: {result}"); + + Console.WriteLine($"Toast closed: {result.Reason}"); } } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor index cc8b9b5577..2a7a768521 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor @@ -1,4 +1,4 @@ -@inject IToastService ToastService +@* @inject IToastService ToastService Make toast @@ -37,4 +37,4 @@ openToastButton.SetDisabled(false); } } - + *@ diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor index b9a65753ae..ed157e450e 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor @@ -1,4 +1,4 @@ -@inject IToastService ToastService +@* @inject IToastService ToastService @@ -38,4 +38,4 @@ openToastButton.SetDisabled(false); } } - + *@ diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor index c52fcba6fc..2931445037 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor @@ -1,4 +1,4 @@ -@inject IToastService ToastService +@* @inject IToastService ToastService Make toast @@ -32,4 +32,4 @@ Console.WriteLine($"Toast result: {result}"); } } - + *@ diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts b/src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts new file mode 100644 index 0000000000..634e415970 --- /dev/null +++ b/src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts @@ -0,0 +1,228 @@ +export const fluentToastStyles: string = ` +:host(:not([opened='true']):not(.animating)) { + display: none; +} + +:host { + display: contents; +} + +:host div[fuib][popover] { + display: grid; + grid-template-columns: auto 1fr auto; + background: var(--colorNeutralBackground1); + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); + font-weight: var(--fontWeightSemibold); + color: var(--colorNeutralForeground1); + border: 1px solid var(--colorTransparentStroke); + border-radius: var(--borderRadiusMedium); + box-shadow: var(--shadow8); + box-sizing: border-box; + min-width: 292px; + max-width: 292px; + height: auto; + padding: 12px; + transition: + top 240ms cubic-bezier(0.22, 1, 0.36, 1), + bottom 240ms cubic-bezier(0.22, 1, 0.36, 1), + left 240ms cubic-bezier(0.22, 1, 0.36, 1), + right 240ms cubic-bezier(0.22, 1, 0.36, 1), + transform 240ms cubic-bezier(0.22, 1, 0.36, 1); +} + +:host([inverted]) div[fuib][popover]{ + color: var(--colorNeutralForegroundInverted2); + background-color: var(--colorNeutralBackgroundInverted); +} + +.media { + display: flex; + grid-column-end: 2; + padding-top: 2px; + padding-inline-end: 8px; + font-size: var(--fontSizeBase400); + color: var(--colorNeutralForeground1); +} + +:host([inverted]) .media { + color: var(--colorNeutralForegroundInverted); +} + +:host([inverted]) .media { + color: var(--colorNeutralForegroundInverted); +} + +.media[data-intent="success"] { + color: var(--colorStatusSuccessForeground1); +} + +.media[data-intent="error"] { + color: var(--colorStatusDangerForeground1); +} + +.media[data-intent="warning"] { + color: var(--colorStatusWarningForeground1); +} + +.media[data-intent="info"] { + color: var(--colorNeutralForeground2); +} + +:host([inverted]) .media[data-intent="success"] { + color: var(--colorStatusSuccessForegroundInverted); +} + +:host([inverted]) .media[data-intent="error"] { + color: var(--colorStatusDangerForegroundInverted); +} + +:host([inverted]) .media[data-intent="warning"] { + color: var(--colorStatusWarningForegroundInverted); +} + +:host([inverted]) .media[data-intent="info"] { + color: var(--colorNeutralForegroundInverted2); +} + +.title { + display: flex; + grid-column-end: 3; + color: var(--colorNeutralForeground1); + word-break: break-word; +} + +:host([inverted]) .title { + color: var(--colorNeutralForegroundInverted2); +} + +.action { + display: flex; + align-items: start; + justify-content: end; + grid-column-end: -1; + padding-inline-start: 12px; + color: var(--colorBrandForeground1); +} + +:host([inverted]) .action { + color: var(--colorBrandForegroundInverted); +} + +.body { + grid-column: 2 / 3; + padding-top: 6px; + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground1); + word-break: break-word; +} + + :host([inverted]) .body { + color: var(--colorNeutralForegroundInverted2); +} + +.subtitle { + grid-column: 2 / 3; + padding-top: 4px; + font-size: var(--fontSizeBase200); + line-height: var(--lineHeightBase200); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground2); +} + +:host([inverted]) .subtitle { + color: var(--colorNeutralForegroundInverted2); +} + +.footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px; + grid-column: 2 / 3; + padding-top: 16px; +} + +:host([inverted]) slot[name="footer"]::slotted(fluent-link[clickable]) { + color: var(--colorBrandForegroundInverted); +} + +.footer ::slotted(*) { + display: contents; +} + +:host(:not([has-media])) .body, +:host(:not([has-media])) .subtitle, +:host(:not([has-media])) .footer { + grid-column: 1 / -1; +} + +:host(:not([has-action])) .title { + grid-column: 2 / -1; +} + +.media[hidden], +.title[hidden], +.action[hidden], +.body[hidden], +.subtitle[hidden], +.footer[hidden] { + display: none !important; +} + +/* Animations */ +:host div[fuib][popover]:popover-open { + opacity: 1; + animation: toast-enter 0.25s cubic-bezier(0.33, 0, 0, 1) forwards; +} + +:host div[fuib][popover].closing { + pointer-events: none; + overflow: hidden; + will-change: opacity, height, margin, padding; + animation: + toast-exit 400ms cubic-bezier(0.33, 0, 0.67, 1) forwards, + toast-collapse-height 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards, + toast-collapse-spacing 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards; +} + +@keyframes toast-enter { + from { opacity: 0; transform: var(--toast-enter-from, translateY(16px)); } + to { opacity: 1; transform: var(--toast-enter-to, translateY(0)); } +} + +@keyframes toast-exit { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes toast-collapse-height { + from { + height: var(--toast-height); + } + to { + height: 0; + } +} + +@keyframes toast-collapse-spacing { + from { + margin-top: var(--toast-margin-top, 0px); + margin-bottom: var(--toast-margin-bottom, 0px); + padding-top: var(--toast-padding-top, 0px); + padding-bottom: var(--toast-padding-bottom, 0px); + } + to { + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } +} +` \ No newline at end of file diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 18b61934ba..5625d7edba 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -1,4 +1,5 @@ import { StartedMode } from "../../d-ts/StartedMode"; +import { fluentToastStyles } from "./FluentToast-Styles"; export namespace Microsoft.FluentUI.Blazor.Components.Toast { @@ -96,234 +97,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { // Set initial styles for the dialog const sheet = new CSSStyleSheet(); - sheet.replaceSync(` - :host(:not([opened='true']):not(.animating)) { - display: none; - } - - :host { - display: contents; - } - - :host div[fuib][popover] { - display: grid; - grid-template-columns: auto 1fr auto; - background: var(--colorNeutralBackground1); - font-size: var(--fontSizeBase300); - line-height: var(--lineHeightBase300); - font-weight: var(--fontWeightSemibold); - color: var(--colorNeutralForeground1); - border: 1px solid var(--colorTransparentStroke); - border-radius: var(--borderRadiusMedium); - box-shadow: var(--shadow8); - box-sizing: border-box; - min-width: 292px; - max-width: 292px; - height: auto; - padding: 12px; - transition: - top 240ms cubic-bezier(0.22, 1, 0.36, 1), - bottom 240ms cubic-bezier(0.22, 1, 0.36, 1), - left 240ms cubic-bezier(0.22, 1, 0.36, 1), - right 240ms cubic-bezier(0.22, 1, 0.36, 1), - transform 240ms cubic-bezier(0.22, 1, 0.36, 1); - } - - :host([inverted]) div[fuib][popover]{ - color: var(--colorNeutralForegroundInverted2); - background-color: var(--colorNeutralBackgroundInverted); - } - - .media { - display: flex; - grid-column-end: 2; - padding-top: 2px; - padding-inline-end: 8px; - font-size: var(--fontSizeBase400); - color: var(--colorNeutralForeground1); - } - - :host([inverted]) .media { - color: var(--colorNeutralForegroundInverted); - } - - :host([inverted]) .media { - color: var(--colorNeutralForegroundInverted); - } - - .media[data-intent="success"] { - color: var(--colorStatusSuccessForeground1); - } - - .media[data-intent="error"] { - color: var(--colorStatusDangerForeground1); - } - - .media[data-intent="warning"] { - color: var(--colorStatusWarningForeground1); - } - - .media[data-intent="info"] { - color: var(--colorNeutralForeground2); - } - - :host([inverted]) .media[data-intent="success"] { - color: var(--colorStatusSuccessForegroundInverted); - } - - :host([inverted]) .media[data-intent="error"] { - color: var(--colorStatusDangerForegroundInverted); - } - - :host([inverted]) .media[data-intent="warning"] { - color: var(--colorStatusWarningForegroundInverted); - } - - :host([inverted]) .media[data-intent="info"] { - color: var(--colorNeutralForegroundInverted2); - } - - .title { - display: flex; - grid-column-end: 3; - color: var(--colorNeutralForeground1); - word-break: break-word; - } - - :host([inverted]) .title { - color: var(--colorNeutralForegroundInverted2); - } - - .action { - display: flex; - align-items: start; - justify-content: end; - grid-column-end: -1; - padding-inline-start: 12px; - color: var(--colorBrandForeground1); - } - - :host([inverted]) .action { - color: var(--colorBrandForegroundInverted); - } - - .body { - grid-column: 2 / 3; - padding-top: 6px; - font-size: var(--fontSizeBase300); - line-height: var(--lineHeightBase300); - font-weight: var(--fontWeightRegular); - color: var(--colorNeutralForeground1); - word-break: break-word; - } - - :host([inverted]) .body { - color: var(--colorNeutralForegroundInverted2); - } - - .subtitle { - grid-column: 2 / 3; - padding-top: 4px; - font-size: var(--fontSizeBase200); - line-height: var(--lineHeightBase200); - font-weight: var(--fontWeightRegular); - color: var(--colorNeutralForeground2); - } - - :host([inverted]) .subtitle { - color: var(--colorNeutralForegroundInverted2); - } - - .footer { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 14px; - grid-column: 2 / 3; - padding-top: 16px; - } - - :host([inverted]) slot[name="footer"]::slotted(fluent-link[clickable]) { - color: var(--colorBrandForegroundInverted); - } - - .footer ::slotted(*) { - display: contents; - } - - :host(:not([has-media])) .body, - :host(:not([has-media])) .subtitle, - :host(:not([has-media])) .footer { - grid-column: 1 / -1; - } - - :host(:not([has-action])) .title { - grid-column: 2 / -1; - } - - .media[hidden], - .title[hidden], - .action[hidden], - .body[hidden], - .subtitle[hidden], - .footer[hidden] { - display: none !important; - } - - /* Animations */ - :host div[fuib][popover]:popover-open { - opacity: 1; - animation: toast-enter 0.25s cubic-bezier(0.33, 0, 0, 1) forwards; - } - - :host div[fuib][popover].closing { - pointer-events: none; - overflow: hidden; - will-change: opacity, height, margin, padding; - animation: - toast-exit 400ms cubic-bezier(0.33, 0, 0.67, 1) forwards, - toast-collapse-height 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards, - toast-collapse-spacing 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards; - } - - @keyframes toast-enter { - from { opacity: 0; transform: var(--toast-enter-from, translateY(16px)); } - to { opacity: 1; transform: var(--toast-enter-to, translateY(0)); } - } - - @keyframes toast-exit { - from { - opacity: 1; - } - to { - opacity: 0; - } - } - - @keyframes toast-collapse-height { - from { - height: var(--toast-height); - } - to { - height: 0; - } - } - - @keyframes toast-collapse-spacing { - from { - margin-top: var(--toast-margin-top, 0px); - margin-bottom: var(--toast-margin-bottom, 0px); - padding-top: var(--toast-padding-top, 0px); - padding-bottom: var(--toast-padding-bottom, 0px); - } - to { - margin-top: 0; - margin-bottom: 0; - padding-top: 0; - padding-bottom: 0; - } - } - `); + sheet.replaceSync(fluentToastStyles); this.shadowRoot!.adoptedStyleSheets = [ ...(this.shadowRoot!.adoptedStyleSheets || []), sheet diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index e614b96483..a80af558e7 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -1,12 +1,13 @@ @namespace Microsoft.FluentUI.AspNetCore.Components @inherits FluentComponentBase @using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using System.Globalization From 28c92a67ad07a7eaf4c38992b63bd1f49386ac73 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 16:27:08 +0200 Subject: [PATCH 07/43] Refactor Toast component lifecycle management and status handling --- .../Toast/Examples/FluentToastDefault.razor | 4 +- .../Components/Toast/FluentToast.razor.cs | 49 ++++++------------- .../Toast/FluentToastProvider.razor.cs | 8 +-- .../Toast/Services/LibraryToastOptions.cs | 2 +- .../Toast/Services/NotificationService.cs | 21 +++----- .../Toast/Services/ToastInstance.cs | 23 ++++++++- 6 files changed, 47 insertions(+), 60 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index a8467e21b5..d525d304d0 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -17,7 +17,7 @@ options.Lifetime = TimeSpan.FromSeconds(5); options.OnStatusChange = (e) => { - Console.WriteLine($"Toast status changed: {e.Status}"); + Console.WriteLine($"Toast status changed to: {e.Status}"); }; @* options.QuickAction1 = "Action"; @@ -34,7 +34,7 @@ }; *@ }); - Console.WriteLine($"Toast closed: {result.Reason}"); + Console.WriteLine($"Toast closed with Reason: {result.Reason}"); } } diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index b02b767ad0..3d31d1a613 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -209,23 +209,15 @@ private async Task OnToggleAsync(DialogToggleEventArgs args) return; } - var toastInstance = ToastInstance as ToastInstance; - var toastEventArgs = new ToastEventArgs(this.ToastInstance, args); - - if (toastInstance is not null && toastEventArgs.Status == ToastLifecycleStatus.Dismissed) - { - toastInstance.LifecycleStatus = ToastLifecycleStatus.Dismissed; - await RaiseOnStatusChangeAsync(toastEventArgs); - } - - var toastState = DialogEventArgs.GetDialogState(args.Type, args.OldState, args.NewState); + var toast = ToastInstance as ToastInstance; + var state = DialogEventArgs.GetDialogState(args.Type, args.OldState, args.NewState); // If the toast state is either Open or Closed, // update the Opened property // and invoke the OpenedChanged callback if necessary. - if (toastState == DialogState.Open || toastState == DialogState.Closed) + if (state == DialogState.Open || state == DialogState.Closed) { - var isOpen = toastState == DialogState.Open; + var isOpen = state == DialogState.Open; if (Opened != isOpen) { @@ -238,20 +230,23 @@ private async Task OnToggleAsync(DialogToggleEventArgs args) } } + if (toast is not null && state == DialogState.Open) + { + toast.SetStatus(ToastLifecycleStatus.Visible); + } + // If the toast instance is defined and the toast state is Closed, // set the result of the ResultCompletion task - if (toastInstance is not null && toastState == DialogState.Closed) + if (toast is not null && state == DialogState.Closed) { - // TODO: Need to change the ToastResult - toastInstance.ResultCompletion.TrySetResult(ToastResult.OfTimedOut()); - toastInstance.LifecycleStatus = ToastLifecycleStatus.Unmounted; + toast.ResultCompletion.TrySetResult(ToastResult.OfTimedOut()); + + toast.SetStatus(ToastLifecycleStatus.Dismissed); if (NotificationService is NotificationService notificationService) { - await notificationService.RemoveToastFromProviderAsync(toastInstance); + await notificationService.RemoveToastFromProviderAsync(toast); } - - await RaiseOnStatusChangeAsync(new ToastEventArgs(toastInstance, ToastLifecycleStatus.Unmounted)); } } @@ -311,21 +306,5 @@ private async Task DismissClickAsync() } await ToastInstance.CloseAsync(); - - // if (ToastInstance.Options.OnStatusChange is not null) - // { - // ToastInstance.Options.OnStatusChange(new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Dismissed)); - // } - } - - /// - /// Raises the status change event asynchronously using the specified toast event arguments. - /// - private async Task RaiseOnStatusChangeAsync(ToastEventArgs args) - { - if (OnStatusChange.HasDelegate) - { - await InvokeAsync(() => OnStatusChange.InvokeAsync(args)); - } } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 37483d73e2..533a058a79 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -91,11 +91,6 @@ private RenderFragment RenderToastContent(IToastInstance? toast) => builder => /// private void SynchronizeToastQueue() { - if (NotificationService is NotificationService service) - { - return; - } - var maxToastCount = configuration.Toast.MaxToastCount; var activeCount = ToastItems.Count(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed); var queuedToasts = ToastItems.Where(toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued) @@ -111,8 +106,7 @@ private void SynchronizeToastQueue() if (toast is ToastInstance instance) { - instance.LifecycleStatus = ToastLifecycleStatus.Visible; - toast.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Visible)); + instance.SetStatus(ToastLifecycleStatus.Visible); activeCount++; } } diff --git a/src/Core/Components/Toast/Services/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs index a3cd6257f6..71419b2d05 100644 --- a/src/Core/Components/Toast/Services/LibraryToastOptions.cs +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -33,7 +33,7 @@ internal LibraryToastOptions() /// /// Gets or sets the default toast position. /// - public ToastPosition? Position { get; set; } = ToastPosition.BottomEnd; + public ToastPosition Position { get; set; } = ToastPosition.BottomEnd; /// /// Gets or sets the default vertical offset in pixels. diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 8b09b32a7a..04a4ebb2b2 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -106,13 +106,14 @@ private ToastInstance ShowToastInstanceCore(Type? componentType, ToastOptions op throw new InvalidOperationException($"A Toast with the ID '{instance.Id}' is already registered."); } - options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Queued)); + // Raise the initial ToastLifecycleStatus.Queued event. + instance.SetStatus(ToastLifecycleStatus.Queued); return instance; } /// - public async Task CloseCoreAsync(IToastInstance toast, ToastResult result) + private async Task CloseCoreAsync(IToastInstance toast, ToastResult result) { if (toast is not ToastInstance instance) { @@ -124,22 +125,11 @@ public async Task CloseCoreAsync(IToastInstance toast, ToastResult result) return; } - // TODO: How to close? - // ... - - instance.LifecycleStatus = ToastLifecycleStatus.Dismissed; - instance.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Dismissed)); - // Remove the Toast from the ToastProvider. await RemoveToastFromProviderAsync(instance); - instance.LifecycleStatus = ToastLifecycleStatus.Unmounted; - // Set the result of the Toast. instance.ResultCompletion.TrySetResult(result); - - // Raise the final ToastLifecycleStatus.Unmounted event. - instance.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Unmounted)); } /// @@ -147,7 +137,7 @@ public async Task CloseCoreAsync(IToastInstance toast, ToastResult result) /// internal async Task RemoveToastFromProviderAsync(IToastInstance? toast) { - if (toast is null) + if (toast is null || toast is not ToastInstance instance) { return; } @@ -157,6 +147,9 @@ internal async Task RemoveToastFromProviderAsync(IToastInstance? toast) return; } + // Raise the final ToastLifecycleStatus.Unmounted event. + instance.SetStatus(ToastLifecycleStatus.Unmounted); + await ServiceProvider.OnUpdatedAsync.Invoke(toast); } } diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index a36561fd00..33f274c8b2 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -44,7 +44,7 @@ internal ToastInstance(INotificationService notificationService, Type? component public Task Result => ResultCompletion.Task; /// - public ToastLifecycleStatus LifecycleStatus { get; internal set; } = ToastLifecycleStatus.Visible; + public ToastLifecycleStatus LifecycleStatus { get; internal set; } = ToastLifecycleStatus.Unmounted; /// public string Id { get; } @@ -63,4 +63,25 @@ public Task CloseAsync(ToastResult result) { return NotificationService.CloseAsync(this, result); } + + /// + /// Sets the lifecycle status of the toast + /// and invokes the callback if provided. + /// + /// The new lifecycle status of the toast. + internal void SetStatus(ToastLifecycleStatus status) + { + if (LifecycleStatus == status) + { + return; + } + + LifecycleStatus = status; + + if (Options.OnStatusChange is not null) + { + var args = new ToastEventArgs(this, status); + Options.OnStatusChange.Invoke(args); + } + } } From dacedc1d5e1419c87317fd1c855e945e269a6b32 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 16:40:49 +0200 Subject: [PATCH 08/43] Refactor Toast component to ensure proper status handling and result setting on dismissal --- src/Core/Components/Toast/FluentToast.razor.cs | 10 +++++----- .../Components/Toast/Services/NotificationService.cs | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 3d31d1a613..bffbb0b781 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -239,14 +239,15 @@ private async Task OnToggleAsync(DialogToggleEventArgs args) // set the result of the ResultCompletion task if (toast is not null && state == DialogState.Closed) { - toast.ResultCompletion.TrySetResult(ToastResult.OfTimedOut()); - toast.SetStatus(ToastLifecycleStatus.Dismissed); if (NotificationService is NotificationService notificationService) { await notificationService.RemoveToastFromProviderAsync(toast); } + + // Set the result of the toast to TimedOut. + toast.ResultCompletion.TrySetResult(ToastResult.OfTimedOut()); } } @@ -282,7 +283,7 @@ private async Task OnToggleAsync(DialogToggleEventArgs args) /// /// Closes the toast component. /// - internal Task CloseAsync() + private Task CloseAsync() { if (!Opened) { @@ -296,7 +297,6 @@ internal Task CloseAsync() /// /// Handles the ToastAction click event, dismissing the toast. /// - /// private async Task DismissClickAsync() { if (ToastInstance is null) @@ -305,6 +305,6 @@ private async Task DismissClickAsync() return; } - await ToastInstance.CloseAsync(); + await ToastInstance.CloseAsync(ToastResult.OfDismissed()); } } diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 04a4ebb2b2..00059326e7 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -125,6 +125,9 @@ private async Task CloseCoreAsync(IToastInstance toast, ToastResult result) return; } + // Raise the ToastLifecycleStatus.Dismissed event before to remove the Toast from the provider. + instance.SetStatus(ToastLifecycleStatus.Dismissed); + // Remove the Toast from the ToastProvider. await RemoveToastFromProviderAsync(instance); From 7395a7443f8e1af9bd80b865bc1c50bf3c19314f Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 16:51:28 +0200 Subject: [PATCH 09/43] Refactor Toast example to support multiple toast intents and improve button layout --- .../Toast/Examples/FluentToastDefault.razor | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index d525d304d0..6f2a722735 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1,37 +1,41 @@ @inject INotificationService NotificationService - - Make toast - + + + Show Info + + + Show Success + + + Show Warning + + + Show Error + + + Show Progress + + @code { - private async Task OpenToastAsync() + + int counter = 1; + + private async Task OpenToastAsync(ToastIntent intent) { var result = await NotificationService.ShowToastAsync(options => { - options.Intent = ToastIntent.Success; - options.Title = $"Toast title"; + options.Intent = intent; + options.Title = $"{intent} toast #{counter++}"; options.Message = "Toasts are used to show brief messages to the user."; - options.Subtitle = "subtitle"; + options.Subtitle = "Sent by Fluent UI Blazor"; options.AllowDismiss = true; options.Lifetime = TimeSpan.FromSeconds(5); options.OnStatusChange = (e) => { - Console.WriteLine($"Toast status changed to: {e.Status}"); - }; - - @* options.QuickAction1 = "Action"; - options.QuickAction1Callback = () => - { - Console.WriteLine("Action 1 executed."); - return Task.CompletedTask; + Console.WriteLine($" . Toast status changed to: {e.Status}"); }; - options.QuickAction2 = "Action"; - options.QuickAction2Callback = () => - { - Console.WriteLine("Action 2 executed."); - return Task.CompletedTask; - }; *@ }); Console.WriteLine($"Toast closed with Reason: {result.Reason}"); From 14fe14538deef508525d948e12908d6bec49e86e Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 17:07:05 +0200 Subject: [PATCH 10/43] Refactor FluentToast component to enhance attribute handling and improve code readability --- .../Toast/FluentToastProvider.razor | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 2f77a46648..00534a48a4 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -17,27 +17,27 @@ @if (toast.ComponentType is null) { + Id="@toast.Id" + Class="@toast.Options.ClassValue" + Style="@toast.Options.StyleValue" + Data="@toast.Options.Data" + Lifetime="@(toast.Options.Lifetime ?? configuration.Toast.Lifetime)" + Position="@(toast.Options.Position ?? configuration.Toast.Position)" + VerticalOffset="@(toast.Options.VerticalOffset ?? configuration.Toast.VerticalOffset)" + HorizontalOffset="@(toast.Options.HorizontalOffset ?? configuration.Toast.HorizontalOffset)" + Inverted="@(toast.Options.Inverted ?? configuration.Toast.Inverted)" + Intent="@toast.Options.Intent" + Politeness="@toast.Options.Politeness" + PauseOnHover="@(toast.Options.PauseOnHover ?? configuration.Toast.PauseOnHover)" + PauseOnWindowBlur="@(toast.Options.PauseOnWindowBlur ?? configuration.Toast.PauseOnWindowBlur)" + OnStatusChange="@GetOnStatusChangeCallback(toast)" + Icon="@toast.Options.Icon" + Title="@toast.Options.Title" + ChildContent="@RenderToastContent(toast)" + Subtitle="@toast.Options.Subtitle" + AllowDismiss="@(toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss)" + DismissAction="@toast.Options.DismissAction" + AdditionalAttributes="@toast.Options.AdditionalAttributes" /> } else { From 9a010f20b5191496e61437412df9460685ceaa11 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 17:25:30 +0200 Subject: [PATCH 11/43] Refactor FluentToastProvider to improve CascadingValue usage and enhance component key handling --- src/Core/Components/Toast/FluentToastProvider.razor | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 00534a48a4..492c429f6e 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -12,12 +12,12 @@ { @foreach (var toast in GetRenderedToasts()) { - @if (toast.ComponentType is null) { - } From 19efa136b4a0b215fdefca2a68ad743adb4b13a0 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 18:11:33 +0200 Subject: [PATCH 12/43] Refactor Toast instance management to support dynamic state updates and improve dismissal handling --- .../Components/Toast/FluentToast.razor.cs | 6 +++++ .../Toast/Services/NotificationService.cs | 24 ++++++++++++------- .../Toast/Services/ToastInstance.cs | 5 ++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index bffbb0b781..2ff4be7781 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -185,6 +185,12 @@ protected override Task OnAfterRenderAsync(bool firstRender) { if (firstRender && ToastInstance is ToastInstance instance) { + instance.UpdateOpenedAsync = async e => + { + Opened = e; + await InvokeAsync(StateHasChanged); + }; + if (!Opened) { Opened = true; diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 00059326e7..5a20b44912 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -145,14 +145,22 @@ internal async Task RemoveToastFromProviderAsync(IToastInstance? toast) return; } - if (!ServiceProvider.Items.TryRemove(toast.Id, out _)) - { - return; - } + // Update the Toast.Opened parameter to false to trigger the closing animation. + await instance.UpdateOpenedAsync(false); - // Raise the final ToastLifecycleStatus.Unmounted event. - instance.SetStatus(ToastLifecycleStatus.Unmounted); - - await ServiceProvider.OnUpdatedAsync.Invoke(toast); + // Fire-and-forget: schedule the removal without blocking the caller. + // to let the UI update and the ToastLifecycleStatus.Dismissed event to be processed first. + // to let the CSS animation to complete before the Toast is removed from the memory. + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(1)); + if (ServiceProvider.Items.TryRemove(toast.Id, out _)) + { + // Raise the final ToastLifecycleStatus.Unmounted event. + instance.SetStatus(ToastLifecycleStatus.Unmounted); + + await ServiceProvider.OnUpdatedAsync.Invoke(toast); + } + }); } } diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 33f274c8b2..9007e874cd 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -31,6 +31,11 @@ internal ToastInstance(INotificationService notificationService, Type? component Index = Interlocked.Increment(ref _counter); } + /// + /// Gets or sets a callback that is invoked when the toast's opened state changes. + /// + internal Func UpdateOpenedAsync { get; set; } = _ => Task.CompletedTask; + /// Type? INotificationInstance.ComponentType => _componentType; From b2f0c8a75ad865a7bcfc2b2cd6181f434cd0c18f Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 18:18:31 +0200 Subject: [PATCH 13/43] Refactor FluentToastDefault example to enhance layout spacing and improve status logging --- .../Components/Toast/Examples/FluentToastDefault.razor | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index 6f2a722735..f0800c0be2 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1,6 +1,6 @@ @inject INotificationService NotificationService - + Show Info @@ -34,11 +34,11 @@ options.Lifetime = TimeSpan.FromSeconds(5); options.OnStatusChange = (e) => { - Console.WriteLine($" . Toast status changed to: {e.Status}"); + Console.WriteLine($" . '{e.Instance?.Options.Title}' status changed to: {e.Status}"); }; }); - Console.WriteLine($"Toast closed with Reason: {result.Reason}"); + Console.WriteLine($"'{intent} toast' closed with Reason: {result.Reason}"); } } From ac38b06caf9cf5ff295aed1086687588fafbe9e2 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 18:50:14 +0200 Subject: [PATCH 14/43] Refactor toast components to improve state management, enhance progress handling, and update close method signature --- .../Toast/Examples/FluentToastDefault.razor | 2 +- .../FluentToastDeterminateProgress.razor | 41 +++++--------- .../Toast/CustomizedProgressToast.razor | 35 ++++++++++++ .../Toast/FluentToastProvider.razor | 56 +++++++++---------- .../Toast/Services/IToastInstance.cs | 5 +- .../Toast/Services/ToastInstance.cs | 6 +- 6 files changed, 84 insertions(+), 61 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index f0800c0be2..dce59d1216 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -22,7 +22,7 @@ int counter = 1; - private async Task OpenToastAsync(ToastIntent intent) + async Task OpenToastAsync(ToastIntent intent) { var result = await NotificationService.ShowToastAsync(options => { diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor index 2a7a768521..95a6fd0da9 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor @@ -1,40 +1,29 @@ -@* @inject IToastService ToastService +@inject INotificationService NotificationService - - Make toast + + Show Toast @code { - FluentButton openToastButton = default!; - private static RenderFragment BuildProgressContent(int value) => - @
- - @($"{value}% complete") -
; + bool InProgress = false; - private async Task OpenToastAsync() + async Task OpenProgressToastAsync() { - openToastButton.SetDisabled(true); + InProgress = true; - var instance = await ToastService.ShowToastInstanceAsync(options => + // Show the "CustomizedProgressToast" razor component + var result = await NotificationService.ShowToastAsync(options => { - options.Type = ToastType.DeterminateProgress; - options.Icon = new Icons.Regular.Size20.ArrowDownload(); options.Title = "Downloading file"; - options.BodyContent = BuildProgressContent(0); + options.Icon = new Icons.Regular.Size24.ArrowDownload(); + options.AllowDismiss = false; + options.Parameters.Add("ProgressValue", 0); }); - for (int i = 0; i <= 100; i += 10) - { - await Task.Delay(500); // Simulate work being done - await instance.UpdateAsync(options => - { - options.BodyContent = BuildProgressContent(i); - }); - } - - openToastButton.SetDisabled(false); + // Completed + InProgress = false; + Console.WriteLine($"'Progress toast' closed with Reason: {result.Reason} and Data: {result.Data}"); } } - *@ diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor new file mode 100644 index 0000000000..c5e7045133 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor @@ -0,0 +1,35 @@ + + +@code { + + // If you want to use this razor component in standalone mode, + // you can use a nullable IToastInstance property. + // If the value is not null, the component is running using the NotificationService. + // `public IToastInstance? ToastInstance { get; set; }` + [CascadingParameter] + public required IToastInstance ToastInstance { get; set; } + + [Parameter] + public int ProgressValue { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Simulate work being done + for (int i = 0; i <= 100; i += 10) + { + await Task.Delay(500); + ProgressValue = i; + StateHasChanged(); + } + + // Close the toast after the progress is complete + await Task.Delay(1000); + await ToastInstance.CloseAsync(ToastCloseReason.Programmatic, "Finished"); + } + } +} \ No newline at end of file diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 492c429f6e..dd4424b1e7 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -15,35 +15,33 @@ - @if (toast.ComponentType is null) - { - - } - else - { - - } + + @RenderToastContent(toast) + @if (toast.ComponentType is not null) + { + + } + } } diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index 1deed4cda7..5fca1f75ef 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -27,6 +27,7 @@ public interface IToastInstance : INotificationInstance /// /// Closes the toast with the specified result. /// - /// Result associated with the close action. - Task CloseAsync(ToastResult result); + /// Reason for closing the toast. + /// Optional data associated with the close action. + Task CloseAsync(ToastCloseReason reason, object? data = null); } diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 9007e874cd..1e955fa2a8 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -63,10 +63,10 @@ public Task CloseAsync() return NotificationService.CloseAsync(this); } - /// - public Task CloseAsync(ToastResult result) + /// + public Task CloseAsync(ToastCloseReason reason, object? data = null) { - return NotificationService.CloseAsync(this, result); + return NotificationService.CloseAsync(this, new ToastResult(reason, data)); } /// From 5d1931760a28b74ab8e021f4d0044728f8032db4 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 18:51:19 +0200 Subject: [PATCH 15/43] Refactor DismissClickAsync method to use ToastCloseReason for improved clarity in dismissal handling --- src/Core/Components/Toast/FluentToast.razor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 2ff4be7781..f62fe09395 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -311,6 +311,6 @@ private async Task DismissClickAsync() return; } - await ToastInstance.CloseAsync(ToastResult.OfDismissed()); + await ToastInstance.CloseAsync(ToastCloseReason.Dismissed); } } From 892a25821cd42ca509212e44d4ee6f648d41b706 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 22:34:44 +0200 Subject: [PATCH 16/43] Refactor toast examples to use INotificationService, improve button states, and enhance toast instance management --- .../FluentToastIndeterminateProgress.razor | 64 +++++++++++-------- .../Toast/Examples/FluentToastInverted.razor | 33 +++------- .../Toast/Services/INotificationService.cs | 7 ++ .../Toast/Services/NotificationService.cs | 11 ++++ 4 files changed, 66 insertions(+), 49 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor index ed157e450e..7e339824d7 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor @@ -1,41 +1,53 @@ -@* @inject IToastService ToastService +@inject INotificationService NotificationService - - - Make toast + + + Start Progress - - Finish process + + Finish Progress @code { - int clickCount = 0; - FluentButton openToastButton = default!; - private async Task OpenToastAsync() + IToastInstance? ProgressToast; + bool InProgress = false; + + // Show a Toast and keep a reference to the instance. + async Task StartProgressAsync() { - // Disable the button to prevent multiple toasts from being opened. - // In a real app, you would likely want to track the toast ID and only disable if that specific toast is open. - openToastButton.SetDisabled(true); - var result = await ToastService.ShowToastAsync(options => + InProgress = true; + + _ = NotificationService.ShowToastAsync(options => { - options.Id = "indeterminate-toast"; - options.Timeout = 0; - options.Type = ToastType.IndeterminateProgress; - options.Intent = ToastIntent.Success; - options.Title = $"Toast title {++clickCount}"; - options.Body = "No idea when this will be finished..."; + options.Id = "my-progress-toast"; + options.Intent = ToastIntent.Progress; + options.Title = "Task in progress"; + options.Message = "No idea when this will be finished..."; + options.AllowDismiss = false; + options.OnStatusChange = (e) => + { + ProgressToast = e.Instance; + }; }); - Console.WriteLine($"Toast result: {result}"); + + // Another way to get the instance of the Toast + // ProgressToast = NotificationService.GetToastInstance("my-progress-toast"); } - private async Task FinishProcessAsync() + // Close the Toast instance. + async Task FinishProgressAsync() { - // In a real app, you would likely keep track of the toast ID and update that specific toast. - await ToastService.DismissAsync("indeterminate-toast"); - // Enable the button again so a new toast can be opened. - openToastButton.SetDisabled(false); + if (ProgressToast is not null) + { + await ProgressToast.CloseAsync(); + ProgressToast = null; + } + + InProgress = false; } } - *@ + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor index 2931445037..f637a1e887 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor @@ -1,35 +1,22 @@ -@* @inject IToastService ToastService +@inject INotificationService NotificationService - - Make toast + + Show Inverted @code { - int clickCount = 0; - private async Task OpenToastAsync() + int counter = 1; + + async Task ShowInvertedToastAsync() { - var result = await ToastService.ShowToastAsync(options => + var result = await NotificationService.ShowToastAsync(options => { options.Intent = ToastIntent.Info; - options.Title = $"Toast title {++clickCount}"; - options.Body = "Toasts are used to show brief messages to the user."; - options.Subtitle = "subtitle"; - options.QuickAction1 = "Action"; - options.QuickAction1Callback = () => - { - Console.WriteLine("Action 1 executed."); - return Task.CompletedTask; - }; - options.AllowDismiss = true; - options.DismissAction = "Close"; - options.OnStatusChange = (e) => - { - Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); - }; options.Inverted = true; + options.Title = $"Inverted toast #{counter++}"; + options.Message = "Toasts are used to show brief messages to the user."; + options.Lifetime = TimeSpan.FromSeconds(5); }); - Console.WriteLine($"Toast result: {result}"); } } - *@ diff --git a/src/Core/Components/Toast/Services/INotificationService.cs b/src/Core/Components/Toast/Services/INotificationService.cs index b3e1743cab..6280a7d5d4 100644 --- a/src/Core/Components/Toast/Services/INotificationService.cs +++ b/src/Core/Components/Toast/Services/INotificationService.cs @@ -42,6 +42,13 @@ public partial interface INotificationService : IFluentServiceBase ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(Action options) where TToast : ComponentBase; + /// + /// Gets the toast instance with the specified ID. + /// + /// + /// + IToastInstance? GetToastInstance(string id); + /// /// Closes the specified toast instance. /// diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 5a20b44912..867935f74f 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -64,6 +64,17 @@ public Task ShowToastAsync(Action options) where TToast : ComponentBase => ShowToastAsync(new ToastOptions(options)); + /// + public IToastInstance? GetToastInstance(string id) + { + if (ServiceProvider.Items.TryGetValue(id, out var instance) && instance is IToastInstance toastInstance) + { + return toastInstance; + } + + return null; + } + /// public Task CloseAsync(IToastInstance toast, object? data = null) { From fba1d7767582a7649c19dd9590e1d9fce29b2f22 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 22:44:35 +0200 Subject: [PATCH 17/43] Refactor toast components to support customizable width, enhance layout, and improve progress display --- .../Examples/FluentToastDeterminateProgress.razor | 1 + .../Examples/Toast/CustomizedProgressToast.razor | 11 +++++++---- .../src/Components/Toast/FluentToast-Styles.ts | 4 ++-- src/Core/Components/Toast/FluentToast.razor.cs | 10 +++++++++- src/Core/Components/Toast/FluentToastProvider.razor | 1 + .../Components/Toast/Services/LibraryToastOptions.cs | 5 +++++ src/Core/Components/Toast/Services/ToastOptions.cs | 5 +++++ 7 files changed, 30 insertions(+), 7 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor index 95a6fd0da9..5199291fb9 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor @@ -19,6 +19,7 @@ options.Title = "Downloading file"; options.Icon = new Icons.Regular.Size24.ArrowDownload(); options.AllowDismiss = false; + options.Width = "400px"; options.Parameters.Add("ProgressValue", 0); }); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor index c5e7045133..9c47d86439 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/Toast/CustomizedProgressToast.razor @@ -1,7 +1,10 @@ - + + + @(ProgressValue)% completed + @code { diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts b/src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts index 634e415970..5d36b785a6 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast-Styles.ts @@ -19,8 +19,8 @@ export const fluentToastStyles: string = ` border-radius: var(--borderRadiusMedium); box-shadow: var(--shadow8); box-sizing: border-box; - min-width: 292px; - max-width: 292px; + min-width: var(--toast-width, 292px); + max-width: var(--toast-width, 292px); height: auto; padding: 12px; transition: diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index f62fe09395..a208440967 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -28,7 +28,9 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) protected string? ClassValue => DefaultClassBuilder.Build(); /// - protected string? StyleValue => DefaultStyleBuilder.Build(); + protected string? StyleValue => DefaultStyleBuilder + .AddStyle("--toast-width", Width) + .Build(); /// /// Gets the instance, if the toast is rendered using the . Otherwise, returns null. @@ -180,6 +182,12 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public RenderFragment? FooterTemplate { get; set; } + /// + /// Gets or sets the width of the toast. + /// + [Parameter] + public string? Width { get; set; } + /// protected override Task OnAfterRenderAsync(bool firstRender) { diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index dd4424b1e7..272bb8619a 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -34,6 +34,7 @@ Subtitle="@toast.Options.Subtitle" AllowDismiss="@(toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss)" DismissAction="@toast.Options.DismissAction" + Width="@(toast.Options.Width ?? configuration.Toast.Width)" AdditionalAttributes="@toast.Options.AdditionalAttributes"> @RenderToastContent(toast) @if (toast.ComponentType is not null) diff --git a/src/Core/Components/Toast/Services/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs index 71419b2d05..1b8828a42e 100644 --- a/src/Core/Components/Toast/Services/LibraryToastOptions.cs +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -70,4 +70,9 @@ internal LibraryToastOptions() /// Default is `false`, which is the recommended behavior according to Fluent UI design guidelines. /// public bool Inverted { get; set; } + + /// + /// Gets or sets the width of the toast. + /// + public string? Width { get; set; } } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index d980458f0d..17af0e35a6 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -163,6 +163,11 @@ public ToastOptions(Action implementationFactory) /// public Icon? Icon { get; set; } + /// + /// Gets or sets the width of the toast. + /// + public string? Width { get; set; } + /// /// Gets the class, including the optional and values. /// From d99d42be41cddf5ec82db6d5ebf54a1d89406496 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sat, 20 Jun 2026 22:47:59 +0200 Subject: [PATCH 18/43] Refactor toast queue synchronization to order queued toasts by index in ascending order --- src/Core/Components/Toast/FluentToastProvider.razor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 533a058a79..7f6d161265 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -94,7 +94,7 @@ private void SynchronizeToastQueue() var maxToastCount = configuration.Toast.MaxToastCount; var activeCount = ToastItems.Count(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed); var queuedToasts = ToastItems.Where(toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued) - .OrderByDescending(toast => toast.Index) + .OrderBy(toast => toast.Index) .ToList(); foreach (var toast in queuedToasts) From b5d45dc352d8f99628c01e190f2fa7544a4aca2b Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 11:32:45 +0200 Subject: [PATCH 19/43] Refactor toast component to replace DismissAction with DismissLabel and DismissTooltip, enhancing customization options for dismiss actions --- .../Examples/FluentToastCustomDismiss.razor | 34 ++++++++----------- .../Toast/Examples/FluentToastDefault.razor | 2 +- .../Components/Toast/FluentToast.md | 4 +-- src/Core/Components/Toast/FluentToast.razor | 12 ++++--- .../Components/Toast/FluentToast.razor.cs | 29 ++++++++++++++-- .../Toast/FluentToastProvider.razor | 4 ++- .../Toast/Services/LibraryToastOptions.cs | 4 +-- .../Components/Toast/Services/ToastOptions.cs | 15 +++++++- src/Core/Events/ToastEventArgs.cs | 26 ++------------ .../Components/Toast/FluentToastTests.razor | 6 ++-- 10 files changed, 75 insertions(+), 61 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor index 6c145684ef..19c00ab4c2 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor @@ -1,33 +1,27 @@ -@* @inject IToastService ToastService +@inject INotificationService NotificationService - - Make toast + + Show Custom Dismiss @code { - int clickCount = 0; - private async Task OpenToastAsync() + async Task OpenCustomDismissAsync() { - var result = await ToastService.ShowToastAsync(options => + var result = await NotificationService.ShowToastAsync(options => { options.Intent = ToastIntent.Success; - options.Title = $"Toast title {++clickCount}"; - options.Body = "This toast has a custom dismiss action."; - options.AllowDismiss = true; - options.DismissAction = "Undo"; - options.DismissActionCallback = () => + options.Title = $"App update available"; + options.Lifetime = TimeSpan.FromSeconds(10); + options.Message = "This toast has a custom dismiss action."; + options.DismissLabel = "Review"; + options.DismissTooltip = "Click to review the update."; + options.DismissCallback = async (e) => { - Console.WriteLine("Undo action executed."); - return Task.CompletedTask; + Console.WriteLine($"Review action clicked."); + await e.Instance.CloseAsync(ToastCloseReason.Dismissed); }; - options.OnStatusChange = (e) => - { - Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); - }; - }); - Console.WriteLine($"Toast result: {result}"); } } - *@ + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index dce59d1216..149e713180 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -34,7 +34,7 @@ options.Lifetime = TimeSpan.FromSeconds(5); options.OnStatusChange = (e) => { - Console.WriteLine($" . '{e.Instance?.Options.Title}' status changed to: {e.Status}"); + Console.WriteLine($" . '{e.Instance.Options.Title}' status changed to: {e.Status}"); }; }); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 9214e19e0a..233e947605 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -128,8 +128,8 @@ This example shows a toast with a custom dismissal configuration. It uses an act You can use the `Inverted` property to show a toast with an inverted color scheme. This allows for showing a dark toast on a light background, or a light toast on a dark background. ->[!Note] When setting `IsDismissable` to `true`, without setting a custom `DismissAction`, a toast will render a default dismiss button using the `FluentButton` component. -As a `FluentButton` has no notion of an `Inverted` property, you need to set an explicit `DismissAction` so a inverted aware link is rendered instead of the default button. +>[!Note] When setting `IsDismissable` to `true`, without setting a custom `DismissLabel`, a toast will render a default dismiss button using the `FluentButton` component. +As a `FluentButton` has no notion of an `Inverted` property, you need to set an explicit `DismissLabel` so a inverted aware link is rendered instead of the default button. {{ FluentToastInverted }} diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index a80af558e7..db53ad6d30 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -53,21 +53,23 @@ @* Slot Action (right of title)*@ @if (AllowDismiss) { - @if (string.IsNullOrEmpty(DismissAction)) + @if (string.IsNullOrEmpty(DismissLabel)) { - + OnClick="@DismissClickAsync" + Tooltip="@DismissTooltip" /> } else { - @DismissAction + @DismissLabel } } diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index a208440967..6407d2fe32 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -158,17 +158,32 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// /// Gets or sets a value indicating whether the toast can be dismissed by the user. Default is . /// When , a dismiss button is rendered; - /// Use to customize its label. + /// Use to customize its label. /// [Parameter] public bool AllowDismiss { get; set; } = true; /// - /// Gets or sets the label for the dismiss action button (e.g., `DismissAction="Close"`). + /// Gets or sets the label for the dismiss action button (e.g., `DismissLabel="Close"`). /// Only relevant when is . /// [Parameter] - public string? DismissAction { get; set; } + public string? DismissLabel { get; set; } + + /// + /// Gets or sets the tooltip for the dismiss action button. + /// Only relevant when is . + /// + [Parameter] + public string? DismissTooltip { get; set; } + + /// + /// Gets or sets the action invoked when the toast is dismissed, clicking on the dismiss button or the . + /// If set, the toast is not closed automatically, and the action is responsible for closing the toast by calling . + /// If not set, the toast is closed setting the to . + /// + [Parameter] + public Action? DismissCallback { get; set; } /// /// Gets or sets the content rendered in the toast body. @@ -319,6 +334,14 @@ private async Task DismissClickAsync() return; } + if (DismissCallback is not null) + { + // The current status is still Visible. + var args = new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Visible); + DismissCallback.Invoke(args); + return; + } + await ToastInstance.CloseAsync(ToastCloseReason.Dismissed); } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 272bb8619a..9c3f89113d 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -33,7 +33,9 @@ Title="@toast.Options.Title" Subtitle="@toast.Options.Subtitle" AllowDismiss="@(toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss)" - DismissAction="@toast.Options.DismissAction" + DismissLabel="@toast.Options.DismissLabel" + DismissTooltip="@toast.Options.DismissTooltip" + DismissCallback="@toast.Options.DismissCallback" Width="@(toast.Options.Width ?? configuration.Toast.Width)" AdditionalAttributes="@toast.Options.AdditionalAttributes"> @RenderToastContent(toast) diff --git a/src/Core/Components/Toast/Services/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs index 1b8828a42e..a8c3f1cace 100644 --- a/src/Core/Components/Toast/Services/LibraryToastOptions.cs +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -61,9 +61,9 @@ internal LibraryToastOptions() /// /// Gets or sets a value indicating whether visible toasts can be dismissed by the user. - /// Default is `false`, which is the recommended behavior according to Fluent UI design guidelines. + /// Default is `true`, which is the recommended behavior according to Fluent UI design guidelines. /// - public bool AllowDismiss { get; set; } + public bool AllowDismiss { get; set; } = true; /// /// Gets or sets a value indicating whether the toast uses inverted colors. diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 17af0e35a6..15d9757afd 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -144,7 +144,20 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets dismiss action label. /// - public string? DismissAction { get; set; } + public string? DismissLabel { get; set; } + + /// + /// Gets or sets the tooltip for the dismiss action button. + /// Only relevant when is . + /// + public string? DismissTooltip { get; set; } + + /// + /// Gets or sets the action invoked when the toast is dismissed, clicking on the dismiss button or the . + /// If set, the toast is not closed automatically, and the action is responsible for closing the toast by calling . + /// If not set, the toast is closed setting the to . + /// + public Action? DismissCallback { get; set; } /// /// Gets or sets the timestamp when the toast was created. diff --git a/src/Core/Events/ToastEventArgs.cs b/src/Core/Events/ToastEventArgs.cs index 6fbe91e5f2..e61e78037c 100644 --- a/src/Core/Events/ToastEventArgs.cs +++ b/src/Core/Events/ToastEventArgs.cs @@ -10,33 +10,13 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastEventArgs : EventArgs { /// - internal ToastEventArgs(IToastInstance? instance, ToastLifecycleStatus status) + internal ToastEventArgs(IToastInstance instance, ToastLifecycleStatus status) { - Id = instance?.Id ?? string.Empty; + Id = instance.Id; Instance = instance; Status = status; } - /// - internal ToastEventArgs(IToastInstance? instance, DialogToggleEventArgs args) - : this(instance, args.Id, args.Type, args.OldState, args.NewState) - { - } - - /// - internal ToastEventArgs(IToastInstance? instance, string? id, string? eventType, string? oldState, string? newState) - { - Id = id ?? string.Empty; - Instance = instance; - - Status = DialogEventArgs.GetDialogState(eventType, oldState, newState) switch - { - DialogState.Open => ToastLifecycleStatus.Visible, - DialogState.Closing => ToastLifecycleStatus.Dismissed, - _ => ToastLifecycleStatus.Queued, - }; - } - /// /// Gets the ID of the FluentToast component. /// @@ -51,5 +31,5 @@ internal ToastEventArgs(IToastInstance? instance, string? id, string? eventType, /// Gets the instance used by the . /// This value may be null if the toast is not managed by the . /// - public IToastInstance? Instance { get; } + public IToastInstance Instance { get; } } diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index d0dbb51ebb..d9c53a25aa 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -151,7 +151,7 @@ [Fact] public void FluentToast_IsDismissable_TrueWithDismissAction_RendersDismissLink() { - var cut = Render(@); + var cut = Render(@); Assert.Contains("Dismiss now", cut.Markup); Assert.Contains("fluent-link", cut.Markup); @@ -213,7 +213,7 @@ { options.Body = "Dismiss callback body"; options.AllowDismiss = true; - options.DismissAction = "Dismiss now"; + options.DismissLabel = "Dismiss now"; options.DismissActionCallback = () => { dismissActionCallbackInvoked = true; @@ -227,7 +227,7 @@ var toastInstance = Assert.IsType(toast.Instance.Instance); Assert.True(toast.Instance.IsDismissable); - Assert.Equal("Dismiss now", toast.Instance.DismissAction); + Assert.Equal("Dismiss now", toast.Instance.DismissLabel); toast.Find("fluent-link").Click(); From 0bbd94b45a41364f9eda9b60989762b6aad38007 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 12:09:10 +0200 Subject: [PATCH 20/43] Refactor toast component to use DismissAction for enhanced customization of dismiss options, including label, tooltip, and callback functionality. --- .../Examples/FluentToastCustomDismiss.razor | 6 ++--- src/Core/Components/Toast/FluentToast.razor | 8 +++--- .../Components/Toast/FluentToast.razor.cs | 25 ++++-------------- .../Toast/FluentToastProvider.razor | 4 +-- .../Components/Toast/Services/ToastOptions.cs | 18 +++---------- .../Toast/Services/ToastOptionsAction.cs | 26 +++++++++++++++++++ 6 files changed, 43 insertions(+), 44 deletions(-) create mode 100644 src/Core/Components/Toast/Services/ToastOptionsAction.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor index 19c00ab4c2..8ef216ed61 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor @@ -14,9 +14,9 @@ options.Title = $"App update available"; options.Lifetime = TimeSpan.FromSeconds(10); options.Message = "This toast has a custom dismiss action."; - options.DismissLabel = "Review"; - options.DismissTooltip = "Click to review the update."; - options.DismissCallback = async (e) => + options.DismissAction.Label = "Review"; + options.DismissAction.Tooltip = "Click to review the update."; + options.DismissAction.CallbackAsync = async (e) => { Console.WriteLine($"Review action clicked."); await e.Instance.CloseAsync(ToastCloseReason.Dismissed); diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index db53ad6d30..7c52cb33f3 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -53,7 +53,7 @@ @* Slot Action (right of title)*@ @if (AllowDismiss) { - @if (string.IsNullOrEmpty(DismissLabel)) + @if (string.IsNullOrEmpty(DismissAction.Label)) { + Tooltip="@DismissAction.Tooltip" /> } else { - @DismissLabel + @DismissAction.Label } } diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 6407d2fe32..c7bc3d81b5 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -158,32 +158,17 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// /// Gets or sets a value indicating whether the toast can be dismissed by the user. Default is . /// When , a dismiss button is rendered; - /// Use to customize its label. + /// Use to customize its label and action. /// [Parameter] public bool AllowDismiss { get; set; } = true; /// - /// Gets or sets the label for the dismiss action button (e.g., `DismissLabel="Close"`). + /// Gets or sets the dismiss label and action button (e.g., `DismissLabel="Close"`). /// Only relevant when is . /// [Parameter] - public string? DismissLabel { get; set; } - - /// - /// Gets or sets the tooltip for the dismiss action button. - /// Only relevant when is . - /// - [Parameter] - public string? DismissTooltip { get; set; } - - /// - /// Gets or sets the action invoked when the toast is dismissed, clicking on the dismiss button or the . - /// If set, the toast is not closed automatically, and the action is responsible for closing the toast by calling . - /// If not set, the toast is closed setting the to . - /// - [Parameter] - public Action? DismissCallback { get; set; } + public ToastOptionsAction DismissAction { get; set; } = new(); /// /// Gets or sets the content rendered in the toast body. @@ -334,11 +319,11 @@ private async Task DismissClickAsync() return; } - if (DismissCallback is not null) + if (DismissAction.CallbackAsync is not null) { // The current status is still Visible. var args = new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Visible); - DismissCallback.Invoke(args); + await DismissAction.CallbackAsync.Invoke(args); return; } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 9c3f89113d..272bb8619a 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -33,9 +33,7 @@ Title="@toast.Options.Title" Subtitle="@toast.Options.Subtitle" AllowDismiss="@(toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss)" - DismissLabel="@toast.Options.DismissLabel" - DismissTooltip="@toast.Options.DismissTooltip" - DismissCallback="@toast.Options.DismissCallback" + DismissAction="@toast.Options.DismissAction" Width="@(toast.Options.Width ?? configuration.Toast.Width)" AdditionalAttributes="@toast.Options.AdditionalAttributes"> @RenderToastContent(toast) diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 15d9757afd..7fe1e747c6 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -142,22 +142,12 @@ public ToastOptions(Action implementationFactory) public bool? AllowDismiss { get; set; } /// - /// Gets or sets dismiss action label. - /// - public string? DismissLabel { get; set; } - - /// - /// Gets or sets the tooltip for the dismiss action button. + /// Gets or sets the dismiss action displayed in the toast. /// Only relevant when is . + /// If `CallbackAsync` is set, the toast is not closed automatically, and the action is responsible for closing the toast by calling . + /// If `CallbackAsync` is not set, the toast is closed setting the to . /// - public string? DismissTooltip { get; set; } - - /// - /// Gets or sets the action invoked when the toast is dismissed, clicking on the dismiss button or the . - /// If set, the toast is not closed automatically, and the action is responsible for closing the toast by calling . - /// If not set, the toast is closed setting the to . - /// - public Action? DismissCallback { get; set; } + public ToastOptionsAction DismissAction { get; } = new ToastOptionsAction(); /// /// Gets or sets the timestamp when the toast was created. diff --git a/src/Core/Components/Toast/Services/ToastOptionsAction.cs b/src/Core/Components/Toast/Services/ToastOptionsAction.cs new file mode 100644 index 0000000000..e2b5e0a99a --- /dev/null +++ b/src/Core/Components/Toast/Services/ToastOptionsAction.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Options for configuring a toast action displayed by the . +/// +public class ToastOptionsAction +{ + /// + /// Gets or sets the label for the action button. + /// + public string? Label { get; set; } + + /// + /// Gets or sets the tooltip for the action button. + /// + public string? Tooltip { get; set; } + + /// + /// Gets or sets the callback to invoke when the action button is clicked. + /// + public Func? CallbackAsync { get; set; } +} \ No newline at end of file From 9a2de33a94c16684c6e0735da96abe9e80e9afd8 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 12:40:38 +0200 Subject: [PATCH 21/43] Refactor toast component to support quick action buttons, enhancing user interaction with customizable actions in the toast footer. --- .../Examples/FluentToastQuickActions.razor | 38 ++++++++++++++++ .../Components/Toast/FluentToast.md | 2 + src/Core/Components/Toast/FluentToast.razor | 2 +- .../Components/Toast/FluentToast.razor.cs | 2 +- .../Toast/FluentToastProvider.razor | 1 + .../Toast/FluentToastProvider.razor.cs | 45 +++++++++++++++++++ .../Components/Toast/Services/ToastOptions.cs | 16 ++++++- 7 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor new file mode 100644 index 0000000000..90535e233d --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor @@ -0,0 +1,38 @@ +@inject INotificationService NotificationService +@inject IJSRuntime JS + + + Show Quick Action Toast + + +@code { + + async Task OpenQuickActionToastAsync() + { + var result = await NotificationService.ShowToastAsync(options => + { + options.Intent = ToastIntent.Info; + options.Title = $"Your dashboard changed"; + options.Lifetime = TimeSpan.FromSeconds(10); + options.Message = "Update were made to Comtoso Dashboard."; + + options.QuickAction1.Label = "Review changes"; + options.QuickAction1.Tooltip = "Click to review changes made in Comtoso Dashboard."; + options.QuickAction1.CallbackAsync = async (e) => + { + Console.WriteLine($"Review action clicked."); + await e.Instance.CloseAsync(ToastCloseReason.Dismissed); + }; + + options.QuickAction2.Label = "See dashboard"; + options.QuickAction2.Tooltip = "Click to see the dashboard."; + options.QuickAction2.CallbackAsync = async (e) => + { + Console.WriteLine($"See dashboard action clicked."); + await JS.InvokeVoidAsync("open", "https://www.microsoft.com/", "_blank"); + await e.Instance.CloseAsync(ToastCloseReason.Dismissed); + }; + }); + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 233e947605..cf0ec496a8 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -124,6 +124,8 @@ This example shows a toast with a custom dismissal configuration. It uses an act {{ FluentToastCustomDismiss }} +{{ FluentToastQuickActions }} + ### Inverted toast You can use the `Inverted` property to show a toast with an inverted color scheme. This allows for showing a dark toast on a light background, or a light toast on a dark background. diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 7c52cb33f3..dc2e83f4c5 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -57,7 +57,7 @@ { - /// Gets or sets the dismiss label and action button (e.g., `DismissLabel="Close"`). + /// Gets or sets the dismiss link and action button (e.g., `DismissLabel="Close"`). /// Only relevant when is . /// [Parameter] diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 272bb8619a..aab557ddc3 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -34,6 +34,7 @@ Subtitle="@toast.Options.Subtitle" AllowDismiss="@(toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss)" DismissAction="@toast.Options.DismissAction" + FooterTemplate="@RenderFooterContent(toast)" Width="@(toast.Options.Width ?? configuration.Toast.Width)" AdditionalAttributes="@toast.Options.AdditionalAttributes"> @RenderToastContent(toast) diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 7f6d161265..bc8938267c 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -4,6 +4,7 @@ using System.Globalization; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -86,6 +87,50 @@ private RenderFragment RenderToastContent(IToastInstance? toast) => builder => builder.AddContent(0, new MarkupStringSanitized(toast.Options.Message, MarkupStringSanitized.Formats.Html, LibraryConfiguration)); }; + /// + /// Renders the footer content of the toast, including the primary and secondary quick actions if they are defined in the toast options. + /// + private RenderFragment RenderFooterContent(IToastInstance toast) => builder => + { + var hasPrimaryAction = !string.IsNullOrEmpty(toast.Options.QuickAction1.Label); + var hasSecondaryAction = !string.IsNullOrEmpty(toast.Options.QuickAction2.Label); + + if (!hasPrimaryAction && !hasSecondaryAction) + { + return; + } + + var inverted = toast.Options.Inverted ?? configuration.Toast.Inverted; + var actions = new List { toast.Options.QuickAction1, toast.Options.QuickAction2 }; + + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(FluentStack.HorizontalGap), "12px"); + builder.AddComponentParameter(2, nameof(FluentStack.ChildContent), (RenderFragment)(stackBuilder => + { + foreach (var action in actions) + { + if (string.IsNullOrEmpty(action.Label)) + { + continue; + } + + stackBuilder.OpenComponent(0); + stackBuilder.AddComponentParameter(1, nameof(FluentLink.OnClick), EventCallback.Factory.Create(this, async () => + { + if (action.CallbackAsync is not null) + { + await action.CallbackAsync.Invoke(new ToastEventArgs(toast, ToastLifecycleStatus.Visible)); + } + })); + stackBuilder.AddComponentParameter(2, nameof(FluentLink.Tooltip), action.Tooltip); + stackBuilder.AddComponentParameter(3, nameof(FluentLink.Style), inverted ? "color: var(--colorBrandForegroundInverted);" : null); + stackBuilder.AddComponentParameter(4, nameof(FluentLink.ChildContent), (RenderFragment)(contentBuilder => contentBuilder.AddContent(0, action.Label))); + stackBuilder.CloseComponent(); + } + })); + builder.CloseComponent(); + }; + /// /// Synchronizes the toast queue by promoting queued toasts to visible status based on the maximum allowed toast count. /// diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 7fe1e747c6..ba4b7bbe78 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -142,13 +142,27 @@ public ToastOptions(Action implementationFactory) public bool? AllowDismiss { get; set; } /// - /// Gets or sets the dismiss action displayed in the toast. + /// Gets or sets the dismiss action link displayed in the toast. /// Only relevant when is . /// If `CallbackAsync` is set, the toast is not closed automatically, and the action is responsible for closing the toast by calling . /// If `CallbackAsync` is not set, the toast is closed setting the to . /// public ToastOptionsAction DismissAction { get; } = new ToastOptionsAction(); + /// + /// Gets or sets the primary action for the toast. + /// This action link in displayed in the footer of the toast, and is used to trigger the most important action related to the toast message. + /// When the user clicks on this action, the toast is not closed automatically, and the action is responsible for closing the toast by calling with the appropriate . + /// + public ToastOptionsAction QuickAction1 { get; } = new ToastOptionsAction(); + + /// + /// Gets or sets the secondary action for the toast. + /// This action link in displayed in the footer of the toast, and is used to trigger the secondary action related to the toast message. + /// When the user clicks on this action, the toast is not closed automatically, and the action is responsible for closing the toast by calling with the appropriate . + /// + public ToastOptionsAction QuickAction2 { get; } = new ToastOptionsAction(); + /// /// Gets or sets the timestamp when the toast was created. /// From b092915ce7c5794666cc700aaf470cace7d4b8e2 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 13:00:26 +0200 Subject: [PATCH 22/43] Refactor toast documentation to improve clarity and structure, adding detailed usage guidelines and examples for better user understanding. --- .../Components/Toast/FluentToast.md | 7 -- .../Components/Toast/FluentToast2.md | 107 ++++++++++++++++++ 2 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index cf0ec496a8..9c75395ae0 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -1,10 +1,3 @@ ---- -title: Toast -route: /Toast -category: 20|Components -icon: FoodToast ---- - # Toast A toast communicates the status of an action someone is trying to take or that something happened elsewhere in the app. Toasts are temporary surfaces. diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md new file mode 100644 index 0000000000..4ae72d221b --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md @@ -0,0 +1,107 @@ +--- +title: Toast +route: /Toast +category: 20|Components +icon: FoodToast +--- + +# Toast + +A toast is an elevated, temporary notification that gives people feedback about an action they just took or informs them about a timely event. + +Use toast notifications for information that is useful and relevant, but not critical. If people must take immediate action, use a dialog instead. If the message is not tied to an immediate user action, consider a message bar. + +The library provides a `FluentToast` component that can be used to display these notifications. +To display a toast, you **must** use the `INotificationService`. +You use the `ToastOptions` class to configure the toast's content and behavior. + +## Before you start + +Add a `FluentProviders` (containing the `FluentToastProvider`) to your layout and inject `INotificationService` where you trigger notifications. + +Use these guidance points to keep toast usage consistent: + +- Show toasts in a non-blocking area, such as an app corner, and keep placement consistent. +- Do not place toasts in the center of the experience. +- In duplicate or multi-window scenarios, show the toast only in the focused window where the action occurs. +- Keep toast content concise: one-line title, short supporting text, and clear action labels. +- Prefer no more than four visible toasts in the same toaster. +- Use timed dismissal for informational success feedback, and persistent behavior for active progress. + +## Accessibility + +Toasts are announced with an alert role and live region behavior based on intent. Use the `Intent` value in `ToastOptions` to apply semantic styling and announcement priority. + +Use assertive intents carefully, because too many interruptions can disrupt screen reader users. + +## Default values + +Global default values (used for all instances) can be set using the `LibraryConfiguration.Toast` member. +The type of this member is `LibraryToastOptions`, and has the following properties (and default values): + +- `MaxToastCount = 4` +- `Lifetime = null` +- `Position = ToastPosition.BottomEnd` +- `VerticalOffset = 16` +- `HorizontalOffset = 20` +- `PauseOnHover = true` +- `PauseOnWindowBlur = true` +- `AllowDismiss = true` +- `Inverted = false` +- `Width = null (290px)` + +The preferred defaults can be set in the `AddFluentUIComponents` method when configuring services. + +## Examples + +### Default + +This example shows the standard toast setup with default behavior and intent. Use it as the baseline pattern for simple status feedback. + +{{ FluentToastDefault }} + +### FluentToastCustomDismiss.razor + +This example shows a toast that uses a custom dismiss action instead of the default dismiss button, useful when you need a tailored close flow. + +{{ FluentToastCustomDismiss }} + +### FluentToastIndeterminateProgress.razor + +This example shows an indeterminate progress toast for operations where completion time is unknown. + +{{ FluentToastIndeterminateProgress }} + +### FluentToastDeterminateProgress.razor + +This example shows a determinate progress toast that updates as the operation advances toward completion. + +{{ FluentToastDeterminateProgress }} + +### FluentToastQuickActions.razor + +This example shows quick action links inside the toast so people can immediately respond to the notification. + +{{ FluentToastQuickActions }} + +### FluentToastInverted.razor + +This example shows an inverted toast style for surfaces that need stronger contrast against the current background. + +{{ FluentToastInverted }} + +## API NotificationService + +{{ API Type=NotificationService }} + +## API FluentToast + +{{ API Type=FluentToast }} + +## API ToastOptions + +{{ API Type=ToastOptions Properties=All }} + +## API FluentToastProvider + +{{ API Type=FluentToastProvider }} From c9414abfe620ecc77cfc2134fde21bdf8e327a3f Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 13:08:09 +0200 Subject: [PATCH 23/43] Refactor toast documentation for clarity and consistency, removing outdated content and enhancing usage guidelines. Update toast component to use transparent button appearance for dismiss action and remove unnecessary timestamp property from ToastOptions. --- .../Components/Toast/FluentToast.md | 148 +++++++----------- .../Components/Toast/FluentToast2.md | 107 ------------- src/Core/Components/Toast/FluentToast.razor | 2 +- .../Components/Toast/Services/ToastOptions.cs | 5 - 4 files changed, 56 insertions(+), 206 deletions(-) delete mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 9c75395ae0..ea9c6f4662 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -1,103 +1,65 @@ -# Toast - -A toast communicates the status of an action someone is trying to take or that something happened elsewhere in the app. Toasts are temporary surfaces. -Use them for information that's useful and relevant, but not critical. - -The library provides a `FluentToast` component that can be used to display these notifications. To display a toast, you **must** use the `ToastService`. You use -the `ToastOptions` class to configure the toast's content and behavior. - -## Types - -Toasts generally fall into three categories: confirmation, progress, and communication. The toast component has slots that can be turned on and off to best -help people achieve their goals. The ideal configuration and usage of each toast type is described below: - -### Confirmation toast - -Confirmation toasts are shown to someone as a direct result of their action. A confirmation toast’s state can be success, error, warning, -informational, or progress. - -### Progress toast - -Progress toasts inform someone about the status of an operation they initiated. - -### Communication toast - -Communication toasts inform someone of messages from the system or another person’s actions. These messages can include mentions, event reminders, replies, -and system updates. -They include a call to action directly linking to a solution or the content that they reference. They can be either temporary or persistent. They’re -dismissible only if there is another surface, like a notification center, where the customer can find this content again later. - -## Behavior - -### Dismissal - -Toasts can have timed, conditional, or express dismissals, dependent on their use case. - -#### Timed dismissal - -If there is no action to take, toast will time out after seven seconds. Timed dismissal is best when there is no further action to take, like for a successful -confirmation toast. - -People who navigate via mouse can pause the timer by hovering over the toast. However, toasts that don’t include actions won’t receive keyboard focus for -people who navigate primarily by keyboard. - -#### Conditional dismissal - -Use conditional dismissal for toasts that should persist until a condition is met, like a progress toast that dismisses once a task is complete. +--- +title: Toast +route: /Toast +category: 20|Components +icon: FoodToast +--- -Don’t use toasts for necessary actions. If you need the encourage people to take an action before moving forward, try a more forceful surface like a message -bar or a dialog. - -#### Express dismissal +# Toast -Include the Close button to allow people to expressly dismiss toasts only if they can find that information again elsewhere, like in a notification center. +A toast is an elevated, temporary notification that gives people feedback about an action they just took or informs them about a timely event. ->[!Note] We do not have a way yet to facilitate showing toast messages on other surfaces like a notification center, so use the express dismissal option with -caution. +Use toast notifications for information that is useful and relevant, but not critical. If people must take immediate action, use a dialog instead. If the message is not tied to an immediate user action, consider a message bar. -### Determinate and indeterminate progress +The library provides a `FluentToast` component that can be used to display these notifications. +To display a toast, you **must** use the `INotificationService`. +You use the `ToastOptions` class to configure the toast's content and behavior. -Progress toasts can be either determinate or indeterminate, depending on the needs of your app and the capabilities of the technology you’re building on. +## Before you start -When the completion time can be predicted, show a determinate progress bar and percentage of completion. Determinate progress bars offer a reliable user -experience since they communicate status and assure people things are still working. +Add a `FluentProviders` (containing the `FluentToastProvider`) to your layout and inject `INotificationService` where you trigger notifications. -If the completion time is unknown or its accuracy is unreliable, show an indeterminate spinner icon instead. +Use these guidance points to keep toast usage consistent: -Although a specific type of toast needs to be specified through the `ToastOptions`, the library does not prevent you from showing both a spinner icon and a -progress bar in the same toast, but we recommend strongly against doing this. +- Show toasts in a non-blocking area, such as an app corner, and keep placement consistent. +- Do not place toasts in the center of the experience. +- In duplicate or multi-window scenarios, show the toast only in the focused window where the action occurs. +- Keep toast content concise: one-line title, short supporting text, and clear action labels. +- Prefer no more than four visible toasts in the same toaster. +- Use timed dismissal for informational success feedback, and persistent behavior for active progress. ## Accessibility -By using the `Intent` property (from `ToastOptions`) semantic styles, icons and aria-live regions and roles used in the toast are automatically applied. +Toasts are announced with an alert role and live region behavior based on intent. Use the `Intent` value in `ToastOptions` to apply semantic styling and announcement priority. -All feedback states except info have an “assertive” aria-live and interrupt any other announcement a screen reader is making. Too many interruptions can disrupt someone’s flow, -so don’t overload people with too many assertive toasts. +Use assertive intents carefully, because too many interruptions can disrupt screen reader users. ## Default values -Global default values (used for all instances) can be set using the `LibraryConfiguration.Toast` member. The type of this member is `LibraryToastOptions`, -and has the following properties (and default values): +Global default values (used for all instances) can be set using the `LibraryConfiguration.Toast` member. +The type of this member is `LibraryToastOptions`, and has the following properties (and default values): -- MaxToastCount (4); -- Timeout (7000); -- Position (ToastPosition.BottomEnd); -- VerticalOffset (16); -- HorizontalOffset (20); -- PauseOnHover (true); -- PauseOnWindowBlur (true); -- IsDismissable (false); -- Inverted (false); +- `MaxToastCount = 4` +- `Lifetime = null` +- `Position = ToastPosition.BottomEnd` +- `VerticalOffset = 16` +- `HorizontalOffset = 20` +- `PauseOnHover = true` +- `PauseOnWindowBlur = true` +- `AllowDismiss = true` +- `Inverted = false` +- `Width = null (290px)` The preferred defaults can be set in the `AddFluentUIComponents` method when configuring services. -### Example + +**Example** ```csharp // Add FluentUI services builder.Services.AddFluentUIComponents(config => { - config.Toast.IsDismissable = true; + config.Toast.AllowDismiss = false; config.Toast.Position = ToastPosition.TopEnd; }); ``` @@ -106,43 +68,43 @@ builder.Services.AddFluentUIComponents(config => ### Default -This example shows a toast with the default configuration, which includes a title and a message. It also has the default intent of `Info`, which applies the corresponding icon. -It shows 2 action links in the footer, which is the maximum number of what is possible for a toast. +This example shows the standard toast setup with default behavior and intent. Use it as the baseline pattern for simple status feedback. {{ FluentToastDefault }} ### Custom dismissal -This example shows a toast with a custom dismissal configuration. It uses an action link (with a custom callback) instead of the standard dismiss icon to dismiss the toast. +This example shows a toast that uses a custom dismiss action instead of the default dismiss button, useful when you need a tailored close flow. {{ FluentToastCustomDismiss }} -{{ FluentToastQuickActions }} +### Indeterminate progress -### Inverted toast +This example shows an indeterminate progress toast for operations where completion time is unknown. -You can use the `Inverted` property to show a toast with an inverted color scheme. This allows for showing a dark toast on a light background, or a light toast on a dark background. +{{ FluentToastIndeterminateProgress }} ->[!Note] When setting `IsDismissable` to `true`, without setting a custom `DismissLabel`, a toast will render a default dismiss button using the `FluentButton` component. -As a `FluentButton` has no notion of an `Inverted` property, you need to set an explicit `DismissLabel` so a inverted aware link is rendered instead of the default button. +### Determinate progress -{{ FluentToastInverted }} +This example shows a determinate progress toast that updates as the operation advances toward completion. -### Indeterminate progress +{{ FluentToastDeterminateProgress }} -This example shows a toast with an indeterminate progress configuration. Timeout has been set to zero, so the toast will never close by itself. Use the 'Finish process' button to dismiss the toast. +### Quick actions -{{ FluentToastIndeterminateProgress }} +This example shows quick action links inside the toast so people can immediately respond to the notification. -### Determinate progress +{{ FluentToastQuickActions }} -This example shows how a toast can be updated during a longer running process (with a predictable duration). +### Inverted toast -{{ FluentToastDeterminateProgress }} +This example shows an inverted toast style for surfaces that need stronger contrast against the current background. + +{{ FluentToastInverted }} -## API ToastService +## API NotificationService -{{ API Type=ToastService }} +{{ API Type=NotificationService }} ## API FluentToast diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md deleted file mode 100644 index 4ae72d221b..0000000000 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast2.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Toast -route: /Toast -category: 20|Components -icon: FoodToast ---- - -# Toast - -A toast is an elevated, temporary notification that gives people feedback about an action they just took or informs them about a timely event. - -Use toast notifications for information that is useful and relevant, but not critical. If people must take immediate action, use a dialog instead. If the message is not tied to an immediate user action, consider a message bar. - -The library provides a `FluentToast` component that can be used to display these notifications. -To display a toast, you **must** use the `INotificationService`. -You use the `ToastOptions` class to configure the toast's content and behavior. - -## Before you start - -Add a `FluentProviders` (containing the `FluentToastProvider`) to your layout and inject `INotificationService` where you trigger notifications. - -Use these guidance points to keep toast usage consistent: - -- Show toasts in a non-blocking area, such as an app corner, and keep placement consistent. -- Do not place toasts in the center of the experience. -- In duplicate or multi-window scenarios, show the toast only in the focused window where the action occurs. -- Keep toast content concise: one-line title, short supporting text, and clear action labels. -- Prefer no more than four visible toasts in the same toaster. -- Use timed dismissal for informational success feedback, and persistent behavior for active progress. - -## Accessibility - -Toasts are announced with an alert role and live region behavior based on intent. Use the `Intent` value in `ToastOptions` to apply semantic styling and announcement priority. - -Use assertive intents carefully, because too many interruptions can disrupt screen reader users. - -## Default values - -Global default values (used for all instances) can be set using the `LibraryConfiguration.Toast` member. -The type of this member is `LibraryToastOptions`, and has the following properties (and default values): - -- `MaxToastCount = 4` -- `Lifetime = null` -- `Position = ToastPosition.BottomEnd` -- `VerticalOffset = 16` -- `HorizontalOffset = 20` -- `PauseOnHover = true` -- `PauseOnWindowBlur = true` -- `AllowDismiss = true` -- `Inverted = false` -- `Width = null (290px)` - -The preferred defaults can be set in the `AddFluentUIComponents` method when configuring services. - -## Examples - -### Default - -This example shows the standard toast setup with default behavior and intent. Use it as the baseline pattern for simple status feedback. - -{{ FluentToastDefault }} - -### FluentToastCustomDismiss.razor - -This example shows a toast that uses a custom dismiss action instead of the default dismiss button, useful when you need a tailored close flow. - -{{ FluentToastCustomDismiss }} - -### FluentToastIndeterminateProgress.razor - -This example shows an indeterminate progress toast for operations where completion time is unknown. - -{{ FluentToastIndeterminateProgress }} - -### FluentToastDeterminateProgress.razor - -This example shows a determinate progress toast that updates as the operation advances toward completion. - -{{ FluentToastDeterminateProgress }} - -### FluentToastQuickActions.razor - -This example shows quick action links inside the toast so people can immediately respond to the notification. - -{{ FluentToastQuickActions }} - -### FluentToastInverted.razor - -This example shows an inverted toast style for surfaces that need stronger contrast against the current background. - -{{ FluentToastInverted }} - -## API NotificationService - -{{ API Type=NotificationService }} - -## API FluentToast - -{{ API Type=FluentToast }} - -## API ToastOptions - -{{ API Type=ToastOptions Properties=All }} - -## API FluentToastProvider - -{{ API Type=FluentToastProvider }} diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index dc2e83f4c5..57bc997521 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -56,7 +56,7 @@ @if (string.IsNullOrEmpty(DismissAction.Label)) { implementationFactory) /// public ToastOptionsAction QuickAction2 { get; } = new ToastOptionsAction(); - /// - /// Gets or sets the timestamp when the toast was created. - /// - public DateTime? TimeStamp { get; set; } - /// /// Gets or sets the action raised when the toast lifecycle status changes. /// From e731a30db0b2403502c0b9df074fa63203225b7a Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 15:03:30 +0200 Subject: [PATCH 24/43] Refactor toast component to update callback naming conventions from CallbackAsync to OnClickAsync for improved clarity and consistency. Add new example for default toast options. --- .../Examples/FluentToastCustomDismiss.razor | 2 +- .../Toast/Examples/FluentToastDefault.razor | 29 +++--------- .../Examples/FluentToastDefaultOptions.razor | 44 +++++++++++++++++++ .../Examples/FluentToastQuickActions.razor | 4 +- .../Components/Toast/FluentToast.md | 9 +++- .../Components/Toast/FluentToast.razor.cs | 4 +- .../Toast/FluentToastProvider.razor.cs | 4 +- .../Toast/Services/INotificationService.cs | 11 +++++ .../Toast/Services/NotificationService.cs | 29 ++++++++++++ .../Toast/Services/ToastOptionsAction.cs | 2 +- 10 files changed, 105 insertions(+), 33 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor index 8ef216ed61..5e9f43c9aa 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor @@ -16,7 +16,7 @@ options.Message = "This toast has a custom dismiss action."; options.DismissAction.Label = "Review"; options.DismissAction.Tooltip = "Click to review the update."; - options.DismissAction.CallbackAsync = async (e) => + options.DismissAction.OnClickAsync = async (e) => { Console.WriteLine($"Review action clicked."); await e.Instance.CloseAsync(ToastCloseReason.Dismissed); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index 149e713180..446bc8e3ab 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1,13 +1,13 @@ @inject INotificationService NotificationService - + @* Show Info - - + *@ + Show Success - + @* Show Warning @@ -15,30 +15,11 @@ Show Progress - + *@ @code { int counter = 1; - - async Task OpenToastAsync(ToastIntent intent) - { - var result = await NotificationService.ShowToastAsync(options => - { - options.Intent = intent; - options.Title = $"{intent} toast #{counter++}"; - options.Message = "Toasts are used to show brief messages to the user."; - options.Subtitle = "Sent by Fluent UI Blazor"; - options.AllowDismiss = true; - options.Lifetime = TimeSpan.FromSeconds(5); - options.OnStatusChange = (e) => - { - Console.WriteLine($" . '{e.Instance.Options.Title}' status changed to: {e.Status}"); - }; - }); - - Console.WriteLine($"'{intent} toast' closed with Reason: {result.Reason}"); - } } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor new file mode 100644 index 0000000000..149e713180 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor @@ -0,0 +1,44 @@ +@inject INotificationService NotificationService + + + + Show Info + + + Show Success + + + Show Warning + + + Show Error + + + Show Progress + + + +@code { + + int counter = 1; + + async Task OpenToastAsync(ToastIntent intent) + { + var result = await NotificationService.ShowToastAsync(options => + { + options.Intent = intent; + options.Title = $"{intent} toast #{counter++}"; + options.Message = "Toasts are used to show brief messages to the user."; + options.Subtitle = "Sent by Fluent UI Blazor"; + options.AllowDismiss = true; + options.Lifetime = TimeSpan.FromSeconds(5); + options.OnStatusChange = (e) => + { + Console.WriteLine($" . '{e.Instance.Options.Title}' status changed to: {e.Status}"); + }; + }); + + Console.WriteLine($"'{intent} toast' closed with Reason: {result.Reason}"); + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor index 90535e233d..b0bc57c10b 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastQuickActions.razor @@ -18,7 +18,7 @@ options.QuickAction1.Label = "Review changes"; options.QuickAction1.Tooltip = "Click to review changes made in Comtoso Dashboard."; - options.QuickAction1.CallbackAsync = async (e) => + options.QuickAction1.OnClickAsync = async (e) => { Console.WriteLine($"Review action clicked."); await e.Instance.CloseAsync(ToastCloseReason.Dismissed); @@ -26,7 +26,7 @@ options.QuickAction2.Label = "See dashboard"; options.QuickAction2.Tooltip = "Click to see the dashboard."; - options.QuickAction2.CallbackAsync = async (e) => + options.QuickAction2.OnClickAsync = async (e) => { Console.WriteLine($"See dashboard action clicked."); await JS.InvokeVoidAsync("open", "https://www.microsoft.com/", "_blank"); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index ea9c6f4662..35d25ae417 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -66,11 +66,18 @@ builder.Services.AddFluentUIComponents(config => ## Examples +### Fastest helper methods + +This example shows the fastest helper methods to display **success**, **info**, **warning**, **error** and **progress** toasts +by using a required title plus optional message and dismiss button details. + +{{ FluentToastDefault }} + ### Default This example shows the standard toast setup with default behavior and intent. Use it as the baseline pattern for simple status feedback. -{{ FluentToastDefault }} +{{ FluentToastDefaultOptions }} ### Custom dismissal diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 3dec4ef54a..682d34bf8e 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -319,11 +319,11 @@ private async Task DismissClickAsync() return; } - if (DismissAction.CallbackAsync is not null) + if (DismissAction.OnClickAsync is not null) { // The current status is still Visible. var args = new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Visible); - await DismissAction.CallbackAsync.Invoke(args); + await DismissAction.OnClickAsync.Invoke(args); return; } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index bc8938267c..f819100e3e 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -117,9 +117,9 @@ private RenderFragment RenderFooterContent(IToastInstance toast) => builder => stackBuilder.OpenComponent(0); stackBuilder.AddComponentParameter(1, nameof(FluentLink.OnClick), EventCallback.Factory.Create(this, async () => { - if (action.CallbackAsync is not null) + if (action.OnClickAsync is not null) { - await action.CallbackAsync.Invoke(new ToastEventArgs(toast, ToastLifecycleStatus.Visible)); + await action.OnClickAsync.Invoke(new ToastEventArgs(toast, ToastLifecycleStatus.Visible)); } })); stackBuilder.AddComponentParameter(2, nameof(FluentLink.Tooltip), action.Tooltip); diff --git a/src/Core/Components/Toast/Services/INotificationService.cs b/src/Core/Components/Toast/Services/INotificationService.cs index 6280a7d5d4..c903533b0c 100644 --- a/src/Core/Components/Toast/Services/INotificationService.cs +++ b/src/Core/Components/Toast/Services/INotificationService.cs @@ -12,6 +12,17 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public partial interface INotificationService : IFluentServiceBase { + /// + /// Shows a success toast with the specified title and message and waits for the close result. + /// + /// The title of the toast. + /// The message content of the toast. + /// The lifetime of the toast in seconds (default is 5 seconds). + /// The label for the dismiss action. + /// The callback action for the dismiss action. When the action is completed, the toast will be closed. + /// A task that represents the asynchronous operation. The task result contains the close result of the toast. + Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + /// /// Shows a toast using the supplied options and waits for the close result. /// diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 867935f74f..baa8854708 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -38,6 +38,35 @@ public NotificationService(IServiceProvider serviceProvider, IFluentLocalizer? l /// protected IFluentLocalizer Localizer { get; } + /// + public Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + { + var options = new ToastOptions + { + Intent = ToastIntent.Success, + Title = title, + Message = message, + Lifetime = lifetime.HasValue ? TimeSpan.FromSeconds(lifetime.Value) : null, + }; + + if (!string.IsNullOrEmpty(dismissLabel)) + { + options.AllowDismiss = true; + options.DismissAction.Label = dismissLabel; + options.DismissAction.OnClickAsync = async (e) => + { + if (dismissOnClickAsync is not null) + { + await dismissOnClickAsync.Invoke(e); + } + + await e.Instance.CloseAsync(ToastCloseReason.Dismissed); + }; + } + + return ShowToastAsync(options); + } + /// public async Task ShowToastAsync(ToastOptions options) { diff --git a/src/Core/Components/Toast/Services/ToastOptionsAction.cs b/src/Core/Components/Toast/Services/ToastOptionsAction.cs index e2b5e0a99a..fac3fa9cb9 100644 --- a/src/Core/Components/Toast/Services/ToastOptionsAction.cs +++ b/src/Core/Components/Toast/Services/ToastOptionsAction.cs @@ -22,5 +22,5 @@ public class ToastOptionsAction /// /// Gets or sets the callback to invoke when the action button is clicked. /// - public Func? CallbackAsync { get; set; } + public Func? OnClickAsync { get; set; } } \ No newline at end of file From 257242570e5940d5078c8f726ec0138a4d6e3174 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 15:18:21 +0200 Subject: [PATCH 25/43] Refactor toast component examples to utilize new notification service methods for improved clarity and consistency. Update button actions to reflect changes in toast display logic. --- .../Toast/Examples/FluentToastDefault.razor | 17 ++--- .../Toast/FluentToastProvider.razor.cs | 59 ++++++++-------- .../Toast/Services/INotificationService.cs | 45 ++++++++++++ .../Toast/Services/NotificationService.cs | 68 ++++++++++++------- 4 files changed, 130 insertions(+), 59 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index 446bc8e3ab..e588649fd5 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1,21 +1,21 @@ @inject INotificationService NotificationService - @* - Show Info - *@ - + Show Success - @* + Show Warning - + Show Error - + + Show Info + + Show Progress - *@ + @code { @@ -23,3 +23,4 @@ int counter = 1; } + diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index f819100e3e..3e4c4120a9 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -77,59 +77,62 @@ private EventCallback GetOnStatusChangeCallback(IToastInstance t => EventCallback.Factory.Create(this, toast.Options.OnStatusChange ?? ((_) => { })); /// - private RenderFragment RenderToastContent(IToastInstance? toast) => builder => + private RenderFragment? RenderToastContent(IToastInstance? toast) { if (toast is null || string.IsNullOrEmpty(toast.Options.Message)) { - return; + return null; } - builder.AddContent(0, new MarkupStringSanitized(toast.Options.Message, MarkupStringSanitized.Formats.Html, LibraryConfiguration)); - }; + return builder => builder.AddContent(0, new MarkupStringSanitized(toast.Options.Message, MarkupStringSanitized.Formats.Html, LibraryConfiguration)); + } /// /// Renders the footer content of the toast, including the primary and secondary quick actions if they are defined in the toast options. /// - private RenderFragment RenderFooterContent(IToastInstance toast) => builder => + private RenderFragment? RenderFooterContent(IToastInstance toast) { var hasPrimaryAction = !string.IsNullOrEmpty(toast.Options.QuickAction1.Label); var hasSecondaryAction = !string.IsNullOrEmpty(toast.Options.QuickAction2.Label); if (!hasPrimaryAction && !hasSecondaryAction) { - return; + return null; } var inverted = toast.Options.Inverted ?? configuration.Toast.Inverted; var actions = new List { toast.Options.QuickAction1, toast.Options.QuickAction2 }; - builder.OpenComponent(0); - builder.AddComponentParameter(1, nameof(FluentStack.HorizontalGap), "12px"); - builder.AddComponentParameter(2, nameof(FluentStack.ChildContent), (RenderFragment)(stackBuilder => + return builder => { - foreach (var action in actions) + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(FluentStack.HorizontalGap), "12px"); + builder.AddComponentParameter(2, nameof(FluentStack.ChildContent), (RenderFragment)(stackBuilder => { - if (string.IsNullOrEmpty(action.Label)) + foreach (var action in actions) { - continue; - } - - stackBuilder.OpenComponent(0); - stackBuilder.AddComponentParameter(1, nameof(FluentLink.OnClick), EventCallback.Factory.Create(this, async () => - { - if (action.OnClickAsync is not null) + if (string.IsNullOrEmpty(action.Label)) { - await action.OnClickAsync.Invoke(new ToastEventArgs(toast, ToastLifecycleStatus.Visible)); + continue; } - })); - stackBuilder.AddComponentParameter(2, nameof(FluentLink.Tooltip), action.Tooltip); - stackBuilder.AddComponentParameter(3, nameof(FluentLink.Style), inverted ? "color: var(--colorBrandForegroundInverted);" : null); - stackBuilder.AddComponentParameter(4, nameof(FluentLink.ChildContent), (RenderFragment)(contentBuilder => contentBuilder.AddContent(0, action.Label))); - stackBuilder.CloseComponent(); - } - })); - builder.CloseComponent(); - }; + + stackBuilder.OpenComponent(0); + stackBuilder.AddComponentParameter(1, nameof(FluentLink.OnClick), EventCallback.Factory.Create(this, async () => + { + if (action.OnClickAsync is not null) + { + await action.OnClickAsync.Invoke(new ToastEventArgs(toast, ToastLifecycleStatus.Visible)); + } + })); + stackBuilder.AddComponentParameter(2, nameof(FluentLink.Tooltip), action.Tooltip); + stackBuilder.AddComponentParameter(3, nameof(FluentLink.Style), inverted ? "color: var(--colorBrandForegroundInverted);" : null); + stackBuilder.AddComponentParameter(4, nameof(FluentLink.ChildContent), (RenderFragment)(contentBuilder => contentBuilder.AddContent(0, action.Label))); + stackBuilder.CloseComponent(); + } + })); + builder.CloseComponent(); + }; + } /// /// Synchronizes the toast queue by promoting queued toasts to visible status based on the maximum allowed toast count. diff --git a/src/Core/Components/Toast/Services/INotificationService.cs b/src/Core/Components/Toast/Services/INotificationService.cs index c903533b0c..4647c1ada8 100644 --- a/src/Core/Components/Toast/Services/INotificationService.cs +++ b/src/Core/Components/Toast/Services/INotificationService.cs @@ -23,6 +23,51 @@ public partial interface INotificationService : IFluentServiceBaseA task that represents the asynchronous operation. The task result contains the close result of the toast. Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + /// + /// Shows a warning toast with the specified title and message and waits for the close result. + /// + /// The title of the toast. + /// The message content of the toast. + /// The lifetime of the toast in seconds (default is 5 seconds). + /// The label for the dismiss action. + /// The callback action for the dismiss action. When the action is completed, the toast will be closed. + /// A task that represents the asynchronous operation. The task result contains the close result of the toast. + Task ShowWarningToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + + /// + /// Shows an error toast with the specified title and message and waits for the close result. + /// + /// The title of the toast. + /// The message content of the toast. + /// The lifetime of the toast in seconds (default is 5 seconds). + /// The label for the dismiss action. + /// The callback action for the dismiss action. When the action is completed, the toast will be closed. + /// A task that represents the asynchronous operation. The task result contains the close result of the toast. + Task ShowErrorToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + + /// + /// Shows an info toast with the specified title and message and waits for the close result. + /// + /// The title of the toast. + /// The message content of the toast. + /// The lifetime of the toast in seconds (default is 5 seconds). + /// The label for the dismiss action. + /// The callback action for the dismiss action. When the action is completed, the toast will be closed. + /// A task that represents the asynchronous operation. The task result contains the close result of the toast. + Task ShowInfoToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + + /// + /// Shows a progress toast with the specified title and message and waits for the close result. + /// The toast persists until explicitly closed (no automatic dismissal by default). + /// + /// The title of the toast. + /// The message content of the toast. + /// The lifetime of the toast in seconds (default is 5 seconds). + /// The label for the dismiss action. + /// The callback action for the dismiss action. When the action is completed, the toast will be closed. + /// A task that represents the asynchronous operation. The task result contains the close result of the toast. + Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + /// /// Shows a toast using the supplied options and waits for the close result. /// diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index baa8854708..0658b51ad3 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -40,32 +40,23 @@ public NotificationService(IServiceProvider serviceProvider, IFluentLocalizer? l /// public Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) - { - var options = new ToastOptions - { - Intent = ToastIntent.Success, - Title = title, - Message = message, - Lifetime = lifetime.HasValue ? TimeSpan.FromSeconds(lifetime.Value) : null, - }; + => ShowSimpleToastAsync(ToastIntent.Success, title, message, lifetime, dismissLabel, dismissOnClickAsync); - if (!string.IsNullOrEmpty(dismissLabel)) - { - options.AllowDismiss = true; - options.DismissAction.Label = dismissLabel; - options.DismissAction.OnClickAsync = async (e) => - { - if (dismissOnClickAsync is not null) - { - await dismissOnClickAsync.Invoke(e); - } + /// + public Task ShowWarningToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + => ShowSimpleToastAsync(ToastIntent.Warning, title, message, lifetime, dismissLabel, dismissOnClickAsync); - await e.Instance.CloseAsync(ToastCloseReason.Dismissed); - }; - } + /// + public Task ShowErrorToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + => ShowSimpleToastAsync(ToastIntent.Error, title, message, lifetime, dismissLabel, dismissOnClickAsync); - return ShowToastAsync(options); - } + /// + public Task ShowInfoToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + => ShowSimpleToastAsync(ToastIntent.Info, title, message, lifetime, dismissLabel, dismissOnClickAsync); + + /// + public Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + => ShowSimpleToastAsync(ToastIntent.Progress, title, message, lifetime, dismissLabel, dismissOnClickAsync); /// public async Task ShowToastAsync(ToastOptions options) @@ -128,6 +119,37 @@ public async Task CloseAllToastsAsync() return toasts.Count; } + /// + /// Internal helper that builds and shows a simple intent-based toast. + /// + private Task ShowSimpleToastAsync(ToastIntent intent, string title, string? message, int? lifetime, string? dismissLabel, Func? dismissOnClickAsync) + { + var options = new ToastOptions + { + Intent = intent, + Title = title, + Message = message, + Lifetime = lifetime.HasValue ? TimeSpan.FromSeconds(lifetime.Value) : null, + }; + + if (!string.IsNullOrEmpty(dismissLabel)) + { + options.AllowDismiss = true; + options.DismissAction.Label = dismissLabel; + options.DismissAction.OnClickAsync = async (e) => + { + if (dismissOnClickAsync is not null) + { + await dismissOnClickAsync.Invoke(e); + } + + await e.Instance.CloseAsync(ToastCloseReason.Dismissed); + }; + } + + return ShowToastAsync(options); + } + /// private ToastInstance ShowToastInstanceCore(Type? componentType, ToastOptions options) { From 132cf7a048c7f675508c70cd9e98671b95d9c18c Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 17:37:29 +0200 Subject: [PATCH 26/43] Refactor toast and notification services to support dynamic component rendering and improve type safety with DynamicallyAccessedMembers attributes. Update toast content rendering logic for better flexibility. --- .../Components/Toast/FluentToast.md | 3 ++ .../Services/INotificationInstance.cs | 3 ++ .../MessageBar/Services/MessageBarInstance.cs | 5 ++- .../Services/NotificationService.cs | 2 +- .../Toast/FluentToastProvider.razor | 10 ++---- .../Toast/FluentToastProvider.razor.cs | 32 +++++++++++++++++-- .../Toast/Services/NotificationService.cs | 2 +- .../Toast/Services/ToastInstance.cs | 5 ++- 8 files changed, 48 insertions(+), 14 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 35d25ae417..78b928bbf2 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -95,6 +95,9 @@ This example shows an indeterminate progress toast for operations where completi This example shows a determinate progress toast that updates as the operation advances toward completion. +It uses `ShowToastAsync(...)`, where the generic type `TToast` is the Razor component **dynamically** rendered inside the toast body. +With this approach, you can open any Razor component in the toast and fully customize its content and behavior. + {{ FluentToastDeterminateProgress }} ### Quick actions diff --git a/src/Core/Components/MessageBar/Services/INotificationInstance.cs b/src/Core/Components/MessageBar/Services/INotificationInstance.cs index 1b68c2df48..1be01fbeb0 100644 --- a/src/Core/Components/MessageBar/Services/INotificationInstance.cs +++ b/src/Core/Components/MessageBar/Services/INotificationInstance.cs @@ -2,6 +2,8 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.FluentUI.AspNetCore.Components; /// @@ -13,6 +15,7 @@ public partial interface INotificationInstance /// Gets the optional component type rendered for this notification. /// When , the default notification component is rendered. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] internal Type? ComponentType { get; } /// diff --git a/src/Core/Components/MessageBar/Services/MessageBarInstance.cs b/src/Core/Components/MessageBar/Services/MessageBarInstance.cs index f9f0ec0b30..52e30b3039 100644 --- a/src/Core/Components/MessageBar/Services/MessageBarInstance.cs +++ b/src/Core/Components/MessageBar/Services/MessageBarInstance.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -14,6 +15,7 @@ public class MessageBarInstance : IMessageBarInstance, IDisposable private static long _counter; internal readonly TaskCompletionSource ResultCompletion = new(); private readonly CancellationTokenSource _lifetimeCts = new(); + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] private readonly Type? _componentType; private bool _disposed; @@ -24,7 +26,7 @@ internal MessageBarInstance(INotificationService notificationService, MessageBar } /// - internal MessageBarInstance(INotificationService notificationService, Type? componentType, MessageBarOptions options) + internal MessageBarInstance(INotificationService notificationService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type? componentType, MessageBarOptions options) { Options = options; NotificationService = notificationService; @@ -34,6 +36,7 @@ internal MessageBarInstance(INotificationService notificationService, Type? comp } /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type? INotificationInstance.ComponentType => _componentType; /// diff --git a/src/Core/Components/MessageBar/Services/NotificationService.cs b/src/Core/Components/MessageBar/Services/NotificationService.cs index a5ef2f2a5f..45eb6d04a0 100644 --- a/src/Core/Components/MessageBar/Services/NotificationService.cs +++ b/src/Core/Components/MessageBar/Services/NotificationService.cs @@ -137,7 +137,7 @@ public async Task CloseAllMessageBarsAsync() } /// - private MessageBarInstance ShowMessageInstanceCore(Type? componentType, MessageBarOptions options) + private MessageBarInstance ShowMessageInstanceCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type? componentType, MessageBarOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index aab557ddc3..6cb9ee2754 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -32,18 +32,12 @@ Icon="@toast.Options.Icon" Title="@toast.Options.Title" Subtitle="@toast.Options.Subtitle" + ChildContent="@RenderToastContent(toast)" AllowDismiss="@(toast.Options.AllowDismiss ?? configuration.Toast.AllowDismiss)" DismissAction="@toast.Options.DismissAction" FooterTemplate="@RenderFooterContent(toast)" Width="@(toast.Options.Width ?? configuration.Toast.Width)" - AdditionalAttributes="@toast.Options.AdditionalAttributes"> - @RenderToastContent(toast) - @if (toast.ComponentType is not null) - { - - } - + AdditionalAttributes="@toast.Options.AdditionalAttributes" /> } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 3e4c4120a9..35e5385522 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -79,12 +79,40 @@ private EventCallback GetOnStatusChangeCallback(IToastInstance t /// private RenderFragment? RenderToastContent(IToastInstance? toast) { - if (toast is null || string.IsNullOrEmpty(toast.Options.Message)) + if (toast is null) { return null; } - return builder => builder.AddContent(0, new MarkupStringSanitized(toast.Options.Message, MarkupStringSanitized.Formats.Html, LibraryConfiguration)); + var hasMessage = !string.IsNullOrEmpty(toast.Options.Message); + var hasComponent = toast.ComponentType is not null; + + if (!hasMessage && !hasComponent) + { + return null; + } + + return builder => + { + if (hasMessage) + { + builder.AddContent(0, new MarkupStringSanitized(toast.Options.Message!, MarkupStringSanitized.Formats.Html, LibraryConfiguration)); + } + + if (hasComponent) + { + builder.OpenComponent(1, toast.ComponentType!); + if (toast.Options.Parameters is not null) + { + foreach (var parameter in toast.Options.Parameters) + { + builder.AddAttribute(2, parameter.Key, parameter.Value); + } + } + + builder.CloseComponent(); + } + }; } /// diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 0658b51ad3..6fd0f23403 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -151,7 +151,7 @@ private Task ShowSimpleToastAsync(ToastIntent intent, string title, } /// - private ToastInstance ShowToastInstanceCore(Type? componentType, ToastOptions options) + private ToastInstance ShowToastInstanceCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type? componentType, ToastOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 1e955fa2a8..872c7ed1e7 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -13,6 +14,7 @@ public class ToastInstance : IToastInstance { private static long _counter; internal readonly TaskCompletionSource ResultCompletion = new(); + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] private readonly Type? _componentType; /// @@ -22,7 +24,7 @@ internal ToastInstance(INotificationService notificationService, ToastOptions op } /// - internal ToastInstance(INotificationService notificationService, Type? componentType, ToastOptions options) + internal ToastInstance(INotificationService notificationService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type? componentType, ToastOptions options) { Options = options; NotificationService = notificationService; @@ -37,6 +39,7 @@ internal ToastInstance(INotificationService notificationService, Type? component internal Func UpdateOpenedAsync { get; set; } = _ => Task.CompletedTask; /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type? INotificationInstance.ComponentType => _componentType; /// From 935f2866fb0b113e1895af63cdd4d3f92d83f02c Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 18:14:29 +0200 Subject: [PATCH 27/43] Refactor toast result handling to include instance context and introduce ToastResultTiming enum for improved result completion control. --- .../Examples/FluentToastDefaultOptions.razor | 2 +- .../Services/NotificationService.cs | 2 +- .../Components/Toast/FluentToast.razor.cs | 2 +- .../Toast/Services/INotificationService.cs | 2 +- .../Toast/Services/NotificationService.cs | 8 +++--- .../Toast/Services/ToastInstance.cs | 19 +++++++++++++- .../Components/Toast/Services/ToastOptions.cs | 6 +++++ .../Components/Toast/Services/ToastResult.cs | 20 ++++++++++---- src/Core/Enums/ToastResultTiming.cs | 26 +++++++++++++++++++ 9 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 src/Core/Enums/ToastResultTiming.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor index 149e713180..4e1bb71cf7 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor @@ -38,7 +38,7 @@ }; }); - Console.WriteLine($"'{intent} toast' closed with Reason: {result.Reason}"); + Console.WriteLine($"'{result.Instance.Options.Title}' closed with Reason: {result.Reason}"); } } diff --git a/src/Core/Components/MessageBar/Services/NotificationService.cs b/src/Core/Components/MessageBar/Services/NotificationService.cs index 45eb6d04a0..4fe4a9bfd5 100644 --- a/src/Core/Components/MessageBar/Services/NotificationService.cs +++ b/src/Core/Components/MessageBar/Services/NotificationService.cs @@ -115,7 +115,7 @@ public async Task CloseAsync(string id, object? data = null) if (notification is IToastInstance toast) { - await CloseCoreAsync((IToastInstance)toast, ToastResult.OfProgrammatic(data)); + await CloseCoreAsync((IToastInstance)toast, ToastResult.OfProgrammatic(toast, data)); return true; } } diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 682d34bf8e..03dceded7a 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -261,7 +261,7 @@ private async Task OnToggleAsync(DialogToggleEventArgs args) } // Set the result of the toast to TimedOut. - toast.ResultCompletion.TrySetResult(ToastResult.OfTimedOut()); + toast.ResultCompletion.TrySetResult(ToastResult.OfTimedOut(instance: toast)); } } diff --git a/src/Core/Components/Toast/Services/INotificationService.cs b/src/Core/Components/Toast/Services/INotificationService.cs index 4647c1ada8..ec27dd3fcf 100644 --- a/src/Core/Components/Toast/Services/INotificationService.cs +++ b/src/Core/Components/Toast/Services/INotificationService.cs @@ -68,7 +68,7 @@ public partial interface INotificationService : IFluentServiceBaseA task that represents the asynchronous operation. The task result contains the close result of the toast. Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); - /// + /// /// Shows a toast using the supplied options and waits for the close result. /// /// Options to configure the toast. diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 6fd0f23403..33dae27407 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -66,7 +66,7 @@ public async Task ShowToastAsync(ToastOptions options) return await instance.Result; } - /// + /// public Task ShowToastAsync(Action options) => ShowToastAsync(new ToastOptions(options)); @@ -103,7 +103,7 @@ public Task CloseAsync(IToastInstance toast, object? data = null) return CloseCoreAsync(toast, result); } - return CloseCoreAsync(toast, ToastResult.OfProgrammatic(data)); + return CloseCoreAsync(toast, ToastResult.OfProgrammatic(toast, data)); } /// @@ -113,7 +113,7 @@ public async Task CloseAllToastsAsync() foreach (var toast in toasts) { - await CloseCoreAsync(toast, ToastResult.OfProgrammatic()); + await CloseCoreAsync(toast, ToastResult.OfProgrammatic(toast)); } return toasts.Count; @@ -193,7 +193,7 @@ private async Task CloseCoreAsync(IToastInstance toast, ToastResult result) // Remove the Toast from the ToastProvider. await RemoveToastFromProviderAsync(instance); - // Set the result of the Toast. + // Set the result of the Toast. instance.ResultCompletion.TrySetResult(result); } diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 872c7ed1e7..e842ad7d35 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -69,7 +69,7 @@ public Task CloseAsync() /// public Task CloseAsync(ToastCloseReason reason, object? data = null) { - return NotificationService.CloseAsync(this, new ToastResult(reason, data)); + return NotificationService.CloseAsync(this, new ToastResult(this, reason, data)); } /// @@ -91,5 +91,22 @@ internal void SetStatus(ToastLifecycleStatus status) var args = new ToastEventArgs(this, status); Options.OnStatusChange.Invoke(args); } + + TryCompleteResultOnStatus(status); + } + + /// + private void TryCompleteResultOnStatus(ToastLifecycleStatus status) + { + if (Options.ResultTiming == ToastResultTiming.Queued && status == ToastLifecycleStatus.Queued) + { + ResultCompletion.TrySetResult(ToastResult.OfQueued(this)); + return; + } + + if (Options.ResultTiming == ToastResultTiming.Visible && status == ToastLifecycleStatus.Visible) + { + ResultCompletion.TrySetResult(ToastResult.OfVisible(this)); + } } } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 6fb17a7a53..bf064688f0 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -180,6 +180,12 @@ public ToastOptions(Action implementationFactory) /// public string? Width { get; set; } + /// + /// Gets or sets when the task is completed. + /// The default is . + /// + public ToastResultTiming ResultTiming { get; set; } = ToastResultTiming.Closed; + /// /// Gets the class, including the optional and values. /// diff --git a/src/Core/Components/Toast/Services/ToastResult.cs b/src/Core/Components/Toast/Services/ToastResult.cs index e7c111742e..7196b51869 100644 --- a/src/Core/Components/Toast/Services/ToastResult.cs +++ b/src/Core/Components/Toast/Services/ToastResult.cs @@ -9,16 +9,21 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public class ToastResult { - internal static ToastResult OfDismissed(object? data = null) => new(ToastCloseReason.Dismissed, data); - internal static ToastResult OfQuickAction(object? data = null) => new(ToastCloseReason.QuickAction, data); - internal static ToastResult OfProgrammatic(object? data = null) => new(ToastCloseReason.Programmatic, data); - internal static ToastResult OfTimedOut(object? data = null) => new(ToastCloseReason.TimedOut, data); + internal static ToastResult OfDismissed(IToastInstance instance, object? data = null) => new(instance, ToastCloseReason.Dismissed, data); + internal static ToastResult OfQuickAction(IToastInstance instance, object? data = null) => new(instance, ToastCloseReason.QuickAction, data); + internal static ToastResult OfProgrammatic(IToastInstance instance, object? data = null) => new(instance, ToastCloseReason.Programmatic, data); + internal static ToastResult OfTimedOut(IToastInstance instance, object? data = null) => new(instance, ToastCloseReason.TimedOut, data); + internal static ToastResult OfVisible(IToastInstance instance) => new(instance, ToastCloseReason.Programmatic, data: null); + internal static ToastResult OfQueued(IToastInstance instance) => new(instance, ToastCloseReason.Programmatic, data: null); /// - protected internal ToastResult(ToastCloseReason reason, object? data) + internal ToastResult(IToastInstance instance, ToastCloseReason reason, object? data) { + ArgumentNullException.ThrowIfNull(instance); + Reason = reason; Data = data; + Instance = instance; } /// @@ -30,4 +35,9 @@ protected internal ToastResult(ToastCloseReason reason, object? data) /// Gets the optional data associated with the result. /// public object? Data { get; } + + /// + /// Gets the toast instance associated with this result. + /// + public IToastInstance Instance { get; internal set; } } diff --git a/src/Core/Enums/ToastResultTiming.cs b/src/Core/Enums/ToastResultTiming.cs new file mode 100644 index 0000000000..e1182d9f58 --- /dev/null +++ b/src/Core/Enums/ToastResultTiming.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Controls when is completed. +/// +public enum ToastResultTiming +{ + /// + /// Complete the result when the toast is closed. + /// + Closed, + + /// + /// Complete the result when the toast becomes visible. + /// + Visible, + + /// + /// Complete the result when the toast is queued. + /// + Queued, +} From e877457a0708a925c54ea5c7b8adf1b59aa02860 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 18:50:40 +0200 Subject: [PATCH 28/43] Refactor toast examples and documentation to enhance result timing control and user interaction. Introduce new example for result timing options and update toast service methods for improved clarity. --- .../Toast/Examples/FluentToastDefault.razor | 16 +++++-- .../Examples/FluentToastDefaultOptions.razor | 1 - .../Examples/FluentToastResultTiming.razor | 47 +++++++++++++++++++ .../Components/Toast/FluentToast.md | 26 ++++++++++ .../Toast/Services/NotificationService.cs | 5 +- src/Core/Enums/ToastResultTiming.cs | 2 +- 6 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index e588649fd5..74a41d98ef 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1,26 +1,32 @@ @inject INotificationService NotificationService - + Show Success - + Show Warning - + Show Error - + Show Info - + Show Progress + + Close Progress + @code { int counter = 1; + + ToastResult? ProgressResult; } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor index 4e1bb71cf7..5a00f5b95b 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor @@ -31,7 +31,6 @@ options.Message = "Toasts are used to show brief messages to the user."; options.Subtitle = "Sent by Fluent UI Blazor"; options.AllowDismiss = true; - options.Lifetime = TimeSpan.FromSeconds(5); options.OnStatusChange = (e) => { Console.WriteLine($" . '{e.Instance.Options.Title}' status changed to: {e.Status}"); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor new file mode 100644 index 0000000000..fe1a957201 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor @@ -0,0 +1,47 @@ +@inject INotificationService NotificationService + + + + Show Lifetime On Close + + + + Show Lifetime On Visible + + + +
    +
  • @ResultTimingOnClose
  • +
  • @ResultTimingOnVisible
  • +
+ +@code { + + string ResultTimingOnClose = ""; + string ResultTimingOnVisible = ""; + + async Task OpenResultTimingOnCloseAsync() + { + var resultOnClose = await NotificationService.ShowToastAsync(options => + { + options.Title = $"App update available"; + options.Lifetime = TimeSpan.FromSeconds(5); + options.ResultTiming = ToastResultTiming.Closed; // ResultTiming = On Close, which is the default behavior + }); + + ResultTimingOnClose = $"Toast closed with reason: {resultOnClose.Reason}"; + } + + async Task OpenResultTimingOnVisibleAsync() + { + var resultOnVisible = await NotificationService.ShowToastAsync(options => + { + options.Title = $"App update available"; + options.Lifetime = TimeSpan.FromSeconds(5); + options.ResultTiming = ToastResultTiming.Visible; // ResultTiming = On Visible, which means the result will complete as soon as the toast is rendered and visible to the user + }); + + ResultTimingOnVisible = $"Toast visible with reason: {resultOnVisible.Reason}"; + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 78b928bbf2..36a5abad5e 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -77,6 +77,11 @@ by using a required title plus optional message and dismiss button details. This example shows the standard toast setup with default behavior and intent. Use it as the baseline pattern for simple status feedback. +In this example, **Success**, **Warning**, **Error**, and **Info** toasts are shown for 5 seconds (`lifetime = 5`) and then close automatically. +The **Progress** toast behaves differently: the line `ProgressResult = await NotificationService.ShowProgressToastAsync()` returns immediately +a toast instance that is stored in `ProgressResult.Instance`. +When `ProgressResult` is available, the [Close Progress] button is enabled so the user can close that toast manually. + {{ FluentToastDefaultOptions }} ### Custom dismissal @@ -106,6 +111,27 @@ This example shows quick action links inside the toast so people can immediately {{ FluentToastQuickActions }} +### Result Timing + +This example shows when your application should consider an interaction with a context notification to be complete and when the .NET code should continue executing +`var result = await NotificationService.ShowToastAsync()`. + +By default, `await NotificationService.ShowToastAsync(...)` resumes only when the toast is closed. + +If you do not want to wait for the result, start the call without waiting for completion: +`_ = NotificationService.ShowToastAsync(...);` + +You can also control when the awaited result is completed with `ResultTiming`: +- `ResultTiming = Closed` (default): code after `await` runs after the toast is closed. +- `ResultTiming = Visible`: code after `await` runs as soon as the toast is visible. + +When using `Visible`, keep the returned `result.Instance` if you need to interact with that toast later (for example, close it programmatically). + +In the sample, both toasts stay visible for 5 seconds. **Show Lifetime On Close** reports the result after the toast closes, +while **Show Lifetime On Visible** reports it immediately when the toast appears. + +{{ FluentToastResultTiming }} + ### Inverted toast This example shows an inverted toast style for surfaces that need stronger contrast against the current background. diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 33dae27407..50069d01c7 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -56,7 +56,7 @@ public Task ShowInfoToastAsync(string title, string? message = null /// public Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) - => ShowSimpleToastAsync(ToastIntent.Progress, title, message, lifetime, dismissLabel, dismissOnClickAsync); + => ShowSimpleToastAsync(ToastIntent.Progress, title, message, lifetime, dismissLabel, dismissOnClickAsync, ToastResultTiming.Visible); /// public async Task ShowToastAsync(ToastOptions options) @@ -122,13 +122,14 @@ public async Task CloseAllToastsAsync() /// /// Internal helper that builds and shows a simple intent-based toast. /// - private Task ShowSimpleToastAsync(ToastIntent intent, string title, string? message, int? lifetime, string? dismissLabel, Func? dismissOnClickAsync) + private Task ShowSimpleToastAsync(ToastIntent intent, string title, string? message, int? lifetime, string? dismissLabel, Func? dismissOnClickAsync, ToastResultTiming? resultTiming = null) { var options = new ToastOptions { Intent = intent, Title = title, Message = message, + ResultTiming = resultTiming ?? ToastResultTiming.Closed, Lifetime = lifetime.HasValue ? TimeSpan.FromSeconds(lifetime.Value) : null, }; diff --git a/src/Core/Enums/ToastResultTiming.cs b/src/Core/Enums/ToastResultTiming.cs index e1182d9f58..fc46e10b6b 100644 --- a/src/Core/Enums/ToastResultTiming.cs +++ b/src/Core/Enums/ToastResultTiming.cs @@ -10,7 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public enum ToastResultTiming { /// - /// Complete the result when the toast is closed. + /// Complete the result when the toast is dismissed or closed. /// Closed, From 4c34c6f4a711758a336765516dfab5268b27c747 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 18:51:23 +0200 Subject: [PATCH 29/43] Remove obsolete toast provider and instance tests, consolidating toast functionality into a more streamlined testing approach. --- .../Toast/FluentToastProviderTests.razor | 260 ----- ...uentToast_WithInstance.verified.razor.html | 12 - .../Components/Toast/FluentToastTests.razor | 894 ------------------ .../Components/Toast/ToastInstanceTests.razor | 325 ------- 4 files changed, 1491 deletions(-) delete mode 100644 tests/Core/Components/Toast/FluentToastProviderTests.razor delete mode 100644 tests/Core/Components/Toast/FluentToastTests.FluentToast_WithInstance.verified.razor.html delete mode 100644 tests/Core/Components/Toast/FluentToastTests.razor delete mode 100644 tests/Core/Components/Toast/ToastInstanceTests.razor diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor deleted file mode 100644 index 4c8f55f787..0000000000 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ /dev/null @@ -1,260 +0,0 @@ -@using System.Collections.Concurrent -@using Xunit; -@inherits FluentUITestContext -@code -{ - - public FluentToastProviderTests() - { - JSInterop.Mode = JSRuntimeMode.Loose; - Services.AddFluentUIComponents(config => - { - config.Toast.MaxToastCount = 2; - config.Toast.Timeout = 1234; - config.Toast.Position = ToastPosition.TopEnd; - config.Toast.VerticalOffset = 44; - config.Toast.HorizontalOffset = 55; - config.Toast.PauseOnHover = true; - config.Toast.PauseOnWindowBlur = true; - config.Toast.AllowDismiss = false; - config.Toast.Inverted = false; - }); - - ToastService = Services.GetRequiredService(); - ToastProvider = Render(); - } - - public IToastService ToastService { get; } - - public IRenderedComponent ToastProvider { get; } - - private static async Task CloseToastAndWaitAsync(IRenderedComponent toast, ToastCloseReason reason) - { - await toast.Instance.Instance!.CloseAsync(reason); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance.Id, - Type = "beforetoggle", - OldState = "open", - NewState = "closed", - }); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - } - - [Fact] - public async Task FluentToast_ProviderDefaults_Applied() - { - var provider = Render(); - - _ = ToastService.ShowToastAsync(options => - { - options.Body = "Uses provider defaults"; - }); - - await Task.CompletedTask; - - var toast = provider.FindComponent(); - Assert.Equal(1234, toast.Instance.Lifetime); - Assert.Equal(ToastPosition.TopEnd, toast.Instance.Position); - Assert.Equal(44, toast.Instance.VerticalOffset); - Assert.Equal(55, toast.Instance.HorizontalOffset); - Assert.True(toast.Instance.PauseOnHover); - Assert.True(toast.Instance.PauseOnWindowBlur); - Assert.False(toast.Instance.IsDismissable); - Assert.False(toast.Instance.Inverted); - } - - [Fact] - public async Task FluentToast_PerToastOverrides_ProviderDefaults() - { - var provider = Render(); - - _ = ToastService.ShowToastAsync(options => - { - options.Body = "Uses overrides"; - options.Timeout = 4321; - options.Position = ToastPosition.BottomStart; - options.VerticalOffset = 11; - options.HorizontalOffset = 22; - options.PauseOnHover = true; - options.PauseOnWindowBlur = true; - options.AllowDismiss = true; - options.Inverted = true; - }); - - await Task.CompletedTask; - - var toast = provider.FindComponent(); - Assert.Equal(4321, toast.Instance.Lifetime); - Assert.Equal(ToastPosition.BottomStart, toast.Instance.Position); - Assert.Equal(11, toast.Instance.VerticalOffset); - Assert.Equal(22, toast.Instance.HorizontalOffset); - Assert.True(toast.Instance.PauseOnHover); - Assert.True(toast.Instance.PauseOnWindowBlur); - Assert.True(toast.Instance.IsDismissable); - Assert.True(toast.Instance.Inverted); - } - - [Fact] - public async Task FluentToast_QueuedUntilProviderHasRoom() - { - var provider = Render(); - var statuses = new List(); - - var firstToastTask = ToastService.ShowToastAsync(options => - { - options.Body = "First toast"; - }); - - var secondToastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Second toast"; - options.OnStatusChange = args => statuses.Add(args.Status); - }); - - var thirdToastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Third toast"; - options.OnStatusChange = args => statuses.Add(args.Status); - }); - - await Task.CompletedTask; - - Assert.Contains("First toast", provider.Markup); - Assert.Contains("Second toast", provider.Markup); - Assert.DoesNotContain("Third toast", provider.Markup); - Assert.Contains(ToastLifecycleStatus.Queued, statuses); - - var firstToast = provider.FindComponent(); - await CloseToastAndWaitAsync(firstToast, ToastCloseReason.Programmatic); - - Assert.Contains("Third toast", provider.Markup); - Assert.Contains(ToastLifecycleStatus.Visible, statuses); - } - - [Fact] - public async Task FluentToast_ProviderClassStyle() - { - Assert.Contains("fluent-toast-provider", ToastProvider.Markup); - Assert.Contains("z-index", ToastProvider.Markup); - - await Task.CompletedTask; - } - - [Fact] - public void FluentToast_SynchronizeToastQueue_WithNullToastService_DoesNothing() - { - var provider = Render(); - var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; - - var exception = Record.Exception(() => method.Invoke(provider.Instance, null)); - - Assert.Null(exception); - } - - [Fact] - public void FluentToast_SynchronizeToastQueue_RendersNewestVisibleToastFirst() - { - var service = new TestToastService(); - var firstToast = new ToastInstance(service, new ToastOptions { Id = "first", Body = "First toast" }); - var secondToast = new ToastInstance(service, new ToastOptions { Id = "second", Body = "Second toast" }); - - service.Items.TryAdd(firstToast.Id, firstToast); - service.Items.TryAdd(secondToast.Id, secondToast); - - var provider = Render(parameters => parameters - .Add(p => p.OverrideToastService, service)); - - var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; - method.Invoke(provider.Instance, null); - provider.Render(); - - var toasts = provider.FindComponents().ToList(); - - Assert.Equal(2, toasts.Count); - Assert.Equal("second", toasts[0].Instance.Instance?.Id); - Assert.Equal("first", toasts[1].Instance.Instance?.Id); - } - - private sealed class TestFluentToastProvider : FluentToastProvider - { - public TestFluentToastProvider(LibraryConfiguration configuration) - : base(configuration) - { - } - - [Parameter] - public IToastService? OverrideToastService { get; set; } - - protected override IToastService? ToastService => OverrideToastService; - } - - private sealed class TestToastService : IToastService - { - public string? ProviderId { get; set; } - - public ConcurrentDictionary Items { get; } = new(); - - public Func OnUpdatedAsync { get; set; } = _ => Task.CompletedTask; - - public Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) - => Task.CompletedTask; - - public Task DismissAsync(IToastInstance Toast) - => Task.CompletedTask; - - public Task DismissAsync(string toastId) - => Task.FromResult(false); - - public Task DismissAllAsync() - => Task.FromResult(0); - - public void Dispose() - { - } - - public Task ShowToastAsync(ToastOptions? options = null) - => Task.FromResult(ToastCloseReason.Programmatic); - - public Task ShowToastAsync(Action options) - => Task.FromResult(ToastCloseReason.Programmatic); - - public Task ShowToastInstanceAsync(ToastOptions? options = null) - => Task.FromResult(new TestToastInstance()); - - public Task ShowToastInstanceAsync(Action options) - => Task.FromResult(new TestToastInstance()); - - public Task UpdateToastAsync(IToastInstance toast, Action update) - => Task.CompletedTask; - - private sealed class TestToastInstance : IToastInstance - { - public string Id { get; } = "toast"; - - public long Index => 0; - - public ToastOptions Options { get; } = new(); - - public Task Result => Task.FromResult(ToastCloseReason.Programmatic); - - public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; - - public Task CancelAsync() => Task.CompletedTask; - - public Task CloseAsync() => Task.CompletedTask; - - public Task CloseAsync(ToastCloseReason reason) => Task.CompletedTask; - - public Task DismissAsync() => Task.CompletedTask; - - public Task UpdateAsync(Action update) => Task.CompletedTask; - } - } -} diff --git a/tests/Core/Components/Toast/FluentToastTests.FluentToast_WithInstance.verified.razor.html b/tests/Core/Components/Toast/FluentToastTests.FluentToast_WithInstance.verified.razor.html deleted file mode 100644 index 69736719a0..0000000000 --- a/tests/Core/Components/Toast/FluentToastTests.FluentToast_WithInstance.verified.razor.html +++ /dev/null @@ -1,12 +0,0 @@ - - - -
-
-
Toast With Instance
- - -
-
- - \ No newline at end of file diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor deleted file mode 100644 index d9c53a25aa..0000000000 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ /dev/null @@ -1,894 +0,0 @@ -@using Xunit; -@inherits FluentUITestContext -@code -{ - - public FluentToastTests() - { - JSInterop.Mode = JSRuntimeMode.Loose; - Services.AddFluentUIComponents(); - - ToastService = Services.GetRequiredService(); - ToastProvider = Render(); - } - - /// - /// Gets the toast service. - /// - public IToastService ToastService { get; } - - /// - /// Gets the toast provider. - /// - public IRenderedComponent ToastProvider { get; } - - private static async Task CloseToastAndWaitAsync(IRenderedComponent toast, ToastCloseReason reason) - { - await toast.Instance.Instance!.CloseAsync(reason); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance.Id, - Type = "beforetoggle", - OldState = "open", - NewState = "closed", - }); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - } - - private static Task DismissToastAndWaitAsync(IRenderedComponent toast) - => CloseToastAndWaitAsync(toast, ToastCloseReason.Dismissed); - - [Fact] - public async Task FluentToast_Render() - { - // Act - _ = ToastService.ShowToastAsync(options => - { - options.Title = "Toast title"; - options.Body = "Toast Content - John"; - }); - - - // Assert - Assert.Contains("fluent-toast-provider", ToastProvider.Markup); - Assert.Contains("Toast Content - John", ToastProvider.Markup); - } - - [Fact] - public void FluentToast_BodyContent() - { - // Arrange & Act - var cut = Render(@Hello World); - - // Assert - Assert.Contains("Hello World", cut.Markup); - Assert.Contains("fluent-toast-b", cut.Markup); - } - - [Fact] - public void FluentToast_Icon_RendersCustomIcon() - { - var icon = new CoreIcons.Regular.Size20.Dismiss(); - var cut = Render(parameters => parameters - .Add(p => p.Message, "Toast body") - .Add(p => p.Icon, icon)); - - Assert.Same(icon, cut.Instance.Icon); - Assert.Contains("slot=\"media\"", cut.Markup); - Assert.DoesNotContain("fluent-spinner", cut.Markup); - } - - [Fact] - public void FluentToast_Inverted_RendersInvertedAttribute() - { - var cut = Render(parameters => parameters - .Add(p => p.Message, "Toast body") - .Add(p => p.Inverted, true)); - - Assert.True(cut.Instance.Inverted); - Assert.Contains("inverted=\"true\"", cut.Markup, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void FluentToast_IndeterminateProgress_RendersSpinner() - { - var cut = Render(parameters => parameters - .Add(p => p.Message, "Toast body") - .Add(p => p.Type, ToastType.IndeterminateProgress)); - - Assert.Equal(ToastType.IndeterminateProgress, cut.Instance.Type); - Assert.Contains("fluent-spinner", cut.Markup); - } - - [Fact] - public async Task FluentToast_ToastOptionsType_IsAppliedFromProvider() - { - _ = ToastService.ShowToastAsync(options => - { - options.Body = "Progress body"; - options.Type = ToastType.IndeterminateProgress; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - - Assert.Equal(ToastType.IndeterminateProgress, toast.Instance.Type); - Assert.Contains("fluent-spinner", toast.Markup); - } - - [Fact] - public async Task FluentToast_ToastOptionsInverted_IsAppliedFromProvider() - { - _ = ToastService.ShowToastAsync(options => - { - options.Body = "Inverted body"; - options.Inverted = true; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - - Assert.True(toast.Instance.Inverted); - Assert.Contains("inverted=\"true\"", toast.Markup, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void FluentToast_Subtitle_RendersWhenNotEmpty() - { - var cut = Render(@); - - Assert.Contains("Toast subtitle", cut.Markup); - } - - [Fact] - public void FluentToast_IsDismissable_TrueWithDismissAction_RendersDismissLink() - { - var cut = Render(@); - - Assert.Contains("Dismiss now", cut.Markup); - Assert.Contains("fluent-link", cut.Markup); - } - - [Fact] - public void FluentToast_IsDismissable_TrueWithNullDismissAction_RendersDismissButton() - { - var cut = Render(@); - - Assert.Contains("Title=\"Dismiss\"", cut.Markup, StringComparison.OrdinalIgnoreCase); - Assert.Contains("fluent-button", cut.Markup); - } - - [Fact] - public async Task FluentToast_DismissButton_OnClick_DismissesToast() - { - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Dismiss button body"; - options.AllowDismiss = true; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - var toastInstance = Assert.IsType(toast.Instance.Instance); - - toast.Find("fluent-button").Click(); - - Assert.Equal(ToastCloseReason.Dismissed, toastInstance.PendingCloseReason); - - await toast.Instance.OnToggleAsync(new() - { - Id = toastInstance.Id, - Type = "beforetoggle", - OldState = "open", - NewState = "closed", - }); - await toast.Instance.OnToggleAsync(new() - { - Id = toastInstance.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - var result = await toastTask; - - Assert.Equal(ToastCloseReason.Dismissed, result); - } - - [Fact] - public async Task FluentToast_DismissAction_InvokesDismissActionCallback() - { - var dismissActionCallbackInvoked = false; - - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Dismiss callback body"; - options.AllowDismiss = true; - options.DismissLabel = "Dismiss now"; - options.DismissActionCallback = () => - { - dismissActionCallbackInvoked = true; - return Task.CompletedTask; - }; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - var toastInstance = Assert.IsType(toast.Instance.Instance); - - Assert.True(toast.Instance.IsDismissable); - Assert.Equal("Dismiss now", toast.Instance.DismissLabel); - - toast.Find("fluent-link").Click(); - - Assert.True(dismissActionCallbackInvoked); - Assert.Equal(ToastCloseReason.Dismissed, toastInstance.PendingCloseReason); - - await toast.Instance.OnToggleAsync(new() - { - Id = toastInstance.Id, - Type = "beforetoggle", - OldState = "open", - NewState = "closed", - }); - await toast.Instance.OnToggleAsync(new() - { - Id = toastInstance.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - var result = await toastTask; - - Assert.Equal(ToastCloseReason.Dismissed, result); - } - - [Fact] - public async Task FluentToast_OnToggle_CallbackInvoked() - { - bool? callbackValue = null; - - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "OnToggle body"; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - var component = toast.Instance; - var toastId = component.Instance!.Id; - typeof(FluentToast).GetProperty(nameof(FluentToast.OnToggle))! - .SetValue(component, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, value => callbackValue = value)); - - await component.OnToggleAsync(new() - { - Id = toastId, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - var result = await toastTask; - - Assert.False(callbackValue); - Assert.False(component.Opened); - Assert.Equal(ToastCloseReason.TimedOut, result); - } - - [Fact] - public async Task FluentToast_OpenedChanged_CallbackInvoked() - { - bool? callbackValue = null; - - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "OpenedChanged body"; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - var component = toast.Instance; - var toastId = component.Instance!.Id; - typeof(FluentToast).GetProperty(nameof(FluentToast.OpenedChanged))! - .SetValue(component, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, value => callbackValue = value)); - - await component.OnToggleAsync(new() - { - Id = toastId, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - var result = await toastTask; - - Assert.False(callbackValue); - Assert.False(component.Opened); - Assert.Equal(ToastCloseReason.TimedOut, result); - } - - [Fact] - public async Task FluentToast_OpenClose() - { - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Auto-close body"; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); - - // Wait for the toast to be closed - var result = await toastTask; - - // Assert - Assert.Equal(ToastCloseReason.Programmatic, result); - } - - [Fact] - public async Task FluentToast_RequestCloseAsync_WhenNotOpened_DoesNothing() - { - var cut = Render(parameters => parameters.Add(p => p.Message, "Toast body")); - var method = typeof(FluentToast).GetMethod("RequestCloseAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; - - var task = (Task)method.Invoke(cut.Instance, null)!; - await task; - - Assert.False(cut.Instance.Opened); - } - - [Fact] - public async Task FluentToast_Instance() - { - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Id = "my-toast"; - options.Body = "Instance body"; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Find the toast component and close it programmatically - var toast = ToastProvider.FindComponent(); - var instanceId = toast.Instance.Instance?.Id; - var instanceIndex = toast.Instance.Instance?.Index; - await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); - - // Wait for the toast to be closed - var result = await toastTask; - - // Assert - Assert.Equal("my-toast", instanceId); - Assert.True(instanceIndex > 0); - Assert.Equal(ToastCloseReason.Programmatic, result); - } - - [Fact] - public async Task FluentToast_Instance_Cancel() - { - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Cancelable body"; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Find the toast and cancel it - var toast = ToastProvider.FindComponent(); - await DismissToastAndWaitAsync(toast); - - // Wait for the toast to be closed - var result = await toastTask; - - // Assert - Assert.Equal(ToastCloseReason.Dismissed, result); - } - - [Fact] - public async Task FluentToast_Instance_CloseWithResult() - { - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Close with result body"; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Find the toast and close it programmatically - var toast = ToastProvider.FindComponent(); - await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); - - // Wait for the toast to be closed - var result = await toastTask; - - // Assert - Assert.Equal(ToastCloseReason.Programmatic, result); - } - - [Fact] - public async Task FluentToast_Instance_CloseNoValue() - { - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Close no value body"; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Find the toast and close it without a value - var toast = ToastProvider.FindComponent(); - await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); - - // Wait for the toast to be closed - var result = await toastTask; - - // Assert - Assert.Equal(ToastCloseReason.Programmatic, result); - } - - [Theory] - [InlineData(ToastLifecycleStatus.Visible, "toggle", "any-old", "open")] - [InlineData(ToastLifecycleStatus.Dismissed, "beforetoggle", "open", "any-new")] - public async Task FluentToast_Toggle_StatusChange(ToastLifecycleStatus expectedStatus, string eventType, string oldState, string newState) - { - ToastEventArgs? capturedArgs = null; - - // Act - _ = ToastService.ShowToastAsync(options => - { - options.Id = "my-id"; - options.Body = "State change body"; - options.OnStatusChange = (args) => - { - capturedArgs = args; - }; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Find the toast and raise a status change via RaiseOnStatusChangeAsync - var toast = ToastProvider.FindComponent(); - var toggleArgs = new DialogToggleEventArgs() - { - Id = "my-id", - Type = eventType, - OldState = oldState, - NewState = newState, - }; - await toast.Instance.RaiseOnStatusChangeAsync(toggleArgs); - - // Assert - Assert.NotNull(capturedArgs); - Assert.Equal(expectedStatus, capturedArgs.Status); - Assert.Equal("my-id", capturedArgs.Id); - Assert.NotNull(capturedArgs.Instance); - } - - [Fact] - public async Task FluentToast_Toggle_IdMismatch() - { - // Act - _ = ToastService.ShowToastAsync(options => - { - options.Id = "my-id"; - options.Body = "Toast Content"; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Call OnToggleAsync with wrong ID — should return early with no side effect - var toast = ToastProvider.FindComponent(); - await toast.Instance.OnToggleAsync(new() { Id = "wrong-id" }); - - // Call OnToggleAsync with correct ID — should proceed without error - await toast.Instance.OnToggleAsync(new() { Id = "my-id", Type = "toggle", NewState = "open" }); - - // Assert: toast is still rendered (toggle does not close it) - Assert.Contains("Toast Content", ToastProvider.Markup); - } - - [Fact] - public async Task FluentToast_HandleToggleAsync_WhenInstanceIsNotToastInstance_DoesNothing() - { - var onToggleInvoked = false; - var openedChangedInvoked = false; - var fakeInstance = new TestNonToastInstance("non-toast-instance"); - - var cut = Render(parameters => parameters - .Add(p => p.Instance, fakeInstance) - .Add(p => p.Opened, true) - .Add(p => p.OnToggle, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, _ => onToggleInvoked = true)) - .Add(p => p.OpenedChanged, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, _ => openedChangedInvoked = true))); - - await cut.Instance.OnToggleAsync(new() - { - Id = fakeInstance.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - Assert.True(cut.Instance.Opened); - Assert.False(onToggleInvoked); - Assert.False(openedChangedInvoked); - } - - [Fact] - public async Task FluentToast_StateChange_ViaClosed() - { - var statusChanges = new List(); - - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "State change body"; - options.Timeout = 1000; - options.OnStatusChange = (args) => - { - statusChanges.Add(args.Status); - }; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Close the toast via the service — should raise dismissed then unmounted statuses - var toast = ToastProvider.FindComponent(); - await ToastService.CloseAsync(toast.Instance.Instance!, ToastCloseReason.Programmatic); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance!.Id, - Type = "beforetoggle", - OldState = "open", - NewState = "closed", - }); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance!.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - // Wait for the task - var result = await toastTask; - - // Assert - Assert.Contains(ToastLifecycleStatus.Dismissed, statusChanges); - Assert.Contains(ToastLifecycleStatus.Unmounted, statusChanges); - Assert.Equal(ToastCloseReason.Programmatic, result); - } - - [Fact] - public async Task FluentToast_OnStatusChange_NoDelegate() - { - // Act - _ = ToastService.ShowToastAsync(options => - { - options.Body = "No delegate body"; - }); - - await Task.CompletedTask; - - // Raise a status change — should not throw even when callback is the empty delegate - var toast = ToastProvider.FindComponent(); - var args = await toast.Instance.RaiseOnStatusChangeAsync(new DialogToggleEventArgs - { - Id = toast.Instance.Instance?.Id, - Type = "toggle", - NewState = "open", - }); - - // Assert: status correctly mapped, no exception thrown - Assert.Equal(ToastLifecycleStatus.Visible, args.Status); - } - - [Fact] - public async Task FluentToast_UpdateBody() - { - _ = ToastService.ShowToastAsync(options => - { - options.Body = "Initial body"; - }); - - await Task.CompletedTask; - - var toast = ToastProvider.FindComponent(); - await toast.Instance.Instance!.UpdateAsync(options => options.Body = "Updated body"); - - ToastProvider.Render(); - - Assert.Contains("Updated body", ToastProvider.Markup); - } - - [Fact] - public void FluentToast_Options_Ctor() - { - // Arrange - var options = new ToastOptions() { Data = "My data", Type = ToastType.Communication }; - var optionsWithFactory = new ToastOptions(o => - { - o.Id = "my-id"; - o.Type = ToastType.Confirmation; - }); - - // Assert - Assert.Equal("My data", options.Data); - Assert.Equal(ToastType.Communication, options.Type); - Assert.Equal("my-id", optionsWithFactory.Id); - Assert.Equal(ToastType.Confirmation, optionsWithFactory.Type); - } - - [Theory] - [InlineData(ToastIntent.Info, Color.Info)] - [InlineData(ToastIntent.Success, Color.Success)] - [InlineData(ToastIntent.Warning, Color.Warning)] - [InlineData(ToastIntent.Error, Color.Error)] - public void FluentToast_Intent_MapsToExpectedColor(ToastIntent intent, Color expectedColor) - { - var cut = Render(parameters => parameters.Add(p => p.Message, "test")); - var color = cut.Instance.GetIntentColor(intent); - - Assert.Equal(expectedColor, color); - } - - [Theory] - [InlineData(ToastIntent.Info, Color.InfoInverted)] - [InlineData(ToastIntent.Success, Color.SuccessInverted)] - [InlineData(ToastIntent.Warning, Color.WarningInverted)] - [InlineData(ToastIntent.Error, Color.ErrorInverted)] - public void FluentToast_Inverted_Intent_MapsToExpectedColor(ToastIntent intent, Color expectedColor) - { - var cut = Render(parameters => parameters - .Add(p => p.Message, "test") - .Add(p => p.Inverted, true)); - var color = cut.Instance.GetIntentColor(intent); - - Assert.Equal(expectedColor, color); - } - - [Theory] - [InlineData(ToastIntent.Info, "Info")] - [InlineData(ToastIntent.Success, "CheckmarkCircle")] - [InlineData(ToastIntent.Warning, "Warning")] - [InlineData(ToastIntent.Error, "DismissCircle")] - public void FluentToast_IntentIcon_MapsToExpectedIcon(ToastIntent intent, string expectedIconTypeName) - { - var cut = Render(parameters => parameters.Add(p => p.Intent, intent)); - var intentIcon = cut.Instance.IntentIcon; - - Assert.NotNull(intentIcon); - Assert.Equal(expectedIconTypeName, intentIcon.GetType().Name); - } - - [Fact] - public async Task FluentToast_MarginPadding() - { - // Arrange - var toastOptions = new ToastOptions() - { - Class = "my-class", - Style = "color: red;", - Margin = "10px", - Padding = "20px", - Body = "Spacing body", - }; - - // Act - _ = ToastService.ShowToastAsync(toastOptions); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Assert: StyleValue includes margin and padding - Assert.Contains("margin", toastOptions.StyleValue); - Assert.Contains("10px", toastOptions.StyleValue); - Assert.Contains("padding", toastOptions.StyleValue); - Assert.Contains("20px", toastOptions.StyleValue); - Assert.Contains("color: red;", toastOptions.StyleValue); - } - - [Fact] - public async Task FluentToast_AdditionalAttributes() - { - // Act - _ = ToastService.ShowToastAsync(options => - { - options.Body = "Toast Content"; - options.AdditionalAttributes = new Dictionary { { "data-test", "my-toast" } }; - }); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - - // Assert: toast content rendered inside the provider - Assert.Contains("Toast Content", ToastProvider.Markup); - Assert.Contains("data-test=\"my-toast\"", ToastProvider.Markup); - } - - [Fact] - public async Task FluentToast_QuickAction1Callback() - { - var invoked = false; - - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Toast Content"; - options.QuickAction1 = "Undo"; - options.QuickAction1Callback = () => - { - invoked = true; - return Task.CompletedTask; - }; - }); - - ToastProvider.Find("fluent-link").Click(); - var toast = ToastProvider.FindComponent(); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance!.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - var result = await toastTask; - - Assert.True(invoked); - Assert.Equal(ToastCloseReason.QuickAction, result); - } - - [Fact] - public async Task FluentToast_QuickAction2Callback() - { - var invoked = false; - - var toastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Toast Content"; - options.QuickAction2 = "Undo"; - options.QuickAction2Callback = () => - { - invoked = true; - return Task.CompletedTask; - }; - }); - - ToastProvider.Find("fluent-link").Click(); - var toast = ToastProvider.FindComponent(); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance!.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - - var result = await toastTask; - - Assert.True(invoked); - Assert.Equal(ToastCloseReason.QuickAction, result); - } - - [Fact] - public async Task FluentToast_DismissById() - { - var toastTask = ToastService.ShowToastAsync(options => - { - options.Id = "dismiss-me"; - options.Body = "Dismiss by id"; - }); - - await Task.CompletedTask; - - var dismissed = await ToastService.DismissAsync("dismiss-me"); - var toast = ToastProvider.FindComponent(); - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance!.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - var result = await toastTask; - - Assert.True(dismissed); - Assert.Equal(ToastCloseReason.Dismissed, result); - } - - [Fact] - public async Task FluentToast_DismissAll() - { - var firstToastTask = ToastService.ShowToastAsync(options => - { - options.Id = "dismiss-all-1"; - options.Body = "First toast"; - }); - - var secondToastTask = ToastService.ShowToastAsync(options => - { - options.Id = "dismiss-all-2"; - options.Body = "Second toast"; - }); - - await Task.CompletedTask; - - var dismissedCount = await ToastService.DismissAllAsync(); - var toasts = ToastProvider.FindComponents().ToList(); - foreach (var toast in toasts) - { - await toast.Instance.OnToggleAsync(new() - { - Id = toast.Instance.Instance!.Id, - Type = "toggle", - OldState = "open", - NewState = "closed", - }); - } - - Assert.Equal(2, dismissedCount); - Assert.Equal(ToastCloseReason.Dismissed, await firstToastTask); - Assert.Equal(ToastCloseReason.Dismissed, await secondToastTask); - } - - [Fact] - public async Task FluentToast_RemoveToast_NullInstance() - { - // Arrange: cast service to concrete type to access the internal method - var service = (ToastService)ToastService; - - // Act: removing a null instance should return without error - await service.RemoveToastFromProviderAsync(null); - - // Assert: no exception thrown - Assert.True(true); - } - - private sealed class TestNonToastInstance(string id) : IToastInstance - { - public string Id { get; } = id; - - public long Index => 0; - - public ToastOptions Options { get; } = new(); - - public Task Result => Task.FromResult(ToastCloseReason.Programmatic); - - public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; - - public Task DismissAsync() => Task.CompletedTask; - - public Task CloseAsync() => Task.CompletedTask; - - public Task CloseAsync(ToastCloseReason reason) => Task.CompletedTask; - - public Task UpdateAsync(Action update) => Task.CompletedTask; - } -} - diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor deleted file mode 100644 index 33e5dc1898..0000000000 --- a/tests/Core/Components/Toast/ToastInstanceTests.razor +++ /dev/null @@ -1,325 +0,0 @@ -@using System.Collections.Concurrent; -@using Bunit; -@using Microsoft.Extensions.DependencyInjection; -@using Xunit; - -@inherits FluentUITestContext - -@code { - - public ToastInstanceTests() - { - JSInterop.Mode = JSRuntimeMode.Loose; - Services.AddFluentUIComponents(); - } - - [Fact] - public async Task DismissAsync_CallsServiceCloseWithDismissedReason() - { - var service = new TestToastService(); - var instance = new ToastInstance(service, new ToastOptions()); - - await instance.DismissAsync(); - - Assert.Same(instance, service.LastClosedToast); - Assert.Equal(ToastCloseReason.Dismissed, service.LastCloseReason); - } - - [Fact] - public async Task CloseAsync_WithReason_CallsServiceCloseWithProvidedReason() - { - var service = new TestToastService(); - var instance = new ToastInstance(service, new ToastOptions()); - - await instance.CloseAsync(ToastCloseReason.QuickAction); - - Assert.Same(instance, service.LastClosedToast); - Assert.Equal(ToastCloseReason.QuickAction, service.LastCloseReason); - } - - [Fact] - public async Task CloseAsync_WithProgrammaticReason_CallsServiceCloseWithProgrammaticReason() - { - var service = new TestToastService(); - var instance = new ToastInstance(service, new ToastOptions()); - - await instance.CloseAsync(ToastCloseReason.Programmatic); - - Assert.Same(instance, service.LastClosedToast); - Assert.Equal(ToastCloseReason.Programmatic, service.LastCloseReason); - } - - [Fact] - public async Task CloseAsync_WithoutReason_CallsServiceCloseWithProgrammaticReason() - { - var service = new TestToastService(); - var instance = new ToastInstance(service, new ToastOptions()); - - await instance.CloseAsync(); - - Assert.Same(instance, service.LastClosedToast); - Assert.Equal(ToastCloseReason.Programmatic, service.LastCloseReason); - } - - [Fact] - public void ToastService_UsesProvidedLocalizer() - { - var localizer = new TestLocalizer(); - var service = new TestableToastService(Services, localizer); - - var value = service.GetLocalized("Toast_TestKey"); - - Assert.Equal("Custom localized value", value); - } - - [Fact] - public void ToastService_UsesDefaultLocalizer_WhenNoLocalizerProvided() - { - var service = new TestableToastService(Services, null); - - var value = service.GetLocalized("Fake_Hello", "Denis"); - - Assert.Equal("Hello Denis", value); - } - - [Fact] - public async Task ToastService_CloseAsync_WhenToastInstanceHasNoFluentToast_RemovesToastAndRaisesUnmounted() - { - var updates = new List(); - ToastEventArgs? statusChange = null; - var options = new ToastOptions - { - OnStatusChange = args => statusChange = args, - }; - var service = new TestableToastService(Services, null); - var instance = new ToastInstance(service, options); - - service.AddItem(instance); - service.SetOnUpdatedAsync(toast => - { - updates.Add(toast); - return Task.CompletedTask; - }); - - await service.CloseAsync(instance, ToastCloseReason.Programmatic); - - var result = await instance.Result; - - Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); - Assert.Equal(ToastCloseReason.Programmatic, result); - Assert.False(service.ContainsItem(instance.Id)); - Assert.Single(updates); - Assert.Same(instance, updates[0]); - Assert.NotNull(statusChange); - Assert.Equal(ToastLifecycleStatus.Unmounted, statusChange.Status); - Assert.Same(instance, statusChange.Instance); - } - - [Fact] - public async Task ToastService_CloseAsync_WhenToastIsNotToastInstance_RemovesToastFromProvider() - { - var service = new TestableToastService(Services, null); - var instance = new TestNonToastInstance("non-toast"); - IToastInstance? updatedToast = null; - - service.AddItem(instance); - service.SetOnUpdatedAsync(toast => - { - updatedToast = toast; - return Task.CompletedTask; - }); - - await service.CloseAsync(instance, ToastCloseReason.Dismissed); - - Assert.False(service.ContainsItem(instance.Id)); - Assert.Same(instance, updatedToast); - } - - [Fact] - public async Task ToastService_DismissAsync_WhenToastIdIsNull_ReturnsFalse() - { - var service = new TestableToastService(Services, null); - - var result = await service.DismissAsync((string)null!); - - Assert.False(result); - } - - [Fact] - public async Task ToastService_DismissAsync_WithToastInstance_ClosesWithDismissedReason() - { - var service = new TestableToastService(Services, null); - var instance = new ToastInstance(service, new ToastOptions()); - - service.AddItem(instance); - - await service.DismissAsync(instance); - - var result = await instance.Result; - - Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); - Assert.Equal(ToastCloseReason.Dismissed, result); - Assert.False(service.ContainsItem(instance.Id)); - } - - [Fact] - public async Task ToastService_ShowToastInstanceAsync_ReturnsLiveInstance() - { - var service = new TestableToastService(Services, null); - service.SetProviderId("provider"); - - var instance = await service.ShowToastInstanceAsync(new ToastOptions { Body = "Live body" }); - - Assert.NotNull(instance); - Assert.True(service.ContainsItem(instance.Id)); - Assert.Equal("Live body", instance.Options.Body); - Assert.Equal(ToastLifecycleStatus.Queued, instance.LifecycleStatus); - } - - [Fact] - public async Task ToastService_ShowToastInstanceAsync_ActionOverload_AppliesOptions() - { - var service = new TestableToastService(Services, null); - service.SetProviderId("provider"); - - var instance = await service.ShowToastInstanceAsync(options => - { - options.Body = "Configured body"; - options.Type = ToastType.Confirmation; - }); - - Assert.Equal("Configured body", instance.Options.Body); - Assert.Equal(ToastType.Confirmation, instance.Options.Type); - } - - [Fact] - public async Task ToastService_UpdateToastAsync_WhenToastIsNotToastInstance_ThrowsArgumentException() - { - var service = new TestableToastService(Services, null); - var toast = new TestNonToastInstance("non-toast"); - - var exception = await Assert.ThrowsAsync(() => service.UpdateToastAsync(toast, options => options.Body = "Updated")); - - Assert.Equal("toast", exception.ParamName); - Assert.Contains("must be a ToastInstance", exception.Message); - } - - [Fact] - public async Task ToastService_ShowToastAsync_WhenProviderNotAvailable_ThrowsFluentServiceProviderException() - { - var service = new TestableToastService(Services, null); - - await Assert.ThrowsAsync>(() => service.ShowToastAsync()); - } - - [Fact] - public async Task ToastService_RemoveToastFromProviderAsync_WhenToastIdDoesNotExist_ThrowsInvalidOperationException() - { - var service = new TestableToastService(Services, null); - var toast = new TestNonToastInstance("missing-toast"); - - var exception = await Assert.ThrowsAsync(() => service.RemoveToastFromProviderAsync(toast)); - - Assert.Equal("Failed to remove Toast from ToastProvider: the ID 'missing-toast' doesn't exist in the ToastServiceProvider.", exception.Message); - } - - private sealed class TestToastService : IToastService - { - public string? ProviderId { get; set; } - - public ConcurrentDictionary Items { get; } = new(); - - public Func OnUpdatedAsync { get; set; } = _ => Task.CompletedTask; - - public IToastInstance? LastClosedToast { get; private set; } - - public ToastCloseReason? LastCloseReason { get; private set; } - - public Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) - { - LastClosedToast = Toast; - LastCloseReason = reason; - return Task.CompletedTask; - } - - public Task DismissAsync(IToastInstance Toast) - => CloseAsync(Toast, ToastCloseReason.Dismissed); - - public Task DismissAsync(string toastId) - => Task.FromResult(false); - - public Task DismissAllAsync() - => Task.FromResult(0); - - public void Dispose() - { - } - - public Task ShowToastAsync(ToastOptions? options = null) - => Task.FromResult(ToastCloseReason.Programmatic); - - public Task ShowToastAsync(Action options) - => Task.FromResult(ToastCloseReason.Programmatic); - - public Task ShowToastInstanceAsync(ToastOptions? options = null) - => Task.FromResult(new TestNonToastInstance("toast-instance")); - - public Task ShowToastInstanceAsync(Action options) - => Task.FromResult(new TestNonToastInstance("toast-instance")); - - public Task UpdateToastAsync(IToastInstance toast, Action update) - => Task.CompletedTask; - } - - private sealed class TestableToastService(IServiceProvider serviceProvider, IFluentLocalizer? localizer) - : ToastService(serviceProvider, localizer) - { - public string GetLocalized(string key, params object[] arguments) - => Localizer[key, arguments]; - - public void AddItem(IToastInstance toast) - => ServiceProvider.Items.TryAdd(toast.Id, toast); - - public bool ContainsItem(string id) - => ServiceProvider.Items.ContainsKey(id); - - public void SetOnUpdatedAsync(Func onUpdatedAsync) - => ServiceProvider.OnUpdatedAsync = onUpdatedAsync; - - public new Task RemoveToastFromProviderAsync(IToastInstance? toast) - => base.RemoveToastFromProviderAsync(toast); - - public void SetProviderId(string providerId) - => typeof(IFluentServiceBase) - .GetProperty(nameof(IFluentServiceBase.ProviderId))! - .SetValue(ServiceProvider, providerId); - } - - private sealed class TestLocalizer : IFluentLocalizer - { - public string this[string key, params object[] arguments] => "Custom localized value"; - } - - private sealed class TestNonToastInstance(string id) : IToastInstance - { - public string Id { get; } = id; - - public long Index => 0; - - public ToastOptions Options { get; } = new(); - - public Task Result => Task.FromResult(ToastCloseReason.Programmatic); - - public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; - - public Task CancelAsync() => Task.CompletedTask; - - public Task DismissAsync() => Task.CompletedTask; - - public Task CloseAsync() => Task.CompletedTask; - - public Task CloseAsync(ToastCloseReason reason) => Task.CompletedTask; - - public Task UpdateAsync(Action update) => Task.CompletedTask; - } -} From 442d77138971bd45e97a4d6edd9828e336bb0281 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 18:56:18 +0200 Subject: [PATCH 30/43] Update toast example to clarify result timing display and improve initial values. Refactor message bar test to correct interface implementation. --- .../Toast/Examples/FluentToastResultTiming.razor | 8 ++++---- .../MessageBar/FluentMessageBarProviderTests.razor | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor index fe1a957201..a88095f4e0 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor @@ -11,14 +11,14 @@
    -
  • @ResultTimingOnClose
  • -
  • @ResultTimingOnVisible
  • +
  • Toast "On Closed": @ResultTimingOnClose
  • +
  • Toast "On Visible": @ResultTimingOnVisible
@code { - string ResultTimingOnClose = ""; - string ResultTimingOnVisible = ""; + string ResultTimingOnClose = "..."; + string ResultTimingOnVisible = "..."; async Task OpenResultTimingOnCloseAsync() { diff --git a/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor b/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor index ffd61d4e1f..5e3b0e3403 100644 --- a/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor +++ b/tests/Core/Components/MessageBar/FluentMessageBarProviderTests.razor @@ -653,7 +653,7 @@ ///
private sealed class FakeMessageBarInstance : IMessageBarInstance { - Type? IMessageBarInstance.ComponentType => null; + Type? INotificationInstance.ComponentType => null; public string Id => "fake"; public long Index => 0; public MessageBarOptions Options { get; } = new() { Section = "fake" }; From 91cafac970abd3c4d8b518b83db2013e2a9baf37 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 18:57:25 +0200 Subject: [PATCH 31/43] Update toast result messages for clarity and consistency in completion feedback. --- .../Components/Toast/Examples/FluentToastResultTiming.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor index a88095f4e0..9a2d12662c 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastResultTiming.razor @@ -29,7 +29,7 @@ options.ResultTiming = ToastResultTiming.Closed; // ResultTiming = On Close, which is the default behavior }); - ResultTimingOnClose = $"Toast closed with reason: {resultOnClose.Reason}"; + ResultTimingOnClose = $"Toast result completed with reason: {resultOnClose.Reason}"; } async Task OpenResultTimingOnVisibleAsync() @@ -41,7 +41,7 @@ options.ResultTiming = ToastResultTiming.Visible; // ResultTiming = On Visible, which means the result will complete as soon as the toast is rendered and visible to the user }); - ResultTimingOnVisible = $"Toast visible with reason: {resultOnVisible.Reason}"; + ResultTimingOnVisible = $"Toast result completed with reason: {resultOnVisible.Reason}"; } } From 8fb45165fc9f916c5c88b44473bb39dfd902a271 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 19:11:15 +0200 Subject: [PATCH 32/43] Refactor MessageBar tests to use dependency injection for NotificationService and improve setup consistency --- .../MessageBar/MessageBarEventArgsTests.cs | 15 +++++++-- .../MessageBar/MessageBarInstanceTests.cs | 33 ++++++++++++------- .../MessageBar/MessageBarServiceTests.cs | 29 ++++++++++------ 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs b/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs index 52e2dfc0dd..51e16d4efe 100644 --- a/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarEventArgsTests.cs @@ -2,17 +2,26 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; using Xunit; namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.MessageBar; -public class MessageBarEventArgsTests +public class MessageBarEventArgsTests : Bunit.BunitContext { + public MessageBarEventArgsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + [Fact] public void MessageBarEventArgs_SetsAllProperties() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var options = new MessageBarOptions { Section = "section-A", Id = "my-id" }; var instance = new MessageBarInstance(service, options); @@ -32,7 +41,7 @@ public void MessageBarEventArgs_SetsAllProperties() public void MessageBarEventArgs_KeepsStatus(MessageBarLifecycleStatus status) { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act diff --git a/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs b/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs index b4edad8971..7f12db1d48 100644 --- a/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs @@ -2,17 +2,26 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; using Xunit; namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.MessageBar; -public class MessageBarInstanceTests +public class MessageBarInstanceTests : Bunit.BunitContext { + public MessageBarInstanceTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + [Fact] public void MessageBarInstance_AutoGeneratedId() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var options = new MessageBarOptions { Section = "section" }; // Act @@ -30,7 +39,7 @@ public void MessageBarInstance_AutoGeneratedId() public void MessageBarInstance_UsesProvidedId() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var options = new MessageBarOptions { Section = "section", Id = "fixed-id" }; // Act @@ -44,7 +53,7 @@ public void MessageBarInstance_UsesProvidedId() public void MessageBarInstance_IncrementsIndex() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); // Act var first = new MessageBarInstance(service, new MessageBarOptions { Section = "s" }); @@ -58,7 +67,7 @@ public void MessageBarInstance_IncrementsIndex() public void MessageBarInstance_ComponentType_DefaultsToNull() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); // Act IMessageBarInstance instance = new MessageBarInstance(service, new MessageBarOptions { Section = "s" }); @@ -71,7 +80,7 @@ public void MessageBarInstance_ComponentType_DefaultsToNull() public void MessageBarInstance_ComponentType_FromCtor() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); // Act IMessageBarInstance instance = new MessageBarInstance(service, typeof(FluentMessageBar), new MessageBarOptions { Section = "s" }); @@ -84,7 +93,7 @@ public void MessageBarInstance_ComponentType_FromCtor() public async Task MessageBarInstance_CloseAsync_NoResult_CallsService() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var options = new MessageBarOptions { Section = "section", Id = "to-close" }; var instance = new MessageBarInstance(service, options); ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); @@ -104,7 +113,7 @@ public async Task MessageBarInstance_CloseAsync_NoResult_CallsService() public async Task MessageBarInstance_CloseAsync_WithResult_PassesReason() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var options = new MessageBarOptions { Section = "section" }; var instance = new MessageBarInstance(service, options); ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); @@ -123,7 +132,7 @@ public async Task MessageBarInstance_CloseAsync_WithResult_PassesReason() public void MessageBarInstance_Dispose_DisposesLifetimeCts() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act @@ -137,7 +146,7 @@ public void MessageBarInstance_Dispose_DisposesLifetimeCts() public void MessageBarInstance_Dispose_Twice_DoesNotThrow() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act @@ -152,7 +161,7 @@ public void MessageBarInstance_Dispose_Twice_DoesNotThrow() public void MessageBarInstance_CancelLifetime_AfterDispose_DoesNotThrow() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); instance.Dispose(); @@ -165,7 +174,7 @@ public void MessageBarInstance_CancelLifetime_AfterDispose_DoesNotThrow() public void MessageBarInstance_CancelLifetime_Twice_DoesNotThrow() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var instance = new MessageBarInstance(service, new MessageBarOptions { Section = "section" }); // Act diff --git a/tests/Core/Components/MessageBar/MessageBarServiceTests.cs b/tests/Core/Components/MessageBar/MessageBarServiceTests.cs index 5f22ccb2d9..a54e97b2c7 100644 --- a/tests/Core/Components/MessageBar/MessageBarServiceTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarServiceTests.cs @@ -2,17 +2,26 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; using Xunit; namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.MessageBar; -public class MessageBarServiceTests +public class MessageBarServiceTests : Bunit.BunitContext { + public MessageBarServiceTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + [Fact] public void MessageBarService_Subscribe_NullProviderId_NoOp() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var serviceBase = (IFluentServiceBase)service; // Act @@ -27,7 +36,7 @@ public void MessageBarService_Subscribe_NullProviderId_NoOp() public void MessageBarService_Subscribe_EmptyProviderId_NoOp() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var serviceBase = (IFluentServiceBase)service; // Act @@ -41,7 +50,7 @@ public void MessageBarService_Subscribe_EmptyProviderId_NoOp() public void MessageBarService_Subscribe_NullCallback_NoOp() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var serviceBase = (IFluentServiceBase)service; // Act @@ -55,7 +64,7 @@ public void MessageBarService_Subscribe_NullCallback_NoOp() public void MessageBarService_Unsubscribe_NullProviderId_NoOp() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); service.Subscribe("provider-1", _ => Task.CompletedTask); // Act @@ -70,7 +79,7 @@ public void MessageBarService_Unsubscribe_NullProviderId_NoOp() public void MessageBarService_Unsubscribe_EmptyProviderId_NoOp() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); service.Subscribe("provider-1", _ => Task.CompletedTask); // Act @@ -85,7 +94,7 @@ public void MessageBarService_Unsubscribe_EmptyProviderId_NoOp() public void MessageBarService_Unsubscribe_OnlyProvider_ResetsProviderId() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); @@ -101,7 +110,7 @@ public void MessageBarService_Unsubscribe_OnlyProvider_ResetsProviderId() public void MessageBarService_Unsubscribe_FirstOfMany_SwitchesProviderId() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); service.Subscribe("provider-2", _ => Task.CompletedTask); @@ -119,7 +128,7 @@ public void MessageBarService_Unsubscribe_FirstOfMany_SwitchesProviderId() public void MessageBarService_Unsubscribe_NotCurrentProviderId_KeepsCurrent() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); service.Subscribe("provider-2", _ => Task.CompletedTask); @@ -137,7 +146,7 @@ public void MessageBarService_Unsubscribe_NotCurrentProviderId_KeepsCurrent() public async Task MessageBarService_Dispatch_SwallowsSubscriberExceptions() { // Arrange - var service = new NotificationService(); + var service = (NotificationService)Services.GetRequiredService(); var goodCalled = false; service.Subscribe("bad", _ => throw new InvalidOperationException("boom")); From 08f2eddc004838f3f7dd10b02e4e1779ec80f8ba Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Sun, 21 Jun 2026 19:14:56 +0200 Subject: [PATCH 33/43] Refactor MessageBar tests to use INotificationInstance for improved type consistency --- .../MessageBar/MessageBarInstanceTests.cs | 8 ++++---- .../MessageBar/MessageBarServiceTests.cs | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs b/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs index 7f12db1d48..33223487dc 100644 --- a/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarInstanceTests.cs @@ -96,8 +96,8 @@ public async Task MessageBarInstance_CloseAsync_NoResult_CallsService() var service = (NotificationService)Services.GetRequiredService(); var options = new MessageBarOptions { Section = "section", Id = "to-close" }; var instance = new MessageBarInstance(service, options); - ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); - ((IFluentServiceBase)service).ProviderId = "provider"; + ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); + ((IFluentServiceBase)service).ProviderId = "provider"; // Act await instance.CloseAsync(); @@ -116,8 +116,8 @@ public async Task MessageBarInstance_CloseAsync_WithResult_PassesReason() var service = (NotificationService)Services.GetRequiredService(); var options = new MessageBarOptions { Section = "section" }; var instance = new MessageBarInstance(service, options); - ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); - ((IFluentServiceBase)service).ProviderId = "provider"; + ((IFluentServiceBase)service).Items.TryAdd(instance.Id, instance); + ((IFluentServiceBase)service).ProviderId = "provider"; // Act await instance.CloseAsync(MessageBarResult.OfDismissed("payload")); diff --git a/tests/Core/Components/MessageBar/MessageBarServiceTests.cs b/tests/Core/Components/MessageBar/MessageBarServiceTests.cs index a54e97b2c7..c210ea9963 100644 --- a/tests/Core/Components/MessageBar/MessageBarServiceTests.cs +++ b/tests/Core/Components/MessageBar/MessageBarServiceTests.cs @@ -22,7 +22,7 @@ public void MessageBarService_Subscribe_NullProviderId_NoOp() { // Arrange var service = (NotificationService)Services.GetRequiredService(); - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; // Act service.Subscribe(null, _ => Task.CompletedTask); @@ -37,7 +37,7 @@ public void MessageBarService_Subscribe_EmptyProviderId_NoOp() { // Arrange var service = (NotificationService)Services.GetRequiredService(); - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; // Act service.Subscribe(string.Empty, _ => Task.CompletedTask); @@ -51,7 +51,7 @@ public void MessageBarService_Subscribe_NullCallback_NoOp() { // Arrange var service = (NotificationService)Services.GetRequiredService(); - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; // Act service.Subscribe("provider-1", null!); @@ -71,7 +71,7 @@ public void MessageBarService_Unsubscribe_NullProviderId_NoOp() service.Unsubscribe(null); // Assert: Existing subscription remains in place. - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; Assert.Equal("provider-1", serviceBase.ProviderId); } @@ -86,7 +86,7 @@ public void MessageBarService_Unsubscribe_EmptyProviderId_NoOp() service.Unsubscribe(string.Empty); // Assert: Existing subscription remains in place. - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; Assert.Equal("provider-1", serviceBase.ProviderId); } @@ -95,7 +95,7 @@ public void MessageBarService_Unsubscribe_OnlyProvider_ResetsProviderId() { // Arrange var service = (NotificationService)Services.GetRequiredService(); - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); // Act @@ -111,7 +111,7 @@ public void MessageBarService_Unsubscribe_FirstOfMany_SwitchesProviderId() { // Arrange var service = (NotificationService)Services.GetRequiredService(); - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); service.Subscribe("provider-2", _ => Task.CompletedTask); @@ -129,7 +129,7 @@ public void MessageBarService_Unsubscribe_NotCurrentProviderId_KeepsCurrent() { // Arrange var service = (NotificationService)Services.GetRequiredService(); - var serviceBase = (IFluentServiceBase)service; + var serviceBase = (IFluentServiceBase)service; service.Subscribe("provider-1", _ => Task.CompletedTask); service.Subscribe("provider-2", _ => Task.CompletedTask); From 4a8d7e1204667dc6bc633075ae63d20b68edba1d Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Mon, 22 Jun 2026 10:30:30 +0200 Subject: [PATCH 34/43] Add NonFocusRestoringTagNames to handle transient elements in dialog focus management --- .../src/Components/Dialog/FluentDialog.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts index 56c232ce97..df4330a058 100644 --- a/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts +++ b/src/Core.Scripts/src/Components/Dialog/FluentDialog.ts @@ -1,5 +1,11 @@ export namespace Microsoft.FluentUI.Blazor.Components.Dialog { + /** + * Tag names of non-modal, transient elements (e.g. toasts) that reuse the + * dialog toggle plumbing but must never restore focus when they open or close. + */ + const NonFocusRestoringTagNames: string[] = ['FLUENT-TOAST-B']; + /** * Display the fluent-dialog with the given id * @param id The id of the fluent-dialog to display @@ -26,6 +32,12 @@ export namespace Microsoft.FluentUI.Blazor.Components.Dialog { export function DialogToggle_PreviousActiveElement(id: string, newState: string): void { const dialog = document.getElementById(id) as any; if (dialog) { + // Exclude non-modal, transient elements that reuse the dialog toggle plumbing + // but must never restore focus when they open or close. + if (NonFocusRestoringTagNames.includes(dialog.tagName)) { + return; + } + if (newState === 'open') { dialog.previousActiveElement = document.activeElement; } From f84f753ba7f556703da7a8e6f1a97b5d51ec34c1 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Mon, 22 Jun 2026 10:44:54 +0200 Subject: [PATCH 35/43] Refactor toast service documentation for clarity and accuracy --- src/Core/Components/Toast/Services/INotificationService.cs | 1 - src/Core/Components/Toast/Services/LibraryToastOptions.cs | 2 +- src/Core/Events/ToastEventArgs.cs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Core/Components/Toast/Services/INotificationService.cs b/src/Core/Components/Toast/Services/INotificationService.cs index ec27dd3fcf..04255f563b 100644 --- a/src/Core/Components/Toast/Services/INotificationService.cs +++ b/src/Core/Components/Toast/Services/INotificationService.cs @@ -58,7 +58,6 @@ public partial interface INotificationService : IFluentServiceBase /// Shows a progress toast with the specified title and message and waits for the close result. - /// The toast persists until explicitly closed (no automatic dismissal by default). ///
/// The title of the toast. /// The message content of the toast. diff --git a/src/Core/Components/Toast/Services/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs index a8c3f1cace..282f2ee28c 100644 --- a/src/Core/Components/Toast/Services/LibraryToastOptions.cs +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -18,7 +18,7 @@ internal LibraryToastOptions() /// /// Gets or sets the maximum number of toasts displayed at the same time. /// Default is 4 toasts, which is the recommended maximum number of toasts to be displayed according to Fluent UI design guidelines. - /// When the maximum count is reached, the oldest toast is dismissed when a new toast is added. + /// When the maximum count is reached, additional toasts are queued and displayed as room becomes available. /// Setting this value to 0 allows an unlimited number of toasts to be displayed, which can lead to a poor user experience and is not recommended. /// public int MaxToastCount { get; set; } = 4; diff --git a/src/Core/Events/ToastEventArgs.cs b/src/Core/Events/ToastEventArgs.cs index e61e78037c..aaeb1f2651 100644 --- a/src/Core/Events/ToastEventArgs.cs +++ b/src/Core/Events/ToastEventArgs.cs @@ -29,7 +29,6 @@ internal ToastEventArgs(IToastInstance instance, ToastLifecycleStatus status) /// /// Gets the instance used by the . - /// This value may be null if the toast is not managed by the . /// public IToastInstance Instance { get; } } From 4e63d9bf8adbe8c9ce077828c629e42b1d15854c Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Mon, 22 Jun 2026 21:10:25 +0200 Subject: [PATCH 36/43] First unit tests --- .../Examples/FluentToastDefaultOptions.razor | 1 + .../Toast/FluentToastProviderTests.razor | 102 +++++ .../Core/Components/Toast/FluentToastTests.cs | 164 ++++++++ .../Toast/NotificationServiceToastTests.cs | 373 ++++++++++++++++++ .../Toast/Templates/CustomToastTemplate.razor | 13 + .../Components/Toast/ToastEventArgsTests.cs | 53 +++ .../Components/Toast/ToastInstanceTests.cs | 190 +++++++++ .../Components/Toast/ToastOptionsTests.cs | 185 +++++++++ .../Core/Components/Toast/ToastResultTests.cs | 132 +++++++ 9 files changed, 1213 insertions(+) create mode 100644 tests/Core/Components/Toast/FluentToastProviderTests.razor create mode 100644 tests/Core/Components/Toast/FluentToastTests.cs create mode 100644 tests/Core/Components/Toast/NotificationServiceToastTests.cs create mode 100644 tests/Core/Components/Toast/Templates/CustomToastTemplate.razor create mode 100644 tests/Core/Components/Toast/ToastEventArgsTests.cs create mode 100644 tests/Core/Components/Toast/ToastInstanceTests.cs create mode 100644 tests/Core/Components/Toast/ToastOptionsTests.cs create mode 100644 tests/Core/Components/Toast/ToastResultTests.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor index 5a00f5b95b..49b56c4f0d 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefaultOptions.razor @@ -30,6 +30,7 @@ options.Title = $"{intent} toast #{counter++}"; options.Message = "Toasts are used to show brief messages to the user."; options.Subtitle = "Sent by Fluent UI Blazor"; + options.Lifetime = TimeSpan.FromSeconds(10); options.AllowDismiss = true; options.OnStatusChange = (e) => { diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor new file mode 100644 index 0000000000..6c54bab0dd --- /dev/null +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -0,0 +1,102 @@ +@using Xunit +@using Microsoft.Extensions.DependencyInjection +@inherits FluentUITestContext + +@code +{ + // A timeout used when a toast is opened but not explicitly closed. + private const int TEST_TIMEOUT = 3000; + + public FluentToastProviderTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + + NotificationService = Services.GetRequiredService(); + } + + /// + /// Gets the notification service. + /// + public INotificationService NotificationService { get; } + + [Fact] + public void FluentToastProvider_RendersEmpty() + { + // Arrange & Act + var cut = Render(@); + + // Assert + Assert.DoesNotContain("); + + // Assert + Assert.Contains("z-index", cut.Markup); + } + + [Fact] + public void FluentToastProvider_GeneratesId() + { + // Arrange & Act + var cut = Render(@); + + // Assert + Assert.False(string.IsNullOrEmpty(cut.FindComponent().Instance.Id)); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_ShowInfo_RendersToast() + { + // Arrange + var cut = Render(@); + + // Act + _ = NotificationService.ShowInfoToastAsync("Info title", "Info message", lifetime: null); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("); + + // Act + _ = NotificationService.ShowToastAsync(new ToastOptions + { + Title = "Direct options", + Intent = ToastIntent.Success, + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => Assert.Contains("Direct options", cut.Markup)); + } + + [Fact] + public void FluentToastProvider_Dispose_DoesNotThrow() + { + // Arrange + var cut = Render(@); + var provider = cut.FindComponent().Instance; + + // Act & Assert + var exception = Record.Exception(() => provider.Dispose()); + Assert.Null(exception); + } +} diff --git a/tests/Core/Components/Toast/FluentToastTests.cs b/tests/Core/Components/Toast/FluentToastTests.cs new file mode 100644 index 0000000000..7a06c1885f --- /dev/null +++ b/tests/Core/Components/Toast/FluentToastTests.cs @@ -0,0 +1,164 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +// BL0005: The GetIntentIcon tests intentionally set [Parameter] properties on a +// non-rendered subclass instance to exercise the icon resolution logic directly. +#pragma warning disable BL0005 + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast; + +public class FluentToastTests : Bunit.BunitContext +{ + public FluentToastTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + /// + /// Subclass exposing the protected GetIntentIcon method for testing. + /// + private sealed class TestableToast : FluentToast + { + public TestableToast(LibraryConfiguration configuration) : base(configuration) + { + } + + public Icon? GetIntentIconForTest() => GetIntentIcon(); + } + + private TestableToast CreateToast() + { + var configuration = Services.GetRequiredService(); + return new TestableToast(configuration); + } + + [Fact] + public void FluentToast_RendersToastElement() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Title, "My Toast") + .Add(p => p.Opened, true)); + + // Assert + Assert.Contains("fluent-toast-b", cut.Markup); + Assert.Contains("My Toast", cut.Markup); + } + + [Fact] + public void FluentToast_AppliesWidthStyle() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Width, "350px") + .Add(p => p.Opened, true)); + + // Assert + Assert.Contains("--toast-width", cut.Markup); + Assert.Contains("350px", cut.Markup); + } + + [Fact] + public void FluentToast_DefaultValues() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true)); + + // Assert + Assert.True(cut.Instance.AllowDismiss); + Assert.Equal(16, cut.Instance.VerticalOffset); + Assert.Equal(20, cut.Instance.HorizontalOffset); + } + + [Theory] + [InlineData(ToastIntent.Success)] + [InlineData(ToastIntent.Warning)] + [InlineData(ToastIntent.Error)] + [InlineData(ToastIntent.Info)] + public void FluentToast_GetIntentIcon_ReturnsIcon_ForIntent(ToastIntent intent) + { + // Arrange + var toast = CreateToast(); + toast.Intent = intent; + + // Act + var icon = toast.GetIntentIconForTest(); + + // Assert + Assert.NotNull(icon); + } + + [Fact] + public void FluentToast_GetIntentIcon_ReturnsNull_WhenNoIntent() + { + // Arrange + var toast = CreateToast(); + + // Act + var icon = toast.GetIntentIconForTest(); + + // Assert + Assert.Null(icon); + } + + [Fact] + public void FluentToast_GetIntentIcon_ReturnsNull_ForProgress() + { + // Arrange + var toast = CreateToast(); + toast.Intent = ToastIntent.Progress; + + // Act + var icon = toast.GetIntentIconForTest(); + + // Assert + Assert.Null(icon); + } + + [Fact] + public void FluentToast_GetIntentIcon_UsesInvertedColor_WhenInverted() + { + // Arrange + var toast = CreateToast(); + toast.Intent = ToastIntent.Success; + toast.Inverted = true; + + // Act + var icon = toast.GetIntentIconForTest(); + + // Assert + Assert.NotNull(icon); + } + + [Fact] + public void FluentToast_RendersDismissButton_WhenAllowDismiss() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.AllowDismiss, true)); + + // Assert + Assert.Contains("slot=\"action\"", cut.Markup); + } + + [Fact] + public void FluentToast_DoesNotRenderDismiss_WhenAllowDismissFalse() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.AllowDismiss, false)); + + // Assert + Assert.DoesNotContain("slot=\"action\"", cut.Markup); + } +} diff --git a/tests/Core/Components/Toast/NotificationServiceToastTests.cs b/tests/Core/Components/Toast/NotificationServiceToastTests.cs new file mode 100644 index 0000000000..f9e1b1450a --- /dev/null +++ b/tests/Core/Components/Toast/NotificationServiceToastTests.cs @@ -0,0 +1,373 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast; + +public class NotificationServiceToastTests : Bunit.BunitContext +{ + private const string TEST_PROVIDER = "toast-provider"; + + public NotificationServiceToastTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + private NotificationService GetService() + => (NotificationService)Services.GetRequiredService(); + + /// + /// Subscribes a fake provider so that considers a provider available. + /// + private NotificationService GetServiceWithProvider() + { + var service = GetService(); + service.Subscribe(TEST_PROVIDER, _ => Task.CompletedTask); + return service; + } + + private static IToastInstance SingleToast(NotificationService service) + => ((IFluentServiceBase)service).Items.Values.OfType().Single(); + + [Fact] + public async Task ShowToastAsync_WithoutProvider_Throws() + { + // Arrange + var service = GetService(); + var options = new ToastOptions { Title = "Title" }; + + // Act & Assert + await Assert.ThrowsAsync>( + () => service.ShowToastAsync(options)); + } + + [Fact] + public async Task ShowToastAsync_NullOptions_Throws() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act & Assert + await Assert.ThrowsAsync( + () => service.ShowToastAsync((ToastOptions)null!)); + } + + [Fact] + public void ShowSuccessToastAsync_SetsSuccessIntent() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowSuccessToastAsync("Title", "Message", lifetime: null); + + // Assert + var instance = SingleToast(service); + Assert.Equal(ToastIntent.Success, instance.Options.Intent); + Assert.Equal("Title", instance.Options.Title); + Assert.Equal("Message", instance.Options.Message); + } + + [Fact] + public void ShowWarningToastAsync_SetsWarningIntent() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowWarningToastAsync("Title", lifetime: null); + + // Assert + Assert.Equal(ToastIntent.Warning, SingleToast(service).Options.Intent); + } + + [Fact] + public void ShowErrorToastAsync_SetsErrorIntent() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowErrorToastAsync("Title", lifetime: null); + + // Assert + Assert.Equal(ToastIntent.Error, SingleToast(service).Options.Intent); + } + + [Fact] + public void ShowInfoToastAsync_SetsInfoIntent() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowInfoToastAsync("Title", lifetime: null); + + // Assert + Assert.Equal(ToastIntent.Info, SingleToast(service).Options.Intent); + } + + [Fact] + public void ShowProgressToastAsync_SetsProgressIntentAndVisibleTiming() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowProgressToastAsync("Title", lifetime: null); + + // Assert + var instance = SingleToast(service); + Assert.Equal(ToastIntent.Progress, instance.Options.Intent); + Assert.Equal(ToastResultTiming.Visible, instance.Options.ResultTiming); + } + + [Fact] + public void ShowSimpleToastAsync_WithLifetime_SetsLifetime() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowInfoToastAsync("Title", lifetime: 7); + + // Assert + Assert.Equal(TimeSpan.FromSeconds(7), SingleToast(service).Options.Lifetime); + } + + [Fact] + public void ShowSimpleToastAsync_WithDismissLabel_SetsAllowDismissAndLabel() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowInfoToastAsync("Title", dismissLabel: "Close", lifetime: null); + + // Assert + var instance = SingleToast(service); + Assert.True(instance.Options.AllowDismiss); + Assert.Equal("Close", instance.Options.DismissAction.Label); + Assert.NotNull(instance.Options.DismissAction.OnClickAsync); + } + + [Fact] + public void ShowToastAsync_AddsInstanceAndRaisesQueuedStatus() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowToastAsync(new ToastOptions { Title = "Title" }); + + // Assert + var instance = SingleToast(service); + Assert.NotNull(instance); + Assert.NotEqual(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); + } + + [Fact] + public void ShowToastAsync_WithAction_BuildsOptions() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowToastAsync(o => o.Title = "Configured"); + + // Assert + Assert.Equal("Configured", SingleToast(service).Options.Title); + } + + [Fact] + public void ShowToastAsync_OfComponent_SetsComponentType() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowToastAsync(new ToastOptions { Title = "Custom" }); + + // Assert + var instance = (INotificationInstance)SingleToast(service); + Assert.Equal(typeof(FluentToast), instance.ComponentType); + } + + [Fact] + public async Task ShowToastAsync_DuplicateId_Throws() + { + // Arrange + var service = GetServiceWithProvider(); + _ = service.ShowToastAsync(new ToastOptions { Id = "dup", Title = "First" }); + + // Act & Assert + await Assert.ThrowsAsync( + () => service.ShowToastAsync(new ToastOptions { Id = "dup", Title = "Second" })); + } + + [Fact] + public void GetToastInstance_ReturnsInstance() + { + // Arrange + var service = GetServiceWithProvider(); + _ = service.ShowToastAsync(new ToastOptions { Id = "get-id", Title = "Title" }); + + // Act + var instance = service.GetToastInstance("get-id"); + + // Assert + Assert.NotNull(instance); + Assert.Equal("get-id", instance!.Id); + } + + [Fact] + public void GetToastInstance_ReturnsNull_WhenNotFound() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + var instance = service.GetToastInstance("missing"); + + // Assert + Assert.Null(instance); + } + + [Fact] + public async Task CloseAsync_ByInstance_SetsDismissedStatus() + { + // Arrange + var service = GetServiceWithProvider(); + _ = service.ShowToastAsync(new ToastOptions { Id = "close-id", Title = "Title" }); + var instance = service.GetToastInstance("close-id")!; + + // Act + await service.CloseAsync(instance); + + // Assert + Assert.Equal(ToastLifecycleStatus.Dismissed, instance.LifecycleStatus); + } + + [Fact] + public async Task CloseAsync_WithToastResult_UsesProvidedResult() + { + // Arrange + var service = GetServiceWithProvider(); + _ = service.ShowToastAsync(new ToastOptions { Id = "close-id", Title = "Title" }); + var instance = service.GetToastInstance("close-id")!; + var providedResult = ToastResult.OfQuickAction(instance, "payload"); + + // Act + await service.CloseAsync(instance, providedResult); + var result = await instance.Result; + + // Assert + Assert.Equal(ToastCloseReason.QuickAction, result.Reason); + Assert.Equal("payload", result.Data); + } + + [Fact] + public async Task CloseAsync_ById_ReturnsTrue_AndClosesToast() + { + // Arrange + var service = GetServiceWithProvider(); + _ = service.ShowToastAsync(new ToastOptions { Id = "by-id", Title = "Title" }); + + // Act + var closed = await service.CloseAsync("by-id"); + + // Assert + Assert.True(closed); + } + + [Fact] + public async Task CloseAsync_ById_ReturnsFalse_WhenMissing() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + var closed = await service.CloseAsync("missing"); + + // Assert + Assert.False(closed); + } + + [Fact] + public async Task CloseAllToastsAsync_ClosesAll_AndReturnsCount() + { + // Arrange + var service = GetServiceWithProvider(); + _ = service.ShowToastAsync(new ToastOptions { Id = "a", Title = "A" }); + _ = service.ShowToastAsync(new ToastOptions { Id = "b", Title = "B" }); + + // Act + var count = await service.CloseAllToastsAsync(); + + // Assert + Assert.Equal(2, count); + } + + [Fact] + public void ShowToastAsync_OfCustomComponent_SetsComponentTypeAndParameters() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act + _ = service.ShowToastAsync(o => + { + o.Parameters[nameof(Templates.CustomToastTemplate.Text)] = "from-test"; + }); + + // Assert + var instance = SingleToast(service); + Assert.Equal(typeof(Templates.CustomToastTemplate), ((INotificationInstance)instance).ComponentType); + Assert.Equal("from-test", instance.Options.Parameters[nameof(Templates.CustomToastTemplate.Text)]); + } + + [Fact] + public void ShowToastAsync_OfCustomComponent_WithOptions_SetsComponentTypeAndParameters() + { + // Arrange + var service = GetServiceWithProvider(); + var options = new ToastOptions(); + options.Parameters[nameof(Templates.CustomToastTemplate.Text)] = "options-text"; + + // Act + _ = service.ShowToastAsync(options); + + // Assert + var instance = SingleToast(service); + Assert.Equal(typeof(Templates.CustomToastTemplate), ((INotificationInstance)instance).ComponentType); + Assert.Equal("options-text", instance.Options.Parameters[nameof(Templates.CustomToastTemplate.Text)]); + } + + [Fact] + public void ShowToastAsync_OfCustomComponent_RendersTemplateInProvider() + { + // Arrange: render the real provider so the toast is displayed. + var cut = Render(); + var service = (NotificationService)Services.GetRequiredService(); + + // Act + _ = service.ShowToastAsync(o => + { + o.Parameters[nameof(Templates.CustomToastTemplate.Text)] = "from-test"; + }); + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("custom-toast", cut.Markup); + Assert.Contains("from-test", cut.Markup); + }); + } +} + diff --git a/tests/Core/Components/Toast/Templates/CustomToastTemplate.razor b/tests/Core/Components/Toast/Templates/CustomToastTemplate.razor new file mode 100644 index 0000000000..7abb07a9d2 --- /dev/null +++ b/tests/Core/Components/Toast/Templates/CustomToastTemplate.razor @@ -0,0 +1,13 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast.Templates + +
+ Custom: @Text +
+ +@code { + [CascadingParameter] + public IToastInstance? ToastInstance { get; set; } + + [Parameter] + public string? Text { get; set; } +} diff --git a/tests/Core/Components/Toast/ToastEventArgsTests.cs b/tests/Core/Components/Toast/ToastEventArgsTests.cs new file mode 100644 index 0000000000..7b4e8fce54 --- /dev/null +++ b/tests/Core/Components/Toast/ToastEventArgsTests.cs @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast; + +public class ToastEventArgsTests : Bunit.BunitContext +{ + public ToastEventArgsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void ToastEventArgs_SetsAllProperties() + { + // Arrange + var service = Services.GetRequiredService(); + var instance = new ToastInstance(service, new ToastOptions { Id = "my-id" }); + + // Act + var args = new ToastEventArgs(instance, ToastLifecycleStatus.Dismissed); + + // Assert + Assert.Equal("my-id", args.Id); + Assert.Equal(ToastLifecycleStatus.Dismissed, args.Status); + Assert.Same(instance, args.Instance); + } + + [Theory] + [InlineData(ToastLifecycleStatus.Queued)] + [InlineData(ToastLifecycleStatus.Visible)] + [InlineData(ToastLifecycleStatus.Dismissed)] + [InlineData(ToastLifecycleStatus.Unmounted)] + public void ToastEventArgs_KeepsStatus(ToastLifecycleStatus status) + { + // Arrange + var service = Services.GetRequiredService(); + var instance = new ToastInstance(service, new ToastOptions()); + + // Act + var args = new ToastEventArgs(instance, status); + + // Assert + Assert.Equal(status, args.Status); + } +} diff --git a/tests/Core/Components/Toast/ToastInstanceTests.cs b/tests/Core/Components/Toast/ToastInstanceTests.cs new file mode 100644 index 0000000000..f6b938800a --- /dev/null +++ b/tests/Core/Components/Toast/ToastInstanceTests.cs @@ -0,0 +1,190 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast; + +public class ToastInstanceTests : Bunit.BunitContext +{ + public ToastInstanceTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + private NotificationService GetService() + => (NotificationService)Services.GetRequiredService(); + + [Fact] + public void ToastInstance_AutoGeneratedId() + { + // Arrange + var service = GetService(); + var options = new ToastOptions(); + + // Act + var instance = new ToastInstance(service, options); + + // Assert + Assert.False(string.IsNullOrEmpty(instance.Id)); + Assert.True(instance.Index > 0); + Assert.Same(options, instance.Options); + Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); + Assert.NotNull(instance.Result); + } + + [Fact] + public void ToastInstance_UsesProvidedId() + { + // Arrange + var service = GetService(); + var options = new ToastOptions { Id = "my-id" }; + + // Act + var instance = new ToastInstance(service, options); + + // Assert + Assert.Equal("my-id", instance.Id); + } + + [Fact] + public void ToastInstance_IncrementsIndex() + { + // Arrange + var service = GetService(); + + // Act + var first = new ToastInstance(service, new ToastOptions()); + var second = new ToastInstance(service, new ToastOptions()); + + // Assert + Assert.True(second.Index > first.Index); + } + + [Fact] + public void ToastInstance_ComponentType_DefaultsToNull() + { + // Arrange + var service = GetService(); + + // Act + INotificationInstance instance = new ToastInstance(service, new ToastOptions()); + + // Assert + Assert.Null(instance.ComponentType); + } + + [Fact] + public void ToastInstance_ComponentType_FromCtor() + { + // Arrange + var service = GetService(); + + // Act + INotificationInstance instance = new ToastInstance(service, typeof(FluentToast), new ToastOptions()); + + // Assert + Assert.Equal(typeof(FluentToast), instance.ComponentType); + } + + [Fact] + public void ToastInstance_SetStatus_UpdatesLifecycleStatus() + { + // Arrange + var instance = new ToastInstance(GetService(), new ToastOptions()); + + // Act + instance.SetStatus(ToastLifecycleStatus.Visible); + + // Assert + Assert.Equal(ToastLifecycleStatus.Visible, instance.LifecycleStatus); + } + + [Fact] + public void ToastInstance_SetStatus_InvokesOnStatusChange() + { + // Arrange + ToastEventArgs? capturedArgs = null; + var options = new ToastOptions + { + OnStatusChange = args => capturedArgs = args, + }; + var instance = new ToastInstance(GetService(), options); + + // Act + instance.SetStatus(ToastLifecycleStatus.Visible); + + // Assert + Assert.NotNull(capturedArgs); + Assert.Equal(ToastLifecycleStatus.Visible, capturedArgs!.Status); + Assert.Same(instance, capturedArgs.Instance); + } + + [Fact] + public void ToastInstance_SetStatus_SameStatus_DoesNotInvokeCallbackAgain() + { + // Arrange + var callCount = 0; + var options = new ToastOptions + { + OnStatusChange = _ => callCount++, + }; + var instance = new ToastInstance(GetService(), options); + + // Act + instance.SetStatus(ToastLifecycleStatus.Visible); + instance.SetStatus(ToastLifecycleStatus.Visible); + + // Assert + Assert.Equal(1, callCount); + } + + [Fact] + public async Task ToastInstance_SetStatus_Queued_CompletesResult_WhenTimingQueued() + { + // Arrange + var options = new ToastOptions { ResultTiming = ToastResultTiming.Queued }; + var instance = new ToastInstance(GetService(), options); + + // Act + instance.SetStatus(ToastLifecycleStatus.Queued); + var result = await instance.Result; + + // Assert + Assert.Equal(ToastCloseReason.Programmatic, result.Reason); + } + + [Fact] + public async Task ToastInstance_SetStatus_Visible_CompletesResult_WhenTimingVisible() + { + // Arrange + var options = new ToastOptions { ResultTiming = ToastResultTiming.Visible }; + var instance = new ToastInstance(GetService(), options); + + // Act + instance.SetStatus(ToastLifecycleStatus.Visible); + var result = await instance.Result; + + // Assert + Assert.Equal(ToastCloseReason.Programmatic, result.Reason); + } + + [Fact] + public void ToastInstance_SetStatus_Visible_DoesNotCompleteResult_WhenTimingClosed() + { + // Arrange + var options = new ToastOptions { ResultTiming = ToastResultTiming.Closed }; + var instance = new ToastInstance(GetService(), options); + + // Act + instance.SetStatus(ToastLifecycleStatus.Visible); + + // Assert + Assert.False(instance.Result.IsCompleted); + } +} diff --git a/tests/Core/Components/Toast/ToastOptionsTests.cs b/tests/Core/Components/Toast/ToastOptionsTests.cs new file mode 100644 index 0000000000..1ab5229dd5 --- /dev/null +++ b/tests/Core/Components/Toast/ToastOptionsTests.cs @@ -0,0 +1,185 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast; + +public class ToastOptionsTests +{ + [Fact] + public void ToastOptions_DefaultValues() + { + // Act + var options = new ToastOptions(); + + // Assert + Assert.Null(options.Id); + Assert.Null(options.Class); + Assert.Null(options.Style); + Assert.Null(options.Margin); + Assert.Null(options.Padding); + Assert.Null(options.Data); + Assert.Null(options.AdditionalAttributes); + Assert.NotNull(options.Parameters); + Assert.Empty(options.Parameters); + Assert.Null(options.Lifetime); + Assert.Null(options.Position); + Assert.Null(options.VerticalOffset); + Assert.Null(options.HorizontalOffset); + Assert.Null(options.Inverted); + Assert.Null(options.Intent); + Assert.Null(options.Politeness); + Assert.Null(options.Title); + Assert.Null(options.Message); + Assert.Null(options.Subtitle); + Assert.Null(options.PauseOnHover); + Assert.Null(options.PauseOnWindowBlur); + Assert.Null(options.AllowDismiss); + Assert.Null(options.Icon); + Assert.Null(options.Width); + Assert.Null(options.OnStatusChange); + Assert.NotNull(options.DismissAction); + Assert.NotNull(options.QuickAction1); + Assert.NotNull(options.QuickAction2); + Assert.Equal(ToastResultTiming.Closed, options.ResultTiming); + } + + [Fact] + public void ToastOptions_FactoryCtor_InvokesFactory() + { + // Act + var options = new ToastOptions(o => + { + o.Title = "Hello"; + o.Message = "World"; + o.Intent = ToastIntent.Success; + }); + + // Assert + Assert.Equal("Hello", options.Title); + Assert.Equal("World", options.Message); + Assert.Equal(ToastIntent.Success, options.Intent); + } + + [Fact] + public void ToastOptions_Properties_CanBeSet() + { + // Act + var options = new ToastOptions + { + Id = "id1", + Class = "my-class", + Style = "color: red;", + Data = 42, + Lifetime = TimeSpan.FromSeconds(3), + Position = ToastPosition.TopEnd, + VerticalOffset = 16, + HorizontalOffset = 20, + Inverted = true, + Intent = ToastIntent.Warning, + Politeness = ToastPoliteness.Assertive, + Title = "Title", + Message = "Message", + Subtitle = "Subtitle", + PauseOnHover = true, + PauseOnWindowBlur = true, + AllowDismiss = true, + Width = "300px", + ResultTiming = ToastResultTiming.Visible, + }; + + // Assert + Assert.Equal("id1", options.Id); + Assert.Equal("my-class", options.Class); + Assert.Equal("color: red;", options.Style); + Assert.Equal(42, options.Data); + Assert.Equal(TimeSpan.FromSeconds(3), options.Lifetime); + Assert.Equal(ToastPosition.TopEnd, options.Position); + Assert.Equal(16, options.VerticalOffset); + Assert.Equal(20, options.HorizontalOffset); + Assert.True(options.Inverted); + Assert.Equal(ToastIntent.Warning, options.Intent); + Assert.Equal(ToastPoliteness.Assertive, options.Politeness); + Assert.Equal("Title", options.Title); + Assert.Equal("Message", options.Message); + Assert.Equal("Subtitle", options.Subtitle); + Assert.True(options.PauseOnHover); + Assert.True(options.PauseOnWindowBlur); + Assert.True(options.AllowDismiss); + Assert.Equal("300px", options.Width); + Assert.Equal(ToastResultTiming.Visible, options.ResultTiming); + } + + [Fact] + public void ToastOptions_ClassValue_IncludesClassAndSpacing() + { + // Arrange + var options = new ToastOptions + { + Class = "my-class", + Margin = "10px", + Padding = "20px", + }; + + // Act + var classValue = options.ClassValue; + + // Assert + Assert.NotNull(classValue); + Assert.Contains("my-class", classValue); + } + + [Fact] + public void ToastOptions_StyleValue_IncludesMarginAndPadding() + { + // Arrange + var options = new ToastOptions + { + Style = "color: red;", + Margin = "10px", + Padding = "20px", + }; + + // Act + var styleValue = options.StyleValue; + + // Assert + Assert.NotNull(styleValue); + Assert.Contains("color: red", styleValue); + Assert.Contains("margin: 10px", styleValue); + Assert.Contains("padding: 20px", styleValue); + } + + [Fact] + public void ToastOptions_SetParameters_Accepts() + { + // Arrange + var options = new ToastOptions(); + + // Act + options.Parameters["Name"] = "John"; + + // Assert + Assert.Single(options.Parameters); + Assert.Equal("John", options.Parameters["Name"]); + } + + [Fact] + public void ToastOptions_Actions_AreIndependentInstances() + { + // Arrange + var options = new ToastOptions(); + + // Act + options.DismissAction.Label = "Dismiss"; + options.QuickAction1.Label = "One"; + options.QuickAction2.Label = "Two"; + + // Assert + Assert.Equal("Dismiss", options.DismissAction.Label); + Assert.Equal("One", options.QuickAction1.Label); + Assert.Equal("Two", options.QuickAction2.Label); + } +} diff --git a/tests/Core/Components/Toast/ToastResultTests.cs b/tests/Core/Components/Toast/ToastResultTests.cs new file mode 100644 index 0000000000..7e5b342bd5 --- /dev/null +++ b/tests/Core/Components/Toast/ToastResultTests.cs @@ -0,0 +1,132 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast; + +public class ToastResultTests : Bunit.BunitContext +{ + public ToastResultTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + private IToastInstance CreateInstance(string? id = null) + { + var service = Services.GetRequiredService(); + return new ToastInstance(service, new ToastOptions { Id = id }); + } + + [Fact] + public void ToastResult_Constructor_ThrowsOnNullInstance() + { + // Act & Assert + Assert.Throws(() => + new ToastResult(null!, ToastCloseReason.Dismissed, data: null)); + } + + [Fact] + public void ToastResult_Constructor_SetsProperties() + { + // Arrange + var instance = CreateInstance(); + var data = new object(); + + // Act + var result = new ToastResult(instance, ToastCloseReason.QuickAction, data); + + // Assert + Assert.Equal(ToastCloseReason.QuickAction, result.Reason); + Assert.Same(data, result.Data); + Assert.Same(instance, result.Instance); + } + + [Fact] + public void ToastResult_OfDismissed_SetsReason() + { + // Arrange + var instance = CreateInstance(); + + // Act + var result = ToastResult.OfDismissed(instance, "payload"); + + // Assert + Assert.Equal(ToastCloseReason.Dismissed, result.Reason); + Assert.Equal("payload", result.Data); + Assert.Same(instance, result.Instance); + } + + [Fact] + public void ToastResult_OfQuickAction_SetsReason() + { + // Arrange + var instance = CreateInstance(); + + // Act + var result = ToastResult.OfQuickAction(instance); + + // Assert + Assert.Equal(ToastCloseReason.QuickAction, result.Reason); + } + + [Fact] + public void ToastResult_OfProgrammatic_SetsReason() + { + // Arrange + var instance = CreateInstance(); + + // Act + var result = ToastResult.OfProgrammatic(instance, 123); + + // Assert + Assert.Equal(ToastCloseReason.Programmatic, result.Reason); + Assert.Equal(123, result.Data); + } + + [Fact] + public void ToastResult_OfTimedOut_SetsReason() + { + // Arrange + var instance = CreateInstance(); + + // Act + var result = ToastResult.OfTimedOut(instance); + + // Assert + Assert.Equal(ToastCloseReason.TimedOut, result.Reason); + } + + [Fact] + public void ToastResult_OfVisible_SetsProgrammaticReasonAndNoData() + { + // Arrange + var instance = CreateInstance(); + + // Act + var result = ToastResult.OfVisible(instance); + + // Assert + Assert.Equal(ToastCloseReason.Programmatic, result.Reason); + Assert.Null(result.Data); + } + + [Fact] + public void ToastResult_OfQueued_SetsProgrammaticReasonAndNoData() + { + // Arrange + var instance = CreateInstance(); + + // Act + var result = ToastResult.OfQueued(instance); + + // Assert + Assert.Equal(ToastCloseReason.Programmatic, result.Reason); + Assert.Null(result.Data); + } +} From 1e4c0efbdaa28f0d18a4e5127fa17611be9823f8 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Mon, 22 Jun 2026 21:21:28 +0200 Subject: [PATCH 37/43] Add unit tests --- .../Toast/FluentToastProviderTests.razor | 162 ++++++++++++++++++ .../Components/Toast/ToastInstanceTests.cs | 76 ++++++++ 2 files changed, 238 insertions(+) diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index 6c54bab0dd..972802a7b7 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -99,4 +99,166 @@ var exception = Record.Exception(() => provider.Dispose()); Assert.Null(exception); } + + // The inverted CSS variable applied to footer quick action links when the toast is inverted. + private const string INVERTED_LINK_COLOR = "color: var(--colorBrandForegroundInverted);"; + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_NotInverted_RendersLinkWithoutInvertedColor() + { + // Arrange + var cut = Render(@); + + // Act + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "Not inverted"; + options.QuickAction1.Label = "Action 1"; + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("Action 1", cut.Markup); + Assert.DoesNotContain(INVERTED_LINK_COLOR, cut.Markup); + }); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_InvertedFromOptions_RendersLinkWithInvertedColor() + { + // Arrange + var cut = Render(@); + + // Act + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "Inverted from options"; + options.Inverted = true; + options.QuickAction1.Label = "Action 1"; + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("Action 1", cut.Markup); + Assert.Contains(INVERTED_LINK_COLOR, cut.Markup); + }); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_InvertedFromConfiguration_RendersLinkWithInvertedColor() + { + // Arrange + Services.GetRequiredService().Toast.Inverted = true; + var cut = Render(@); + + // Act + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "Inverted from configuration"; + options.QuickAction1.Label = "Action 1"; + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("Action 1", cut.Markup); + Assert.Contains(INVERTED_LINK_COLOR, cut.Markup); + }); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_PrimaryAndSecondaryActions_RendersBothLinks() + { + // Arrange + var cut = Render(@); + + // Act + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "Both actions"; + options.QuickAction1.Label = "Action 1"; + options.QuickAction2.Label = "Action 2"; + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("slot=\"footer\"", cut.Markup); + Assert.Contains("Action 1", cut.Markup); + Assert.Contains("Action 2", cut.Markup); + }); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_PrimaryActionOnly_RendersPrimaryLink() + { + // Arrange + var cut = Render(@); + + // Act + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "Primary action only"; + options.QuickAction1.Label = "Action 1"; + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("slot=\"footer\"", cut.Markup); + Assert.Contains("Action 1", cut.Markup); + Assert.DoesNotContain("Action 2", cut.Markup); + }); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_SecondaryActionOnly_RendersSecondaryLink() + { + // Arrange + var cut = Render(@); + + // Act + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "Secondary action only"; + options.QuickAction2.Label = "Action 2"; + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("slot=\"footer\"", cut.Markup); + Assert.Contains("Action 2", cut.Markup); + Assert.DoesNotContain("Action 1", cut.Markup); + }); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_NoActions_RendersNoFooter() + { + // Arrange + var cut = Render(@); + + // Act + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "No actions"; + }); + await Task.CompletedTask; + + // Assert + cut.WaitForAssertion(() => + { + Assert.Contains("No actions", cut.Markup); + Assert.DoesNotContain("slot=\"footer\"", cut.Markup); + }); + } } diff --git a/tests/Core/Components/Toast/ToastInstanceTests.cs b/tests/Core/Components/Toast/ToastInstanceTests.cs index f6b938800a..3717be0a7f 100644 --- a/tests/Core/Components/Toast/ToastInstanceTests.cs +++ b/tests/Core/Components/Toast/ToastInstanceTests.cs @@ -187,4 +187,80 @@ public void ToastInstance_SetStatus_Visible_DoesNotCompleteResult_WhenTimingClos // Assert Assert.False(instance.Result.IsCompleted); } + + [Fact] + public void ToastInstance_NotificationService_ReturnsOwningService() + { + // Arrange + var service = GetService(); + + // Act + var instance = new ToastInstance(service, new ToastOptions()); + + // Assert + Assert.Same(service, instance.NotificationService); + } + + /// + /// Subscribes a fake provider so the service considers a provider available, + /// then shows a toast and returns its registered . + /// + private ToastInstance ShowRegisteredToast(NotificationService service, ToastOptions options) + { + service.Subscribe("toast-provider", _ => Task.CompletedTask); + _ = service.ShowToastAsync(options); + return (ToastInstance)service.GetToastInstance(options.Id!)!; + } + + [Fact] + public async Task ToastInstance_CloseAsync_ClosesThroughService_WithProgrammaticReason() + { + // Arrange + var service = GetService(); + var instance = ShowRegisteredToast(service, new ToastOptions { Id = "close-1", Title = "Title" }); + + // Act + await instance.CloseAsync(); + var result = await instance.Result; + + // Assert + Assert.Equal(ToastLifecycleStatus.Dismissed, instance.LifecycleStatus); + Assert.Equal(ToastCloseReason.Programmatic, result.Reason); + Assert.Same(instance, result.Instance); + } + + [Fact] + public async Task ToastInstance_CloseAsync_WithReasonAndData_PassesThemToResult() + { + // Arrange + var service = GetService(); + var instance = ShowRegisteredToast(service, new ToastOptions { Id = "close-2", Title = "Title" }); + + // Act + await instance.CloseAsync(ToastCloseReason.QuickAction, "payload"); + var result = await instance.Result; + + // Assert + Assert.Equal(ToastLifecycleStatus.Dismissed, instance.LifecycleStatus); + Assert.Equal(ToastCloseReason.QuickAction, result.Reason); + Assert.Equal("payload", result.Data); + Assert.Same(instance, result.Instance); + } + + [Fact] + public async Task ToastInstance_CloseAsync_WithReasonOnly_HasNullData() + { + // Arrange + var service = GetService(); + var instance = ShowRegisteredToast(service, new ToastOptions { Id = "close-3", Title = "Title" }); + + // Act + await instance.CloseAsync(ToastCloseReason.TimedOut); + var result = await instance.Result; + + // Assert + Assert.Equal(ToastCloseReason.TimedOut, result.Reason); + Assert.Null(result.Data); + } } + From 2e38fff5a9a837fba89de9bd50f77671495f6a9d Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Mon, 22 Jun 2026 21:40:19 +0200 Subject: [PATCH 38/43] Add unit tests --- .../Toast/FluentToastProviderTests.razor | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index 972802a7b7..ee48fc095a 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -1,3 +1,5 @@ +@using System.Linq +@using System.Reflection @using Xunit @using Microsoft.Extensions.DependencyInjection @inherits FluentUITestContext @@ -261,4 +263,48 @@ Assert.DoesNotContain("slot=\"footer\"", cut.Markup); }); } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_FooterContent_QuickActionClicked_InvokesOnClickAsync() + { + // Arrange + var invoked = false; + var cut = Render(@); + + _ = NotificationService.ShowToastAsync(options => + { + options.Title = "Quick action click"; + options.QuickAction1.Label = "Action 1"; + options.QuickAction1.OnClickAsync = _ => + { + invoked = true; + return Task.CompletedTask; + }; + }); + await Task.CompletedTask; + + cut.WaitForAssertion(() => Assert.Contains("Action 1", cut.Markup)); + + // Act + var link = cut.FindAll("fluent-link").First(element => element.TextContent.Contains("Action 1")); + await cut.InvokeAsync(() => link.Click()); + + // Assert + Assert.True(invoked); + } + + [Fact] + public void FluentToastProvider_RenderToastContent_NullToast_ReturnsNull() + { + // Arrange + var cut = Render(@); + var provider = cut.FindComponent().Instance; + var method = typeof(FluentToastProvider).GetMethod("RenderToastContent", BindingFlags.NonPublic | BindingFlags.Instance); + + // Act + var result = method!.Invoke(provider, new object?[] { null }); + + // Assert + Assert.Null(result); + } } From 1497bbc60979776e32bbbdb54f42f30e8ff5c66b Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Mon, 22 Jun 2026 22:04:56 +0200 Subject: [PATCH 39/43] Add Unit Tests --- .../Toast/FluentToastProviderTests.razor | 48 ++++ .../Core/Components/Toast/FluentToastTests.cs | 259 ++++++++++++++++++ 2 files changed, 307 insertions(+) diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index ee48fc095a..9d1a1933ae 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -307,4 +307,52 @@ // Assert Assert.Null(result); } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_SynchronizeToastQueue_PromotesQueuedToastsUpToMaxCount() + { + // Arrange + Services.GetRequiredService().Toast.MaxToastCount = 2; + var cut = Render(@); + + // Act + for (var i = 1; i <= 4; i++) + { + _ = NotificationService.ShowToastAsync(new ToastOptions { Title = $"Queued toast {i}" }); + } + await Task.CompletedTask; + + // Assert + var items = ((IFluentServiceBase)NotificationService).Items; + cut.WaitForAssertion(() => + { + var toasts = items.Values.OfType().ToList(); + Assert.Equal(2, toasts.Count(toast => toast.LifecycleStatus == ToastLifecycleStatus.Visible)); + Assert.Equal(2, toasts.Count(toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued)); + }); + } + + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToastProvider_SynchronizeToastQueue_PromotesAllQueuedToasts_WhenUnderMaxCount() + { + // Arrange + Services.GetRequiredService().Toast.MaxToastCount = 5; + var cut = Render(@); + + // Act + for (var i = 1; i <= 3; i++) + { + _ = NotificationService.ShowToastAsync(new ToastOptions { Title = $"Visible toast {i}" }); + } + await Task.CompletedTask; + + // Assert + var items = ((IFluentServiceBase)NotificationService).Items; + cut.WaitForAssertion(() => + { + var toasts = items.Values.OfType().ToList(); + Assert.Equal(3, toasts.Count(toast => toast.LifecycleStatus == ToastLifecycleStatus.Visible)); + Assert.DoesNotContain(toasts, toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued); + }); + } } diff --git a/tests/Core/Components/Toast/FluentToastTests.cs b/tests/Core/Components/Toast/FluentToastTests.cs index 7a06c1885f..c14fef1ff5 100644 --- a/tests/Core/Components/Toast/FluentToastTests.cs +++ b/tests/Core/Components/Toast/FluentToastTests.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using Bunit; +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Microsoft.JSInterop; using Xunit; @@ -161,4 +162,262 @@ public void FluentToast_DoesNotRenderDismiss_WhenAllowDismissFalse() // Assert Assert.DoesNotContain("slot=\"action\"", cut.Markup); } + + [Fact] + public void FluentToast_RendersSpinner_ForProgressIntent() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.Intent, ToastIntent.Progress)); + + // Assert + Assert.Contains("slot=\"media\"", cut.Markup); + Assert.Contains("fluent-spinner", cut.Markup); + } + + [Fact] + public void FluentToast_RendersIntentIcon_ForInfoIntent() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.Intent, ToastIntent.Info)); + + // Assert + Assert.Contains("slot=\"media\"", cut.Markup); + } + + [Fact] + public void FluentToast_RendersCustomIcon_WhenIconSet() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.Icon, new CoreIcons.Regular.Size20.Info())); + + // Assert + Assert.Contains("slot=\"media\"", cut.Markup); + } + + [Fact] + public void FluentToast_RendersDismissLink_WhenDismissLabelSet() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.AllowDismiss, true) + .Add(p => p.DismissAction, new ToastOptionsAction { Label = "Close" })); + + // Assert + Assert.Contains("slot=\"action\"", cut.Markup); + Assert.Contains("Close", cut.Markup); + } + + [Fact] + public void FluentToast_RendersSubtitle_WhenSubtitleSet() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.Subtitle, "My subtitle")); + + // Assert + Assert.Contains("slot=\"subtitle\"", cut.Markup); + Assert.Contains("My subtitle", cut.Markup); + } + + [Fact] + public void FluentToast_RendersFooterTemplate_WhenSet() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.FooterTemplate, (RenderFragment)(builder => builder.AddContent(0, "My footer")))); + + // Assert + Assert.Contains("slot=\"footer\"", cut.Markup); + Assert.Contains("My footer", cut.Markup); + } + + [Fact] + public void FluentToast_RendersChildContent_WhenSet() + { + // Arrange & Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.ChildContent, (RenderFragment)(builder => builder.AddContent(0, "My body")))); + + // Assert + Assert.Contains("My body", cut.Markup); + Assert.Contains("-body", cut.Markup); + } + + [Fact] + public void FluentToast_DismissButton_ClosesToast_WhenNoInstance() + { + // Arrange + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.AllowDismiss, true)); + + // Act + cut.Find("fluent-button").Click(); + + // Assert + Assert.False(cut.Instance.Opened); + } + + [Fact] + public void FluentToast_DismissButton_IsNoOp_WhenAlreadyClosed() + { + // Arrange + var cut = Render(parameters => parameters + .Add(p => p.Opened, false) + .Add(p => p.AllowDismiss, true)); + + // Act + cut.Find("fluent-button").Click(); + + // Assert + Assert.False(cut.Instance.Opened); + } + + [Fact] + public void FluentToast_WithInstance_OpensOnFirstRender_AndTogglesVisible() + { + // Arrange + var service = (NotificationService)Services.GetRequiredService(); + var instance = new ToastInstance(service, new ToastOptions { Id = "toast-open", Title = "Hello" }); + + // Act + var cut = Render(parameters => parameters + .Add(p => p.Opened, false) + .AddCascadingValue(instance)); + + // Assert: OnAfterRenderAsync opens the toast when an instance is cascaded. + Assert.True(cut.Instance.Opened); + + // Act: notify the component that the underlying dialog is opened. + cut.Find("fluent-toast-b").TriggerEvent("ondialogtoggle", new DialogToggleEventArgs + { + Id = "toast-open", + Type = "toggle", + NewState = "open", + }); + + // Assert + Assert.Equal(ToastLifecycleStatus.Visible, instance.LifecycleStatus); + } + + [Fact] + public async Task FluentToast_WithInstance_ToggleClosed_DismissesAndCompletesResult() + { + // Arrange + var service = (NotificationService)Services.GetRequiredService(); + var instance = new ToastInstance(service, new ToastOptions { Id = "toast-close", Title = "Bye" }); + + bool? openedChangedValue = null; + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.OpenedChanged, EventCallback.Factory.Create(this, value => openedChangedValue = value)) + .AddCascadingValue(instance)); + + // Act + cut.Find("fluent-toast-b").TriggerEvent("ondialogtoggle", new DialogToggleEventArgs + { + Id = "toast-close", + Type = "toggle", + NewState = "closed", + }); + + // Assert + Assert.False(cut.Instance.Opened); + Assert.False(openedChangedValue); + Assert.Equal(ToastLifecycleStatus.Dismissed, instance.LifecycleStatus); + + var result = await instance.Result; + Assert.Equal(ToastCloseReason.TimedOut, result.Reason); + } + + [Fact] + public void FluentToast_Toggle_IsIgnored_WhenIdDoesNotMatch() + { + // Arrange + var service = (NotificationService)Services.GetRequiredService(); + var instance = new ToastInstance(service, new ToastOptions { Id = "toast-id", Title = "Title" }); + + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .AddCascadingValue(instance)); + + // Act: send a toggle event for a different toast id. + cut.Find("fluent-toast-b").TriggerEvent("ondialogtoggle", new DialogToggleEventArgs + { + Id = "other-id", + Type = "toggle", + NewState = "closed", + }); + + // Assert: the mismatched event is ignored and nothing changes. + Assert.True(cut.Instance.Opened); + Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); + } + + [Fact] + public void FluentToast_WithInstance_DismissButton_InvokesDismissAction() + { + // Arrange + var service = (NotificationService)Services.GetRequiredService(); + var instance = new ToastInstance(service, new ToastOptions { Id = "toast-action", Title = "Title" }); + + var invoked = false; + var dismissAction = new ToastOptionsAction + { + OnClickAsync = _ => + { + invoked = true; + return Task.CompletedTask; + }, + }; + + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.AllowDismiss, true) + .Add(p => p.DismissAction, dismissAction) + .AddCascadingValue(instance)); + + // Act + cut.Find("fluent-button").Click(); + + // Assert + Assert.True(invoked); + } + + [Fact] + public void FluentToast_WithInstance_DismissButton_ClosesInstance() + { + // Arrange + var service = (NotificationService)Services.GetRequiredService(); + var instance = new ToastInstance(service, new ToastOptions { Id = "toast-dismiss", Title = "Title" }); + + var cut = Render(parameters => parameters + .Add(p => p.Opened, true) + .Add(p => p.AllowDismiss, true) + .AddCascadingValue(instance)); + + // Make the toast visible so the service does not ignore the close request. + cut.Find("fluent-toast-b").TriggerEvent("ondialogtoggle", new DialogToggleEventArgs + { + Id = "toast-dismiss", + Type = "toggle", + NewState = "open", + }); + + // Act + cut.Find("fluent-button").Click(); + + // Assert + Assert.Equal(ToastLifecycleStatus.Dismissed, instance.LifecycleStatus); + } } From 608fc3dc72baf2c03ae39efdef53e31f320721f1 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Mon, 22 Jun 2026 22:15:24 +0200 Subject: [PATCH 40/43] Add unit tests --- .../Toast/Services/NotificationService.cs | 7 +- .../Toast/NotificationServiceToastTests.cs | 155 ++++++++++++++++++ 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index 50069d01c7..be5d80a9af 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -21,23 +21,18 @@ public partial class NotificationService : FluentServiceBase class. ///
/// List of services available in the application. - /// Localizer for the application. [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarEventArgs))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarInstance))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(INotificationInstance))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IMessageBarInstance))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IToastInstance))] - public NotificationService(IServiceProvider serviceProvider, IFluentLocalizer? localizer) + public NotificationService(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; _jsRuntime = serviceProvider.GetRequiredService(); - Localizer = localizer ?? FluentLocalizerInternal.Default; ServiceProvider.OnUpdatedAsync = DispatchOnUpdatedAsync; } - /// - protected IFluentLocalizer Localizer { get; } - /// public Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) => ShowSimpleToastAsync(ToastIntent.Success, title, message, lifetime, dismissLabel, dismissOnClickAsync); diff --git a/tests/Core/Components/Toast/NotificationServiceToastTests.cs b/tests/Core/Components/Toast/NotificationServiceToastTests.cs index f9e1b1450a..309f6a4faa 100644 --- a/tests/Core/Components/Toast/NotificationServiceToastTests.cs +++ b/tests/Core/Components/Toast/NotificationServiceToastTests.cs @@ -369,5 +369,160 @@ public void ShowToastAsync_OfCustomComponent_RendersTemplateInProvider() Assert.Contains("from-test", cut.Markup); }); } + + [Fact] + public async Task ShowSimpleToastAsync_DismissOnClick_InvokesCallbackAndClosesToast() + { + // Arrange + var service = GetServiceWithProvider(); + var callbackInvoked = false; + + _ = service.ShowInfoToastAsync( + "Title", + dismissLabel: "Close", + lifetime: null, + dismissOnClickAsync: _ => + { + callbackInvoked = true; + return Task.CompletedTask; + }); + + var instance = SingleToast(service); + var args = new ToastEventArgs(instance, ToastLifecycleStatus.Visible); + + // Act + await instance.Options.DismissAction.OnClickAsync!.Invoke(args); + + // Assert + Assert.True(callbackInvoked); + Assert.Equal(ToastLifecycleStatus.Dismissed, instance.LifecycleStatus); + } + + [Fact] + public async Task ShowSimpleToastAsync_DismissOnClick_WithoutCallback_ClosesToast() + { + // Arrange + var service = GetServiceWithProvider(); + + _ = service.ShowInfoToastAsync("Title", dismissLabel: "Close", lifetime: null); + + var instance = SingleToast(service); + var args = new ToastEventArgs(instance, ToastLifecycleStatus.Visible); + + // Act + await instance.Options.DismissAction.OnClickAsync!.Invoke(args); + + // Assert + Assert.Equal(ToastLifecycleStatus.Dismissed, instance.LifecycleStatus); + } + + [Fact] + public async Task CloseCoreAsync_NonToastInstance_DoesNothing() + { + // Arrange + var service = GetServiceWithProvider(); + var fake = new FakeToastInstance(); + + // Act & Assert (does not throw and returns) + await service.CloseAsync(fake); + } + + [Fact] + public async Task CloseCoreAsync_AlreadyUnmounted_DoesNotSetResult() + { + // Arrange + var service = GetServiceWithProvider(); + + // A freshly created instance defaults to the Unmounted status. + var instance = new ToastInstance(service, new ToastOptions { Id = "unmounted" }); + Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); + + // Act + await service.CloseAsync(instance); + + // Assert + Assert.False(instance.Result.IsCompleted); + } + + [Fact] + public async Task RemoveToastFromProviderAsync_Null_DoesNothing() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act & Assert (does not throw) + await service.RemoveToastFromProviderAsync(null); + } + + [Fact] + public async Task RemoveToastFromProviderAsync_NonToastInstance_DoesNothing() + { + // Arrange + var service = GetServiceWithProvider(); + + // Act & Assert (does not throw) + await service.RemoveToastFromProviderAsync(new FakeToastInstance()); + } + + [Fact] + public async Task RemoveToastFromProviderAsync_RemovesToast_AndRaisesUnmountedStatus() + { + // Arrange + var service = GetService(); + INotificationInstance? notified = null; + service.Subscribe(TEST_PROVIDER, instance => + { + notified = instance; + return Task.CompletedTask; + }); + + _ = service.ShowToastAsync(new ToastOptions { Id = "remove-id", Title = "Title" }); + var instance = service.GetToastInstance("remove-id")!; + + // Act + await service.CloseAsync(instance); + + // Assert: the fire-and-forget removal completes after the closing animation delay. + await WaitForAsync(() => service.GetToastInstance("remove-id") is null); + Assert.Null(service.GetToastInstance("remove-id")); + Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); + Assert.NotNull(notified); + Assert.Equal("remove-id", notified!.Id); + } + + private static async Task WaitForAsync(Func condition, int timeoutMilliseconds = 5000) + { + var elapsed = 0; + const int interval = 50; + + while (!condition() && elapsed < timeoutMilliseconds) + { + await Task.Delay(interval); + elapsed += interval; + } + } + + /// + /// A test double implementing that is not a , + /// used to exercise the type-guard branches of the service. + /// + private sealed class FakeToastInstance : IToastInstance + { + public ToastOptions Options { get; } = new(); + + public Task Result => Task.FromResult(ToastResult.OfProgrammatic(this)); + + public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Queued; + + public string Id { get; } = "fake-id"; + + public long Index => 0; + + Type? INotificationInstance.ComponentType => null; + + public Task CloseAsync() => Task.CompletedTask; + + public Task CloseAsync(ToastCloseReason reason, object? data = null) => Task.CompletedTask; + } } From 94602182a63eeb44600871533570d04981b30905 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Tue, 23 Jun 2026 17:50:57 +0200 Subject: [PATCH 41/43] Refactor toast lifetime handling to default to 7 seconds and allow indefinite display for interactive toasts --- .../FluentToastDeterminateProgress.razor | 1 + .../FluentToastIndeterminateProgress.razor | 1 + .../Toast/Examples/FluentToastInverted.razor | 1 - .../Components/Toast/FluentToast.md | 14 +++++++++--- .../Toast/FluentToastProvider.razor | 2 +- .../Toast/FluentToastProvider.razor.cs | 20 +++++++++++++++++ .../Toast/Services/INotificationService.cs | 22 +++++++++---------- .../Toast/Services/LibraryToastOptions.cs | 5 ++++- .../Toast/Services/NotificationService.cs | 10 ++++----- .../Components/Toast/Services/ToastOptions.cs | 2 +- 10 files changed, 55 insertions(+), 23 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor index 5199291fb9..7e025460e3 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor @@ -17,6 +17,7 @@ var result = await NotificationService.ShowToastAsync(options => { options.Title = "Downloading file"; + options.Lifetime = TimeSpan.Zero; // Keep the toast open until closed after the progress is complete options.Icon = new Icons.Regular.Size24.ArrowDownload(); options.AllowDismiss = false; options.Width = "400px"; diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor index 7e339824d7..1dff65d1c9 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor @@ -26,6 +26,7 @@ options.Id = "my-progress-toast"; options.Intent = ToastIntent.Progress; options.Title = "Task in progress"; + options.Lifetime = TimeSpan.Zero; // Keep the toast open until closed programmatically options.Message = "No idea when this will be finished..."; options.AllowDismiss = false; options.OnStatusChange = (e) => diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor index f637a1e887..0c20192808 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor @@ -16,7 +16,6 @@ options.Inverted = true; options.Title = $"Inverted toast #{counter++}"; options.Message = "Toasts are used to show brief messages to the user."; - options.Lifetime = TimeSpan.FromSeconds(5); }); } } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 36a5abad5e..af6ce4f473 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -40,7 +40,7 @@ Global default values (used for all instances) can be set using the `LibraryConf The type of this member is `LibraryToastOptions`, and has the following properties (and default values): - `MaxToastCount = 4` -- `Lifetime = null` +- `Lifetime = 7 seconds` - `Position = ToastPosition.BottomEnd` - `VerticalOffset = 16` - `HorizontalOffset = 20` @@ -52,6 +52,10 @@ The type of this member is `LibraryToastOptions`, and has the following properti The preferred defaults can be set in the `AddFluentUIComponents` method when configuring services. +> [!NOTE] By default, toasts stay on the screen for seven seconds. On hover or focus, the toast’s timer will pause +> and resume when the person navigates away from it. +> When a toast contains at least one quick action and no explicit `Lifetime` is set, the default lifetime +> is automatically set to `0` to disable automatic closing. **Example** @@ -77,7 +81,7 @@ by using a required title plus optional message and dismiss button details. This example shows the standard toast setup with default behavior and intent. Use it as the baseline pattern for simple status feedback. -In this example, **Success**, **Warning**, **Error**, and **Info** toasts are shown for 5 seconds (`lifetime = 5`) and then close automatically. +In this example, **Success**, **Warning**, **Error**, and **Info** toasts are shown for 7 seconds (`lifetime = 7`) and then close automatically. The **Progress** toast behaves differently: the line `ProgressResult = await NotificationService.ShowProgressToastAsync()` returns immediately a toast instance that is stored in `ProgressResult.Instance`. When `ProgressResult` is available, the [Close Progress] button is enabled so the user can close that toast manually. @@ -109,6 +113,10 @@ With this approach, you can open any Razor component in the toast and fully cust This example shows quick action links inside the toast so people can immediately respond to the notification. +> [!NOTE] When a toast contains at least one quick action and no explicit `Lifetime` is set, the default lifetime +> is automatically set to `0` to disable automatic closing. This keeps the toast on screen so the user has time +> to interact with the action. To override this behavior, set an explicit `Lifetime` value in the `ToastOptions`. + {{ FluentToastQuickActions }} ### Result Timing @@ -127,7 +135,7 @@ You can also control when the awaited result is completed with `ResultTiming`: When using `Visible`, keep the returned `result.Instance` if you need to interact with that toast later (for example, close it programmatically). -In the sample, both toasts stay visible for 5 seconds. **Show Lifetime On Close** reports the result after the toast closes, +In the sample, both toasts stay visible for 7 seconds. **Show Lifetime On Close** reports the result after the toast closes, while **Show Lifetime On Visible** reports it immediately when the toast appears. {{ FluentToastResultTiming }} diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 6cb9ee2754..7254b597f3 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -19,7 +19,7 @@ Class="@toast.Options.ClassValue" Style="@toast.Options.StyleValue" Data="@toast.Options.Data" - Lifetime="@(toast.Options.Lifetime ?? configuration.Toast.Lifetime)" + Lifetime="@(GetLifetime(toast))" Position="@(toast.Options.Position ?? configuration.Toast.Position)" VerticalOffset="@(toast.Options.VerticalOffset ?? configuration.Toast.VerticalOffset)" HorizontalOffset="@(toast.Options.HorizontalOffset ?? configuration.Toast.HorizontalOffset)" diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 35e5385522..8045e82456 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -115,6 +115,26 @@ private EventCallback GetOnStatusChangeCallback(IToastInstance t }; } + private TimeSpan GetLifetime(IToastInstance toast) + { + // If the toast has a specific lifetime defined, use it. + if (toast.Options.Lifetime.HasValue) + { + return toast.Options.Lifetime.Value; + } + + // Keep the toast open until the user interacts with it or dismisses it. + var hasPrimaryAction = !string.IsNullOrEmpty(toast.Options.QuickAction1.Label); + var hasSecondaryAction = !string.IsNullOrEmpty(toast.Options.QuickAction2.Label); + if (hasPrimaryAction || hasSecondaryAction) + { + return TimeSpan.Zero; + } + + // Otherwise, use the default lifetime from the configuration, or TimeSpan.Zero if not defined. + return configuration.Toast.Lifetime ?? TimeSpan.Zero; + } + /// /// Renders the footer content of the toast, including the primary and secondary quick actions if they are defined in the toast options. /// diff --git a/src/Core/Components/Toast/Services/INotificationService.cs b/src/Core/Components/Toast/Services/INotificationService.cs index 04255f563b..eee3347ebc 100644 --- a/src/Core/Components/Toast/Services/INotificationService.cs +++ b/src/Core/Components/Toast/Services/INotificationService.cs @@ -17,57 +17,57 @@ public partial interface INotificationService : IFluentServiceBase /// The title of the toast. /// The message content of the toast. - /// The lifetime of the toast in seconds (default is 5 seconds). + /// The lifetime of the toast in seconds (default is 7 seconds). /// The label for the dismiss action. /// The callback action for the dismiss action. When the action is completed, the toast will be closed. /// A task that represents the asynchronous operation. The task result contains the close result of the toast. - Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null); /// /// Shows a warning toast with the specified title and message and waits for the close result. /// /// The title of the toast. /// The message content of the toast. - /// The lifetime of the toast in seconds (default is 5 seconds). + /// The lifetime of the toast in seconds (default is 7 seconds). /// The label for the dismiss action. /// The callback action for the dismiss action. When the action is completed, the toast will be closed. /// A task that represents the asynchronous operation. The task result contains the close result of the toast. - Task ShowWarningToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + Task ShowWarningToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null); /// /// Shows an error toast with the specified title and message and waits for the close result. /// /// The title of the toast. /// The message content of the toast. - /// The lifetime of the toast in seconds (default is 5 seconds). + /// The lifetime of the toast in seconds (default is 7 seconds). /// The label for the dismiss action. /// The callback action for the dismiss action. When the action is completed, the toast will be closed. /// A task that represents the asynchronous operation. The task result contains the close result of the toast. - Task ShowErrorToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + Task ShowErrorToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null); /// /// Shows an info toast with the specified title and message and waits for the close result. /// /// The title of the toast. /// The message content of the toast. - /// The lifetime of the toast in seconds (default is 5 seconds). + /// The lifetime of the toast in seconds (default is 7 seconds). /// The label for the dismiss action. /// The callback action for the dismiss action. When the action is completed, the toast will be closed. /// A task that represents the asynchronous operation. The task result contains the close result of the toast. - Task ShowInfoToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + Task ShowInfoToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null); /// /// Shows a progress toast with the specified title and message and waits for the close result. /// /// The title of the toast. /// The message content of the toast. - /// The lifetime of the toast in seconds (default is 5 seconds). + /// The lifetime of the toast in seconds (default is 7 seconds). /// The label for the dismiss action. /// The callback action for the dismiss action. When the action is completed, the toast will be closed. /// A task that represents the asynchronous operation. The task result contains the close result of the toast. - Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null); + Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null); - /// + /// /// Shows a toast using the supplied options and waits for the close result. /// /// Options to configure the toast. diff --git a/src/Core/Components/Toast/Services/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs index 282f2ee28c..6635846752 100644 --- a/src/Core/Components/Toast/Services/LibraryToastOptions.cs +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -8,6 +8,9 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public class LibraryToastOptions { + // Recommanded default lifetime is 7 seconds according to Fluent UI design guidelines. + internal const int DefaultLifetimeSeconds = 7; + /// /// Initializes a new instance of the class. /// @@ -28,7 +31,7 @@ internal LibraryToastOptions() /// When set to a positive value, the toast is automatically removed after this duration elapses. /// When `null`, the toast stays visible until it is dismissed programmatically or by the user. /// - public TimeSpan? Lifetime { get; set; } + public TimeSpan? Lifetime { get; set; } = TimeSpan.FromSeconds(DefaultLifetimeSeconds); /// /// Gets or sets the default toast position. diff --git a/src/Core/Components/Toast/Services/NotificationService.cs b/src/Core/Components/Toast/Services/NotificationService.cs index be5d80a9af..e7ebceeb99 100644 --- a/src/Core/Components/Toast/Services/NotificationService.cs +++ b/src/Core/Components/Toast/Services/NotificationService.cs @@ -34,23 +34,23 @@ public NotificationService(IServiceProvider serviceProvider) } /// - public Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + public Task ShowSuccessToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null) => ShowSimpleToastAsync(ToastIntent.Success, title, message, lifetime, dismissLabel, dismissOnClickAsync); /// - public Task ShowWarningToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + public Task ShowWarningToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null) => ShowSimpleToastAsync(ToastIntent.Warning, title, message, lifetime, dismissLabel, dismissOnClickAsync); /// - public Task ShowErrorToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + public Task ShowErrorToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null) => ShowSimpleToastAsync(ToastIntent.Error, title, message, lifetime, dismissLabel, dismissOnClickAsync); /// - public Task ShowInfoToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + public Task ShowInfoToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null) => ShowSimpleToastAsync(ToastIntent.Info, title, message, lifetime, dismissLabel, dismissOnClickAsync); /// - public Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = 5, string? dismissLabel = null, Func? dismissOnClickAsync = null) + public Task ShowProgressToastAsync(string title, string? message = null, int? lifetime = LibraryToastOptions.DefaultLifetimeSeconds, string? dismissLabel = null, Func? dismissOnClickAsync = null) => ShowSimpleToastAsync(ToastIntent.Progress, title, message, lifetime, dismissLabel, dismissOnClickAsync, ToastResultTiming.Visible); /// diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index bf064688f0..663d299c9a 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -74,7 +74,7 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets the lifetime of the toast. /// When set to a positive value, the toast is automatically removed after this duration elapses. - /// When `null`, the toast stays visible until it is dismissed programmatically or by the user. + /// When `TimeSpan.Zero`, the toast stays visible until it is dismissed programmatically or by the user. /// public TimeSpan? Lifetime { get; set; } From 6675b3d9a8120341b1fb3a6f6d0579095b25b9b1 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Tue, 23 Jun 2026 17:57:19 +0200 Subject: [PATCH 42/43] Add tests for FluentToastProvider.GetLifetime method to validate lifetime behavior based on options and actions --- .../Toast/FluentToastProviderTests.razor | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index 9d1a1933ae..8ac0ad358e 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -355,4 +355,95 @@ Assert.DoesNotContain(toasts, toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued); }); } + + /// + /// Invokes the private GetLifetime method on the provider for the given toast. + /// + private static TimeSpan InvokeGetLifetime(FluentToastProvider provider, IToastInstance toast) + { + var method = typeof(FluentToastProvider).GetMethod("GetLifetime", BindingFlags.NonPublic | BindingFlags.Instance); + return (TimeSpan)method!.Invoke(provider, new object?[] { toast })!; + } + + [Fact] + public void FluentToastProvider_GetLifetime_OptionsLifetimeDefined_ReturnsOptionsLifetime() + { + // Arrange + var cut = Render(@); + var provider = cut.FindComponent().Instance; + var options = new ToastOptions { Lifetime = TimeSpan.FromSeconds(12) }; + var toast = new ToastInstance((NotificationService)NotificationService, options); + + // Act + var lifetime = InvokeGetLifetime(provider, toast); + + // Assert + Assert.Equal(TimeSpan.FromSeconds(12), lifetime); + } + + [Fact] + public void FluentToastProvider_GetLifetime_PrimaryActionDefined_ReturnsZero() + { + // Arrange + var cut = Render(@); + var provider = cut.FindComponent().Instance; + var options = new ToastOptions(); + options.QuickAction1.Label = "Action 1"; + var toast = new ToastInstance((NotificationService)NotificationService, options); + + // Act + var lifetime = InvokeGetLifetime(provider, toast); + + // Assert + Assert.Equal(TimeSpan.Zero, lifetime); + } + + [Fact] + public void FluentToastProvider_GetLifetime_SecondaryActionDefined_ReturnsZero() + { + // Arrange + var cut = Render(@); + var provider = cut.FindComponent().Instance; + var options = new ToastOptions(); + options.QuickAction2.Label = "Action 2"; + var toast = new ToastInstance((NotificationService)NotificationService, options); + + // Act + var lifetime = InvokeGetLifetime(provider, toast); + + // Assert + Assert.Equal(TimeSpan.Zero, lifetime); + } + + [Fact] + public void FluentToastProvider_GetLifetime_NoLifetimeNoActions_ReturnsConfigurationLifetime() + { + // Arrange + Services.GetRequiredService().Toast.Lifetime = TimeSpan.FromSeconds(5); + var cut = Render(@); + var provider = cut.FindComponent().Instance; + var toast = new ToastInstance((NotificationService)NotificationService, new ToastOptions()); + + // Act + var lifetime = InvokeGetLifetime(provider, toast); + + // Assert + Assert.Equal(TimeSpan.FromSeconds(5), lifetime); + } + + [Fact] + public void FluentToastProvider_GetLifetime_NoLifetimeNoActionsNoConfiguration_ReturnsZero() + { + // Arrange + Services.GetRequiredService().Toast.Lifetime = null; + var cut = Render(@); + var provider = cut.FindComponent().Instance; + var toast = new ToastInstance((NotificationService)NotificationService, new ToastOptions()); + + // Act + var lifetime = InvokeGetLifetime(provider, toast); + + // Assert + Assert.Equal(TimeSpan.Zero, lifetime); + } } From 27da99d12e7bcfcc1c63c0ccb49b8c7573c56ce3 Mon Sep 17 00:00:00 2001 From: Denis VOITURON Date: Tue, 23 Jun 2026 18:08:54 +0200 Subject: [PATCH 43/43] Enhance Toast documentation with detailed descriptions of toast types and dismissal behaviors --- .../Components/Toast/FluentToast.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index af6ce4f473..e200a2ca9a 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -28,6 +28,69 @@ Use these guidance points to keep toast usage consistent: - Prefer no more than four visible toasts in the same toaster. - Use timed dismissal for informational success feedback, and persistent behavior for active progress. +## Types + +Toasts generally fall into three categories: confirmation, progress, and communication. +The ideal configuration and usage of each toast type is described below: + +**Confirmation toast** + +Confirmation toasts are shown to someone as a direct result of their action. +A confirmation toast’s state can be success, error, warning, informational, or progress. + +**Progress toast** + +Progress toasts inform someone about the status of an operation they initiated. + +**Communication toast** + +Communication toasts inform someone of messages from the system or another person’s actions. +These messages can include mentions, event reminders, replies, and system updates. +They include a call to action directly linking to a solution or the content that they reference. +They can be either temporary or persistent. They’re dismissible only if there is another surface, +like a notification center, where the customer can find this content again later. + +## Behavior + +### Dismissal + +Toasts can have timed, conditional, or express dismissals, dependent on their use case. + +**Timed dismissal** + +If there is no action to take, toast will time out after seven seconds. +Timed dismissal is best when there is no further action to take, like for a successful confirmation toast. + +People who navigate via mouse can pause the timer by hovering over the toast. +However, toasts that don’t include actions won’t receive keyboard focus for people who navigate primarily by keyboard. + +**Conditional dismissal** + +Use conditional dismissal for toasts that should persist until a condition is met, like a progress toast that dismisses once a task is complete. + +Don’t use toasts for necessary actions. If you need the encourage people to take an action before moving forward, +try a more forceful surface like a message bar or a dialog. + +**Express dismissal** + +Include the "Close" button to allow people to expressly dismiss toasts only if they can find that information again elsewhere, +like in a notification center. + +>[!Note] We do not have a way yet to facilitate showing toast messages on other surfaces like a notification center, so use the express dismissal option with caution. + +### Determinate and indeterminate progress + +Progress toasts can be either determinate or indeterminate, depending on the needs of your app and the +capabilities of the technology you’re building on. + +When the completion time can be predicted, show a determinate progress bar and percentage of completion. +Determinate progress bars offer a reliable user experience since they communicate status and assure people things are still working. + +If the completion time is unknown or its accuracy is unreliable, show an indeterminate spinner icon instead. + +Although a specific type of toast needs to be specified through the `ToastOptions`, the library does not prevent you +from showing both a spinner icon and a progress bar in the same toast, but we recommend strongly against doing this. + ## Accessibility Toasts are announced with an alert role and live region behavior based on intent. Use the `Intent` value in `ToastOptions` to apply semantic styling and announcement priority.