From 02661917b87403e2ca8c49a93a8452ee73eab10d Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 18 Apr 2025 15:58:32 +0200 Subject: [PATCH] Initial MessageBar work --- README.md | 6 +- .../Examples/MessageBarDefault.razor | 96 ++++++ .../Components/MessageBar/FluentMessageBar.md | 8 + .../Layout/DemoMainLayout.razor | 2 +- spelling.dic | 7 + .../Dialog/Services/IDialogInstance.cs | 2 +- .../MessageBar/FluentMessageBar.razor | 142 ++++++++ .../MessageBar/FluentMessageBar.razor.cs | 312 ++++++++++++++++++ .../MessageBar/FluentMessageBar.razor.css | 206 ++++++++++++ .../MessageBar/FluentMessageBarProvider.razor | 12 + .../FluentMessageBarProvider.razor.cs | 193 +++++++++++ .../FluentMessageBarProvider.razor.css | 6 + src/Core/Components/MessageBar/Message.cs | 149 +++++++++ .../MessageBar/MessageBarEventArgs.cs | 68 ++++ .../MessageBar/Services/IMessageInstance.cs | 64 ++++ .../MessageBar/Services/IMessageService.cs | 48 +++ .../MessageBar/Services/MessageInstance.cs | 51 +++ .../MessageBar/Services/MessageOptions.cs | 130 ++++++++ .../MessageBar/Services/MessageService.cs | 296 +++++++++++++++++ src/Core/Enums/FluentUITheme.cs | 31 ++ src/Core/Enums/LocalizationDirection.cs | 25 ++ src/Core/Enums/MessageBarLayout.cs | 26 ++ src/Core/Enums/MessageBarShape.cs | 26 ++ src/Core/Enums/MessageBarState.cs | 31 ++ src/Core/Enums/MessageIntent.cs | 44 +++ src/Core/Enums/MessageType.cs | 19 ++ .../Extensions/ServiceCollectionExtensions.cs | 1 + src/Core/Infrastructure/ActionButton.cs | 22 ++ src/Core/Infrastructure/ActionLink.cs | 32 ++ src/Core/Infrastructure/CountdownTimer.cs | 54 +++ src/Core/Localization/TimeAgoResource.resx | 156 +++++++++ 31 files changed, 2260 insertions(+), 5 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarDefault.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md create mode 100644 src/Core/Components/MessageBar/FluentMessageBar.razor create mode 100644 src/Core/Components/MessageBar/FluentMessageBar.razor.cs create mode 100644 src/Core/Components/MessageBar/FluentMessageBar.razor.css create mode 100644 src/Core/Components/MessageBar/FluentMessageBarProvider.razor create mode 100644 src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs create mode 100644 src/Core/Components/MessageBar/FluentMessageBarProvider.razor.css create mode 100644 src/Core/Components/MessageBar/Message.cs create mode 100644 src/Core/Components/MessageBar/MessageBarEventArgs.cs create mode 100644 src/Core/Components/MessageBar/Services/IMessageInstance.cs create mode 100644 src/Core/Components/MessageBar/Services/IMessageService.cs create mode 100644 src/Core/Components/MessageBar/Services/MessageInstance.cs create mode 100644 src/Core/Components/MessageBar/Services/MessageOptions.cs create mode 100644 src/Core/Components/MessageBar/Services/MessageService.cs create mode 100644 src/Core/Enums/FluentUITheme.cs create mode 100644 src/Core/Enums/LocalizationDirection.cs create mode 100644 src/Core/Enums/MessageBarLayout.cs create mode 100644 src/Core/Enums/MessageBarShape.cs create mode 100644 src/Core/Enums/MessageBarState.cs create mode 100644 src/Core/Enums/MessageIntent.cs create mode 100644 src/Core/Enums/MessageType.cs create mode 100644 src/Core/Infrastructure/ActionButton.cs create mode 100644 src/Core/Infrastructure/ActionLink.cs create mode 100644 src/Core/Infrastructure/CountdownTimer.cs create mode 100644 src/Core/Localization/TimeAgoResource.resx diff --git a/README.md b/README.md index 0460642c4f..824d82791d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ When using [Visual Studio](https://visualstudio.microsoft.com/), you can also us The templates will be available under the **Blazor** category. ### - Manual Install -To start using the **Fluent UI Blazor components** from scratch, you first need to install the main [Nuget package](https://www.nuget.org/packages/Microsoft.FluentUI.AspNetCore.Components/) in the project you want to use the library and its components. +To start using the **Fluent UI Blazor components** from scratch, you first need to install the main [NuGet package](https://www.nuget.org/packages/Microsoft.FluentUI.AspNetCore.Components/) in the project you want to use the library and its components. You can use the NuGet package manager in your IDE or use the following command when using a CLI: ```shell @@ -200,13 +200,13 @@ We also have a document that explains and shows how to [write and develop unit t ## 🔹Joining the Community -Looking to get answers to questions or engage with us in real-time? Our community is active on [Gitter](https://app.gitter.im/#/room/#fluentui-blazor:gitter.im) and [Discord](https://discord.gg/FcSNfg4). Submit requests +Looking to get answers to questions or engage with us in real-time? Our community is active on [Discord](https://discord.gg/FcSNfg4). Submit requests and issues on [GitHub](https://github.com/microsoft/fluentui-blazor/issues/new/choose), or join us by contributing on [some good first issues via GitHub](https://github.com/microsoft/fluentui-blazor/labels/community:good-first-issue). We look forward to building an amazing open source community with you! ## 🔹Contact -* Join the DotNetEvolution server and chat with us in real-time on [Discord](https://discord.gg/M5cBTfp6J2). You can also find us on [Gitter](https://app.gitter.im/#/room/#fluentui-blazor:gitter.im). +* Join the DotNetEvolution server and chat with us in real-time on [Discord](https://discord.gg/M5cBTfp6J2). * Submit requests and issues (only) on [GitHub](https://github.com/microsoft/fluentui-blazor/issues/new/choose). * Contribute by helping out on some of our recommended first issues on [GitHub](https://github.com/microsoft/fluentui-blazor/labels/community:good-first-issue). diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarDefault.razor new file mode 100644 index 0000000000..8482757890 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/Examples/MessageBarDefault.razor @@ -0,0 +1,96 @@ +@inject IMessageService MessageService +Add simple +Add with options + +@code +{ + + ActionLink link = new() + { + Text = "Learn more", + Href = "https://bing.com", + OnClick = (e) => { Console.WriteLine($"Message 'learn more' clicked"); return Task.CompletedTask; } + }; + + int counter = 0; + + async Task AddInTopBarSimple() + { + var message = $"This is a message bar that provides information to the user (#{counter++})"; + var type = Enum.GetValues()[counter % 4]; + await MessageService.ShowMessageBarAsync(message, type, App.MESSAGES_TOP); + } + + async Task AddInTopBarWithOptions() + { + await MessageService.ShowMessageBarAsync(options => + { + options.Title = $"Simple message #{counter++}"; + options.Intent = Enum.GetValues()[counter % 4]; + options.Link = link; + options.Section = App.MESSAGES_TOP; + }); + } + + + // void AddInNotificationCenter() + // { + // MessageService.ShowMessageBar(options => + // { + // options.Intent = Enum.GetValues()[counter % 4]; + // options.Title = $"Simple message #{counter++}"; + // options.Body = MessageBarSamples.OneRandomMessage; + // options.Link = link; + // options.Timestamp = DateTime.Now; + // options.Section = App.MESSAGES_NOTIFICATION_CENTER; + // }); + // } + + + // async Task AddInDialog() + // { + // MessageService.ShowMessageBar(options => + // { + // options.Intent = Enum.GetValues()[counter % 4]; + // options.Title = $"Simple message #{counter++}"; + // options.Body = MessageBarSamples.OneRandomMessage; + // options.Link = link; + // options.Timestamp = DateTime.Now; + // options.Section = App.MESSAGES_DIALOG; + // }); + + // await OpenDialogAsync(); + // } + + // private async Task OpenDialogAsync() + // { + // DialogParameters parameters = new() + // { + // Title = $"Hi {simplePerson.Firstname}!", + // PrimaryAction = "Yes", + // PrimaryActionEnabled = false, + // SecondaryAction = "No", + // Width = "500px", + // Height = "500px", + // Content = simplePerson, + // TrapFocus = true, + // Modal = true, + // }; + + // IDialogReference dialog = await DialogService.ShowDialogAsync(simplePerson, parameters); + // DialogResult? result = await dialog.Result; + // } + + // private async Task AddNonDismissibleMessage() + // { + // var message = $"Simple non-dismissible message #{counter++}"; + // var type = Enum.GetValues()[counter % 4]; + // await MessageService.ShowMessageBarAsync(options => + // { + // options.Title = message; + // options.Intent = type; + // options.Section = App.MESSAGES_TOP; + // options.AllowDismiss = false; + // }); + // } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md new file mode 100644 index 0000000000..ad518f16f2 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/MessageBar/FluentMessageBar.md @@ -0,0 +1,8 @@ +--- +title: MessageBar +route: /MessageBar +--- + +# MessageBar + +{{ MessageBarDefault }} diff --git a/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor b/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor index 06faf3987c..1846e0b092 100644 --- a/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor +++ b/examples/Demo/FluentUI.Demo.Client/Layout/DemoMainLayout.razor @@ -58,7 +58,6 @@ @* ------------------- *@ @* Body *@ @* ------------------- *@ - @if (IsHomePage()) { @@ -71,6 +70,7 @@ else { + @Body diff --git a/spelling.dic b/spelling.dic index b5fff9416b..2baadac0e3 100644 --- a/spelling.dic +++ b/spelling.dic @@ -51,3 +51,10 @@ tabindex tablist tabpanel textarea +singleline +messagebar +NuGet +blazor +fadein +fluentui +lightgray diff --git a/src/Core/Components/Dialog/Services/IDialogInstance.cs b/src/Core/Components/Dialog/Services/IDialogInstance.cs index e93f01b053..180623de19 100644 --- a/src/Core/Components/Dialog/Services/IDialogInstance.cs +++ b/src/Core/Components/Dialog/Services/IDialogInstance.cs @@ -5,7 +5,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Interface for DialogReference +/// Interface for DialogInstance /// public interface IDialogInstance { diff --git a/src/Core/Components/MessageBar/FluentMessageBar.razor b/src/Core/Components/MessageBar/FluentMessageBar.razor new file mode 100644 index 0000000000..65a9e0d6d3 --- /dev/null +++ b/src/Core/Components/MessageBar/FluentMessageBar.razor @@ -0,0 +1,142 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase +@if (Visible) +{ + @* Default *@ + + @if (Type == MessageType.MessageBar) + { + + + + @if (!String.IsNullOrEmpty(Message?.Options.Title)) + { + @((MarkupString)(Message.Options.Title)) + } + @if (ChildContent is not null) + { + @ChildContent + } + else + { + @((MarkupString)Message!.Options.Body!) + } + @if (Link is not null) + { + + @Link?.Text + + } + @if (ShowPrimaryAction) + { + + @PrimaryAction?.Text + + } + @if (ShowSecondaryAction) + { + + @SecondaryAction?.Text + + } + Action + @if (AllowDismiss) + { + + } + + + } + + @* Notification *@ + @if (Type == MessageType.Notification) + { +
+ + @* Icon *@ +
+ +
+ + @* Message *@ + @if (!String.IsNullOrEmpty(Title)) + { +
+ @((MarkupString)(Title)) +
+ } + + @* Close *@ + + @if (AllowDismiss) + { +
+ +
+ } + + @* Detailed content *@ +
+ @ChildContent + @if (!String.IsNullOrEmpty(Message?.Options.Body)) + { + @((MarkupString)Message.Options.Body) + } + @if (Link is not null) + { + + @Link?.Text + + } + @if (ShowPrimaryAction) + { + + @PrimaryAction?.Text + + } + +
+ + @* Recorded time *@ + @if (Timestamp != null) + { +
+ @((DateTime.Now - Timestamp)) @* ?.ToTimeAgo()) *@ +
+ } + +
+ } +} diff --git a/src/Core/Components/MessageBar/FluentMessageBar.razor.cs b/src/Core/Components/MessageBar/FluentMessageBar.razor.cs new file mode 100644 index 0000000000..1e78b9b7c9 --- /dev/null +++ b/src/Core/Components/MessageBar/FluentMessageBar.razor.cs @@ -0,0 +1,312 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public partial class FluentMessageBar : FluentComponentBase, IDisposable +{ + private CountdownTimer? _countdownTimer; + private Color? _color; + + /// + protected string? ClassValue => new CssBuilder(Class) + //.AddClass("fluent-messagebar", () => Type == MessageType.MessageBar) + //.AddClass("dark", () => GlobalDesign.Theme == FluentUITheme.Dark) + .AddClass("fluent-messagebar-notification", () => Type == MessageType.Notification) + //.AddClass("intent-info", () => Intent == MessageIntent.Info) + //.AddClass("intent-warning", () => Intent == MessageIntent.Warning) + //.AddClass("intent-error", () => Intent == MessageIntent.Error) + //.AddClass("intent-success", () => Intent == MessageIntent.Success) + .AddClass("intent-custom", () => Intent == MessageIntent.Custom) + .Build(); + + /// + protected string? StyleValue => new StyleBuilder(Style).Build(); + + /// + /// Gets or sets the type of message bar. + /// Default is MessageType.MessageBar. See for more details. + /// + [Parameter] + public MessageType Type { get; set; } = MessageType.MessageBar; + + /// + /// Gets or sets the actual message instance shown in the message bar. + /// + [Parameter] + public IMessageInstance Message { get; set; } = default!; //= MessageInstance.Empty(); + + /// + /// Gets or sets the message to be shown when not using the MessageService methods. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the intent of the message bar. + /// Default is MessageIntent.Info. See for more details. + /// + [Parameter] + public MessageIntent? Intent { get; set; } + + /// + /// Gets or sets the icon to show in the message bar based on the intent of the message. See for more details. + /// + [Parameter] + public Icon? Icon { get; set; } + //{ + // get + // { + // if (Content.Options.Icon != null && Content.Intent == MessageIntent.Custom) + // { + // return Content.Options.Icon; + // } + + // return Content.Intent switch + // { + // MessageIntent.Info => new CoreIcons.Filled.Size20.Info(), + // MessageIntent.Warning => new CoreIcons.Filled.Size20.Warning(), + // MessageIntent.Error => new CoreIcons.Filled.Size20.DismissCircle(), + // MessageIntent.Success => new CoreIcons.Filled.Size20.CheckmarkCircle(), + // _ => null, + // }; + // } + + // set + // { + // Content.Options.Icon = value; + // } + //} + + /// + /// Gets or sets the visibility of the message bar. + /// Default is true. + /// + [Parameter] + public bool Visible { get; set; } = true; + + /// + /// Gets or sets the title. + /// Most important info to be shown in the message bar. + /// + [Parameter] + public string? Title { get; set; } + //{ + // get + // { + // return Content.Title; + // } + + // set + // { + // Content.Title = value; + // } + //} + + /// + /// Gets or sets the time on which the message was created. + /// Default is DateTime.Now. + /// Only used when MessageType is Notification. + /// + [Parameter] + public DateTime? Timestamp { get; set; } + //{ + // get + // { + // return Content.Options.Timestamp; + // } + + // set + // { + // Content.Options.Timestamp = value; + // } + //} + + /// + /// Gets or sets the color of the icon. + /// Only applied when intent is MessageBarIntent.Custom. + /// Default is Color.Accent. + /// + [Parameter] + public Color? IconColor { get; set; } = Color.Primary; + + /// + /// Gets or sets the ability to dismiss the notification. + /// Default is true. + /// + [Parameter] + public bool AllowDismiss { get; set; } = true; + + /// + /// Gets or sets the fade in animation for the MessageBar. + /// Default is true. + /// + [Parameter] + public bool FadeIn { get; set; } = true; + + /// + /// Command executed when the user clicks on the button. + /// + [Parameter] + public EventCallback OnStateChange { get; set; } + + ///// + ///// On app and page level a Message bar should NOT have rounded corners. On component level it should. + ///// + //[Parameter] + //public bool RoundedCorners { get; set; } = true; + + /// + /// A link can be shown after the message. + /// + protected ActionLink? Link => Message.Options.Link; + + /// + /// Button to show as primary action. + /// + protected ActionButton? PrimaryAction => Message.Options.PrimaryAction; + + /// + /// Button to show as secondary action. + /// + protected ActionButton? SecondaryAction => Message.Options.SecondaryAction; + + /// + protected bool ShowPrimaryAction => !string.IsNullOrEmpty(Message.Options.PrimaryAction?.Text); + + /// + protected bool ShowSecondaryAction => !string.IsNullOrEmpty(Message.Options.SecondaryAction?.Text); + + /// + protected override void OnInitialized() + { + if (Message.Options.Icon != null && Message.Options.Intent == MessageIntent.Custom) + { + Icon = Message.Options.Icon; + } + else + { + Icon = Message.Options.Intent switch + { + MessageIntent.Info => new CoreIcons.Filled.Size20.Info(), + MessageIntent.Warning => new CoreIcons.Filled.Size20.Warning(), + MessageIntent.Error => new CoreIcons.Filled.Size20.DismissCircle(), + MessageIntent.Success => new CoreIcons.Filled.Size20.CheckmarkCircle(), + _ => null, + }; + } + } + + /// + protected override async Task OnParametersSetAsync() + { + _color = Message.Options.Intent switch + { + MessageIntent.Info => Color.Info, + MessageIntent.Warning => Color.Warning, + MessageIntent.Error => Color.Error, + MessageIntent.Success => Color.Success, + _ => IconColor, + }; + + if (Message.Options.Timeout.HasValue) + { + if (Message.Options.Timeout == 0) + { + return; + } + + _countdownTimer = new CountdownTimer(Message.Options.Timeout.Value).OnElapsed(DismissClicked); + await _countdownTimer!.StartAsync(); + } + } + + /// + protected Task LinkClickedAsync() + { + if (Link?.OnClick != null) + { + return Link.OnClick.Invoke(Message); + } + + return Task.CompletedTask; + } + + /// + protected Task PrimaryActionClickedAsync(MouseEventArgs e) + { + if (PrimaryAction?.OnClick != null) + { + return PrimaryAction.OnClick.Invoke(Message); + } + + return Task.CompletedTask; + } + + /// + protected Task SecondaryActionClickedAsync(MouseEventArgs e) + { + if (SecondaryAction?.OnClick != null) + { + return SecondaryAction.OnClick.Invoke(Message); + } + + return Task.CompletedTask; + } + + /// + protected void DismissClicked() + { + Visible = false; + //Message.Close(); + } + + /// + /// Pause the timeout countdown counter. + /// + protected void PauseTimeout() + { + Console.WriteLine("[FluentMessageBar] Pause Timeout"); + _countdownTimer?.Pause(); + } + + /// + /// Resume the timeout countdown counter. + /// + protected void ResumeTimeout() + { + Console.WriteLine("[FluentMessageBar] Resume Timeout"); + _countdownTimer?.Resume(); + } + + /// + private async Task RaiseOnStateChangeAsync(MessageBarEventArgs args) + { + if (OnStateChange.HasDelegate) + { + await InvokeAsync(() => OnStateChange.InvokeAsync(args)); + } + + return args; + } + + /// + internal Task RaiseOnStateChangeAsync(IMessageInstance instance, MessageBarState state) => RaiseOnStateChangeAsync(new MessageBarEventArgs(instance, state)); + + /// + /// Dispose the component. + /// + public void Dispose() + { + _countdownTimer?.Dispose(); + _countdownTimer = null; + + //GlobalDesign.OnChange -= StateHasChanged; + } +} diff --git a/src/Core/Components/MessageBar/FluentMessageBar.razor.css b/src/Core/Components/MessageBar/FluentMessageBar.razor.css new file mode 100644 index 0000000000..89eee7aed8 --- /dev/null +++ b/src/Core/Components/MessageBar/FluentMessageBar.razor.css @@ -0,0 +1,206 @@ +/* OneRow */ + +.fluent-messagebar { + font-family: var(--body-font); + background-color: var(--neutral-layer-floating); + color: var(--neutral-foreground-rest); + display: grid; + grid-template-columns: 24px 1fr auto; + width: 100%; + padding: 0px 3px; + align-items: center; + min-height: 36px; + border-radius: calc(var(--control-corner-radius) * 1px); + padding: 0 12px; + column-gap: 8px; +} + +.fluent-messagebar[animation="fadein"] { + animation: fadein 1.5s; +} + +.fluent-messagebar-container-action { + display: flex; + padding: 6px 0px; + justify-content: flex-end; + align-items: center; + gap: 8px; +} + + .fluent-messagebar.intent-info { + background-color: #f5f5f5; + border: 1px solid #d1d1d1; + + } + + .fluent-messagebar.dark.intent-info { + background-color: #141414; + border: 1px solid #666; + } + + .fluent-messagebar.intent-warning { + background-color: #FDF6F3; + border: 1px solid #f4bfab; + } + + .fluent-messagebar.dark.intent-warning { + background-color: #411200; + border: 1px solid #DA3B01; + } + .fluent-messagebar.intent-error { + background-color: #FDF3F4; + border: 1px solid #f1bbbc; + } + + .fluent-messagebar.dark.intent-error { + background-color: #3F1011; + border: 1px solid #D13438; + } + .fluent-messagebar.intent-success { + background-color: #f1faf1; + border: 1px solid #9fd89f; + } + + .fluent-messagebar.dark.intent-success { + background-color: #052505; + border: 1px solid #107C10; + } + .fluent-messagebar.intent-custom { + background-color: var(--neutral-layer-1); + border: 1px solid var(--neutral-stroke-layer-rest); + } + +.fluent-messagebar-icon { + grid-column: 1; + display: flex; + justify-content:center; + } + +.fluent-messagebar-message { + grid-column: 2; + padding: 10px 0; + align-self: center; + font-size: 12px; + font-weight: 400; + line-height: 16px; + +} + +.fluent-messagebar-message .title{ + font-weight: 600; + padding: 0 4px 0 0; + +} + +.fluent-messagebar-message ::deep fluent-anchor { + margin-inline-start: 5px; + } + +::deep .fluent-messagebar-action { + height: 24px; + +} + +.fluent-messagebar-close { + grid-column: 3; + padding: 4px; + justify-self: center; + align-self: center; + fill: var(--neutral-fill-strong-active); + cursor: pointer; + max-width: 16px; + max-height: 16px; +} + +/* Notification */ + +.fluent-messagebar-notification { + font-family: var(--body-font); + color: var(--neutral-foreground-rest); + display: grid; + grid-template-columns: 24px 1fr auto; + grid-template-rows: 36px 1fr auto; + width: 100%; + min-height: 36px; + padding: 0 12px; + column-gap: 8px; +} + + .fluent-messagebar-notification.intent-info { + fill: #797775; /* Gray */ + } + + .fluent-messagebar-notification.intent-warning { + fill: #d83b01; /* Orange */ + } + + .fluent-messagebar-notification.intent-error { + fill: #a80000; /* Red */ + } + + .fluent-messagebar-notification.intent-success { + fill: #107c10; /* Green */ + } + +.fluent-messagebar-notification-icon { + grid-column: 1; + grid-row: 1; + display: flex; + justify-content: center; +} + +.fluent-messagebar-notification-message { + grid-column: 2; + grid-row: 1; + padding: 10px 0; + align-self: center; + font-weight: 600; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.fluent-messagebar-notification-content { + grid-column: 1 / 4; + grid-row: 2; + padding: 6px 6px; +} + +.fluent-messagebar-notification-content ::deep fluent-anchor { + margin-inline-start: 5px; +} + +::deep .fluent-messagebar-notification-action { + margin-left: 5px; +} + +.fluent-messagebar-notification-close { + grid-column: 3; + grid-row: 1; + padding: 4px; + display: flex; + justify-content: center; + justify-self: center; + cursor: pointer; +} + +.fluent-messagebar-notification-time { + grid-column: 2 / 4; + grid-row: 3; + font-size: 12px; + right: 10px; + text-align: right; + padding: 0px 4px 4px 0px; +} + +@keyframes fadein { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor new file mode 100644 index 0000000000..5998aabe9c --- /dev/null +++ b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor @@ -0,0 +1,12 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase + +@if (MessageService != null && MessagesToShow?.Count() > 0) +{ +
+ @foreach (var message in MessagesToShow) + { + + } +
+} diff --git a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs new file mode 100644 index 0000000000..8098abf171 --- /dev/null +++ b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.cs @@ -0,0 +1,193 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public partial class FluentMessageBarProvider : FluentComponentBase +{ + /// + public FluentMessageBarProvider() + { + Id = Identifier.NewId(); + } + + /// + internal string? ClassValue => DefaultClassBuilder + .AddClass("fluent-messagebar-provider") + .Build(); + + /// + internal string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the injected service provider. + /// + [Inject] + public IServiceProvider? ServiceProvider { get; set; } + + /// + /// Gets or sets the injected navigation manager. + /// + [Inject] + private NavigationManager NavigationManager { get; set; } = default!; + + /// + protected virtual IMessageService? MessageService => GetCachedServiceOrNull(); + + /// + /// Display only messages for this section. + /// + [Parameter] + public string? Section { get; set; } + + /// + /// Displays messages as a single line (with the message only) + /// or as a card (with the detailed message). + /// + [Parameter] + public MessageType Type { get; set; } = MessageType.MessageBar; + + /// + /// Maximum number of messages displayed. Rest is stored in memory to be displayed when an shown message is closed. + /// Default value is 5 + /// Set a value equal to or less than zero, to display all messages for this (or all categories if not set). + /// + [Parameter] + public int? MaxMessageCount { get; set; } = 5; + + /// + /// Display the newest messages on top (true) or on bottom (false). + /// + [Parameter] + public bool NewestOnTop { get; set; } = true; + + /// + /// Clear all (shown and stored) messages when the user navigates to a new page. + /// + [Parameter] + public bool ClearAfterNavigation { get; set; } = false; + + /// + protected IEnumerable? AllMessagesForSection + { + get + { + return string.IsNullOrEmpty(Section) + ? MessageService?.Items.Values + : MessageService?.Items.Values.Where(x => string.Equals(x.Options.Section, Section, StringComparison.OrdinalIgnoreCase)); + } + } + + /// + protected IEnumerable? MessagesToShow + { + get + { + if (MaxMessageCount.HasValue) + { + var maxMessages = MaxMessageCount.Value > 0 ? MaxMessageCount.Value : int.MaxValue; + + return NewestOnTop + ? AllMessagesForSection?.Reverse().TakeLast(maxMessages) + : AllMessagesForSection?.TakeLast(maxMessages); + } + + return NewestOnTop + ? MessageService?.MessagesToShow(-1, Section).Reverse() + : MessageService?.MessagesToShow(-1, Section); + } + } + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + if (MessageService is not null) + { + MessageService.ProviderId = Id; + MessageService.OnUpdatedAsync = async (item) => + { + await InvokeAsync(StateHasChanged); + }; + + //MessageService.OnUpdatedAsync += OnMessageItemsUpdatedHandler; + //MessageService.OnMessageItemsUpdatedAsync += OnMessageItemsUpdatedHandlerAsync; + + if (ClearAfterNavigation) + { + NavigationManager.LocationChanged += ClearMessages; + } + } + } + + /// + /// Only for Unit Tests + /// + /// + internal void UpdateId(string? id) + { + Id = id; + + if (MessageService is not null) + { + MessageService.ProviderId = id; + } + } + + /// + protected virtual void OnMessageItemsUpdatedHandler() + { + _ = InvokeAsync(StateHasChanged); + } + + /// + protected virtual async Task OnMessageItemsUpdatedHandlerAsync() + { + await Task.Run(() => + { + _ = InvokeAsync(StateHasChanged); + }); + } + + private void ClearMessages(object? sender, LocationChangedEventArgs args) + { + if (AllMessagesForSection?.Any() == true) + { + _ = InvokeAsync(() => + { + MessageService?.Clear(Section); + StateHasChanged(); + }); + } + } + + /// + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + if (MessageService is not null) + { + //MessageService.MessageItemsUpdated -= OnMessageItemsUpdatedHandler; + //MessageService.OnMessageItemsUpdatedAsync -= OnMessageItemsUpdatedHandlerAsync; + } + + NavigationManager.LocationChanged -= ClearMessages; + } + } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + } +} diff --git a/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.css b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.css new file mode 100644 index 0000000000..16a75921fa --- /dev/null +++ b/src/Core/Components/MessageBar/FluentMessageBarProvider.razor.css @@ -0,0 +1,6 @@ +.fluent-messagebar-provider { + display: flex; + flex-direction: column; + row-gap: calc( var(--design-unit) * 2px); + padding-bottom: calc(var(--design-unit) * 1px); +} diff --git a/src/Core/Components/MessageBar/Message.cs b/src/Core/Components/MessageBar/Message.cs new file mode 100644 index 0000000000..14ada33a75 --- /dev/null +++ b/src/Core/Components/MessageBar/Message.cs @@ -0,0 +1,149 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a message that can be shown in a . +/// +public class Message +{ + /// + /// Gets or sets the unique identifier of the message. + /// + public string Id { get; set; } = Identifier.NewId(); + + /// + /// Gets or sets the provider ID associated with this message. + /// + public string? ProviderId { get; set; } + + /// + public Message() + { + Options = new MessageOptions(); + } + + /// + internal Message(MessageOptions options) + { + Options = options; + } + + internal Action? OnClose; + + /// + internal MessageOptions Options { get; } + + /// + /// Gets or sets the intent of the message. + /// Default is MessageIntent.Info. + /// See for more details. + /// + public MessageIntent? Intent => Options.Intent ?? MessageIntent.Info; + + /// + /// Indication of in which message bar the message needs to be shown. Default is null. + /// + public string? Section => Options.Section; + + /// + /// Gets or sets the title. + /// Most important info to be shown in the message bar. + /// + public string? Title + { + get + { + return Options.Title; + } + + set + { + Options.Title = value; + } + } + + /// + /// Gets or sets the message to be shown in the message bar. + /// + public string? Body + { + get + { + return Options.Body; + } + + set + { + Options.Body = value; + } + } + + /// + /// Gets or sets whether the message bar is dismissible. + /// + public bool AllowDismiss + { + get + { + return Options.AllowDismiss; + } + set + { + Options.AllowDismiss = value; + } + } + + /// + /// Gets or sets the link to be shown in the message bar (after the body). + /// + public ActionLink? Link + { + get + { + return Options.Link; + } + + set + { + Options.Link = value; + } + } + + /// + /// Gets or sets the timeout after which the message is automatically dismissed. + /// + public int? Timeout + { + get + { + return Options.Timeout; + } + + set + { + Options.Timeout = value; + } + } + + /// + /// Close the message bar. + /// + public void Close() + { + OnClose?.Invoke(this); + } + + /// + internal static Message Empty() + { + return new Message(new MessageOptions() + { + Intent = MessageIntent.Info, + }); + } +} diff --git a/src/Core/Components/MessageBar/MessageBarEventArgs.cs b/src/Core/Components/MessageBar/MessageBarEventArgs.cs new file mode 100644 index 0000000000..e5a376d716 --- /dev/null +++ b/src/Core/Components/MessageBar/MessageBarEventArgs.cs @@ -0,0 +1,68 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Event arguments for the FluentMessageBar component. +/// +public class MessageBarEventArgs : EventArgs +{ + ///// + //internal MessageBarEventArgs(FluentMessageBar messageBar, string? id, string? eventType, string? oldState, string? newState) + //{ + // Id = id ?? string.Empty; + // Instance = messageBar.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; + // } + //} + + /// + internal MessageBarEventArgs(IMessageInstance instance, MessageBarState state) + { + Id = instance.Id; + Instance = instance; + State = state; + } + + /// + /// Gets the ID of the FluentMessageBar component. + /// + public string Id { get; } + + /// + /// Gets the state of the FluentMessageBar component. + /// + public MessageBarState State { get; } + + /// + /// Gets the instance used by the . + /// + public IMessageInstance? Instance { get; } +} diff --git a/src/Core/Components/MessageBar/Services/IMessageInstance.cs b/src/Core/Components/MessageBar/Services/IMessageInstance.cs new file mode 100644 index 0000000000..59258ed131 --- /dev/null +++ b/src/Core/Components/MessageBar/Services/IMessageInstance.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Interface for MessageInstance +/// + +public interface IMessageInstance +{ + ///// + ///// Gets the component type of the message. + ///// + //internal Type ComponentType { get; } + + /// + /// Gets the unique identifier for the message. + /// If this value is not set in the , a new identifier is generated. + /// + string Id { get; } + + /// + /// Gets the index of the message (sequential number). + /// + long Index { get; } + + /// + /// Gets the options used to configure the message. + /// + MessageOptions Options { get; } + + ///// + ///// Gets the result of the message. + ///// + //Task Result { get; } + + /// + /// Dismisses the message. + /// + /// + Task DismissAsync(); + + ///// + ///// Closes the message with the specified result. + ///// + ///// + //Task CloseAsync(); + + ///// + ///// Closes the message with the specified result. + ///// + ///// Result to close the message with. + ///// + //Task CloseAsync(MessageResult result); + + ///// + ///// Closes the message with the specified result. + ///// + ///// Result to close the message with. + ///// + //Task CloseAsync(T result); +} diff --git a/src/Core/Components/MessageBar/Services/IMessageService.cs b/src/Core/Components/MessageBar/Services/IMessageService.cs new file mode 100644 index 0000000000..7ca11c6bc8 --- /dev/null +++ b/src/Core/Components/MessageBar/Services/IMessageService.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Interface for implementing a MessageService +/// +public partial interface IMessageService : IFluentServiceBase +{ + ///// + //Action MessageItemsUpdated { get; set; } + + ///// + //Func OnMessageItemsUpdatedAsync { get; set; } + + /// + /// Dismisses the message. + /// + /// Instance of the message to close. + /// + Task DismissAsync(IMessageInstance message); + + /// + IEnumerable MessagesToShow(int count = 5, string? section = null); + + /// + Task ShowMessageBarAsync(Action options); + + /// + Task ShowMessageBarAsync(string title); + + /// + Task ShowMessageBarAsync(string title, MessageIntent intent); + + /// + Task ShowMessageBarAsync(string title, MessageIntent intent, string section); + + /// + void Clear(string? section = null); + + /// + void Remove(IMessageInstance message); + + /// + int Count(string? section); +} diff --git a/src/Core/Components/MessageBar/Services/MessageInstance.cs b/src/Core/Components/MessageBar/Services/MessageInstance.cs new file mode 100644 index 0000000000..381ad0b5ba --- /dev/null +++ b/src/Core/Components/MessageBar/Services/MessageInstance.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a message instance used with the . +/// +public class MessageInstance : IMessageInstance +{ + private static long _counter; + //private readonly Type _componentType; + //internal readonly TaskCompletionSource ResultCompletion = new(); + + /// + internal MessageInstance(IMessageService messageService, /*Type componentType,*/ MessageOptions options) + { + //_componentType = componentType; + Options = options; + MessageService = messageService; + Id = string.IsNullOrEmpty(options.Id) ? Identifier.NewId() : options.Id; + Index = Interlocked.Increment(ref _counter); + } + + ///// + //Type IMessageInstance.ComponentType => _componentType; + + /// + internal IMessageService MessageService { get; } + + /// + internal FluentMessageBar? FluentMessageBar { get; set; } + + /// + public MessageOptions Options { get; internal set; } + + /// " + public string Id { get; } + + /// " + public long Index { get; } + + /// + public Task DismissAsync() + { + return MessageService.DismissAsync(this); + } +} diff --git a/src/Core/Components/MessageBar/Services/MessageOptions.cs b/src/Core/Components/MessageBar/Services/MessageOptions.cs new file mode 100644 index 0000000000..c7b19ec9b1 --- /dev/null +++ b/src/Core/Components/MessageBar/Services/MessageOptions.cs @@ -0,0 +1,130 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public class MessageOptions : IFluentComponentBase +{ + /// + /// Initializes a new instance of the class. + /// + public MessageOptions() + { + } + + /// + /// Initializes a new instance of the class + /// using the specified implementation factory. + /// + /// + public MessageOptions(Action implementationFactory) + { + implementationFactory.Invoke(this); + } + /// + /// Gets or sets the unique identifier of the Dialog element. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the CSS class names. + /// If given, these will be included in the class attribute of the `fluent-dialog` or `fluent-drawer` element. + /// To apply you styles to the `dialog` element, you need to create a class like `my-class::part(dialog) { ... }` + /// + public string? Class { get; set; } + + /// + /// Gets or sets the in-line styles. + /// If given, these will be included in the style attribute of the `dialog` element. + /// + public string? Style { get; set; } + + /// + /// Gets or sets the component CSS margin property. + /// + public string? Margin { get; set; } + + /// + /// Gets or sets the component CSS padding property. + /// + public string? Padding { get; set; } + + /// + /// Gets or sets custom data, to attach any user data object to the component. + /// + public object? Data { get; set; } + + /// + /// Gets or sets a collection of additional attributes that will be applied to the created element. + /// + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + /// Gets or sets the identification of the the message belongs to. + /// + public string? Section { get; set; } + + /// + /// Gets or sets the timestamp of the message. + /// + public DateTime? Timestamp { get; set; } + + /// + /// Gets or sets the icon to show in the message bar based on the intent of the message. See for more details. + /// + public Icon? Icon { get; set; } + + /// + /// Gets or sets the title. + /// Most important info to be shown in the message bar. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the message to be shown in the message bar after the title. + /// + public string? Body { get; set; } + + /// + /// Gets or sets the link to be shown in the message bar after the title/message. + /// + public ActionLink? Link { get; set; } + + /// + /// Gets or sets the action to be executed when the message bar is closed. + /// + public Func? OnClose { get; set; } + + /// + /// Action to be executed for the primary button. + /// + public ActionButton? PrimaryAction { get; set; } = new(); + + /// + /// Action to be executed for the secondary button. + /// + public ActionButton? SecondaryAction { get; set; } = new(); + + /// + /// Gets or sets the intent of the message bar. + /// Default is MessageIntent.Info. + /// + public MessageIntent? Intent { get; set; } + + /// + /// Gets or sets a value indicating whether the message will be removed after navigation. + /// + public bool ClearAfterNavigation { get; set; } + + /// + /// Gets or sets the timeout in milliseconds after which the message bar is removed. Default is null. + /// + public int? Timeout { get; set; } + + /// + /// Gets or sets whether the message bar can be dismissed. + /// + public bool AllowDismiss { get; set; } = true; +} diff --git a/src/Core/Components/MessageBar/Services/MessageService.cs b/src/Core/Components/MessageBar/Services/MessageService.cs new file mode 100644 index 0000000000..685beb63f9 --- /dev/null +++ b/src/Core/Components/MessageBar/Services/MessageService.cs @@ -0,0 +1,296 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Service for showing messages in a MessageBar or notification center. +/// +public class MessageService : FluentServiceBase, IMessageService, IAsyncDisposable +{ + private readonly NavigationManager? _navigationManager; + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// List of services available in the application. + /// Localizer for the application. + /// Navigation manager for the application. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageBarEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageInstance))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IMessageInstance))] + public MessageService(IServiceProvider serviceProvider, IFluentLocalizer? localizer, NavigationManager navigationManager) + { + _navigationManager = navigationManager; + _navigationManager.LocationChanged += NavigationManager_LocationChanged; + _serviceProvider = serviceProvider; + Localizer = localizer ?? FluentLocalizerInternal.Default; + } + + /// + /// Gets or sets the provider ID. + /// + public string? ProviderId { get; set; } + + /// + protected IFluentLocalizer Localizer { get; } + + /// + public async Task DismissAsync(IMessageInstance message) + { + var messageInstance = message as MessageInstance; + + // Raise the DialogState.Dismissing event + messageInstance?.FluentMessageBar?.RaiseOnStateChangeAsync(message, MessageBarState.Dismissing); + + // Remove the message from the MessageProvider + await RemoveMessageFromProviderAsync(message); + + // Raise the MessageState.Dismissed event + messageInstance?.FluentMessageBar?.RaiseOnStateChangeAsync(messageInstance, MessageBarState.Dismissed); + } + + ///// + //public Action MessageItemsUpdated { get; set; } = default!; + ///// + //public Func OnMessageItemsUpdatedAsync { get; set; } = default!; + + /// + private ReaderWriterLockSlim MessageLock { get; } = new ReaderWriterLockSlim(); + + /// + /// Retrieve messages to show in the message bar. + /// + /// Number of messages to get (defaults to 5) + /// Optional section to retrieve messages for + /// + public virtual IEnumerable MessagesToShow(int count = 5, string? section = null) + { + MessageLock.EnterReadLock(); + try + { + var messages = string.IsNullOrEmpty(section) + ? ServiceProvider.Items.Values + : ServiceProvider.Items.Values.Where(x => string.Equals(x.Options.Section, section, StringComparison.OrdinalIgnoreCase)); + + return count > 0 ? messages.Take(count) : messages; + } + finally + { + MessageLock.ExitReadLock(); + } + } + + /// + /// Show a message based on the provided parameters in a message bar. + /// + /// Main info + /// + public async Task ShowMessageBarAsync(string title) + { + return await ShowMessageBarAsync(options => + { + options.Title = title; + options.Intent = MessageIntent.Info; + }); + } + + /// + /// Show a message based on the provided parameters in a message bar. + /// + /// Main info + /// Intent of the message + /// + public async Task ShowMessageBarAsync(string title, MessageIntent intent) + { + return await ShowMessageBarAsync(options => + { + options.Title = title; + options.Intent = intent; + options.Section = string.Empty; + }); + } + + /// + /// Show a message based on the provided parameters in a message bar. + /// + /// Main info + /// Intent of the message + /// Section to show the message bar in + /// + public async Task ShowMessageBarAsync(string title, MessageIntent intent, string section) + { + return await ShowMessageBarAsync(options => + { + options.Title = title; + options.Intent = intent; + options.Section = section; + }); + } + + /// + /// Show a message based on the provided message options in a message bar. + /// + /// Message options + /// + public virtual async Task ShowMessageBarAsync(Action options) + { + if (this.ProviderNotAvailable()) + { + throw new FluentServiceProviderException(); + } + + var messageOptions = new MessageOptions(); + options(messageOptions); + + var instance = new MessageInstance(this, messageOptions); + + // Add the message to the service, and render it. + ServiceProvider.Items.TryAdd(instance?.Id ?? "", instance ?? throw new InvalidOperationException("Failed to create FluentMessageBar.")); + + await ServiceProvider.OnUpdatedAsync.Invoke(instance); + + return instance; + } + + /// + /// Clear all messages (per section, if provided) from the message bar. + /// + /// Optional section + public virtual void Clear(string? section = null) + { + MessageLock.EnterWriteLock(); + try + { + RemoveMessages(section); + } + finally + { + MessageLock.ExitWriteLock(); + } + + //MessageItemsUpdated?.Invoke(); + } + + /// + /// Remove a message from the message bar. + /// + /// Message to remove + public virtual void Remove(IMessageInstance message) + { + //message.OnClose -= Remove; + //_ = message.Options.OnClose?.Invoke(message); + + MessageLock.EnterWriteLock(); + try + { + var index = ServiceProvider.Items.Values.ToList().IndexOf(message); + if (index < 0) + { + return; + } + + ServiceProvider.Items.TryRemove(message.Id, out _); + } + finally + { + MessageLock.ExitWriteLock(); + } + + //MessageItemsUpdated?.Invoke(); + } + + /// + private void NavigationManager_LocationChanged(object? sender, LocationChangedEventArgs e) + { + MessagesToShow().Where(s => s.Options.ClearAfterNavigation) + .ToList() + .ForEach(Remove); + } + + /// + /// Remove all messages (per section, if provided) from the message bar. + /// + /// Optional section + private void RemoveMessages(string? section = null) + { + if (ServiceProvider.Items.Values.Count == 0) + { + return; + } + + var messages = string.IsNullOrEmpty(section) + ? ServiceProvider.Items.Values + : ServiceProvider.Items.Values.Where(i => string.Equals(i.Options.Section, section, StringComparison.OrdinalIgnoreCase)); + + foreach (var message in messages) + { + //message.OnClose -= Remove; + } + + if (string.IsNullOrEmpty(section)) + { + ServiceProvider.Items.Clear(); + } + else + { + //ServiceProvider.Items.Remove(i => string.Equals(i.Section, section, StringComparison.OrdinalIgnoreCase), out _); + } + } + + /// + /// Count the number of messages (per section, if provided) in the message bar . + /// + /// Optional section + /// int + public int Count(string? section) => section is null + ? ServiceProvider.Items.Count + : ServiceProvider.Items.Values.Count(x => string.Equals(x.Options.Section, section, StringComparison.OrdinalIgnoreCase)); + + /// + /// Removes the message from the MessageBarProvider. + /// + /// + /// + /// + internal Task RemoveMessageFromProviderAsync(IMessageInstance? message) + { + if (message is null) + { + return Task.CompletedTask; + } + + // Remove the HTML code from the MessageBarProvider + if (!ServiceProvider.Items.TryRemove(message.Id, out _)) + { + throw new InvalidOperationException($"Failed to remove message from MessageBarProvider: the ID '{message.Id}' doesn't exist in the MessageBar ServiceProvider."); + } + + return ServiceProvider.OnUpdatedAsync.Invoke(message); + } + + /// + /// Disposes the MessageService and cleans up resources. + /// + /// + public async ValueTask DisposeAsync() + { + if (_navigationManager != null) + { + _navigationManager.LocationChanged -= NavigationManager_LocationChanged; + } + + RemoveMessages(section: null); + + // Dispose of any other resources if necessary + await Task.CompletedTask; + + // Suppress finalization to comply with CA1816 + GC.SuppressFinalize(this); + } +} diff --git a/src/Core/Enums/FluentUITheme.cs b/src/Core/Enums/FluentUITheme.cs new file mode 100644 index 0000000000..735e86241d --- /dev/null +++ b/src/Core/Enums/FluentUITheme.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The size of the label in a . +/// +public enum FluentUITheme +{ + /// + /// Light theme. + /// + [Description("light")] + Light, + + /// + /// Dark theme. + /// + [Description("dark")] + Dark, + + /// + /// System theme. + /// + [Description("system")] + System, +} diff --git a/src/Core/Enums/LocalizationDirection.cs b/src/Core/Enums/LocalizationDirection.cs new file mode 100644 index 0000000000..f903df66b7 --- /dev/null +++ b/src/Core/Enums/LocalizationDirection.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The (reading) direction of objects in the UI. +/// +public enum LocalizationDirection +{ + /// + /// Left to right. + /// + [Description("ltr")] + LeftToRight, + + /// + /// Right to left. + /// + [Description("rtl")] + RightToLeft, +} diff --git a/src/Core/Enums/MessageBarLayout.cs b/src/Core/Enums/MessageBarLayout.cs new file mode 100644 index 0000000000..4766f6e840 --- /dev/null +++ b/src/Core/Enums/MessageBarLayout.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Specifies the layout of a . +/// + +public enum MessageBarLayout +{ + /// + /// Single line. + /// + [Description("singleline")] + SingleLine, + + /// + /// Multi line. + /// + [Description("multiline")] + MultiLine, +} diff --git a/src/Core/Enums/MessageBarShape.cs b/src/Core/Enums/MessageBarShape.cs new file mode 100644 index 0000000000..4d22e32d92 --- /dev/null +++ b/src/Core/Enums/MessageBarShape.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Specifies the shape of a . +/// + +public enum MessageBarShape +{ + /// + /// Square shape. + /// + [Description("square")] + Square, + + /// + /// Rounded shape. + /// + [Description("rounded")] + Rounded, +} diff --git a/src/Core/Enums/MessageBarState.cs b/src/Core/Enums/MessageBarState.cs new file mode 100644 index 0000000000..c19f00c0a5 --- /dev/null +++ b/src/Core/Enums/MessageBarState.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents the state of a message bar. +/// +public enum MessageBarState +{ + /// + /// The message bar is dismissed. + /// + Dismissed, + + /// + /// The message bar is showing. + /// + Opening, + + /// + /// The message bar is shown. + /// + Open, + + /// + /// The message bar is dismissing. + /// + Dismissing, +} diff --git a/src/Core/Enums/MessageIntent.cs b/src/Core/Enums/MessageIntent.cs new file mode 100644 index 0000000000..a7ff3d25ce --- /dev/null +++ b/src/Core/Enums/MessageIntent.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Specifies the intent of a . +/// + +public enum MessageIntent +{ + /// + /// Info intent. + /// + [Description("info")] + Info, + + /// + /// Success intent. + /// + [Description("success")] + Success, + + /// + /// Warning intent. + /// + [Description("warning")] + Warning, + + /// + /// Error intent. + /// + [Description("error")] + Error, + + /// + /// Custom intent. + /// + [Description("custom")] + Custom, +} diff --git a/src/Core/Enums/MessageType.cs b/src/Core/Enums/MessageType.cs new file mode 100644 index 0000000000..b7d1499537 --- /dev/null +++ b/src/Core/Enums/MessageType.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +public enum MessageType +{ + /// + /// To be displayed in a at the top of screen, dialog or card. + /// + MessageBar, + + /// + /// To be displayed in a notification center. + /// + Notification, +} diff --git a/src/Core/Extensions/ServiceCollectionExtensions.cs b/src/Core/Extensions/ServiceCollectionExtensions.cs index 5bc85ff76d..c7d397470a 100644 --- a/src/Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Extensions/ServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddFluentUIComponents(this IServiceCollection s // Add services services.Add(provider => options ?? new(), serviceLifetime); services.Add(serviceLifetime); + services.Add(serviceLifetime); services.Add(provider => options?.Localizer ?? FluentLocalizerInternal.Default, serviceLifetime); if (configuration == null || configuration.Tooltip.UseServiceProvider) diff --git a/src/Core/Infrastructure/ActionButton.cs b/src/Core/Infrastructure/ActionButton.cs new file mode 100644 index 0000000000..38034c14cc --- /dev/null +++ b/src/Core/Infrastructure/ActionButton.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// An actionable button that can be used in a message bar. +/// +/// The type of the parameter for the button's click action. +public class ActionButton +{ + /// + /// Gets or sets the text to show for the button. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the function to call when the button is clicked. + /// + public Func? OnClick { get; set; } +} diff --git a/src/Core/Infrastructure/ActionLink.cs b/src/Core/Infrastructure/ActionLink.cs new file mode 100644 index 0000000000..2df4d5196b --- /dev/null +++ b/src/Core/Infrastructure/ActionLink.cs @@ -0,0 +1,32 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// An actionable link that can be used in a message bar. +/// +/// The type of the parameter passed to the click event. +public class ActionLink +{ + /// + /// Gets or sets the text to show for the link. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the address to navigate to when the link is clicked. + /// + public string? Href { get; set; } + + /// + /// Gets or sets the target window or frame to open the link in. + /// + public LinkTarget? Target { get; set; } + + /// + /// Gets or sets the function to call when the link is clicked. + /// + public Func? OnClick { get; set; } +} diff --git a/src/Core/Infrastructure/CountdownTimer.cs b/src/Core/Infrastructure/CountdownTimer.cs new file mode 100644 index 0000000000..185ef9877e --- /dev/null +++ b/src/Core/Infrastructure/CountdownTimer.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +internal class CountdownTimer : IDisposable +{ + private readonly PeriodicTimer _timer; + private readonly int _ticksToTimeout; + private readonly CancellationToken _cancellationToken; + private int _percentComplete; + private bool _paused; + private Action? _elapsedDelegate; + + internal CountdownTimer(int timeout, CancellationToken cancellationToken = default) + { + _ticksToTimeout = 100; + _timer = new PeriodicTimer(TimeSpan.FromMilliseconds(timeout / 100)); + _cancellationToken = cancellationToken; + } + + internal CountdownTimer OnElapsed(Action elapsedDelegate) + { + _elapsedDelegate = elapsedDelegate; + return this; + } + internal async Task StartAsync() + { + _percentComplete = 0; + await DoWorkAsync(); + } + + private async Task DoWorkAsync() + { + while (await _timer.WaitForNextTickAsync(_cancellationToken) && !_cancellationToken.IsCancellationRequested) + { + if (!_paused) + { + _percentComplete++; + + if (_percentComplete == _ticksToTimeout) + { + _elapsedDelegate?.Invoke(); + } + } + } + } + + internal void Pause() => _paused = true; + internal void Resume() => _paused = false; + + public void Dispose() => _timer.Dispose(); +} diff --git a/src/Core/Localization/TimeAgoResource.resx b/src/Core/Localization/TimeAgoResource.resx new file mode 100644 index 0000000000..5731a8665b --- /dev/null +++ b/src/Core/Localization/TimeAgoResource.resx @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} day ago + + + {0} days ago + + + {0} hour ago + + + {0} hours ago + + + {0} minute ago + + + {0} minutes ago + + + {0} month ago + + + {0} months ago + + + Just now + + + {0} seconds ago + + + {0} year ago + + + {0} years ago + + \ No newline at end of file